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

docs: v3 契约、命名与「不写兼容代码」原则写进规范

本轮 heartbeat 试点暴露出的规则,逐条落到 skill 与 CLAUDE.md:

- 响应信封定死为 Laravel Resource 的原生形状,控制器里不要任何 helper;
  错误抛异常,不在控制器里拼
- 类名用 <v2 资源名>V3Controller,URL 也沿用 v2 资源名只换版本前缀,
  唯一允许的调整是集合复数化。改名会切断 v2↔v3 的对应关系
- 一个 index 只服务一个契约(同一套权限模型 / 必填参数 / 响应结构),
  过滤是允许且正常的;机械检验是 rules() 里出现 required_if 链
- 列表端点必须能把结果集收敛到可读范围
- 文案走 __(),翻译合并进既有语言文件,不新建
- 迁移期内一律不碰 v2 代码,包括不加 @deprecated——要标废弃走 overrides
- **不写兼容代码**:每个资源切 v3 要彻底,不留开关、双分支与归一化层。
  回退靠 git revert;生产上没有运行时切换这回事
- FormRequest 只在有入参时才需要;契约测试尚未落地(spectator 未装)是已知缺口

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 недель назад
Родитель
Сommit
e03fbf3ab5
2 измененных файлов с 354 добавлено и 53 удалено
  1. 285 41
      .claude/skills/v3-resource/SKILL.md
  2. 69 12
      CLAUDE.md

+ 285 - 41
.claude/skills/v3-resource/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: v3-resource
-description: "把 mint 的一个资源**从 /api/v2 迁移到 /api/v3**,或在 v3 下新建资源时用。按固定链路走完:FormRequest 定入参、Resource 的 @return array{} 定出参、契约测试卡住漂移、重生成 OpenAPI、生成前端 TS 类型、切换 dashboard-v6、删旧路由。触发:用户说「把 X 迁到 v3」「做 v3 重构」「v3 加个资源」「废掉 view 开关」「接口要符合 REST 标准」,或任务涉及 v2→v3 迁移、API 标准化、契约测试、从 spec 生成前端类型。**不要用于**:在现有 v2 接口上加 view 值、加字段、修 bug 这类增量改动(那用 api-change skill,改动范围小得多);也不要用于把巴利语料、译文、术语、批注写进 WikiPali 数据库(那是 wikipali:write skill)。"
+description: "把 mint 的一个资源**从 /api/v2 迁移到 /api/v3**,或在 v3 下新建资源时用。按固定链路走完:FormRequest 定入参、Resource 的 @return array{} 定出参、补测试、重生成 OpenAPI、生成前端 TS 类型、把 dashboard-v6 彻底切过去(不留兼容分支)、在文档上标记 v2 旧端点。迁移期内一律不碰 v2 代码。触发:用户说「把 X 迁到 v3」「做 v3 重构」「v3 加个资源」「废掉 view 开关」「接口要符合 REST 标准」,或任务涉及 v2→v3 迁移、API 标准化、从 spec 生成前端类型。**不要用于**:在现有 v2 接口上加 view 值、加字段、修 bug 这类增量改动(那用 api-change skill,改动范围小得多);也不要用于把巴利语料、译文、术语、批注写进 WikiPali 数据库(那是 wikipali:write skill)。"
 ---
 
 # v3 资源迁移
@@ -48,18 +48,46 @@ grep -rn "/api/v2/channel" dashboard-v6/src
 **试点顺序:先做完 A 类 3 条**(零线上风险,用来打磨样板与 CI 三道闸),
 再从 `channel` 开始啃 B 类。不要一上来就动 B 类。
 
