Explorar el Código

docs: 补 CLAUDE.md 与 api-change / v3-resource 两个 skill

- CLAUDE.md:monorepo 总览、活跃与冻结目录、后端与前端约定、OpenAPI 生成链路、
  过渡期 v2 冻结规则(v4 在线上依赖 161 条 v2 路径)、渐进式还债、测试约定
- api-change skill:v2 增量改动的六步流程,动手前先按 A/B/C 分区判断能不能动
- v3-resource skill:v2→v3 迁移的七步链路,含 FormRequest / Resource /
  契约测试模板、灰度开关、Service 下沉、migration 只准加不准删
- 两个 skill 互相写明边界,避免误用

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda hace 3 semanas
padre
commit
19c72866ad
Se han modificado 3 ficheros con 711 adiciones y 0 borrados
  1. 192 0
      .claude/skills/api-change/SKILL.md
  2. 279 0
      .claude/skills/v3-resource/SKILL.md
  3. 240 0
      CLAUDE.md

+ 192 - 0
.claude/skills/api-change/SKILL.md

@@ -0,0 +1,192 @@
+---
+name: api-change
+description: "在**现有 /api/v2 接口上做增量改动**时用,按固定顺序走完后端、OpenAPI 规格、前端三处,避免漏改。触发:给 v2 列表接口的 view 参数加值、给表或模型加字段、改 v2 的请求参数或返回结构、修 v2 控制器的 bug,以及任何会同时牵动 api-v13 与 dashboard-v6 的 v2 改动。用户说「加个 view」「加个字段」「改接口」「这个接口要返回 xxx」且对象是 v2 时适用。**不要用于**:把资源迁到 /api/v3、在 v3 下新建资源、废掉 view 开关做标准化重构(这些一律用 v3-resource skill);也不要用于把巴利语料、译文、术语、批注写进 WikiPali 数据库(那是 wikipali:write skill);纯前端样式、纯文档改动也不需要本 skill。"
+---
+
+# v2 接口改动流程
+
+## 先确认没拿错 skill
+
+| 你要做的事 | 用哪个 |
+| --- | --- |
+| 在现有 **v2** 接口上加 view 值 / 加字段 / 改参数 / 修 bug | `api-change` |
+| 把资源**迁到 v3**、v3 新建资源、废掉 view 开关做标准化 | `v3-resource` |
+| 把巴利语料、译文、术语、批注写进 **WikiPali 数据库** | `wikipali:write` |
+
+判断依据是**动的是 v2 还是 v3**:在 v2 原地改用 `api-change`,迁到 v3 用 `v3-resource`。
+拿错了就停下换对的,不要将就。
+
+mint 的 OpenAPI 规格是**从 api-v13 源码生成的**,前端类型是**手写的**。
+所以一次接口改动必须走完三处,顺序固定:后端 → 规格 → 前端。
+
+## 动手前:确认这条路径动不动得
+
+**`dashboard-v4` 在线上跑,已冻结不再升级,依赖 161 条 v2 路径。破坏 v2 = 线上崩,
+而且不会有人发现**(v4 无人维护、没有测试)。所以先分区:
+
+```bash
+grep -rn "/v2/channel" dashboard-v4/dashboard/src   # 命中 = v4 在用
+grep -rn "/api/v2/channel" dashboard-v6/src
+```
+
+| 分区 | 谁在用 | 条数 | 本 skill 该怎么做 |
+| --- | --- | --- | --- |
+| **A** | 只有 v6 | 3 | 可自由改,也可直接迁 v3 |
+| **B** | v4 + v6 | 118 | 只准**加**,不准改形状;新能力去 v3 建 |
+| **C** | 只有 v4 | 43 | **只修 bug,别的什么都不做**——等 v4 下线整块删 |
+
+v4 真实代码在 `dashboard-v4/dashboard/src`(不是 `dashboard-v4/src`),
+另有一批绕过请求封装的裸 URL(Upload 的 `action`、导出 `href`),grep 时别漏。
+
+### v2 铁律
+
+1. **不得改变响应形状、字段名、状态码。** 加字段安全,改/删不安全。
+2. **修 bug 修在 Service 层**,v2 控制器只当适配层。同一处修复才能同时惠及 v3——
+   这是 v2 改动成为 v3 资产的唯一途径。
+3. **碰之前先录快照测试**:把该端点当前的真实响应录下来,改完形状没变才算修对。
+4. **migration 只准加不准删**:加列必须 nullable 或带默认值;禁止删列、改列名、
+   改类型、加 NOT NULL。
+5. **不准删 v2 路由**,除非先枚举过 `BookTreeWithTags.tsx:44` 与
+   `SearchVocabulary.tsx:102` 的 `/v2/${api}` 动态调用可能传入的资源名。
+
+v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agentApi.ts:14`)
+均不构成生产依赖,已确认。
+
+## 第 0 步:先 grep 出全集,列给用户确认
+
+动手前必须先找全牵连点,不要边改边找。
+
+```bash
+# 某个 view 值涉及哪些地方
+grep -rn "'user-in-chapter'" api-v13/app dashboard-v6/src
+
+# 某个字段涉及哪些地方
+grep -rn "source_type" api-v13/app api-v13/database dashboard-v6/src openapi/public
+```
+
+把命中的文件列成清单给用户过目,再开始改。
+
+## 第 1 步:后端改逻辑(api-v13)
+
+**加 view 值:** 在控制器的 `switch ($request->input('view'))` 里加 case。
+注意多数控制器的 switch 没有 default 保护,加 case 时顺手确认新分支一定给
+`$table` 赋了值。
+
+**加字段:** migration → Model(`$fillable` / `$casts`)→ 对应的
+`app/Http/Resources/XxxResource.php` 的 `toArray()`。
+
+字段名要先核实,不要凭印象:
+
+```bash
+cd api-v13 && php artisan tinker --execute 'print_r(\Illuminate\Support\Facades\Schema::getColumnListing("channels"));'
+```
+
+## 第 2 步:同一次编辑里改 docblock(最容易漏)
+
+规格是从注释生成的,注释没跟上,文档立刻就是错的。
+
+- 加 view 值 → 在 `@queryParam view` 的 `Enum:` 列表里加上这个值,并在描述里
+  写清它的口径与权限要求
+- 加入参 → 补 `@bodyParam` 或 `@queryParam`
+- 加出参 → 确保它进了 Resource 的 `toArray()`(生成器只认 Resource)
+
+标签语法(位置固定):`@标签 <参数名> <类型> [required] <中文描述>`,
+描述里可写 `Enum: a,b,c` / `Default: x` / `Example: y`。
+范例见 `api-v13/app/Http/Controllers/ChannelController.php` 的 `index`。
+
+## 第 3 步:补测试(加 view 值时**必须**)
+
+**新增 view 值 → 必须补 Pest 测试**,至少两条:一条验证返回预期数据,一条验证
+权限不足时被拒。每个 view 是一条独立的业务路径,新写的查询与权限逻辑没人跑过,
+最容易藏 bug。
+
+模板抄 `api-v13/tests/Feature/ChannelUserEditListTest.php`,
+`makeStudio` / `makeChannel` / `authHeader` 是现成 helper:
+
+```php
+it('lists the channels the user can edit', function () {
+    $me = makeStudio('me');
+    $mine = makeChannel($me, 'my channel');
+
+    $rows = $this->getJson('/api/v2/channel?view=user-edit', authHeader($me))
+        ->assertOk()->json('data.rows');
+
+    expect(array_column($rows, 'uid'))->toContain($mine);
+});
+
+it('requires authentication', function () {
+    $this->getJson('/api/v2/channel?view=user-edit')->assertJsonPath('ok', false);
+});
+```
+
+**新增字段 → 不用改测试。** 项目里的断言都是 `toHaveKeys(...)` 这种「包含即可」
+的写法,全仓库没有 `assertExactJson` / `assertJsonStructure`,多返回一个字段
+不会打破任何测试。
+
+需要改已有测试的只有:改了现有字段的名字或类型、改了权限规则、改了现有 view 的返回口径。
+
+## 第 4 步:重新生成规格
+
+```bash
+cd openapi && php scripts/generate.php && npm run lint
+```
+
+lint 必须是 0 error。生成器会重建 `resources/auto/` 下的全部文件,不要手改那里面
+的任何东西。
+
+## 第 5 步:前端跟上(dashboard-v6)
+
+先重新生成类型:
+
+```bash
+npx openapi-typescript openapi/public/assets/protocol/main.yaml -o dashboard-v6/src/api/schema.d.ts
+```
+
+**跑过 `generate.php` 就必须跑这条**,不用问用户。`schema.d.ts` 已提交进版本库,
+它的改动是本次改动的一部分,要一并留在工作区。
+
+- `src/api/<资源>.ts`:类型从 `schema.d.ts` 引用(`import type { paths } from "./schema"`,
+  不写 `.d.ts` 后缀),**不要手写字段**;加调用函数。
+  这里是前端类型与请求的汇集处。
+- 再改用到它的 `src/features/`、`src/pages/`。
+- 调用走 `src/request.ts`,路径硬编码完整的 `/api/v2/...`,**没有 baseURL**。
+- 不要擅自安装依赖,不要引入 RTK Query / react-query 这类数据层。
+
+## 第 6 步:验收
+
+```bash
+cd api-v13 && vendor/bin/pint --dirty --format agent && php artisan test --compact
+cd dashboard-v6 && npm run build    # tsc 会把漏改的类型报出来
+```
+
+三条都要过:pint 无报错、测试全绿(`ExampleTest` 是历史遗留失败,可忽略)、
+前端构建通过。
+
+## 第 7 步:顺手还债(有边界)
+
+这个库有六年欠债。改到哪还到哪,但**只还你这次真正改到的那个方法的债**——
+同文件的其他方法一律不动,哪怕一眼看到问题;看到了就加 `// FIXME:` 留给下次。
+
+对你改到的方法,做得到就做,做不到就 FIXME:
+
+- 补中文 docblock 与参数标签(第 2 步已经在做)
+- 返回值走 Resource,而不是 `return $this->ok($model)` 吐原始模型
+- 给碰到的 Resource 补 `@return array{...}` 类型声明,类型要核实数据库列定义,
+  不要照字段名想当然(`type`、`source_id` 这类常是 varchar 而非数字)
+- `$this->error()` 参数位置写对:`error(string $message, mixed $data, int $status)`
+- `switch` 补 `default`,避免 `$table` 未定义直接 500
+- `$this->error(...)` 后漏掉的 `return` 补上
+
+**功能改动和还债拆成两个 commit**(`feat`/`fix` 在前,`refactor` 在后),
+review 时能分开看。
+
+**不要**为了顺手重写整个控制器,**不要**改你没读懂的逻辑,**不要**在功能改动里
+夹带大范围重构。拿不准就写 FIXME。
+
+## 收尾
+
+改完留在工作区,**不要自动 git commit**,等用户 review。
+
+报告里必须用一句话列出这次顺带还了哪些债,例如
+「顺带:`index` 补了 docblock、switch 加了 default 保护」。没列出来的清理动作
+等于没经过 review。

