diff --git a/.env.example b/.env.example index 2c9fef1..b91dd0d 100644 --- a/.env.example +++ b/.env.example @@ -176,7 +176,7 @@ VITE_APP_NAME="${APP_NAME}" LOTTERY_DEFAULT_CURRENCY=NPR # lottery_settings 表读缓存 TTL(秒);调小更易立即看到后台改值,调大减库压 LOTTERY_SETTINGS_CACHE_TTL=60 -# 本地开发:Authorization: Bearer dev:{players.id};仅 APP_ENV=local 且为 true 时生效,生产务必 false +# 开发绕过:Authorization: Bearer dev:{players.id};仅当 APP_ENV 为 local 或 testing 且为 true 时生效(PHPUnit 依赖 testing),生产务必 false LOTTERY_PLAYER_AUTH_DEV_BYPASS=false # 校验主站 JWT 的算法(与签发方一致) diff --git a/app/Http/Middleware/NegotiateLotteryLocale.php b/app/Http/Middleware/NegotiateLotteryLocale.php index 1c65f4f..2588243 100644 --- a/app/Http/Middleware/NegotiateLotteryLocale.php +++ b/app/Http/Middleware/NegotiateLotteryLocale.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Support\LotteryLocale; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,62 +17,18 @@ use Symfony\Component\HttpFoundation\Response; * 【协商顺序】① 请求头 X-Locale(必须为 config 支持的 zh/en/ne 之一);② Accept-Language 首选项;③ fallback。 * * 【与前端】浏览器或 App 可直接带 X-Locale,或仅靠 Accept-Language;无需前端维护文案 JSON。 + * + * 【与异常 JSON】{@see LotteryLocale} 与 middleware 同源,在未命中路由、`lottery_locale` 未写入时仍可从 Header 推导。 */ class NegotiateLotteryLocale { public function handle(Request $request, Closure $next): Response { - $locale = $this->resolveLocale($request); + $locale = LotteryLocale::resolve($request); // attribute 名称固定为 lottery_locale,与 Request::lotteryLocale() 宏一致 $request->attributes->set('lottery_locale', $locale); app()->setLocale($locale); return $next($request); } - - /** 综合 X-Locale 与 Accept-Language,得到最终语言代码 */ - private function resolveLocale(Request $request): string - { - /** @var list $supported */ - $supported = array_values(array_unique(config('lottery.locales.supported', ['en', 'zh', 'ne']))); - $fallback = (string) config('lottery.locales.fallback', 'en'); - - $header = strtolower(trim((string) $request->header('X-Locale', ''))); - if ($header !== '' && in_array($header, $supported, true)) { - return $header; - } - - return $this->fromAcceptLanguage((string) $request->header('Accept-Language', ''), $supported, $fallback); - } - - /** - * 解析标准 Accept-Language(可带权重 q=),只认主语言段 zh / en / ne。 - * - * @param list $supported config('lottery.locales.supported') - */ - private function fromAcceptLanguage(string $header, array $supported, string $fallback): string - { - if ($header === '') { - return $fallback; - } - - foreach (explode(',', $header) as $part) { - $code = strtolower(trim(explode(';', trim($part), 2)[0] ?? '')); - if ($code === '') { - continue; - } - $primary = explode('-', $code, 2)[0] ?? $code; - if ($primary === 'zh' && in_array('zh', $supported, true)) { - return 'zh'; - } - if ($primary === 'ne' && in_array('ne', $supported, true)) { - return 'ne'; - } - if ($primary === 'en' && in_array('en', $supported, true)) { - return 'en'; - } - } - - return $fallback; - } } diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php new file mode 100644 index 0000000..c117725 --- /dev/null +++ b/app/Lottery/ErrorCode.php @@ -0,0 +1,25 @@ + $supported */ + $supported = array_values(array_unique(config('lottery.locales.supported', ['en', 'zh', 'ne']))); + $fallback = (string) config('lottery.locales.fallback', 'en'); + + $header = strtolower(trim((string) $request->header('X-Locale', ''))); + if ($header !== '' && in_array($header, $supported, true)) { + return $header; + } + + return self::fromAcceptLanguage((string) $request->header('Accept-Language', ''), $supported, $fallback); + } + + /** @param list $supported */ + private static function fromAcceptLanguage(string $header, array $supported, string $fallback): string + { + if ($header === '') { + return $fallback; + } + + foreach (explode(',', $header) as $part) { + $code = strtolower(trim(explode(';', trim($part), 2)[0] ?? '')); + if ($code === '') { + continue; + } + $primary = explode('-', $code, 2)[0] ?? $code; + if ($primary === 'zh' && in_array('zh', $supported, true)) { + return 'zh'; + } + if ($primary === 'ne' && in_array('ne', $supported, true)) { + return 'ne'; + } + if ($primary === 'en' && in_array('en', $supported, true)) { + return 'en'; + } + } + + return $fallback; + } +} diff --git a/app/Support/LotteryMessage.php b/app/Support/LotteryMessage.php index 4a836c8..b198f17 100644 --- a/app/Support/LotteryMessage.php +++ b/app/Support/LotteryMessage.php @@ -22,7 +22,7 @@ final class LotteryMessage public static function sso(Request $request, int $code): string { $fallback = (string) config('lottery.locales.fallback', 'en'); - $locale = (string) ($request->attributes->get('lottery_locale') ?? $fallback); + $locale = (string) ($request->attributes->get('lottery_locale') ?? LotteryLocale::resolve($request)); $key = 'sso.'.$code; // 对应 lang/{locale}/sso.php 内键名,如 '8001' $msg = trans($key, [], $locale); diff --git a/bootstrap/app.php b/bootstrap/app.php index f98c50d..b38605f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,9 +9,21 @@ |-------------------------------------------------------------------------- */ +use App\Http\Middleware\EnsureAdminApi; +use App\Http\Middleware\EnsurePlayerApi; +use App\Http\Middleware\NegotiateLotteryLocale; +use App\Lottery\ErrorCode; +use App\Support\ApiResponse; +use App\Support\LotteryLocale; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -24,15 +36,115 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { // 多语言:必须在其他 api 中间件之前执行,以便鉴权失败时也能按语言返回 msg $middleware->api(prepend: [ - \App\Http\Middleware\NegotiateLotteryLocale::class, + NegotiateLotteryLocale::class, ]); $middleware->alias([ // 玩家端需登录路由使用;解析 Bearer → Player - 'lottery.player' => \App\Http\Middleware\EnsurePlayerApi::class, + 'lottery.player' => EnsurePlayerApi::class, // 后台 API 预留:Sanctum / RBAC - 'lottery.admin' => \App\Http\Middleware\EnsureAdminApi::class, + 'lottery.admin' => EnsureAdminApi::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + /** + * 统一 JSON:与 {@see ApiResponse} 一致,供三端消费;多语言与 {@see LotteryLocale} 对齐。 + * 覆盖:校验失败、模型/路由 404、限流、未处理异常(生产不泄露堆栈文案)。 + */ + $locale = static function (Request $request): string { + return (string) ($request->attributes->get('lottery_locale') ?? LotteryLocale::resolve($request)); + }; + + $exceptions->render(function (ValidationException $e, Request $request) use ($locale) { + if (! $request->is('api/*')) { + return null; + } + + return ApiResponse::error( + trans('api.validation_failed', [], $locale($request)), + ErrorCode::ValidationFailed->value, + ['errors' => $e->errors()], + 422, + ); + }); + + $exceptions->render(function (ModelNotFoundException $e, Request $request) use ($locale) { + if (! $request->is('api/*')) { + return null; + } + + return ApiResponse::error( + trans('api.not_found', [], $locale($request)), + ErrorCode::NotFound->value, + null, + 404, + ); + }); + + $exceptions->render(function (NotFoundHttpException $e, Request $request) use ($locale) { + if (! $request->is('api/*')) { + return null; + } + + return ApiResponse::error( + trans('api.not_found', [], $locale($request)), + ErrorCode::NotFound->value, + null, + 404, + ); + }); + + $exceptions->render(function (TooManyRequestsHttpException $e, Request $request) use ($locale) { + if (! $request->is('api/*')) { + return null; + } + + return ApiResponse::error( + trans('api.too_many_requests', [], $locale($request)), + ErrorCode::TooManyRequests->value, + null, + 429, + ); + }); + + $exceptions->render(function (HttpException $e, Request $request) use ($locale) { + if (! $request->is('api/*')) { + return null; + } + if ($e instanceof NotFoundHttpException || $e instanceof TooManyRequestsHttpException) { + return null; + } + + $status = $e->getStatusCode(); + $msg = $e->getMessage(); + if ($msg === '') { + $msg = trans('api.client_error', [], $locale($request)); + } + $code = $status >= 500 ? ErrorCode::InternalError->value : ErrorCode::ClientHttpError->value; + + return ApiResponse::error($msg, $code, null, $status); + }); + + $exceptions->render(function (Throwable $e, Request $request) use ($locale) { + if (! $request->is('api/*')) { + return null; + } + if ($e instanceof ValidationException + || $e instanceof ModelNotFoundException + || $e instanceof NotFoundHttpException + || $e instanceof TooManyRequestsHttpException + || $e instanceof HttpException + ) { + return null; + } + + $showDetails = (bool) config('app.debug'); + $msg = $showDetails ? $e->getMessage() : trans('api.server_error', [], $locale($request)); + + return ApiResponse::error( + $msg !== '' ? $msg : trans('api.server_error', [], $locale($request)), + ErrorCode::InternalError->value, + $showDetails ? ['exception' => $e::class] : null, + 500, + ); + }); })->create(); diff --git a/config/lottery.php b/config/lottery.php index b5aa911..53a3851 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -35,7 +35,7 @@ return [ /* | player_auth:配合 app/Services/PlayerTokenResolver.php | - | dev_bypass:仅当 APP_ENV=local 且 LOTTERY_PLAYER_AUTH_DEV_BYPASS=true 时, + | dev_bypass:仅当 APP_ENV∈{local, testing} 且 LOTTERY_PLAYER_AUTH_DEV_BYPASS=true 时, | 允许 Authorization: Bearer dev:{players.id} | jwt.* :主站签发的 JWT 内取站点、玩家字段的路径名(与主站约定一致) */ diff --git a/lang/en/api.php b/lang/en/api.php new file mode 100644 index 0000000..348277e --- /dev/null +++ b/lang/en/api.php @@ -0,0 +1,9 @@ + 'The given data was invalid.', + 'client_error' => 'This request could not be completed.', + 'not_found' => 'The requested resource was not found.', + 'too_many_requests' => 'Too many requests. Please try again later.', + 'server_error' => 'Something went wrong. Please try again later.', +]; diff --git a/lang/ne/api.php b/lang/ne/api.php new file mode 100644 index 0000000..f45727f --- /dev/null +++ b/lang/ne/api.php @@ -0,0 +1,9 @@ + 'दिइएको डाटा अमान्य छ।', + 'client_error' => 'यो अनुरोध पूरा गर्न सकिएन।', + 'not_found' => 'अनुरोध गरिएको स्रोत फेला परेन।', + 'too_many_requests' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।', + 'server_error' => 'केही गडबड भयो। कृपया पछि प्रयास गर्नुहोस्।', +]; diff --git a/lang/zh/api.php b/lang/zh/api.php new file mode 100644 index 0000000..0339f06 --- /dev/null +++ b/lang/zh/api.php @@ -0,0 +1,9 @@ + '请求参数校验未通过。', + 'client_error' => '请求无法完成。', + 'not_found' => '请求的资源不存在。', + 'too_many_requests' => '请求过于频繁,请稍后再试。', + 'server_error' => '服务暂时不可用,请稍后再试。', +]; diff --git a/tests/Feature/PlayerFoundationTest.php b/tests/Feature/PlayerFoundationTest.php new file mode 100644 index 0000000..1822ee8 --- /dev/null +++ b/tests/Feature/PlayerFoundationTest.php @@ -0,0 +1,68 @@ +create([ + 'site_code' => 'main', + 'site_player_id' => 'uid-42', + 'username' => 'alice', + 'nickname' => 'A', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/player/me') + ->assertOk() + ->assertJsonPath('code', 0) + ->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 () { + $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')); +}); + +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('msg', __('api.not_found', [], 'zh')); +}); + +test('player me works with main site jwt when dev bypass is off', function () { + config(['lottery.player_auth.dev_bypass' => false]); + config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret']); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'jwt-user-1', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $jwt = JWT::encode([ + 'site_code' => 'main', + 'site_player_id' => 'jwt-user-1', + 'exp' => time() + 3600, + ], 'jwt-test-secret', 'HS256'); + + $this->withHeader('Authorization', 'Bearer '.$jwt) + ->getJson('/api/v1/player/me') + ->assertOk() + ->assertJsonPath('data.site_player_id', 'jwt-user-1'); +});