-### 灰度开关(第 6 步前端切换时必须加)
+### 不写兼容代码
+
+**重构是为了提升,不是为了兼容。** 后端 v2 / v3 共存,但前端要分别对待:
+
+> 灰度是**按资源**一部分一部分地做,而每一部分都要**彻底**。
+> 一个资源切到 v3,就把它的 v2 调用删干净——不留开关、不留双分支、
+> 不写把两种响应抹平的归一化层。
+
+反面教材(曾经写过,已删):
+
+```ts
+// ✗ 不要这样
+if (isV3("health")) {
+  const d = unwrapQuiet(await api.GET("/v3/heartbeat"));
+  return { status: d.data?.status ?? "ok", checkedAt: d.data?.checked_at ?? "" };
+}
+const res = await get<V2Health>("/api/v2/heartbeat");     // v2 分支
+return { status: "ok", checkedAt: (res as { createdAt?: string }).createdAt ?? "" };
+```
+
+这段有三样兼容代码:运行时开关、两条分支、一个抹平两种形状的接口。
+**这种代码以后删起来很麻烦**——没人敢确认开关还有没有人用。
+
+正确写法是切完就只剩一条路:
 
 ```ts
-// src/api/channel.ts
-const USE_V3 = import.meta.env.VITE_V3_RESOURCES?.split(",") ?? [];
-const base = USE_V3.includes("channel")
-  ? "/api/v3/channels"
-  : "/api/v2/channel";
+export const apiServerHealth = async (): Promise<Heartbeat> =>
+  unwrapQuiet(await api.GET("/v3/heartbeat")).data ?? {};
 ```
 
-每个资源独立切换、独立回滚——出问题把资源名从环境变量里摘掉即可。
-v4 永远走 v2,不受任何影响。
+**回退靠 `git revert` 那个 commit**,不靠代码里的开关。一个资源一个 commit,
+这也是「一次只迁一个资源」的另一个理由。
+
+生产上本来也不存在「运行时切换」这个选项:前端是 Vite 构建出的静态产物,由
+nginx 伺服;无论改代码、改 `.env` 还是改 ops 的配置,都要**重新构建 + 重新部署**,
+没有热更新。所以代码里的开关一分钱好处都买不到,只留下删不掉的负担。
+
+同理,**v3 不要 import v2 的东西**:v3 用 `src/api/error.ts` 的 `ApiError`,
+v2 用 `src/request.ts` 的 `HttpError`,两边不共用类型。等 v2 调用点全部消失,
+`request.ts` 整个删掉,不牵连 v3。
 
 ### 逻辑下沉到 Service
 
@@ -124,15 +152,171 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
 
    > 别把这条读成「一个 index 只能走一个分支」。Laravel 官方没有这句话,
    > index 内部有多少 `where` 条件分支都无所谓,那叫过滤。