+ 279 - 0
.claude/skills/v3-resource/SKILL.md

@@ -0,0 +1,279 @@
+---
+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)。"
+---
+
+# v3 资源迁移
+
+## 先确认没拿错 skill
+
+| 你要做的事                                               | 用哪个           |
+| -------------------------------------------------------- | ---------------- |
+| 在现有**v2** 接口上加 view 值 / 加字段 / 改参数 / 修 bug | `api-change`     |
+| 把资源**迁到 v3**、v3 新建资源、废掉 view 开关做标准化   | `v3-resource`    |
+| 把巴利语料、译文、术语、批注写进**WikiPali 数据库**      | `wikipali:write` |
+
+判断依据是**动的是 v2 还是 v3**:在 v2 原地改用 `api-change`,迁到 v3 用 `v3-resource`。
+拿错了就停下换对的,不要将就。
+
+## 为什么这么做
+
+v2 的规格是**从代码里推断**出来的,注释一旦漂移,文档就错,agent 只能猜。
+v3 要反过来:**声明是强制的,spec 和前端类型都是生成的,CI 卡住漂移**。
+
+三件事,缺一不可:`声明 → 生成 → 契约测试`。
+
+关键不在于找到完美的声明语法,而在于**声明与实际不符时会红灯**。
+
+## 过渡期铁律:迁移 ≠ 删 v2
+
+**`dashboard-v4` 在线上跑,已冻结不再升级,依赖 161 条 v2 路径。**
+v6 还在测试上线阶段,等它稳定才会下线 v4。所以过渡期内:
+
+> **v2 原地保留,v3 是并存的新端点。迁完一个资源也不要删它的 v2 版本。**
+
+### 先分区,决定这个资源怎么迁
+
+```bash
+grep -rn "/v2/channel" dashboard-v4/dashboard/src   # 命中 = v4 在用
+grep -rn "/api/v2/channel" dashboard-v6/src
+```
+
+| 分区  | 谁在用  | 条数 | 迁移策略                                                    |
+| ----- | ------- | ---- | ----------------------------------------------------------- |
+| **A** | 只有 v6 | 3    | 自由迁。`heartbeat`、`chapter-content`、`paragraph-content` |
+| **B** | v4 + v6 | 118  | v3 建新端点,v6 灰度切;v2 原地冻结不动                     |
+| **C** | 只有 v4 | 43   | **不迁**。等 v4 下线整块删,投入任何功夫都是沉没成本        |
+
+**试点顺序:先做完 A 类 3 条**(零线上风险,用来打磨样板与 CI 三道闸),
+再从 `channel` 开始啃 B 类。不要一上来就动 B 类。
+
+### 灰度开关(第 6 步前端切换时必须加)
+
+```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,不受任何影响。
+
+### 逻辑下沉到 Service
+
+不要把逻辑在 v2 和 v3 各写一遍,那样 v2 的紧急修复成不了 v3 的资产,反而双倍维护。
+
+```
+                 ┌─ v2 Controller(冻结的适配层,只做形状转换)
+Service(唯一逻辑)┤
+                 └─ v3 Controller(FormRequest + Resource 新契约)
+```
+
+现成范例:`TipitakaReadParaController` 注入 `PaliContentService`。
+迁移时把 v2 控制器里的业务逻辑抽到 Service,v2 控制器改成薄适配层——
+**但它的响应形状、字段名、状态码一个都不准变**,v4 会碎。
+
+### migration 只准加不准删
+
+加列必须 nullable 或带默认值;禁止删列、改列名、改类型、加 NOT NULL。
+v4 的代码在读这些列,而你不会去改 v4。要改名就新列并存 + 双写,等 v4 下线再清理。
+
+v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agentApi.ts:14`)
+均不构成生产依赖,已确认。
+
+## 一次只迁一个资源
+
+不要大爆炸,不要一次迁多个资源。v2 与 v3 并存,逐个资源走完整条链,
+每次的 diff 要小到人能 review 完。**没走完全部 7 步就不算迁完。**
+
+## v3 硬规范
+
+新端点必须满足,不满足就不要写:
+
+1. **不准有 `view=` 万能开关。** 语义不同 → 拆成独立端点
+   (`GET /v3/channels/editable`);只是过滤 → 用 `filter[owner]=me`(JSON:API 风格)。
+   每个 view 是独立业务路径,挤在一个端点里就必须逐个测、switch 漏 default 就 500。
+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` 这种
+   动词化端点。(注意:现有 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 这类旁路。
+
+## 迁移七步
+
+### 0. 先盘点,列给用户确认
+
+```bash
+grep -rn "channel" api-v13/routes/api.php | grep v2
+grep -rn "/api/v2/channel" dashboard-v6/src
+cd api-v13 && php artisan tinker --execute 'print_r(\Illuminate\Support\Facades\Schema::getColumnListing("channels"));'
+```
+
+产出三份清单:这个资源在 v2 有哪些端点(含 view 值)、前端哪些文件在调、
+表有哪些列及其**真实类型**。列给用户确认后再动手。
+
+### 1. FormRequest 定入参
+
+v3 **禁止 inline `validate()`**,一律 FormRequest。`rules()` 既是运行时强制,
+又是 requestBody 的真声明——它不可能和实际行为不符,因为它就是实际行为。
+
+```php
+class IndexChannelRequest extends FormRequest
+{
+    /** @return array<string, string> */
+    public function rules(): array
+    {
+        return [
+            'filter.owner' => 'string|in:me,shared,system',
+            'filter.lang' => 'string|size:7',
+            'page' => 'integer|min:1',
+            'per_page' => 'integer|min:1|max:200',
+        ];
+    }
+}
+```
+
+### 2. Resource 定出参
+
+必须写 `@return array{...}` 声明。**类型去查 `information_schema` 核实**,
+不要照字段名想当然——`type`、`source_id` 这类常是 varchar 而非数字。
+
+```php
+/**
+ * @return array{
+ *     uid: string,
+ *     name: string,
+ *     summary: string|null,
+ *     type: string,
+ *     status: int,
+ *     created_at: string,
+ *     progress?: float
+ * }
+ */
+public function toArray($request): array
+```
+
+`key?:` 表示只在部分口径下返回;`|null` 会生成 `nullable: true`。
+范例见 `api-v13/app/Http/Resources/ChannelResource.php`。
+
+### 3. 契约测试(整套方案的地基)
+
+前置:装 `hotmeteor/spectator`(目前**尚未安装**,第一个资源迁移时一并装上)。
+
+契约测试让注释不可能悄悄漂移——写错了 CI 就红。每个端点至少三条:
+
+```php
+it('lists channels', function () {
+    $me = makeStudio('me');
+    makeChannel($me, 'my channel');
+
+    $this->getJson('/api/v3/channels?filter[owner]=me', authHeader($me))
+        ->assertValidRequest()      // 请求符合 spec
+        ->assertValidResponse(200); // 响应符合 spec,字段类型对不上就失败
+});
+
+it('rejects anonymous access', function () {
+    $this->getJson('/api/v3/channels')->assertStatus(401);
+});
+
+it('rejects invalid filter', function () {
+    $this->getJson('/api/v3/channels?filter[owner]=bogus', authHeader(makeStudio('me')))
+        ->assertStatus(422);
+});
+```
+
+`makeStudio` / `makeChannel` / `authHeader` 是现成 helper,见
+`tests/Feature/ChannelUserEditListTest.php`。
+
+**每个 view 值拆出来的新端点都要单独测**——这是业务路径分叉,新写的查询与权限
+逻辑没人跑过,最容易藏 bug。
+
+### 4. 重新生成规格
+
+```bash
+cd openapi && php scripts/generate.php && npm run lint
+```
+
+lint 必须 0 error。不要手改 `resources/auto/` 下的任何文件。
+
+### 5. 生成前端类型
+
+```bash
+npx openapi-typescript openapi/public/assets/protocol/main.yaml -o dashboard-v6/src/api/schema.d.ts
+```
+
+**跑过 `generate.php` 就必须跑这条**,不用问用户。`schema.d.ts` 已提交进版本库,
+它的改动是本次改动的一部分,要一并留在工作区。
+
+**类型一律从生成产物取,不要手写字段**:
+
+```ts
+import type { paths } from "./schema";   // 不写 .d.ts 后缀
+
+type ChannelListResponse =
+  paths["/v3/channels"]["get"]["responses"][200]["content"]["application/json"];
+type ChannelListQuery =
+  paths["/v3/channels"]["get"]["parameters"]["query"];
+```
+
+后端改了字段而前端没跟 → `npm run build` 直接报错。这是整条链路的最后一道闸。
+
+### 6. 前端切过去
+
+- `src/api/<资源>.ts`:调用路径改 `/api/v3/...`,类型引用 `schema.d.ts`
+- 再改 `src/features/`、`src/pages/` 里的调用点
+- 调用仍走 `src/request.ts`,路径硬编码完整 URL,**没有 baseURL**
+- 按上面「灰度开关」那节加 `VITE_V3_RESOURCES` 开关
+
+**`openapi-fetch` 尚未引入。** 它能把路径字面量与 HTTP 方法也纳入类型检查
+(现在手写 URL 拼错了编译器抓不到),但**由用户自己安装**,agent 不要装。
+用户确认装好之前,按上面的写法走。
+
+### 7. 处理 v2 旧路由
+
+**过渡期内不要删。** 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}` 动态调用可能传入的资源名。
+
+## 验收:CI 三道闸
+
+```bash
+cd api-v13 && vendor/bin/pint --dirty --format agent && php artisan test --compact
+cd openapi && php scripts/generate.php && git diff --exit-code   # spec 没重生成就失败
+cd dashboard-v6 && npm run build                                  # 前端类型对不上就失败
+```
+
+三条都要过。**没有这三道闸,上面所有规范半年内都会腐化**——这比任何文档都重要。
+(`tests/Feature/ExampleTest.php` 是历史遗留失败,可忽略。)
+
+## 禁止事项
+
+- 不要一次迁多个资源
+- 不要在迁移里夹带无关重构(看到问题写 `// FIXME:`)
+- 不要手写前端类型,不要手改 `resources/auto/`
+- **不要擅自安装依赖**(含 `openapi-fetch`)。需要就说明理由,等用户装完通知
+- 不要保留 `view=` 开关,不要保留 `ok:false` + HTTP 200(**仅指新建的 v3 端点**;
+  v2 那份原样不动)
+- **不要删 B 类和 C 类的 v2 路由**,v4 在线上用着
+- 不要把逻辑在 v2 和 v3 各写一遍,抽到 Service 共用
+- 不要凭字段名猜类型,一律查库核实
+- 改完留在工作区,**不要自动 git commit**
+
+## 收尾报告
+
+用一句话说清:迁了哪个资源、v2 的哪几个端点/view 映射成了 v3 的哪几个端点、
+补了几条契约测试、三道闸的结果。

