feat: 添加 JWT 支持与开发环境配置,更新 API 路由与中间件

This commit is contained in:
2026-05-08 14:41:01 +08:00
parent bbf58cb076
commit 9f8080cefe
18 changed files with 383 additions and 14 deletions

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* 玩家端 Bearer 鉴权失败时抛出,由 EnsurePlayerApi 捕获并转为 JSON。
*
* @property-read int $lotteryCode 业务错误码(对齐 docs/04-领域字典 §10SSO 80008999
* @property-read int $httpStatus HTTP 状态码401 未授权、503 服务未配置等)
*/
final class PlayerAuthenticationException extends RuntimeException
{
public function __construct(
string $message,
public readonly int $lotteryCode,
public readonly int $httpStatus = 401,
) {
parent::__construct($message);
}
}

View File

@@ -6,7 +6,10 @@ use App\Http\Controllers\Controller;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/** 探路由用,上线前可删除或改为需登录 */
/**
* 无需登录(当前 admin 中间件为直通):确认 `/api/v1/admin` 前缀可达。
* 路由GET /api/v1/admin/ping
*/
class PingController extends Controller
{
public function __invoke(): JsonResponse

View File

@@ -10,6 +10,8 @@ class HealthController extends Controller
{
/**
* 健康检查Next / 网关探活。路径GET /api/v1/health
*
* 非调试环境不返回框架版本号,避免信息泄露。
*/
public function __invoke(): JsonResponse
{
@@ -19,7 +21,7 @@ class HealthController extends Controller
];
if (config('app.debug')) {
$payload['laravel'] = app()->version();
$payload['laravel'] = app()->version(); // 仅本地/调试
}
return ApiResponse::success($payload);

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api\V1\Player;
use App\Http\Controllers\Controller;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 鉴权自检:返回当前 Token 对应的玩家公开字段(不含密码)。
*
* 路由GET /api/v1/player/me ,需 middleware lottery.player。
*/
class MeController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$player = $request->lotteryPlayer();
// 理论上不会为 null路由已套 EnsurePlayerApi保留断言便于排查配置错误
abort_if($player === null, 500, 'lottery_player missing');
return ApiResponse::success([
'id' => $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'default_currency' => $player->default_currency,
'status' => $player->status,
]);
}
}

View File