-2. **真实 HTTP 状态码。** 禁用 v2 的 `ok:false` + HTTP 200。401 未登录、403 无权限、
-   404 不存在、422 校验失败。
-3. **错误体用 RFC 9457 Problem Details**:`{type, title, status, detail, errors}`。
-4. **资源名用复数名词**:`/v3/channels/{uid}`。不要 `/v2/channel-my-number` 这种
+2. **响应信封 = Laravel Resource 的原生形状,不要自己拼**
+
+   | | 形状 | media type |
+   | --- | --- | --- |
+   | 成功·单个 | `{data: {...}}` | `application/json` |
+   | 成功·列表 | `{data: [...], meta: {...}}` | `application/json` |
+   | 失败 | `{type, title, status, detail, instance, errors?}` | `application/problem+json` |
+
+   **判断成败只看 HTTP 状态码,不看响应体字段。** 这是与 v2 最根本的区别——
+   v2 靠 `ok` 字段、业务失败也回 200;v3 靠状态码。
+
+   **成功:直接 return Resource,控制器里没有任何 helper。**
+
+   ```php
+   return ChannelV3Resource::make($channel);                              // 单个
+   return ChannelV3Resource::collection($query->paginate($per_page));     // 列表
+   ```
+
+   所有 `*V3Resource` 继承 `App\Http\Resources\V3Resource`。它只做一件事:
+   让 `::collection()` 产出 `V3ResourceCollection`,裁掉分页里的绝对 URL
+   (顶层 `links`、`meta.path`、`meta.links` 都是 `APP_URL` 拼的,反代下会拼错,
+   前端也用不到)。其余全交给框架:`meta` 里的 `current_page` / `per_page` /
+   `total` / `last_page` / `from` / `to` 都是 paginator 自己算的。
+
+   载荷不是 Eloquent 模型时直接用基类,`additional()` 补 meta:
+
+   ```php
+   return V3Resource::collection($items)->additional(['meta' => [
+       'current_page' => $page, 'total' => $total, 'has_more' => $more,
+   ]]);
+   ```
+
+   meta 的键**跟框架走**(snake_case);领域特有的字段另起名字,不要占用
+   `per_page` / `from` / `to` 这些有既定含义的键(例如按字节切页的阅读接口
+   用 `page_size` / `first_para` / `last_para`)。
+
+   **失败:抛异常,不要在控制器里拼错误响应。** `bootstrap/app.php` 的处理器
+   会统一渲染成 Problem Details,**只接管 `api/v3/*`**,v2 不受影响:
+
+   ```php
+   abort(404, __('site.not_found'));                             // → 404
+   throw ValidationException::withMessages([                     // → 422,带 errors
+       'channel' => __('site.invalid_parameter'),
+   ]);
+   $this->authorize('update', $channel);                         // → 403
+   // FormRequest 校验失败自动 → 422,什么都不用写
+   ```
+
+   不必为 `ModelNotFoundException`(`findOrFail`)和 `AuthorizationException`
+   单独注册处理器:`prepareException()` 在 render 回调之前已把它们转成
+   404 / 403。`AuthenticationException` 则不在那张表里,已显式处理成 401。
+
+   **需要自定义 type / 扩展字段的业务错误**才写 `BusinessException`:
+
+   ```php
+   throw new BusinessException(__('sentence.locked'), 409, 'sentence-locked',
+       ['locked_by' => $editor->nickname]);
+   ```
+
+   它自带 `render()`,优先级高于所有处理器。**5xx 的 detail 在生产环境会被兜底
+   处理器抹掉**(防泄露),所以「预期的 5xx、文案必须送达」的场景必须用它,
+   不能用 `abort(503)`——见 `HeartbeatV3Controller` 的停机分支。
+
+   **v2 的 `ok()` / `error()` 不准出现在 v3 控制器里。**
+
+3. **资源名用复数名词**:`/v3/channels/{uid}`。不要 `/v2/channel-my-number` 这种
    动词化端点。(注意:现有 v3 端点 search / progress / tipitaka-read-para 是早期
    遗留的单数命名,新资源不要照抄。)