+ 240 - 0
CLAUDE.md

@@ -0,0 +1,240 @@
+# CLAUDE.md
+
+wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。本仓库是 monorepo。
+
+## 目录状态(重要)
+
+**活跃维护:**
+
+| 目录 | 是什么 | 技术栈 |
+| --- | --- | --- |
+| `api-v13/` | 后端,唯一在维护的 API | Laravel 13 / PHP 8.5 / Pest 4 / Inertia 3 |
+| `dashboard-v6/` | 前端主站 | React 19 / Vite / antd 6 / Redux Toolkit |
+| `openapi/` | API 规格与在线文档(Swagger UI) | 规格由脚本从 `api-v13` 源码生成 |
+| `docker/` | 容器工具链与开发环境 | |
+| `magnolia/`、`ai-translate/`、`langchain-server/`、`open-ai-server/`、`sutta-pitaka-importer/`、`scripts/` | 周边服务与工具 | |
+
+**冻结但仍在线上运行(代码不要改,但它的依赖决定了后端能动什么):**
+
+- `dashboard-v4/` —— 线上生产前端,依赖 161 条 `/api/v2` 路径。
+  **破坏 v2 就是线上事故**,详见下面「过渡期」一节。真实代码在
+  `dashboard-v4/dashboard/src`,不是 `dashboard-v4/src`。
+
+**已冻结,不再维护,不要改动、不要参照其写法、不要当作现状依据:**
+
+- `api-v8/`
+- `coconut/`
+- `tulip/`
+
+用户说“后端”指 `api-v13`,说“前端”指 `dashboard-v6`。在这两个目录之外动手前先确认。
+
+## 后端 api-v13
+
+- 详细的 Laravel/Pest/Pint 约定见 `api-v13/CLAUDE.md`(Laravel Boost 生成),在该目录工作时必须遵守。
+- 路由集中在 `routes/api.php`:`/api/v2/*` 是主力业务接口(约 140 个 `apiResource`),
+  `/api/v3/*` 是新版检索与阅读接口,`/api/ops/*` 是运维接口,需
+  `Authorization: Bearer <APP_OPS_TOKEN>`。
+- 响应统一封装 `{ok, message, data}`(见 `app/Http/Controllers/Controller.php` 的
+  `ok()` / `error()`)。**业务失败时 HTTP 状态码仍可能是 200,以 `ok` 字段为准**,
+  新代码沿用这个约定。
+- 列表接口的通用查询参数:`search`、`order`、`dir`、`limit`、`offset`。
+- 响应结构尽量走 `app/Http/Resources/*Resource`。
+- 改完 PHP 跑 `vendor/bin/pint --dirty --format agent`;测试 `php artisan test --compact`,
+  测试连独立的 `mint_test` 库(不要让 `RefreshDatabase` 打到开发库)。
+- `storage/resources` 是 git submodule(clove)。
+
+## 前端 dashboard-v6
+
+- `src/request.ts` 是唯一的请求封装(原生 fetch,导出 `get/post/put/patch/delete_/upload/download/graphql`)。
+  **没有 baseURL**:每个调用点硬编码完整路径 `/api/v2/...`,开发期由 `vite.config.ts` 的
+  proxy 转发到 `http://127.0.0.1:8000`。新增调用请沿用这个写法。
+- `src/api/index.ts` 里的 `api_url()` 和 `VITE_API_BASE` 是死代码,没有任何地方使用。
+- 目录:`features/`(业务模块)、`pages/`、`components/`、`reducers/` + `store.ts`(RTK)、
+  `locales/`(react-intl 多语言)。
+- 命令:`npm run dev` / `npm run build` / `npm run lint`。
+- **接口类型不要手写**,从 OpenAPI 生成(见下)。RTK 只用了普通 slice,
+  没有 RTK Query / react-query / SWR,不要擅自引入数据层。
+
+### 接口类型生成(agent 自动执行,不用问)
+
+`dashboard-v6/src/api/schema.d.ts` 由 OpenAPI 规格生成,**已提交进版本库**,
+不要加进 `.gitignore`。生成命令(在仓库根目录执行):
+
+```bash
+npx openapi-typescript openapi/public/assets/protocol/main.yaml -o dashboard-v6/src/api/schema.d.ts
+```
+
+**何时必须重跑(满足任一条就直接跑,不必询问用户):**
+
+- 跑过 `openapi/scripts/generate.php` 之后 —— 规格变了,类型就得跟着变
+- 改了 api-v13 的路由、控制器 docblock、FormRequest 规则、或 Resource 的
+  `@return array{...}` 声明
+- 前端要用某个接口或字段,但 `schema.d.ts` 里查不到它
+- 前端构建报接口类型相关的错误,怀疑类型是旧的
+
+跑完记得把 `schema.d.ts` 的改动一并留在工作区,它是这次改动的一部分。
+
+`src/api/<资源>.ts` 里的类型从它引用,不要再手写字段:
+
+```ts
+import type { paths } from "./schema";   // 注意:不写 .d.ts 后缀
+
+type ChannelResponse =
+  paths["/v2/channel-name/{name}"]["get"]["responses"][200]["content"]["application/json"];
+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` 取。
+
+## OpenAPI 规格
+
+`openapi/public/assets/protocol/` 下的规格**全部自动生成,不要手改**。
+
+```bash
+cd openapi
+php scripts/generate.php   # 从 api-v13 的 route:list + 控制器 AST 重新生成
+npm run lint               # redocly 校验,应为 0 error
+```
+
+- 生成器解析控制器的文档注释、`$request->input()/query()`、`switch($request->input('view'))`
+  的 case、`validate()` 规则、以及返回的 `*Resource::toArray()` 键。
+- **接口参数说明写在控制器的文档注释里**,用 `@queryParam` / `@bodyParam` / `@urlParam`
+  标签(`@标签 名字 类型 [required] 描述`,描述里可写 `Enum:` / `Default:` / `Example:`),
+  生成器会解析成精确的 OpenAPI 参数。示例见 `ChannelController@index`。
+- **响应字段的类型来自 Resource 的 `@return array{...}` 声明**;没写声明就只能按
+  字段名猜(会猜错,如 `channels.type` 是 varchar 却被猜成 integer)。
+  范例见 `api-v13/app/Http/Resources/ChannelResource.php`。
+- 不适合写进注释的长篇补充才放 `openapi/public/assets/protocol/overrides/<同名 slug>.yaml`,
+  深度合并到自动结果之上(注意数组是整体替换)。
+- **后端路由或控制器有改动,就重跑生成器**,否则规格立刻过时。
+- 细节见 `openapi/README.md`。
+
+## 过渡期:v2 是线上冻结面(最高优先级,先读这节)
+
+**`dashboard-v4` 正在线上运行,已冻结不再升级,它依赖 161 条 v2 路径。**
+`dashboard-v6` 还在测试上线阶段。等 v6 稳定后才会下线 v4。在那之前:
+
+> **破坏 v2 = 线上崩,而且不会有人发现** —— v4 无人维护、没有测试、改了也没人跑。
+
+### 调用面分区(2026-09 盘点)
+
+| 分区 | 谁在用 | 条数 | 策略 |
+| --- | --- | --- | --- |
+| **A** | 只有 v6 | 3 | 可自由迁 v3。`heartbeat`、`chapter-content`、`paragraph-content` |
+| **B** | v4 + v6 | 118 | v2 **原地冻结**,在 v3 另建新端点,v6 灰度切过去 |
+| **C** | 只有 v4 | 43 | **什么都不做**。不补注释、不写测试、不迁移,等 v4 下线整块删 |
+
+判断某条路径属于哪区:
+
+```bash
+grep -rn "/v2/channel" dashboard-v4/dashboard/src   # 命中 = v4 在用,动不得
+grep -rn "/api/v2/channel" dashboard-v6/src
+```
+
+**v4 的真实代码在 `dashboard-v4/dashboard/src`**(不是 `dashboard-v4/src`),
+且有一批绕过请求封装的裸 URL(antd Upload 的 `action`、导出 `href`),grep 时别漏。
+
+### 铁律
+
+1. **不得改变 v2 的响应形状、字段名、状态码。** 加字段是安全的,改/删不是。
+2. **修 bug 修在 Service 层**,v2 控制器只当适配层,不再新增业务逻辑。
+   这样同一处修复能同时惠及 v2 与 v3——这是 v2 改动成为 v3 资产的唯一途径。
+3. **碰之前先录快照测试**(characterization test)。不必一次给 161 条都补,
+   但要动哪条就先把它当前的真实响应录下来,改完形状没变才算修对。
+4. **migration 只准加,不准改删**:加列必须 nullable 或带默认值;
+   禁止删列、改列名、改类型、加 NOT NULL。v4 的代码在读这些列,而你不会去改 v4。
+5. **删除任何 v2 路由前**,必须先枚举 `dashboard-v4/dashboard/src` 里两处动态调用
+   (`components/corpus/BookTreeWithTags.tsx:44`、`components/dict/SearchVocabulary.tsx:102`
+   的 `/v2/${api}`)可能传入的资源名。在枚举完成前,v2 路由一律不准删。
+
+### v4 与 v3 的关系(已澄清)
+
+v4 有两处引用 v3,**均不构成生产依赖**:
+
+- `components/chat/ChatInput.tsx:127` 调 `/v3/search-suggest`——所在的三个页面
+  线上不可达,用户会彻底封闭该组件。
+- `services/agentApi.ts:14` 硬编码 `http://localhost:8000/api/v3`,是未完成的开发残留,
+  生产环境必然请求失败。
+
+所以 **v3 端点可以自由改**。仍建议改 v3 时顺手 grep 一下 v4,成本近乎为零。
+
+### 退出条件
+
+v6 全量稳定 → 下线 v4 → 删掉 C 类与 B 类的 v2 残留 → v2 前缀整体下线。
+
+## v3 重构
+
+正在把资源从 `/api/v2` 逐个迁到 `/api/v3`。原则是 `声明 → 生成 → 契约测试`:
+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,不受影响。
+
+**做迁移时使用 `v3-resource` skill**,里面有完整的七步链路与模板。
+一次只迁一个资源。试点顺序:先 A 类 3 条(零线上风险,用来打磨样板与 CI 三道闸),
+再从 `channel` 开始啃 B 类。
+
+## 渐进式还债
+
+这个代码库有六年历史,欠债很多。规则是**碰到什么还什么**,但必须让每次的还债量
+小到人能 review 完。三条硬约束:
+
+**1. 只还你这次真正改到的那个方法的债,不扩散。**
+改 `ChannelController@index` 就只清理 `index`,同文件的 `store`、`destroy` 一律不动,
+哪怕一眼就看到问题。看到了就加 `// FIXME:`,留给下次碰到它的人。
+
+**2. 功能改动和还债拆成两个 commit。**
+先 `feat`/`fix` 提交功能本身,再 `refactor` 提交清理。这样 review 时能分开看,
+清理动作出了问题也能单独回滚。
+
+**3. 每次改动结尾,用一句话列出还了哪些债。**
+比如「顺带:`index` 补了 docblock、switch 加了 default 保护」。没列出来的清理动作
+等于没做过 review。
+
+碰到旧方法时按这个顺序还(做得到就做,做不到就 FIXME):
+
+- 补中文 docblock 与 `@queryParam` / `@bodyParam` 标签(规格直接受益)
+- 返回值走 `app/Http/Resources/` 的 Resource,而不是 `return $this->ok($model)` 吐原始模型
+- 给碰到的 Resource 的 `toArray()` 补 `@return array{...}` 声明(响应类型直接受益)
+- `$this->error()` 的参数位置写对:`error(string $message, mixed $data, int $status)`
+- `switch` 补 `default` 分支,避免 `$table` 未定义直接 500
+- `$this->error(...)` 后面漏掉的 `return` 补上
+- 给你这次碰到的 view 值补测试
+
+**不要做的事:** 不要为了"顺手"重写整个控制器、不要改动你没读懂的逻辑、
+不要在功能改动里夹带大范围重构。拿不准就写 FIXME,成本最低。
+
+现有的 FIXME 清单:`grep -rn FIXME api-v13/app/Http/Controllers/`
+
+## 测试约定
+
+**每个 view 值都要有测试覆盖。** 列表接口的 `view` 参数是业务路径分叉,每个值背后
+是一段独立的查询与权限逻辑,新增的那段代码没人跑过,最可能藏 bug。
+
+- **新增 view 值 → 必须补 Pest 测试**,至少两条:一条验证它返回预期数据,
+  一条验证权限不足时被拒。模板见 `tests/Feature/ChannelUserEditListTest.php`,
+  `makeStudio` / `makeChannel` / `authHeader` 是现成 helper。
+- **新增字段 → 不用改测试**。项目里的断言都是 `toHaveKeys(...)` 这种「包含即可」
+  的写法,没有 `assertExactJson` / `assertJsonStructure`,多返回一个字段不会打破谁。
+- 需要改已有测试的只有:改了现有字段的名字或类型、改了权限规则、改了现有 view 的返回口径。
+- 测试连独立的 `mint_test` 库,`RefreshDatabase` 不会清开发库。
+
+## 工作约定
+
+- 改完代码留在工作区,等用户 review,不要自动 `git commit`。
+- 文档文件只在用户明确要求时创建。
+- **不要擅自安装或升级任何依赖**(npm、composer 都一样)。需要新依赖就说明理由,
+  等用户自己装完通知你。