feat: 增强玩家 API,新增 locale 和时间字段,更新钱包 API 以支持可用余额计算,添加错误码与多语言支持
This commit is contained in:
@@ -7,21 +7,34 @@ use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 从请求头解析玩家身份,返回已落库的 {@see Player}。
|
||||
*
|
||||
* 两种模式(互斥优先级:先判断 dev,再走 JWT):
|
||||
* 1) 开发绕过:当 `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且运行环境为 `local` 或 **`testing`**(PHPUnit)时,
|
||||
* 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(**禁止在生产开启**)。
|
||||
* 2) 生产:使用 `MAIN_SITE_SSO_JWT_SECRET` 验签 JWT(默认 HS256),
|
||||
* 从 payload 读取 `site_code`、`site_player_id`(字段名可 env 覆盖)再查 players 表。
|
||||
* ## Dev bypass(开发绕过)与正式 JWT 的互斥关系
|
||||
*
|
||||
* 错误码约定(见 {@see ErrorCode} 玩家 SSO 段):
|
||||
* | 条件 | Authorization 示例 | 行为 |
|
||||
* | :--- | :--- | :--- |
|
||||
* | `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且 `APP_ENV` 为 `local` 或 `testing`,且 token 以 `dev:` 开头 | `Bearer dev:12` | **仅走 dev**:按 `players.id` 查库,不验 JWT,不要求配置 `MAIN_SITE_SSO_JWT_SECRET`。 |
|
||||
* | 同上环境且开关为 true,但 token **不是** `dev:` | `Bearer eyJ...` | **走正式 JWT**:验签、建档逻辑见下;`dev:` 前缀不会当作 JWT 解析。 |
|
||||
* | `dev_bypass` 为 false 或非 local/testing | `Bearer dev:12` | **不走 dev**:整段当作 JWT 解码,必然失败(8002)。 |
|
||||
* | 任意环境 | `Bearer eyJ...` | **走正式 JWT**:必须配置 `MAIN_SITE_SSO_JWT_SECRET`;验签成功后按 `site_code` + `site_player_id` **首次自动建档**(PRD),并刷新 `last_login_at`。 |
|
||||
*
|
||||
* ## 正式 JWT(主站 SSO)
|
||||
*
|
||||
* - 成功验签后:若库中无映射行则 `firstOrCreate`(默认币种 `lottery.default_currency`、status=active)。
|
||||
* - 并发首登可能触发唯一键冲突,会捕获后重查。
|
||||
* - 解析成功后校验 {@see Player::status}:非 active 时抛 {@see ErrorCode::PlayerAccountSuspended}(HTTP 403)。
|
||||
*
|
||||
* 错误码见 {@see ErrorCode} 玩家 SSO 段(8001–8005)。
|
||||
*/
|
||||
final class PlayerTokenResolver
|
||||
{
|
||||
/** players.status:与迁移注释一致 */
|
||||
private const PLAYER_STATUS_ACTIVE = 0;
|
||||
|
||||
public function resolve(Request $request): Player
|
||||
{
|
||||
$header = $request->header('Authorization', '');
|
||||
@@ -38,22 +51,33 @@ final class PlayerTokenResolver
|
||||
throw new PlayerAuthenticationException('Token 为空', ErrorCode::PlayerAuthorizationInvalid->value);
|
||||
}
|
||||
|
||||
// 本地 dev: 优先于 JWT,避免未配密钥时仍能测需登录接口
|
||||
// 仅当 dev bypass 开启且 token 形如 dev:{id} 时走开发分支;否则一律按 JWT 处理
|
||||
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
|
||||
return $this->resolveDevToken($token);
|
||||
$player = $this->resolveDevToken($token);
|
||||
} else {
|
||||
// 与 .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)',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
$player = $this->resolveJwt($token, $secret);
|
||||
}
|
||||
|
||||
// 与 .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)',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
$this->assertPlayerActive($player);
|
||||
|
||||
// 正式 JWT 已在 resolveJwt 内写入 last_login_at;dev 仅在此处写入
|
||||
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
|
||||
$player->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return $player->refresh();
|
||||
}
|
||||
|
||||
return $this->resolveJwt($token, $secret);
|
||||
return $player;
|
||||
}
|
||||
|
||||
private function devBypassAllowed(): bool
|
||||
@@ -102,16 +126,49 @@ final class PlayerTokenResolver
|
||||
throw new PlayerAuthenticationException('JWT 缺少站点或玩家标识', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
// 首期:库中必须先有该行;若需「首次进入自动建档」可在此处 firstOrCreate
|
||||
$player = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('site_player_id', $sitePlayerId)
|
||||
->first();
|
||||
$now = now();
|
||||
$defaults = [
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => (string) config('lottery.default_currency', 'NPR'),
|
||||
'status' => self::PLAYER_STATUS_ACTIVE,
|
||||
'last_login_at' => $now,
|
||||
];
|
||||
|
||||
if ($player === null) {
|
||||
throw new PlayerAuthenticationException('玩家未建档', ErrorCode::PlayerNotRegistered->value);
|
||||
try {
|
||||
$player = Player::query()->firstOrCreate(
|
||||
[
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => $sitePlayerId,
|
||||
],
|
||||
$defaults,
|
||||
);
|
||||
} catch (QueryException $e) {
|
||||
// 并发首登时可能重复插入唯一键,改为加载已有行
|
||||
$player = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('site_player_id', $sitePlayerId)
|
||||
->first();
|
||||
if ($player === null) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return $player;
|
||||
if (! $player->wasRecentlyCreated) {
|
||||
$player->forceFill(['last_login_at' => $now])->save();
|
||||
}
|
||||
|
||||
return $player->refresh();
|
||||
}
|
||||
|
||||
private function assertPlayerActive(Player $player): void
|
||||
{
|
||||
if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号已冻结或不可用',
|
||||
ErrorCode::PlayerAccountSuspended->value,
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user