-5. **分页统一**:用 Laravel 自带 paginator,返回 `data` + `meta{page, per_page, total}`。
-6. **时间一律 ISO 8601 UTC;ID 一律字符串。** 不要让同一字段在不同端点类型摇摆。
-7. **认证统一 Sanctum bearer。** 不准从 cookie 里摸 user_id 这类旁路。
+
+   **类名用 `<v2 资源名>V3Controller`**,不要另起炉灶起新名字:
+
+   | v2 | v3 |
+   | --- | --- |
+   | `HeartbeatController` | `HeartbeatV3Controller` |
+   | `ChannelController` | `ChannelV3Controller` |
+   | `DhammaTermController` | `DhammaTermV3Controller` |
+
+   Resource、FormRequest、测试同理:`HeartbeatV3Resource`、
+   `IndexChannelV3Request`、`HeartbeatV3Test`。
+
+   理由:新旧类会长期共存(v4 下线前 v2 一直在),名字带上 v2 的资源名才能
+   一眼看出父子关系。**不要按新端点的语义另起名**——`/v3/heartbeat` 别叫
+   `HealthController`,那会和既有的 `HealthCheckController` 混淆,也切断了与
+   `HeartbeatController` 的血缘。
+
+   **URL 也沿用 v2 的资源名,只换版本前缀**,不要借迁移之机改名:
+   `/v2/heartbeat` → `/v3/heartbeat`,不要改叫 `/v3/health`。
+   改名会切断 v2↔v3 的对应关系,迁移期内两套并存,对不上号就很难排查。
+
+   唯一允许的调整是**按 REST 规范做复数化**(集合用复数,单例保持单数):
+
+   | v2 | v3 | 说明 |
+   | --- | --- | --- |
+   | `/v2/heartbeat` | `/v3/heartbeat` | 单例资源,保持单数 |
+   | `/v2/channel` | `/v3/channels` | 集合,复数化 |
+   | `/v2/terms` | `/v3/terms` | 已经是复数,不动 |
+
+   动词化端点(`/v2/channel-my-number`)不在此列,它们本来就要按
+   「路由怎么设计」那节重新落位。
+
+   v3 控制器的文档注释里写明取代了哪个 v2 端点:
+
+   ```php
+   /**
+    * 存活检查
+    *
+    * 取代 `GET /v2/heartbeat`(`HeartbeatController@index`)。v2 那份在
+    * dashboard-v4 下线前原样保留,不要改动。
+    */
+   ```
+4. **分页交给框架。**
+
+   ```php
+   return ChannelV3Resource::collection($query->paginate($request->integer('per_page', 20)));
+   ```
+
+   `paginate()` 自己会读 `page` 查询参数,count、页数、边界全由框架算,
+   `meta` 由 `V3ResourceCollection` 输出(已裁掉绝对 URL)。控制器里只写业务查询。
+
+   只有**不是 Eloquent 查询**的端点才手写 meta(例如 `TipitakaReadChapterController`
+   按字节累加切页),用 `V3Resource::collection($items)->additional(['meta' => [...]])`,
+   **对外形状必须与框架一致**:`{data: [...], meta: {...}}`,键名跟 snake_case。
+
+   **列表端点必须能把结果集收敛到可读范围。** 不加条件就能拉全表的端点是设计
+   错误——`sentences` 有 414 万行,没人会翻 20 万页。设计时先问「用户实际带着
+   什么条件来」:若那条件必填且互斥,按第 1 条它就不是 filter 而是契约的一部分,
+   应该体现在路由上(`/v3/channels/{channel}/books/{book}/sentences`)。
+
+   收敛之后性能不是问题:实测 sentences 按 channel+book 过滤后 3,019 行,
+   `count(*)` 8ms,offset 5000 仍是 4ms。不需要 `cursorPaginate`。
+
+5. **时间一律 ISO 8601 UTC;ID 一律字符串。** 不要让同一字段在不同端点类型摇摆。
+6. **认证统一 Sanctum bearer。** 不准从 cookie 里摸 user_id 这类旁路。
+7. **面向用户的文案一律走 `__()`,不准硬编码。**
+
+   翻译文件在 **`api-v13/resources/lang/{locale}/`**(注意不是 Laravel 11+ 默认的
+   `lang/`,本项目把 langPath 指到了 resources 下)。支持 8 个语言:
+   `en`、`zh-Hans`、`zh-Hant`、`my`、`th`、`si`、`vi`、`lo`。
+
+   **不要新建语言文件,合并进已有的那 11 个**:`site`、`labels`、`buttons`、
+   `home`、`library`、`auth`、`validation`、`passwords`、`pagination`、
+   `grammar`、`language`。(注意没有 `messages.php`。)
+   API 的服务状态类文案放 **`site.php`**,界面标签放 `labels.php`,
+   按钮文字放 `buttons.php`,各就各位。
+
+   新增文案至少补 `en` 与 `zh-Hans` 两份,其余语言缺失会自动回退到 `en`,
+   不必一次补齐。
+
+   RFC 9457 的字段分工:**`title` 保持稳定英文**(按 RFC 它应在所有场合一致,
+   便于机器识别),**`detail` 是面向人的文案,走 `__()`**。
+
+   ```php
+   'title'  => 'Service Unavailable',
+   'detail' => __('site.maintenance'),
+   ```
+
+   配一条测试守住退化——翻译文件缺失时 `__()` 会原样吐出键名:
+
+   ```php
+   expect($detail)->not->toBe('site.maintenance');
+   ```
+
+   > ⚠️ 已知问题:`SetLocale` 中间件只挂在 `web` 组,**api 组没有语言协商**,
+   > 所以目前 API 响应永远是 `en`,`Accept-Language` 与 `?lang=` 均无效。
+   > 文案仍然必须外置到翻译文件(否则将来想修也修不了),但别指望它现在会变中文。
 
 ## 路由怎么设计(Laravel 官方形态)
 
