From f1b38ef421c8af92ec226a59143dbfd516e5a8b0 Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 9 May 2026 11:26:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=20API=20=E9=89=B4=E6=9D=83=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20token=20=E6=9C=89=E6=95=88=E5=A4=A9=E6=95=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=90=86=E4=B8=8E=E9=94=99=E8=AF=AF=E7=A0=81?= =?UTF-8?q?=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../PlayerAuthenticationException.php | 3 +- .../Api/V1/Admin/Auth/LoginController.php | 7 +- .../Api/V1/Wallet/WalletBalanceController.php | 6 +- app/Http/Middleware/EnsurePlayerApi.php | 3 +- app/Lottery/ErrorCode.php | 76 ++++++++++++++++--- app/Services/PlayerTokenResolver.php | 33 ++++---- app/Support/ApiResponse.php | 2 +- app/Support/LotteryMessage.php | 5 +- config/lottery.php | 9 +++ tests/Feature/AdminAuthLoginTest.php | 4 +- tests/Feature/PlayerFoundationTest.php | 10 ++- tests/Feature/WalletBalanceTest.php | 5 +- 13 files changed, 124 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index b91dd0d..d126ce0 100644 --- a/.env.example +++ b/.env.example @@ -186,6 +186,9 @@ LOTTERY_JWT_CLAIM_SITE_CODE=site_code # JWT 内表示主站玩家标识的 claim 名 LOTTERY_JWT_CLAIM_SITE_PLAYER_ID=site_player_id +# 管理端登录:Sanctum PAT 有效天数(签发时刻起),至少 1;到期需重新登录 +ADMIN_API_TOKEN_TTL_DAYS=7 + # 主站站点根 URL(SSO、跳转等) MAIN_SITE_BASE_URL= # 主站 JWT 验签密钥(与主站约定,勿泄露) diff --git a/app/Exceptions/PlayerAuthenticationException.php b/app/Exceptions/PlayerAuthenticationException.php index 1906291..6483450 100644 --- a/app/Exceptions/PlayerAuthenticationException.php +++ b/app/Exceptions/PlayerAuthenticationException.php @@ -2,12 +2,13 @@ namespace App\Exceptions; +use App\Lottery\ErrorCode; use RuntimeException; /** * 玩家端 Bearer 鉴权失败时抛出,由 EnsurePlayerApi 捕获并转为 JSON。 * - * @property-read int $lotteryCode 业务错误码(对齐 docs/04-领域字典 §10,SSO 段 8000–8999) + * @property-read int $lotteryCode 业务错误码({@see ErrorCode}) * @property-read int $httpStatus HTTP 状态码(401 未授权、503 服务未配置等) */ final class PlayerAuthenticationException extends RuntimeException diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php index 9aa378d..418aa59 100644 --- a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php +++ b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php @@ -70,7 +70,12 @@ final class LoginController ); } - $plainToken = $admin->createToken('admin-api')->plainTextToken; + $ttlDays = (int) config('lottery.admin_api.token_ttl_days', 7); + $plainToken = $admin->createToken( + 'admin-api', + ['*'], + now()->addDays(max(1, $ttlDays)), + )->plainTextToken; $admin->forceFill(['last_login_at' => now()])->save(); diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php index 98a35cb..b469ffc 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Wallet; use App\Http\Controllers\Controller; +use App\Lottery\ErrorCode; use App\Models\Player; use App\Models\PlayerWallet; use App\Support\ApiResponse; @@ -58,7 +59,7 @@ class WalletBalanceController extends Controller } /** - * @return string|JsonResponse 合法币种码或错误响应(code 1003:参数非法) + * @return string|JsonResponse 合法币种码或错误响应({@see ErrorCode::WalletInvalidCurrency}) */ private function resolveCurrencyCode(Request $request, Player $player): string|JsonResponse { @@ -72,10 +73,9 @@ class WalletBalanceController extends Controller // 币种码:字母数字,长度 1–16,与 migrations 字段一致 if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) { - // 业务码占用 1000–1999 钱包段;1003 已在 PRD 保留为「金额超出限制」,币种非法单用 1005 return ApiResponse::error( __('wallet.invalid_currency'), - 1005, + ErrorCode::WalletInvalidCurrency->value, null, 400, ); diff --git a/app/Http/Middleware/EnsurePlayerApi.php b/app/Http/Middleware/EnsurePlayerApi.php index be84e61..09fdb84 100644 --- a/app/Http/Middleware/EnsurePlayerApi.php +++ b/app/Http/Middleware/EnsurePlayerApi.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Exceptions\PlayerAuthenticationException; +use App\Lottery\ErrorCode; use App\Services\PlayerTokenResolver; use App\Support\ApiResponse; use App\Support\LotteryMessage; @@ -15,7 +16,7 @@ use Symfony\Component\HttpFoundation\Response; * * - 成功:解析 Bearer → Player,写入 request attribute `lottery_player`。 * - 失败:直接 JSON 返回,不进入控制器;其中 msg 经由 LotteryMessage::sso() 按请求语言翻译 - * (依赖前置的 NegotiateLotteryLocale),code 仍为文档约定业务码(如 8001)。 + * (依赖前置的 NegotiateLotteryLocale),code 为 {@see ErrorCode} 中玩家鉴权段。 * * PlayerAuthenticationException 的 getMessage() 仅作开发与日志用语,可与 API msg 语种不一致。 */ diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index 856cbf8..baacc3d 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -3,35 +3,87 @@ namespace App\Lottery; /** - * HTTP JSON 中与 `ApiResponse` 对齐的业务码常量(对齐 docs/04 §10)。 - * SSO 等特殊场景仍由各模块直接写整数(如 EnsurePlayerApi 使用的 8001)。 + * HTTP JSON 业务码 `code`(与 `ApiResponse`、docs/04 §10 对齐)。 + * + * 区间约定:0 成功;1000–1999 钱包;2000–2999 下注;8000–8999 SSO/权限;9000–9999 系统。 + * 新增错误时在此登记,业务代码引用本枚举,勿散落魔法数字。 */ enum ErrorCode: int { - /** 管理端 API:未登录或 Token 无效 */ + /* ========== 成功 ========== */ + + /** 业务成功(与 `ApiResponse::success` 默认一致) */ + case Success = 0; + + /* ========== 1000–1999 钱包 / 转账 ========== */ + + /** PRD:余额不足 */ + case WalletInsufficientBalance = 1001; + + /** PRD:处理中(转账) */ + case WalletTransferPending = 1002; + + /** PRD:金额超出限制 */ + case WalletAmountExceedsLimit = 1003; + + /** + * PRD:钱包查询等场景下请求参数无效;当前用于 `currency` 非法(与 1003 语义区分)。 + */ + case WalletInvalidCurrency = 1005; + + /* ========== 2000–2999 下注 / 注单(PRD 保留,业务未实现时亦可提前登记) ========== */ + + /** PRD:当期已封盘 */ + case DrawClosed = 2001; + + /** PRD:玩法已关闭 */ + case PlayModeClosed = 2002; + + /** PRD:下注语境余额不足(可与 1001 同语义) */ + case BetInsufficientBalance = 2003; + + /* ========== 8000–8999 玩家 SSO / Bearer 鉴权 ========== */ + + /** 无 Bearer / 格式错误 / token 为空 */ + case PlayerAuthorizationInvalid = 8001; + + /** JWT 无效或过期、dev: 格式错误、缺少站点或玩家标识等 */ + case PlayerTokenInvalid = 8002; + + /** 库中无对应玩家(未建档) */ + case PlayerNotRegistered = 8003; + + /** 未配置 `MAIN_SITE_SSO_JWT_SECRET`(通常 HTTP 503) */ + case PlayerSsoSecretNotConfigured = 8004; + + /* ========== 8100–8199 管理端 API ========== */ + + /** 未登录或 Token 无效 */ case AdminUnauthenticated = 8110; - /** 管理端登录:验证码错误或过期 */ + /** 登录:验证码错误或过期 */ case AdminCaptchaInvalid = 8111; - /** 管理端登录:账号或密码不匹配(对外统一措辞) */ + /** 登录:账号或密码不匹配(对外统一措辞) */ case AdminCredentialsInvalid = 8112; - /** 管理端登录:账号已禁用 */ + /** 登录:账号已禁用 */ case AdminAccountDisabled = 8113; - /** 表单 / Query 校验失败(见 ValidationException → 422) */ + /* ========== 9000–9999 系统 / 框架 ========== */ + + /** 表单或 Query 校验失败(ValidationException → 422) */ case ValidationFailed = 9001; - /** 资源或路由不存在 */ + /** 模型或路由不存在 */ case NotFound = 9004; + /** `abort(4xx)` 等客户端类 Http 异常归类 */ + case ClientHttpError = 9010; + /** 请求过于频繁 */ case TooManyRequests = 9031; - /** `abort(4xx)` 等客户端类 Http 异常(HTTP 状态码见响应头;本码作业务归类) */ - case ClientHttpError = 9010; - - /** 未分类服务端异常(生产环境不向客户端暴露细节) */ + /** 未分类服务端异常 */ case InternalError = 9999; } diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 4d7ded4..c0f1367 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Exceptions\PlayerAuthenticationException; +use App\Lottery\ErrorCode; use App\Models\Player; use Firebase\JWT\JWT; use Firebase\JWT\Key; @@ -17,11 +18,7 @@ use Illuminate\Http\Request; * 2) 生产:使用 `MAIN_SITE_SSO_JWT_SECRET` 验签 JWT(默认 HS256), * 从 payload 读取 `site_code`、`site_player_id`(字段名可 env 覆盖)再查 players 表。 * - * 错误码约定(见中间件返回 JSON 的 code): - * - 8001:无 Bearer / Token 空 / Header 格式不对 - * - 8002:JWT 无效、过期或缺少站点/玩家字段 - * - 8003:数据库无对应玩家(未建档) - * - 8004:未配置 MAIN_SITE_SSO_JWT_SECRET(HTTP 503) + * 错误码约定(见 {@see ErrorCode} 玩家 SSO 段): */ final class PlayerTokenResolver { @@ -29,13 +26,16 @@ final class PlayerTokenResolver { $header = $request->header('Authorization', ''); if (! is_string($header) || ! str_starts_with($header, 'Bearer ')) { - throw new PlayerAuthenticationException('缺少或非法 Authorization', 8001); + throw new PlayerAuthenticationException( + '缺少或非法 Authorization', + ErrorCode::PlayerAuthorizationInvalid->value, + ); } // 标准:`Authorization: Bearer `,此处去掉前缀 7 字节 "Bearer " $token = trim(substr($header, 7)); if ($token === '') { - throw new PlayerAuthenticationException('Token 为空', 8001); + throw new PlayerAuthenticationException('Token 为空', ErrorCode::PlayerAuthorizationInvalid->value); } // 本地 dev: 优先于 JWT,避免未配密钥时仍能测需登录接口 @@ -46,7 +46,11 @@ final class PlayerTokenResolver // 与 .env 中 MAIN_SITE_SSO_JWT_SECRET 一致,用于 firebase/php-jwt 验签 $secret = config('lottery.main_site.sso_jwt_secret'); if (! is_string($secret) || $secret === '') { - throw new PlayerAuthenticationException('SSO 未配置(MAIN_SITE_SSO_JWT_SECRET)', 8004, 503); + throw new PlayerAuthenticationException( + 'SSO 未配置(MAIN_SITE_SSO_JWT_SECRET)', + ErrorCode::PlayerSsoSecretNotConfigured->value, + 503, + ); } return $this->resolveJwt($token, $secret); @@ -61,12 +65,15 @@ final class PlayerTokenResolver private function resolveDevToken(string $token): Player { if (! preg_match('/^dev:(\d+)$/', $token, $m)) { - throw new PlayerAuthenticationException('开发 Token 格式应为 dev:{玩家ID}', 8002); + throw new PlayerAuthenticationException( + '开发 Token 格式应为 dev:{玩家ID}', + ErrorCode::PlayerTokenInvalid->value, + ); } $player = Player::query()->find((int) $m[1]); if ($player === null) { - throw new PlayerAuthenticationException('玩家不存在', 8003); + throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value); } return $player; @@ -81,7 +88,7 @@ final class PlayerTokenResolver $claims = JWT::decode($jwt, new Key($secret, $alg)); } catch (\Throwable $e) { // 签名错误、exp 过期、格式损坏等均归 8002 - throw new PlayerAuthenticationException('Token 无效或已过期', 8002); + throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value); } // 与主站约定 JWT 里字段名;若主站用 sub/iss 等可改 env LOTTERY_JWT_CLAIM_* @@ -92,7 +99,7 @@ final class PlayerTokenResolver $sitePlayerId = data_get($claims, $pidKey); if (! is_string($siteCode) || $siteCode === '' || ! is_string($sitePlayerId) || $sitePlayerId === '') { - throw new PlayerAuthenticationException('JWT 缺少站点或玩家标识', 8002); + throw new PlayerAuthenticationException('JWT 缺少站点或玩家标识', ErrorCode::PlayerTokenInvalid->value); } // 首期:库中必须先有该行;若需「首次进入自动建档」可在此处 firstOrCreate @@ -102,7 +109,7 @@ final class PlayerTokenResolver ->first(); if ($player === null) { - throw new PlayerAuthenticationException('玩家未建档', 8003); + throw new PlayerAuthenticationException('玩家未建档', ErrorCode::PlayerNotRegistered->value); } return $player; diff --git a/app/Support/ApiResponse.php b/app/Support/ApiResponse.php index 4d5f2e1..47c7a3e 100644 --- a/app/Support/ApiResponse.php +++ b/app/Support/ApiResponse.php @@ -7,7 +7,7 @@ use Illuminate\Http\JsonResponse; /** * 对外 API 统一 JSON 结构:{ code, msg, data }。 * - * - code=0 表示成功;非 0 为业务码(见 docs/04-领域字典与编码规范.md)。 + * - `code=0` 即 {@see \App\Lottery\ErrorCode::Success};非 0 见 `ErrorCode` 与 docs/04 §10。 * - error() 的 HTTP 状态可与 code 独立(如鉴权失败 401 + code 8001)。 */ final class ApiResponse diff --git a/app/Support/LotteryMessage.php b/app/Support/LotteryMessage.php index b198f17..468f6ef 100644 --- a/app/Support/LotteryMessage.php +++ b/app/Support/LotteryMessage.php @@ -2,13 +2,14 @@ namespace App\Support; +use App\Lottery\ErrorCode; use Illuminate\Http\Request; /** * 【业务文案翻译辅助类】 * * 从 lang/{locale}/sso.php 等语言包取字符串,供 JSON 里「msg」字段使用。 - * 当前实现:玩家 SSO / Bearer 鉴权相关错误码(8001–8004),见 docs/04 错误码段。 + * `App\Lottery\ErrorCode` 中玩家 SSO 段(8001–8004)的各语言 `msg`。 * * 依赖 NegotiateLotteryLocale 已写入 lottery_locale;若未写则使用 fallback 语言包。 */ @@ -17,7 +18,7 @@ final class LotteryMessage /** * 取 SSO 鉴权类错误的用户可见文案(与 ApiResponse 的 msg 对应)。 * - * @param int $code 业务错误码,如 8001 + * @param int $code {@see ErrorCode} 玩家 SSO 段(8001–8004) */ public static function sso(Request $request, int $code): string { diff --git a/config/lottery.php b/config/lottery.php index 53a3851..bb89922 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -48,4 +48,13 @@ return [ ], ], + /* + | admin_api:Sanctum Personal Access Token(auth:sanctum + lottery.admin) + | + | token_ttl_days:签发时刻起有效日历天数,到期后 Laravel 拒绝该 token,需重新登录。 + */ + 'admin_api' => [ + 'token_ttl_days' => max(1, (int) env('ADMIN_API_TOKEN_TTL_DAYS', 7)), + ], + ]; diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 0d9e0e1..0572238 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -34,7 +34,7 @@ test('admin login returns bearer token when captcha passes validation', function ]); $resp->assertOk() - ->assertJsonPath('code', 0) + ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.admin.username', 'tester') ->assertJsonPath('data.admin.nickname', '测试昵称') ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email']]]); @@ -52,7 +52,7 @@ test('admin captcha exposes key and image base64', function () { $resp = $this->getJson('/api/v1/admin/auth/captcha'); $resp->assertOk() - ->assertJsonPath('code', 0); + ->assertJsonPath('code', ErrorCode::Success->value); $data = $resp->json('data'); expect($data)->toBeArray() diff --git a/tests/Feature/PlayerFoundationTest.php b/tests/Feature/PlayerFoundationTest.php index 1822ee8..4269800 100644 --- a/tests/Feature/PlayerFoundationTest.php +++ b/tests/Feature/PlayerFoundationTest.php @@ -1,5 +1,6 @@ withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/player/me') ->assertOk() - ->assertJsonPath('code', 0) + ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.id', $player->id) ->assertJsonPath('data.site_player_id', 'uid-42') ->assertJsonPath('data.username', 'alice'); }); test('player auth missing bearer returns localized sso 8001', function () { + $code = ErrorCode::PlayerAuthorizationInvalid->value; $this->withHeader('Accept-Language', 'zh-CN,zh;q=0.9') ->getJson('/api/v1/player/me') ->assertStatus(Response::HTTP_UNAUTHORIZED) - ->assertJsonPath('code', 8001) - ->assertJsonPath('msg', __('sso.8001', [], 'zh')); + ->assertJsonPath('code', $code) + ->assertJsonPath('msg', __("sso.$code", [], 'zh')); }); test('api unknown route returns unified not_found json without hitting locale middleware', function () { $this->withHeader('X-Locale', 'zh') ->getJson('/api/v1/player/__no_route__xxx') ->assertStatus(Response::HTTP_NOT_FOUND) - ->assertJsonPath('code', 9004) + ->assertJsonPath('code', ErrorCode::NotFound->value) ->assertJsonPath('msg', __('api.not_found', [], 'zh')); }); diff --git a/tests/Feature/WalletBalanceTest.php b/tests/Feature/WalletBalanceTest.php index 8e7769e..e0eaf41 100644 --- a/tests/Feature/WalletBalanceTest.php +++ b/tests/Feature/WalletBalanceTest.php @@ -1,5 +1,6 @@ getJson('/api/v1/wallet/balance'); $response->assertOk() - ->assertJsonPath('code', 0) + ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.balance', 0) ->assertJsonPath('data.frozen_balance', 0) ->assertJsonPath('data.currency_code', 'NPR') @@ -45,5 +46,5 @@ test('wallet balance rejects illegal currency query', function () { $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/wallet/balance?currency=!!') ->assertStatus(400) - ->assertJsonPath('code', 1005); + ->assertJsonPath('code', ErrorCode::WalletInvalidCurrency->value); });