Просмотр исходного кода

fix: v3 按 Accept-Language 本地化错误文案(BUG-5)

中文界面里 v3 接口的错误显示成英文:api 组没有语言协商,__() 永远取 en;
前端也没把界面语言告诉后端。

- 新增 V3\NegotiateLocale,只挂 v3 路由组:继承 web 组 SetLocale 的标签解析
  (q 值、zh-CN/zh-TW 归简繁、主语言回退),但只读 Accept-Language,
  不读 ?lang=(v3 里 lang 多是业务参数)、不读写 cookie / session;
  响应带 Content-Language 与 Vary: Accept-Language。v2 不挂
- 兜底用 mint.default_language 而非 app.locale:App::setLocale() 会改写 app.locale,
  常驻进程里会串到下一个请求
- dashboard-v6 client.ts 按界面语言设置 Accept-Language(浏览器自带的是系统语言)
- Problem Details 的 title 仍是稳定英文,本地化的是 detail 与 errors
- v3-resource skill 第 11 条的「已知问题」改为用法说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 1 неделя назад
Родитель
Сommit
1e93225f73

+ 10 - 3
.claude/skills/v3-resource/SKILL.md

@@ -669,9 +669,16 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    expect($detail)->not->toBe('site.maintenance');
    ```
 
-   > ⚠️ 已知问题:`SetLocale` 中间件只挂在 `web` 组,**api 组没有语言协商**,
-   > 所以目前 API 响应永远是 `en`,`Accept-Language` 与 `?lang=` 均无效。
-   > 文案仍然必须外置到翻译文件(否则将来想修也修不了),但别指望它现在会变中文。
+   **语言协商**:v3 路由组挂了 `App\Http\Middleware\V3\NegotiateLocale`,按请求头
+   `Accept-Language` 选语言(q 值排序;`zh-CN`→`zh-Hans`、`zh-TW`→`zh-Hant`;匹配不上用
+   `mint.default_language`),响应带 `Content-Language` 与 `Vary: Accept-Language`。
+   **只认请求头**:不读 `?lang=`(不少端点的 `lang` 是业务参数,如译文语言)、不读写
+   cookie / session。前端:dashboard-v6 的 `src/api/client.ts` 按界面语言设置这个头。
+   v2 不挂(错误文案在 v4 下线前保持原样)。
+
+   测试里要验证某种语言,直接带头:
+   `$this->postJson($url, $data, ['Accept-Language' => 'zh-Hans'])`,范例见
+   `tests/Feature/LocaleV3Test.php`。
 
 ## 路由怎么设计
 

+ 48 - 0
api-v13/app/Http/Middleware/V3/NegotiateLocale.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Http\Middleware\V3;
+
+use App\Http\Middleware\SetLocale;
+use Closure;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\App;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * v3 的语言协商:按 `Accept-Language` 决定 `__()` 用哪种语言。
+ *
+ * 只挂在 v3 路由组上(routes/api.php)。之前 api 组没有任何语言协商,`__()` 永远取
+ * en,中文界面里显示英文错误(dashboard-test BUG-5)。
+ *
+ * 继承 web 组的 {@see SetLocale},复用它的标签解析(q 值排序、大小写不敏感、
+ * zh-CN/zh-TW 归简繁、th-TH → th),但**只读请求头**:
+ *
+ * - 不读 `?lang=` / cookie:API 的调用方(v6、mobile)明确知道界面语言,直接放请求头里;
+ *   `?lang=` 在不少 v3 端点上已有业务含义(如译文语言),不能拿来当界面语言。
+ * - 不读写 cookie / session:API 无状态,api 组也没有 session。尤其不能读 web 组写的
+ *   `language` cookie——Laravel 页面与 dashboard-v6 同域,这个 cookie 会跟着 v6 的请求
+ *   一起发来,若它优先,用户在 Laravel 页面选过的语言就会盖过 v6 明确声明的界面语言。
+ *   Laravel 页面将来要调 v3 并显示错误文案,也应像 v6 一样自己带 Accept-Language
+ *   (取 `<html lang>`,即 `app()->getLocale()`)。
+ *
+ * 匹配不上就用 `config('mint.default_language')`。Problem Details 的 `title` 按 RFC 9457
+ * 保持稳定英文,不受这里影响;本地化的是 `detail` 与 `errors`。
+ */
+class NegotiateLocale extends SetLocale
+{
+    public function handle(Request $request, Closure $next): Response
+    {
+        $supported = array_keys(config('mint.languages'));
+
+        // 兜底用 mint.default_language 而不是 app.locale:App::setLocale() 会顺带改写
+        // config('app.locale'),常驻进程(Octane、队列)里上一个请求的语言会串到下一个
+        App::setLocale($this->getBrowserLocale($request, $supported) ?? config('mint.default_language', 'en'));
+
+        $response = $next($request);
+        // 响应随 Accept-Language 而变,告诉缓存别混用
+        $response->headers->set('Vary', trim($response->headers->get('Vary').', Accept-Language', ', '));
+        $response->headers->set('Content-Language', App::getLocale());
+
+        return $response;
+    }
+}

+ 3 - 1
api-v13/routes/api.php

@@ -124,6 +124,7 @@ use App\Http\Controllers\WbwSentenceController;
 use App\Http\Controllers\WbwTemplateController;
 use App\Http\Controllers\WebHookController;
 use App\Http\Controllers\WordIndexController;
+use App\Http\Middleware\V3\NegotiateLocale;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Route;
 
@@ -347,7 +348,8 @@ Route::group([
 | 新增一个领域就在这里加一行 require,并在 routes/v3/ 下建同名文件。
 |
 */
-Route::prefix('v3')->as('v3.')->group(function () {
+// 语言协商只给 v3:v2 的错误文案在 dashboard-v4 下线前保持原样
+Route::prefix('v3')->as('v3.')->middleware(NegotiateLocale::class)->group(function () {
     require __DIR__.'/v3/system.php';
     require __DIR__.'/v3/search.php';
     require __DIR__.'/v3/tipitaka.php';

+ 68 - 0
api-v13/tests/Feature/LocaleV3Test.php

@@ -0,0 +1,68 @@
+<?php
+
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v3 的语言协商(V3\NegotiateLocale):按 Accept-Language 本地化 Problem Details 的
+ * detail 与 errors。title 按 RFC 9457 保持稳定英文。
+ *
+ * 用两个不写库、不发信的失败请求做探针:
+ *   POST /api/v3/sessions(缺字段)  → 422,validation.php 的文案
+ *   POST /api/v3/invites(错验证码) → 422,messages.php 的文案
+ */
+function wrongCode(array $headers = [])
+{
+    return test()->postJson('/api/v3/invites', ['email' => 'locale@example.test', 'code' => '000000'], $headers);
+}
+
+it('falls back to en without Accept-Language', function () {
+    wrongCode()
+        ->assertStatus(422)
+        ->assertJsonPath('errors.code.0', 'The verification code is incorrect or has expired.')
+        ->assertHeader('Content-Language', 'en');
+});
+
+it('localizes messages and validation errors to zh-Hans', function () {
+    wrongCode(['Accept-Language' => 'zh-Hans'])
+        ->assertJsonPath('errors.code.0', '验证码不正确或已过期。')
+        ->assertJsonPath('detail', '验证码不正确或已过期。')
+        ->assertHeader('Content-Language', 'zh-Hans');
+
+    $this->postJson('/api/v3/sessions', [], ['Accept-Language' => 'zh-Hans'])
+        ->assertStatus(422)
+        ->assertJsonPath('errors.login.0', 'login 字段是必填项。');
+});
+
+it('keeps the Problem title in English', function () {
+    $title = wrongCode(['Accept-Language' => 'zh-Hans'])->json('title');
+
+    expect($title)->toBe(wrongCode()->json('title'));
+});
+
+it('maps regional Chinese tags to simplified and traditional', function (string $header, string $expected) {
+    wrongCode(['Accept-Language' => $header])->assertJsonPath('errors.code.0', $expected);
+})->with([
+    'zh-CN' => ['zh-CN', '验证码不正确或已过期。'],
+    'zh-TW' => ['zh-TW', '驗證碼不正確或已過期。'],
+    'zh-Hant-HK' => ['zh-Hant-HK', '驗證碼不正確或已過期。'],
+]);
+
+it('honours q values and skips unsupported languages', function () {
+    wrongCode(['Accept-Language' => 'fr-FR, en;q=0.5, zh-CN;q=0.9'])
+        ->assertHeader('Content-Language', 'zh-Hans');
+
+    wrongCode(['Accept-Language' => 'fr-FR, de'])
+        ->assertHeader('Content-Language', 'en');
+});
+
+it('marks responses as varying by Accept-Language', function () {
+    $vary = wrongCode(['Accept-Language' => 'zh-Hans'])->headers->get('Vary');
+
+    expect($vary)->toContain('Accept-Language');
+});
+
+it('does not set cookies', function () {
+    expect(wrongCode(['Accept-Language' => 'zh-Hans'])->headers->getCookies())->toBeEmpty();
+});

+ 9 - 1
dashboard-v6/src/api/client.ts

@@ -8,16 +8,24 @@
 import createClient, { type Middleware } from "openapi-fetch";
 
 import { get as getToken } from "../reducers/session";
+import { get as getUiLang } from "../locales";
 import { ApiError, reportApiError } from "./error";
 import type { paths } from "./schema";
 
-/** 每次请求注入 Bearer token,等价于 request.ts 里 options() 做的事 */
+/**
+ * 每次请求注入 Bearer token(等价于 request.ts 里 options() 做的事),
+ * 并用界面语言设置 Accept-Language。
+ *
+ * 后端 v3 按 Accept-Language 本地化错误文案(V3\NegotiateLocale)。浏览器自动带的
+ * Accept-Language 是系统语言,不是用户在界面里选的语言,所以这里显式覆盖。
+ */
 const authMiddleware: Middleware = {
   onRequest({ request }) {
     const token = getToken();
     if (token) {
       request.headers.set("Authorization", `Bearer ${token}`);
     }
+    request.headers.set("Accept-Language", getUiLang());
     return request;
   },
 };