@@ -188,10 +372,13 @@ cd api-v13 && php artisan tinker --execute 'print_r(\Illuminate\Support\Facades\
 产出三份清单:这个资源在 v2 有哪些端点(含 view 值)、前端哪些文件在调、
 表有哪些列及其**真实类型**。列给用户确认后再动手。
 
-### 1. FormRequest 定入参
+### 1. FormRequest 定入参(有入参才需要)
 
-v3 **禁止 inline `validate()`**,一律 FormRequest。`rules()` 既是运行时强制,
-又是 requestBody 的真声明——它不可能和实际行为不符,因为它就是实际行为。
+**有入参就必须用 FormRequest,禁止 inline `validate()`;零入参的端点不要建
+空 FormRequest**——那只是仪式,没有任何声明价值。
+
+`rules()` 既是运行时强制,又是 requestBody 的真声明——它不可能和实际行为不符,
+因为它就是实际行为。
 
 ```php
 class IndexChannelRequest extends FormRequest
@@ -232,11 +419,19 @@ public function toArray($request): array
 `key?:` 表示只在部分口径下返回;`|null` 会生成 `nullable: true`。
 范例见 `api-v13/app/Http/Resources/ChannelResource.php`。
 
-### 3. 契约测试(整套方案的地基)
+### 3. 测试(契约测试尚未落地,这是已知缺口)
+
+**现状:`hotmeteor/spectator` 未安装,项目里也没有可用的 JSON Schema 校验器,
+所以目前写不了真正的契约测试。**这意味着**规格与实现漂移不会被自动拦截**——
+这是整套方案里唯一没落地的一环,写测试时要清楚自己没有这层保护。
+
+在装上之前,写普通的 Pest 形状断言:状态码、关键字段存在性与类型、权限分支。
+每个端点至少三条:正常返回、无权限被拒、参数非法被拒(无入参的端点可省最后一条)。
+测试文件开头用注释写明「这里断言的是形状而非契约」,装上 spectator 后好定位替换。
+
+装上之后,把形状断言换成 `assertValidRequest()` / `assertValidResponse()`:
 
-前置:装 `hotmeteor/spectator`(目前**尚未安装**,第一个资源迁移时一并装上)。
 
-契约测试让注释不可能悄悄漂移——写错了 CI 就红。每个端点至少三条:
 
 ```php
 it('lists channels', function () {
@@ -272,6 +467,27 @@ cd openapi && php scripts/generate.php && npm run lint
 
 lint 必须 0 error。不要手改 `resources/auto/` 下的任何文件。
 
+生成器对 v3 路由(`api/v3/*`)用的是另一套信封,不用你操心:
+
+- 成功响应是 `{data: ...}`(Laravel Resource 的默认包装),
+  `index` 额外带 `meta{page, per_page, total}`
+- 错误响应引用 `ProblemDetails`(RFC 9457),不是 v2 的 `{ok, message, data}`
+- 默认挂 401;**有入参时**才挂 422
+
+两个标签控制这部分,写在控制器方法的文档注释里:
+
+```php
+/**
+ * 存活检查
+ *
+ * @unauthenticated                       // 该端点无需登录,不要挂 401
+ *
+ * @responseStatus 503 服务已进入停机维护状态   // 声明额外状态码,响应体是 Problem Details
+ */
+```
+
+范例见 `api-v13/app/Http/Controllers/HealthController.php`。
+
 ### 5. 生成前端类型
 
 ```bash
@@ -296,28 +512,56 @@ type ChannelListQuery =
 
 ### 6. 前端切过去
 
-- `src/api/<资源>.ts`:调用路径改 `/api/v3/...`,类型引用 `schema.d.ts`
-- 再改 `src/features/`、`src/pages/` 里的调用点
-- 调用仍走 `src/request.ts`,路径硬编码完整 URL,**没有 baseURL**
-- 按上面「灰度开关」那节加 `VITE_V3_RESOURCES` 开关
+v3 的调用**一律走类型化客户端** `src/api/client.ts`(基于 `openapi-fetch`,已安装)。
+v2 的老调用继续用 `src/request.ts`,不要动。
 
-**`openapi-fetch` 尚未引入。** 它能把路径字面量与 HTTP 方法也纳入类型检查
-(现在手写 URL 拼错了编译器抓不到),但**由用户自己安装**,agent 不要装。
-用户确认装好之前,按上面的写法走。
+```ts
+import { api, unwrap } from "./client";
+
+// 路径写 OpenAPI 规格里的 path,client 的 baseUrl 已经是 "/api"
+const data = unwrap(await api.GET("/v3/channels", {
+  params: { query: { "filter[lang]": "zh-Hans" } },
+}));
+```
+
+这样写的收益:**路径字面量、HTTP 方法、路径参数名、响应字段类型全部编译期检查**。
+路径拼错、对只有 GET 的端点用 POST,`npm run build` 直接报错——手写 URL 拿不到这层保护。
+
+两个必须知道的点:
+
+- **`openapi-fetch` 非 2xx 不抛异常**,返回 `{ data, error }`。一律用
+  `client.ts` 的 `unwrap()`,不要绕过它直接读 `data`。
+- **错误不用在调用点处理**:`unwrap()` 失败时弹提示(消息来自后端已本地化的
+  `detail`)并抛出,全局兜底收尾。**不要写 try/catch 只为弹窗**,也不要再写
+  `if (!json.ok)`。
+- 自己渲染失败状态的调用(轮询、状态条)用 `unwrapQuiet()`,只抛不弹——
+  否则网络一断就每隔几秒弹一次。见 `api-health.ts`。
+- 表单要字段级错误时才自己 catch,用 `fieldErrorsOf(e)` 取 422 的 `errors`。
+- **token 注入已在 `client.ts` 的中间件里做掉**,不要在调用处再拼 Authorization。
 
-### 7. 处理 v2 旧路由
+还要做的:
 
-**过渡期内不要删。** v4 在线上依赖它们,删了就是线上事故。
+- 把资源名加进 `src/api/v3-migration.ts` 的 `V3_RESOURCES`,两条分支都保留
+- 改 `src/features/`、`src/pages/` 里的调用点
+- 不要引入 RTK Query / react-query 这类数据层
+
+### 7. 标记 v2 旧路由(不碰 v2 代码)
+
+**过渡期内一律不动 v2**——不删路由、不改控制器,连加 `@deprecated` 注释都不行。
+修改 v2 的唯一理由是紧急 bug 修复。v4 在线上依赖它们,任何改动都是风险。
+删除留到 v4 下线后统一处理。
+
+要在文档上标记某个 v2 端点已被取代,走 **overrides**,这样不碰一行 PHP:
+
+```yaml
+# openapi/public/assets/protocol/overrides/v2-heartbeat.yaml
+get:
+  deprecated: true
+  description: |
+    已被 `GET /v3/heartbeat` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+```
 
-- **A 类**(只有 v6 用):v6 切完、测试全绿后可以删。删前再 grep 一遍确认无遗留:
-  ```bash
-  grep -rn "/api/v2/chapter-content" dashboard-v6/src dashboard-v4/dashboard/src
-  ```
-- **B 类**(v4 也在用):**保留**。给 v2 那个控制器方法加 `@deprecated` 标注
-  (生成器认这个标签,Swagger UI 会显示废弃),并在注释里写明"已迁至 /v3/xxx,
-  待 dashboard-v4 下线后删除"。
-- 任何删除动作的前置条件:先枚举 `BookTreeWithTags.tsx:44` 与
-  `SearchVocabulary.tsx:102` 的 `/v2/${api}` 动态调用可能传入的资源名。
+同时在 v3 控制器的文档注释里写明它取代了哪个 v2 端点,让两边都能追溯。
 
 ## 验收:CI 三道闸
 
@@ -335,7 +579,7 @@ cd dashboard-v6 && npm run build                                  # 前端类型
 - 不要一次迁多个资源
 - 不要在迁移里夹带无关重构(看到问题写 `// FIXME:`)
 - 不要手写前端类型,不要手改 `resources/auto/`
-- **不要擅自安装依赖**(含 `openapi-fetch`)。需要就说明理由,等用户装完通知
+- **不要擅自安装依赖**。需要就说明理由,等用户装完通知
 - 不要保留 `view=` 开关,不要保留 `ok:false` + HTTP 200(**仅指新建的 v3 端点**;
   v2 那份原样不动)
 - **不要删 B 类和 C 类的 v2 路由**,v4 在线上用着

+ 69 - 12
CLAUDE.md

@@ -42,6 +42,10 @@ wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。
 - 改完 PHP 跑 `vendor/bin/pint --dirty --format agent`;测试 `php artisan test --compact`,
   测试连独立的 `mint_test` 库(不要让 `RefreshDatabase` 打到开发库)。
 - `storage/resources` 是 git submodule(clove)。
+- 面向用户的文案一律走 `__()`。翻译文件在 **`resources/lang/{locale}/`**(不是
+  `lang/`),共 8 个语言。**不要新建语言文件,合并进已有的 11 个**
+  (`site`/`labels`/`buttons`/`home`/`library`/`auth`/… 没有 `messages.php`);
+  服务状态类文案放 `site.php`。至少补 `en` 与 `zh-Hans`,其余自动回退 en。
 
 ## 前端 dashboard-v6
 
@@ -87,9 +91,18 @@ type ChannelListQuery = paths["/v2/channel"]["get"]["parameters"]["query"];
 后端改了字段而前端没跟 → `npm run build` 直接报错。
 验证类型时用 `npx tsc -b --force`(`tsc -b` 有增量缓存,不加 `--force` 可能不重新检查)。
 
-**`openapi-fetch` 尚未引入。** 计划中要加(它能把路径字面量和 HTTP 方法也纳入类型
-检查,现在手写 URL 拼错了编译器抓不到),但**由用户自己安装**。在用户确认装好之前,
-调用照旧走 `src/request.ts`,只是类型从 `schema.d.ts` 取。
+**v3 的调用走 `src/api/client.ts`**(基于 `openapi-fetch`,已安装):
+
+```ts
+import { api, unwrap } from "./client";
+const data = unwrap(await api.GET("/v3/heartbeat"));
+```
+
+路径字面量、HTTP 方法、路径参数名、响应字段类型全部编译期检查。client 的
+baseUrl 是 `/api`,所以路径写规格里的 path;token 由中间件注入;非 2xx 时
+`openapi-fetch` 不抛异常,用 `unwrap()` 转成 `HttpError`。
+
+**v2 的调用继续用 `src/request.ts`,不要动。**
 
 ## OpenAPI 规格
 
@@ -183,15 +196,44 @@ v6 全量稳定 → 下线 v4 → 删掉 C 类与 B 类的 v2 残留 → v2 前
 FormRequest 定入参、Resource 的 `@return array{...}` 定出参、契约测试卡住漂移、
 spec 与前端类型全部生成。v3 端点不准有 `view=` 开关、不准 `ok:false` + HTTP 200。
 
-**迁移不等于删 v2。** 过渡期内 v2 原地保留,v3 是并存的新端点,v6 按资源灰度切换:
-
-```ts
-// src/api/channel.ts
-const USE_V3 = import.meta.env.VITE_V3_RESOURCES?.split(',') ?? [];
-const base = USE_V3.includes('channel') ? '/api/v3/channels' : '/api/v2/channel';
-```
-
-出问题把资源名从环境变量里摘掉即可秒级回滚。v4 永远走 v2,不受影响。
+**后端 v2 原地保留,前端切干净。** v2 与 v3 在后端共存到 v4 下线;但前端每迁
+一个资源就要**彻底**切过去——删掉该资源的 v2 调用,不留开关、不留双分支、
+不写抹平两种响应的归一化层。**重构是为了提升,不是为了兼容**;兼容代码以后
+删起来很麻烦,没人敢确认还有没有人在用。
+
+回退靠 `git revert` 那个 commit,不靠代码里的开关——这也是「一次只迁一个资源、
+一个资源一个 commit」的理由。生产上本来也没有「运行时切换」这回事:前端是静态
+构建产物,改代码、改 `.env`、改 ops 配置都得重新构建部署,没有热更新。
+
+v3 前端代码不要 import v2 的东西:v3 用 `src/api/error.ts` 的 `ApiError`,
+v2 用 `src/request.ts` 的 `HttpError`。等 v2 调用点全部消失,`request.ts`
+整个删掉,不牵连 v3。
+
+**v3 响应信封(已定死)**:就是 Laravel Resource 的原生形状——单个 `{data: {...}}`,
+列表 `{data: [...], meta: {...}}`,失败 `{type, title, status, detail, instance, errors?}`
++ `application/problem+json`。**判断成败只看 HTTP 状态码。**
+
+控制器里**没有任何响应 helper**:成功直接 `return XxxV3Resource::make()/::collection()`,
+失败 `abort()` / `throw ValidationException` / `throw BusinessException`,
+由 `bootstrap/app.php` 统一渲染(只接管 `api/v3/*`)。
+`*V3Resource` 继承 `App\Http\Resources\V3Resource`,它负责裁掉分页里的绝对 URL。
+**v2 的 `ok()` / `error()` 不准出现在 v3 控制器里。**
+
+前端对应(**只管 v3**,v2 的 `request.ts` 错误语义不同,迁移期内不碰):
+
+- `unwrap()` —— 失败时弹提示并抛出。业务代码只写成功路径,**不用 try/catch、
+  不用判断 `ok` 字段**(v2 那套在全仓库重复了 221 处)
+- `unwrapQuiet()` —— 只抛不弹,给自己渲染失败状态的调用用(如心跳轮询)
+- `ApiError` —— v3 专用错误类型,与 v2 的 `HttpError` 互不依赖
+- `src/api/error.ts` —— 提示的唯一出口,将来换 Notification 只改这里;
+  `installApiErrorHandler()` 在 `main.tsx` 装全局兜底,消掉未捕获 promise 的噪音
+- 表单要字段级错误时自己 catch,用 `fieldErrorsOf(e)` 取 422 的 `errors`
+
+**类名约定:`<v2 资源名>V3Controller`**(`HeartbeatController` → `HeartbeatV3Controller`),
+Resource / FormRequest / 测试同理。新旧长期共存,名字带上 v2 资源名才能一眼看出父子
+关系;不要按新端点语义另起名。**URL 同样沿用 v2 资源名**,只换版本前缀
+(`/v2/heartbeat` → `/v3/heartbeat`),唯一允许的调整是集合复数化
+(`/v2/channel` → `/v3/channels`)。
 
 **做迁移时使用 `v3-resource` skill**,里面有完整的七步链路与模板。
 一次只迁一个资源。试点顺序:先 A 类 3 条(零线上风险,用来打磨样板与 CI 三道闸),
@@ -242,6 +284,21 @@ const base = USE_V3.includes('channel') ? '/api/v3/channels' : '/api/v2/channel'
 - 需要改已有测试的只有:改了现有字段的名字或类型、改了权限规则、改了现有 view 的返回口径。
 - 测试连独立的 `mint_test` 库,`RefreshDatabase` 不会清开发库。
 
+## 分支与发布(先读这条)
+
+**线上生产跑的就是本仓库的 `development` 分支。** 没有单独的发布分支,
+也没有 CI 部署流程(`api-v13/.github/workflows/` 只有 lint 和 tests)。
+
+所以:**提交到 `development` 就等于把代码推向生产路径**。这让下面两条约定
+不只是习惯问题,而是安全边界:
+
+- 改完留在工作区,**永远不要自动 `git commit`**,等用户 review
+- **永远不要 `git push`**,除非用户明确要求
+
+`dashboard-v4/deploy/` 里那套 ansible 是 v4 时代的遗留:它指向
+`iapt-platform/mint` 的 `laravel` 分支,而本仓库的 remote 是
+`visuddhinanda/mint`。**不要把它当作现在的发布流程依据。**
+
 ## 工作约定
 
 - 改完代码留在工作区,等用户 review,不要自动 `git commit`。