refactor: 重构语言协商中间件,简化语言解析逻辑并增强异常处理,更新相关配置以支持多环境开发
This commit is contained in:
@@ -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 的算法(与签发方一致)
|
||||
|
||||
@@ -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<string> $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<string> $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;
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Lottery/ErrorCode.php
Normal file
25
app/Lottery/ErrorCode.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Lottery;
|
||||
|
||||
/**
|
||||
* HTTP JSON 中与 `ApiResponse` 对齐的业务码常量(对齐 docs/04 §10)。
|
||||
* SSO 等特殊场景仍由各模块直接写整数(如 EnsurePlayerApi 使用的 8001)。
|
||||
*/
|
||||
enum ErrorCode: int
|
||||
{
|
||||
/** 表单 / Query 校验失败(见 ValidationException → 422) */
|
||||
case ValidationFailed = 9001;
|
||||
|
||||
/** 资源或路由不存在 */
|
||||
case NotFound = 9004;
|
||||
|
||||
/** 请求过于频繁 */
|
||||
case TooManyRequests = 9031;
|
||||
|
||||
/** `abort(4xx)` 等客户端类 Http 异常(HTTP 状态码见响应头;本码作业务归类) */
|
||||
case ClientHttpError = 9010;
|
||||
|
||||
/** 未分类服务端异常(生产环境不向客户端暴露细节) */
|
||||
case InternalError = 9999;
|
||||
}
|
||||
52
app/Support/LotteryLocale.php
Normal file
52
app/Support/LotteryLocale.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 与 NegotiateLotteryLocale 相同的协商规则;供异常 render 等非中间件链路使用。
|
||||
*/
|
||||
final class LotteryLocale
|
||||
{
|
||||
public static function resolve(Request $request): string
|
||||
{
|
||||
/** @var list<string> $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<string> $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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 内取站点、玩家字段的路径名(与主站约定一致)
|
||||
*/
|
||||
|
||||
9
lang/en/api.php
Normal file
9
lang/en/api.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'validation_failed' => '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.',
|
||||
];
|
||||
9
lang/ne/api.php
Normal file
9
lang/ne/api.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'validation_failed' => 'दिइएको डाटा अमान्य छ।',
|
||||
'client_error' => 'यो अनुरोध पूरा गर्न सकिएन।',
|
||||
'not_found' => 'अनुरोध गरिएको स्रोत फेला परेन।',
|
||||
'too_many_requests' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।',
|
||||
'server_error' => 'केही गडबड भयो। कृपया पछि प्रयास गर्नुहोस्।',
|
||||
];
|
||||
9
lang/zh/api.php
Normal file
9
lang/zh/api.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'validation_failed' => '请求参数校验未通过。',
|
||||
'client_error' => '请求无法完成。',
|
||||
'not_found' => '请求的资源不存在。',
|
||||
'too_many_requests' => '请求过于频繁,请稍后再试。',
|
||||
'server_error' => '服务暂时不可用,请稍后再试。',
|
||||
];
|
||||
68
tests/Feature/PlayerFoundationTest.php
Normal file
68
tests/Feature/PlayerFoundationTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('player me returns profile with dev bearer', function () {
|
||||
$player = Player::query()->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');
|
||||
});
|
||||
Reference in New Issue
Block a user