feat: 增强管理员 API 鉴权,新增 token 有效天数配置,更新相关异常处理与错误码引用

This commit is contained in:
2026-05-09 11:26:39 +08:00
parent 8a70c029f6
commit f1b38ef421
13 changed files with 124 additions and 42 deletions

View File

@@ -2,12 +2,13 @@
namespace App\Exceptions;
use App\Lottery\ErrorCode;
use RuntimeException;
/**
* 玩家端 Bearer 鉴权失败时抛出,由 EnsurePlayerApi 捕获并转为 JSON。
*
* @property-read int $lotteryCode 业务错误码(对齐 docs/04-领域字典 §10SSO 80008999
* @property-read int $lotteryCode 业务错误码({@see ErrorCode}
* @property-read int $httpStatus HTTP 状态码401 未授权、503 服务未配置等)
*/
final class PlayerAuthenticationException extends RuntimeException

View File

@@ -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();

View File

@@ -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
// 币种码:字母数字,长度 116与 migrations 字段一致
if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) {
// 业务码占用 10001999 钱包段1003 已在 PRD 保留为「金额超出限制」,币种非法单用 1005
return ApiResponse::error(
__('wallet.invalid_currency'),
1005,
ErrorCode::WalletInvalidCurrency->value,
null,
400,
);

View File

@@ -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() 按请求语言翻译
* (依赖前置的 NegotiateLotteryLocalecode 仍为文档约定业务码(如 8001
* (依赖前置的 NegotiateLotteryLocalecode {@see ErrorCode} 中玩家鉴权段
*
* PlayerAuthenticationException getMessage() 仅作开发与日志用语,可与 API msg 语种不一致。
*/

View File

@@ -3,35 +3,87 @@
namespace App\Lottery;
/**
* HTTP JSON `ApiResponse` 对齐的业务码常量(对齐 docs/04 §10
* SSO 等特殊场景仍由各模块直接写整数(如 EnsurePlayerApi 使用的 8001)。
* HTTP JSON 业务码 `code` `ApiResponse`docs/04 §10 对齐)。
*
* 区间约定0 成功10001999 钱包20002999 下注80008999 SSO/权限90009999 系统。
* 新增错误时在此登记,业务代码引用本枚举,勿散落魔法数字。
*/
enum ErrorCode: int
{
/** 管理端 API未登录或 Token 无效 */
/* ========== 成功 ========== */
/** 业务成功(与 `ApiResponse::success` 默认一致) */
case Success = 0;
/* ========== 10001999 钱包 / 转账 ========== */
/** PRD余额不足 */
case WalletInsufficientBalance = 1001;
/** PRD处理中转账 */
case WalletTransferPending = 1002;
/** PRD金额超出限制 */
case WalletAmountExceedsLimit = 1003;
/**
* PRD钱包查询等场景下请求参数无效当前用于 `currency` 非法(与 1003 语义区分)。
*/
case WalletInvalidCurrency = 1005;
/* ========== 20002999 下注 / 注单PRD 保留,业务未实现时亦可提前登记) ========== */
/** PRD当期已封盘 */
case DrawClosed = 2001;
/** PRD玩法已关闭 */
case PlayModeClosed = 2002;
/** PRD下注语境余额不足可与 1001 同语义) */
case BetInsufficientBalance = 2003;
/* ========== 80008999 玩家 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;
/* ========== 81008199 管理端 API ========== */
/** 未登录或 Token 无效 */
case AdminUnauthenticated = 8110;
/** 管理端登录:验证码错误或过期 */
/** 登录:验证码错误或过期 */
case AdminCaptchaInvalid = 8111;
/** 管理端登录:账号或密码不匹配(对外统一措辞) */
/** 登录:账号或密码不匹配(对外统一措辞) */
case AdminCredentialsInvalid = 8112;
/** 管理端登录:账号已禁用 */
/** 登录:账号已禁用 */
case AdminAccountDisabled = 8113;
/** 表单 / Query 校验失败(见 ValidationException → 422 */
/* ========== 90009999 系统 / 框架 ========== */
/** 表单或 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;
}

View File

@@ -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 格式不对
* - 8002JWT 无效、过期或缺少站点/玩家字段
* - 8003:数据库无对应玩家(未建档)
* - 8004:未配置 MAIN_SITE_SSO_JWT_SECRETHTTP 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 <token>`,此处去掉前缀 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;

View File

@@ -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

View File

@@ -2,13 +2,14 @@
namespace App\Support;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
/**
* 【业务文案翻译辅助类】
*
* lang/{locale}/sso.php 等语言包取字符串,供 JSON 里「msg」字段使用。
* 当前实现:玩家 SSO / Bearer 鉴权相关错误码80018004 docs/04 错误码段
* `App\Lottery\ErrorCode` 中玩家 SSO 80018004的各语言 `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 80018004
*/
public static function sso(Request $request, int $code): string
{