From 77a898df2250ebdf01371be797ed600d9c6d4b2d Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Wed, 4 Mar 2026 11:39:11 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=8E=A5=E5=8F=A3]=E9=89=B4=E6=9D=83authToken?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=99=BB=E5=BD=95login-=E6=B3=A8=E5=86=8Creg?= =?UTF-8?q?ister-=E9=80=80=E5=87=BAlogout,=20=E5=B9=B6=E5=B0=86=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BF=A1=E6=81=AF=E4=BF=9D=E5=AD=98=E5=88=B0redis?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/.env.example | 10 +- server/app/api/cache/UserCache.php | 114 +++++++++++ .../api/controller/AuthTokenController.php | 35 ++++ server/app/api/controller/UserController.php | 83 ++++++++ server/app/api/logic/UserLogic.php | 180 ++++++++++++++++++ .../api/middleware/CheckApiAuthMiddleware.php | 74 +++++++ server/app/dice/model/player/DicePlayer.php | 64 +++++++ server/config/api.php | 16 ++ server/config/route.php | 12 +- server/config/think-cache.php | 4 +- 10 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 server/app/api/cache/UserCache.php create mode 100644 server/app/api/controller/AuthTokenController.php create mode 100644 server/app/api/controller/UserController.php create mode 100644 server/app/api/logic/UserLogic.php create mode 100644 server/app/api/middleware/CheckApiAuthMiddleware.php create mode 100644 server/config/api.php diff --git a/server/.env.example b/server/.env.example index 79589c8..8294542 100644 --- a/server/.env.example +++ b/server/.env.example @@ -7,8 +7,8 @@ DB_USER = root DB_PASSWORD = 123456 DB_PREFIX = -# 缓存方式,支持file|redis -CACHE_MODE = file +# 缓存方式,支持file|redis(API 用户登录缓存需使用 redis) +CACHE_MODE = redis # Redis配置 REDIS_HOST = 127.0.0.1 @@ -16,6 +16,12 @@ REDIS_PORT = 6379 REDIS_PASSWORD = '' REDIS_DB = 0 +# API 鉴权与用户(可选,不填则用默认值) +# API_AUTH_TOKEN_EXP = 86400 +# API_USER_TOKEN_EXP = 604800 +# API_USER_CACHE_EXPIRE = 604800 +# API_USER_ENCRYPT_KEY = dafuweng_api_user_cache_key_32 + # 验证码配置,支持cache|session CAPTCHA_MODE = cache LOGIN_CAPTCHA_ENABLE = false diff --git a/server/app/api/cache/UserCache.php b/server/app/api/cache/UserCache.php new file mode 100644 index 0000000..82e0170 --- /dev/null +++ b/server/app/api/cache/UserCache.php @@ -0,0 +1,114 @@ + 0, + 'plat' => 'api', + 'access_exp' => $exp, + ]); + + return $this->success([ + 'auth-token' => $tokenResult['access_token'], + 'expires_in' => $tokenResult['expires_in'], + ]); + } +} diff --git a/server/app/api/controller/UserController.php b/server/app/api/controller/UserController.php new file mode 100644 index 0000000..b8bbf68 --- /dev/null +++ b/server/app/api/controller/UserController.php @@ -0,0 +1,83 @@ +post('phone', ''); + $password = $request->post('password', ''); + if ($phone === '' || $password === '') { + return $this->fail('请填写手机号和密码'); + } + $logic = new UserLogic(); + $data = $logic->login($phone, $password); + return $this->success([ + 'user' => $data['user'], + 'user-token' => $data['user-token'], + 'user_id' => $data['user_id'], + ]); + } + + /** + * 注册 + * POST /api/user/register + * body: phone (+60), password, nickname(可选) + */ + public function register(Request $request): Response + { + $phone = $request->post('phone', ''); + $password = $request->post('password', ''); + $nickname = $request->post('nickname'); + if ($phone === '' || $password === '') { + return $this->fail('请填写手机号和密码'); + } + $logic = new UserLogic(); + $data = $logic->register($phone, $password, $nickname ? (string) $nickname : null); + return $this->success([ + 'user' => $data['user'], + 'user-token' => $data['user-token'], + 'user_id' => $data['user_id'], + ]); + } + + /** + * 退出登录 + * POST /api/user/logout + * header: user-token(或 Authorization: Bearer ) + * 将当前 user-token 加入黑名单,之后该 token 无法再用于获取 user_id + */ + public function logout(Request $request): Response + { + $token = $request->header('user-token'); + if (empty($token)) { + $auth = $request->header('authorization'); + if ($auth && stripos($auth, 'Bearer ') === 0) { + $token = trim(substr($auth, 7)); + } + } + if (empty($token)) { + return $this->fail('请携带 user-token'); + } + if (UserLogic::logout($token)) { + return $this->success('已退出登录'); + } + return $this->fail('退出失败或 token 已失效'); + } +} diff --git a/server/app/api/logic/UserLogic.php b/server/app/api/logic/UserLogic.php new file mode 100644 index 0000000..8f7ce4c --- /dev/null +++ b/server/app/api/logic/UserLogic.php @@ -0,0 +1,180 @@ +find(); + if (!$user) { + throw new ApiException('手机号未注册'); + } + if ((int) $user->status !== self::STATUS_NORMAL) { + throw new ApiException('账号已被禁用'); + } + $hashed = $this->hashPassword($password); + if ($user->password !== $hashed) { + throw new ApiException('密码错误'); + } + + $userArr = $user->hidden(['password'])->toArray(); + UserCache::setUser((int) $user->id, $userArr); + + $userToken = $this->generateUserToken((int) $user->id); + return [ + 'user' => $userArr, + 'user-token' => $userToken, + 'user_id' => (int) $user->id, + ]; + } + + /** + * 注册:手机号 + 密码(+60),创建玩家并返回用户信息与 user-token,写入 Redis + */ + public function register(string $phone, string $password, ?string $nickname = null): array + { + self::validatePhone($phone); + + if (strlen($password) < 6) { + throw new ApiException('密码至少 6 位'); + } + + $exists = DicePlayer::where('phone', $phone)->find(); + if ($exists) { + throw new ApiException('该手机号已注册'); + } + + $user = new DicePlayer(); + $user->phone = $phone; + $user->username = $phone; + if ($nickname !== null && $nickname !== '') { + $user->name = $nickname; + } + // name 未传时由 DicePlayer::onBeforeInsert 默认设为 uid + $user->password = $this->hashPassword($password); + $user->status = self::STATUS_NORMAL; + $user->save(); + + $userArr = $user->hidden(['password'])->toArray(); + UserCache::setUser((int) $user->id, $userArr); + + $userToken = $this->generateUserToken((int) $user->id); + return [ + 'user' => $userArr, + 'user-token' => $userToken, + 'user_id' => (int) $user->id, + ]; + } + + /** + * 与 DicePlayerLogic 一致的密码加密:md5(salt . password) + */ + private function hashPassword(string $password): string + { + return md5(self::PASSWORD_SALT . $password); + } + + /** + * 生成 user-token(JWT,plat=api_user,id=user_id) + */ + private function generateUserToken(int $userId): string + { + $exp = config('api.user_token_exp', 604800); + $result = JwtToken::generateToken([ + 'id' => $userId, + 'plat' => 'api_user', + 'access_exp' => $exp, + ]); + return $result['access_token']; + } + + /** + * 根据 user-token 获取 user_id(不写缓存,仅解析 JWT) + * 若 token 已通过退出接口加入黑名单,返回 null + */ + public static function getUserIdFromToken(string $userToken): ?int + { + if (UserCache::isTokenBlacklisted($userToken)) { + return null; + } + try { + $decoded = JwtToken::verify(1, $userToken); + $extend = $decoded['extend'] ?? []; + if (($extend['plat'] ?? '') !== 'api_user') { + return null; + } + $id = $extend['id'] ?? null; + return $id !== null ? (int) $id : null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * 退出登录:将当前 user-token 加入黑名单,使该 token 失效 + */ + public static function logout(string $userToken): bool + { + try { + $decoded = JwtToken::verify(1, $userToken); + $exp = (int) ($decoded['exp'] ?? 0); + $ttl = $exp > time() ? $exp - time() : 86400; + return UserCache::addTokenToBlacklist($userToken, $ttl); + } catch (\Throwable $e) { + return false; + } + } + + /** + * 从 Redis 获取用户信息(key = base64(user_id)),未命中则查 DicePlayer 并回写缓存 + */ + public static function getCachedUser(int $userId): array + { + $cached = UserCache::getUser($userId); + if (!empty($cached)) { + return $cached; + } + $user = DicePlayer::find($userId); + if (!$user) { + return []; + } + $arr = $user->hidden(['password'])->toArray(); + UserCache::setUser($userId, $arr); + return $arr; + } +} diff --git a/server/app/api/middleware/CheckApiAuthMiddleware.php b/server/app/api/middleware/CheckApiAuthMiddleware.php new file mode 100644 index 0000000..e2ddf66 --- /dev/null +++ b/server/app/api/middleware/CheckApiAuthMiddleware.php @@ -0,0 +1,74 @@ +path(), '/'); + if ($this->isWhitelist($path)) { + return $handler($request); + } + + $token = $request->header('auth-token'); + if (empty($token)) { + $auth = $request->header('authorization'); + if ($auth && stripos($auth, 'Bearer ') === 0) { + $token = trim(substr($auth, 7)); + } + } + if (empty($token)) { + throw new ApiException('缺少 auth-token,请先调用 /api/authToken 获取', 401); + } + + try { + // ACCESS_TOKEN = 1(JwtToken 内部私有常量) + $decoded = JwtToken::verify(1, $token); + $extend = $decoded['extend'] ?? []; + if (($extend['plat'] ?? '') !== 'api') { + throw new ApiException('auth-token 无效', 401); + } + } catch (JwtTokenExpiredException $e) { + Log::error('code=401, auth-token 已过期,请重新获取, 报错信息'. $e); + throw new ApiException('auth-token 已过期,请重新获取', 401); + } catch (JwtTokenException $e) { + Log::error('code=401, message=auth-token 无效, 报错信息'. $e); + throw new ApiException($e->getMessage() ?: 'auth-token 无效', 401); + } catch (\Throwable $e) { + Log::error('code=401, message=auth-token 校验失败, 报错信息'. $e); + throw new ApiException('auth-token 校验失败', 401); + } + + return $handler($request); + } + + private function isWhitelist(string $path): bool + { + foreach (self::WHITELIST as $prefix) { + if ($path === $prefix || str_starts_with($path, $prefix . '/')) { + return true; + } + } + return false; + } +} diff --git a/server/app/dice/model/player/DicePlayer.php b/server/app/dice/model/player/DicePlayer.php index 02cc1a1..d8d29d8 100644 --- a/server/app/dice/model/player/DicePlayer.php +++ b/server/app/dice/model/player/DicePlayer.php @@ -7,6 +7,7 @@ namespace app\dice\model\player; use plugin\saiadmin\basic\think\BaseModel; +use app\dice\model\lottery_config\DiceLotteryConfig; /** * 大富翁-玩家模型 @@ -16,6 +17,7 @@ use plugin\saiadmin\basic\think\BaseModel; * @property $id ID * @property $username 用户名 * @property $phone 手机 + * @property $uid uid * @property $name 昵称 * @property $password 密码 * @property $status 状态 @@ -47,6 +49,68 @@ class DicePlayer extends BaseModel */ protected $table = 'dice_player'; + /** + * 新增前:生成唯一 uid,昵称 name 默认使用 uid + * 用 try-catch 避免表尚未含 uid 时 getAttr/getData 抛 InvalidArgumentException + */ + public static function onBeforeInsert($model): void + { + parent::onBeforeInsert($model); + try { + $uid = $model->getAttr('uid'); + } catch (\Throwable $e) { + $uid = null; + } + if ($uid === null || $uid === '') { + $uid = self::generateUid(); + $model->setAttr('uid', $uid); + } + try { + $name = $model->getAttr('name'); + } catch (\Throwable $e) { + $name = null; + } + if ($name === null || $name === '') { + $model->setAttr('name', $uid); + } + // 彩金池权重默认取 type=0 的奖池配置 + self::setDefaultWeightsFromLotteryConfig($model); + } + + /** + * 从 DiceLotteryConfig type=0 取 t1_wight~t5_wight 作为玩家未设置时的默认值 + */ + protected static function setDefaultWeightsFromLotteryConfig(DicePlayer $model): void + { + $config = DiceLotteryConfig::where('type', 0)->find(); + if (!$config) { + return; + } + $fields = ['t1_wight', 't2_wight', 't3_wight', 't4_wight', 't5_wight']; + foreach ($fields as $field) { + try { + $val = $model->getAttr($field); + } catch (\Throwable $e) { + $val = null; + } + if ($val === null || $val === '') { + try { + $model->setAttr($field, $config->getAttr($field)); + } catch (\Throwable $e) { + // 忽略字段不存在 + } + } + } + } + + /** + * 生成唯一标识 uid(12 位十六进制) + */ + public static function generateUid(): string + { + return strtoupper(substr(bin2hex(random_bytes(6)), 0, 12)); + } + /** * 用户名 搜索 */ diff --git a/server/config/api.php b/server/config/api.php new file mode 100644 index 0000000..ddc2548 --- /dev/null +++ b/server/config/api.php @@ -0,0 +1,16 @@ + (int) env('API_AUTH_TOKEN_EXP', 86400), + // user-token 有效期(秒),默认 7 天 + 'user_token_exp' => (int) env('API_USER_TOKEN_EXP', 604800), + // 用户信息 Redis 缓存过期时间(秒),默认 7 天 + 'user_cache_expire' => (int) env('API_USER_CACHE_EXPIRE', 604800), + // 用户缓存 Redis key 前缀 + 'user_cache_prefix' => env('API_USER_CACHE_PREFIX', 'api:user:'), + // 用户信息加密密钥(用于 Redis 中 value 的加密),建议 32 位 + 'user_encrypt_key' => env('API_USER_ENCRYPT_KEY', 'dafuweng_api_user_cache_key_32'), +]; diff --git a/server/config/route.php b/server/config/route.php index a5064fc..a7f00e5 100644 --- a/server/config/route.php +++ b/server/config/route.php @@ -13,9 +13,15 @@ */ use Webman\Route; - - - +use app\api\middleware\CheckApiAuthMiddleware; + +// API 路由:需先调用 /api/authToken 获取 auth-token,请求时携带 header: auth-token 或 Authorization: Bearer +Route::group('/api', function () { + Route::any('/authToken', [app\api\controller\AuthTokenController::class, 'index']); + Route::post('/user/login', [app\api\controller\UserController::class, 'login']); + Route::post('/user/register', [app\api\controller\UserController::class, 'register']); + Route::post('/user/logout', [app\api\controller\UserController::class, 'logout']); +})->middleware([CheckApiAuthMiddleware::class]); diff --git a/server/config/think-cache.php b/server/config/think-cache.php index 3114dd0..e26cf48 100644 --- a/server/config/think-cache.php +++ b/server/config/think-cache.php @@ -1,7 +1,7 @@ env('CACHE_MODE', 'file'), + // 默认缓存驱动(API 用户缓存依赖 Redis,建议设为 redis 并配置 REDIS_*) + 'default' => env('CACHE_MODE', 'redis'), // 缓存连接方式配置 'stores' => [ // redis缓存