@@ -6,7 +6,10 @@ use App\Http\Controllers\Controller;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/** 探路由用,上线前可删除或改为需登录 */
/**
* 无需登录:仅供网关/前端确认「玩家 API 前缀」可达。
* 路由GET /api/v1/player/ping
*/
class PingController extends Controller
{
public function __invoke(): JsonResponse

View File

@@ -7,7 +7,9 @@ use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 后台 API:后续在此校验管理员登录(如 Sanctum RBAC
* 后台 API 守卫:后续在此注入 Sanctumadmin_users与权限校验
*
* 当前为占位直通,勿在生产暴露敏感 admin 路由前长期保持空实现。
*/
class EnsureAdminApi
{

View File

@@ -2,17 +2,32 @@
namespace App\Http\Middleware;
use App\Exceptions\PlayerAuthenticationException;
use App\Services\PlayerTokenResolver;
use App\Support\ApiResponse;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 玩家端 API后续在此校验 SSO / Bearer Token并解析当前 players.id
* 玩家端受保护路由前置:解析 Authorization失败时直接返回 { code, msg, data },不进入控制器
*
* 成功后在 request 上挂 `lottery_player`,控制器内使用 `$request->lotteryPlayer()`
*(由 AppServiceProvider 注册的宏,返回 ?Player
*/
class EnsurePlayerApi
{
public function handle(Request $request, Closure $next): Response
{
try {
$player = app(PlayerTokenResolver::class)->resolve($request);
} catch (PlayerAuthenticationException $e) {
return ApiResponse::error($e->getMessage(), $e->lotteryCode, null, $e->httpStatus);
}
// 使用 attributes避免与 Laravel 内置 input 混淆
$request->attributes->set('lottery_player', $player);
return $next($request);
}
}

34
app/Models/Player.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 主站玩家在本地映射账号(表 players SSO JWT site_code + site_player_id 对应。
*/
class Player extends Model
{
protected $fillable = [
'site_code',
'site_player_id',
'username',
'nickname',
'default_currency',
'status',
'last_login_at',
];
protected function casts(): array
{
return [
'last_login_at' => 'datetime',
];
}
public function wallets(): HasMany
{
return $this->hasMany(PlayerWallet::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 彩票侧余额(表 player_wallets player + wallet_type + currency 唯一。
*/
class PlayerWallet extends Model
{
protected $fillable = [
'player_id',
'wallet_type',
'currency_code',
'balance',
'frozen_balance',
'status',
'version',
];
protected function casts(): array
{
return [
'balance' => 'integer',
'frozen_balance' => 'integer',
'version' => 'integer',
];
}
public function player(): BelongsTo
{
return $this->belongsTo(Player::class);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\Player;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +21,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
// 仅在通过 EnsurePlayerApi 后可用;未走中间件时为 null
Request::macro('lotteryPlayer', function (): ?Player {
/** @var Request $this */
return $this->attributes->get('lottery_player');
});
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Services;
use App\Exceptions\PlayerAuthenticationException;
use App\Models\Player;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Http\Request;
/**
* 从请求头解析玩家身份,返回已落库的 {@see Player}
*
* 两种模式(互斥优先级:先判断 dev再走 JWT
* 1) 开发绕过:仅当 `APP_ENV=local` `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true`
* 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(勿上生产)。
* 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
*/
final class PlayerTokenResolver
{
public function resolve(Request $request): Player
{
$header = $request->header('Authorization', '');
if (! is_string($header) || ! str_starts_with($header, 'Bearer ')) {
throw new PlayerAuthenticationException('缺少或非法 Authorization', 8001);
}
// 标准:`Authorization: Bearer <token>`,此处去掉前缀 7 字节 "Bearer "
$token = trim(substr($header, 7));
if ($token === '') {
throw new PlayerAuthenticationException('Token 为空', 8001);
}
// 本地 dev: 优先于 JWT避免未配密钥时仍能测需登录接口
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
return $this->resolveDevToken($token);
}
// 与 .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);
}
return $this->resolveJwt($token, $secret);
}
private function devBypassAllowed(): bool
{
return (bool) config('lottery.player_auth.dev_bypass')
&& app()->environment('local');
}
private function resolveDevToken(string $token): Player
{
if (! preg_match('/^dev:(\d+)$/', $token, $m)) {
throw new PlayerAuthenticationException('开发 Token 格式应为 dev:{玩家ID}', 8002);
}
$player = Player::query()->find((int) $m[1]);
if ($player === null) {
throw new PlayerAuthenticationException('玩家不存在', 8003);
}
return $player;
}
private function resolveJwt(string $jwt, string $secret): Player
{
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
try {
/** @var object $claims */
$claims = JWT::decode($jwt, new Key($secret, $alg));
} catch (\Throwable $e) {
// 签名错误、exp 过期、格式损坏等均归 8002
throw new PlayerAuthenticationException('Token 无效或已过期', 8002);
}
// 与主站约定 JWT 里字段名;若主站用 sub/iss 等可改 env LOTTERY_JWT_CLAIM_*
$siteKey = (string) config('lottery.player_auth.jwt.claim_site_code', 'site_code');
$pidKey = (string) config('lottery.player_auth.jwt.claim_site_player_id', 'site_player_id');
$siteCode = data_get($claims, $siteKey);
$sitePlayerId = data_get($claims, $pidKey);
if (! is_string($siteCode) || $siteCode === '' || ! is_string($sitePlayerId) || $sitePlayerId === '') {
throw new PlayerAuthenticationException('JWT 缺少站点或玩家标识', 8002);
}
// 首期:库中必须先有该行;若需「首次进入自动建档」可在此处 firstOrCreate
$player = Player::query()
->where('site_code', $siteCode)
->where('site_player_id', $sitePlayerId)
->first();
if ($player === null) {
throw new PlayerAuthenticationException('玩家未建档', 8003);
}
return $player;
}
}

View File

@@ -5,7 +5,10 @@ namespace App\Support;
use Illuminate\Http\JsonResponse;
/**
* PRD / docs/04-领域字典与编码规范.md 对齐{ code, msg, data }
* 对外 API 统一 JSON 结构{ code, msg, data }
*
* - code=0 表示成功;非 0 为业务码(见 docs/04-领域字典与编码规范.md
* - error() HTTP 状态可与 code 独立(如鉴权失败 401 + code 8001)。
*/
final class ApiResponse
{