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

docs: 记录 v3 重构过程中定下的约定

这轮重构里定的规则,不写下来下一个 agent 会照着代码抄而不是照着规则抄。

CLAUDE.md:

- 铁律 2 从「修 bug 修在 Service 层,惠及 v2 与 v3」换成决策树。改 v2 只有两个
  理由:必须 1 小时内修好的,以及会污染/丢失数据库数据的。其余一律「bug 报告 =
  迁移触发器」,迁 v3、在 v3 修、v6 同 commit 切过去。第二条不是为了照顾 v4
  用户——v4 和 v6 共用一个数据库,v2 的写入端点污染的是 v6 也在读的那批行。
- Service 分界线:契约相关的不共享、v3 重写进 App\Services\V3\;纯能力共享现成的。
  「不要为了共享逻辑去改 v2」——抽取动作本身就要改 v2。
- 铁律 3 的快照测试缩到只服务上面那两条分支,不必给 161 条都补。
- 「已登记的改名例外」改成「v2 → v3 对照表」:改名不需要审批,但改了要留映射。
- v3 的类进 V3 子命名空间,类名不带 V3。

skill:

- 新增路由四道判据(领域-功能开头 / 必填进路径 / 变量≤2 / 归属校验进路径),
  附「砍掉查询串还成立吗」的检验法和非层级参数的例外。明确写了这是本项目的约定
  不是行业标准——Fielding 本人说过 REST API must not define fixed resource
  names or hierarchies,RFC 3986 把 path 与 query 都定义成标识资源。
- /v3/me/ 的判断法:{owner} 会不会有 me 以外的值 → magic ID 还是独立端点。
- 硬规范新增第 5 条「控制器只是胶水层」。官方那句「10 行以内即可」是以项目静态
  为前提的,本仓库六年下来的经验相反:业务逻辑散落在一百多个控制器里之后既没法
  复用也没法单测。所以规则比官方更硬,没有「少量可以接受」这条缝。
- 第 7 条认证扩成完整约定:auth.v3 中间件怎么挂、为什么别写在控制器第一行。
- keyset 游标、Resource 不准查库(含受限列与 $keyType 两个陷阱、N+1 守卫测试的
  写法)、路由约束必须挂在每条路由上(RouteRegistrar 的 where 是替换不是合并)、
  路由文件按领域拆、生成器现在支持什么。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 недель назад
Родитель
Сommit
e3369b20d4
2 измененных файлов с 389 добавлено и 44 удалено
  1. 314 33
      .claude/skills/v3-resource/SKILL.md
  2. 75 11
      CLAUDE.md

+ 314 - 33
.claude/skills/v3-resource/SKILL.md

@@ -89,19 +89,33 @@ nginx 伺服;无论改代码、改 `.env` 还是改 ops 的配置,都要**
 v2 用 `src/request.ts` 的 `HttpError`,两边不共用类型。等 v2 调用点全部消失,
 `request.ts` 整个删掉,不牵连 v3。
 
-### 逻辑下沉到 Service
+### Service 怎么用:不为共享去改 v2
 
-不要把逻辑在 v2 和 v3 各写一遍,那样 v2 的紧急修复成不了 v3 的资产,反而双倍维护。
+**不要把 v2 控制器里的逻辑抽到 Service 再让两边共用。** 抽取动作本身就要改 v2,
+而一个 Service 同时服务两套契约,最后一定会长出开关和分支——正是本 skill 禁止的兼容代码。
+**两个都照顾,两个都照顾不好。**
+
+按代码种类分:
+
+| 种类 | 怎么办 |
+| --- | --- |
+| **契约相关**:查询构造、权限判定、响应组装、view 分支 | **不共享,v3 重写** |
+| **纯能力**:与请求/响应形状无关,进去模型出来数据 | **共享现成的** |
+
+现成的纯能力 Service(已经在被两边共享,继续用):
 
 ```
-                 ┌─ v2 Controller(冻结的适配层,只做形状转换)
-Service(唯一逻辑)┤
-                 └─ v3 Controller(FormRequest + Resource 新契约)
+PaliContentService   ← ChapterContentController(v2) / V3\TipitakaReadingController / …
+OpenSearchService    ← TipitakaContentController(v2) / V3\SearchPlusController / …
+UserService          ← 全仓库(v2 经 UserApi 转发壳,v3 直接注入)
 ```
 
-现成范例:`TipitakaReadParaController` 注入 `PaliContentService`。
-迁移时把 v2 控制器里的业务逻辑抽到 Service,v2 控制器改成薄适配层——
-**但它的响应形状、字段名、状态码一个都不准变**,v4 会碎。
+v3 需要而现成 Service 没有的逻辑,**v3 自己写一份**;v2 里那份同名逻辑原地烂着,
+等 v4 下线一起删。
+
+**那 v2 的 bug 怎么办?** 常规做法是「bug 报告 = 迁移触发器」:迁 v3、在 v3 修、
+v6 同 commit 切过去,v2 那份不碰。只有两种情况直接改 v2——必须 1 小时内修好的,
+以及会污染 / 丢失数据库数据的(v4 和 v6 共用一个库)。详见根目录 CLAUDE.md 铁律 2。
 
 ### migration 只准加不准删
 
@@ -166,12 +180,12 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    **成功:直接 return Resource,控制器里没有任何 helper。**
 
    ```php
-   return ChannelV3Resource::make($channel);                              // 单个
-   return ChannelV3Resource::collection($query->paginate($per_page));     // 列表
+   return ChannelResource::make($channel);                              // 单个
+   return ChannelResource::collection($query->paginate($per_page));     // 列表
    ```
 
-   所有 `*V3Resource` 继承 `App\Http\Resources\V3Resource`。它只做一件事:
-   让 `::collection()` 产出 `V3ResourceCollection`,裁掉分页里的绝对 URL
+   所有 v3 Resource 继承 `App\Http\Resources\V3\BaseResource`。它只做一件事:
+   让 `::collection()` 产出 `BaseResourceCollection`,裁掉分页里的绝对 URL
    (顶层 `links`、`meta.path`、`meta.links` 都是 `APP_URL` 拼的,反代下会拼错,
    前端也用不到)。其余全交给框架:`meta` 里的 `current_page` / `per_page` /
    `total` / `last_page` / `from` / `to` 都是 paginator 自己算的。
@@ -179,7 +193,7 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    载荷不是 Eloquent 模型时直接用基类,`additional()` 补 meta:
 
    ```php
-   return V3Resource::collection($items)->additional(['meta' => [
+   return BaseResource::collection($items)->additional(['meta' => [
        'current_page' => $page, 'total' => $total, 'has_more' => $more,
    ]]);
    ```
@@ -213,7 +227,7 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
 
    它自带 `render()`,优先级高于所有处理器。**5xx 的 detail 在生产环境会被兜底
    处理器抹掉**(防泄露),所以「预期的 5xx、文案必须送达」的场景必须用它,
-   不能用 `abort(503)`——见 `HeartbeatV3Controller` 的停机分支。
+   不能用 `abort(503)`——见 `V3\HeartbeatController` 的停机分支。
 
    **v2 的 `ok()` / `error()` 不准出现在 v3 控制器里。**
 
@@ -221,16 +235,20 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    动词化端点。(注意:现有 v3 端点 search / progress / tipitaka-read-para 是早期
    遗留的单数命名,新资源不要照抄。)
 
-   **类名用 `<v2 资源名>V3Controller`**,不要另起炉灶起新名字:
+   **v3 的类放在 `V3\` 子命名空间,名字里不带 `V3`**,资源名沿用 v2 的:
 
    | v2 | v3 |
    | --- | --- |
-   | `HeartbeatController` | `HeartbeatV3Controller` |
-   | `ChannelController` | `ChannelV3Controller` |
-   | `DhammaTermController` | `DhammaTermV3Controller` |
+   | `App\Http\Controllers\HeartbeatController` | `App\Http\Controllers\V3\HeartbeatController` |
+   | `App\Http\Controllers\ChannelController` | `App\Http\Controllers\V3\ChannelController` |
+   | `App\Http\Resources\ChannelResource` | `App\Http\Resources\V3\ChannelResource` |
+   | — | `App\Http\Requests\V3\IndexChannelRequest` |
+
+   命名空间已经说明了版本,类名再带一次是冗余;同名也不冲突。基类是
+   `App\Http\Resources\V3\BaseResource`。测试保持 `tests/Feature/` 扁平,
+   文件名用 `<资源>V3Test` 区分。
 
-   Resource、FormRequest、测试同理:`HeartbeatV3Resource`、
-   `IndexChannelV3Request`、`HeartbeatV3Test`。
+   **判据:`/v3/*` 路由指向的控制器,全都应该在 `Controllers/V3/` 下。**
 
    理由:新旧类会长期共存(v4 下线前 v2 一直在),名字带上 v2 的资源名才能
    一眼看出父子关系。**不要按新端点的语义另起名**——`/v3/heartbeat` 别叫
@@ -241,6 +259,10 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    `/v2/heartbeat` → `/v3/heartbeat`,不要改叫 `/v3/health`。
    改名会切断 v2↔v3 的对应关系,迁移期内两套并存,对不上号就很难排查。
 
+   **但改名是允许的,不需要审批**——v3 是破坏性重构,v2 有不少名字本来就名不副实。
+   改了就往根目录 CLAUDE.md 的「v2 → v3 对照表」加一行。那张表不是门禁是地图:
+   看到 v3 的名字与 v2 对不上时先查表,别当成错误去"修正"。
+
    唯一允许的调整是**按 REST 规范做复数化**(集合用复数,单例保持单数):
 
    | v2 | v3 | 说明 |
@@ -265,14 +287,14 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
 4. **分页交给框架。**
 
    ```php
-   return ChannelV3Resource::collection($query->paginate($request->integer('per_page', 20)));
+   return ChannelResource::collection($query->paginate($request->integer('per_page', 20)));
    ```
 
    `paginate()` 自己会读 `page` 查询参数,count、页数、边界全由框架算,
    `meta` 由 `V3ResourceCollection` 输出(已裁掉绝对 URL)。控制器里只写业务查询。
 
-   只有**不是 Eloquent 查询**的端点才手写 meta(例如 `TipitakaReadChapterController`
-   按字节累加切页),用 `V3Resource::collection($items)->additional(['meta' => [...]])`,
+   只有**不是 Eloquent 查询**的端点才手写 meta(例如 `V3\TipitakaReadingController`
+   按字节累加切页),用 `BaseResource::collection($items)->additional(['meta' => [...]])`,
    **对外形状必须与框架一致**:`{data: [...], meta: {...}}`,键名跟 snake_case。
 
    **列表端点必须能把结果集收敛到可读范围。** 不加条件就能拉全表的端点是设计
@@ -283,9 +305,102 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    收敛之后性能不是问题:实测 sentences 按 channel+book 过滤后 3,019 行,
    `count(*)` 8ms,offset 5000 仍是 4ms。不需要 `cursorPaginate`。
 
-5. **时间一律 ISO 8601 UTC;ID 一律字符串。** 不要让同一字段在不同端点类型摇摆。
-6. **认证统一 Sanctum bearer。** 不准从 cookie 里摸 user_id 这类旁路。
-7. **面向用户的文案一律走 `__()`,不准硬编码。**
+   **但"下载全量"这类端点必须用 keyset 游标,不能把结果集捞进 PHP。**
+   实测最大的 channel 有 **522,718 段、跨 217 本书**:
+
+   | 做法 | 代价 |
+   | --- | --- |
+   | `->pluck()` 全量再在 PHP 里切片 | 821 ms,**峰值内存 281 MB** |
+   | keyset 取一页 | **3 ms** |
+
+   keyset 的三条约定:
+
+   - **游标用行值比较**:`whereRaw('(book_id, paragraph) > (?, ?)')`。比
+     `book > ? or (book = ? and para > ?)` 短,而且能走复合索引。
+   - **游标对客户端不透明**:服务端在 `meta.next_cursor` 给出,客户端原样回传,
+     `null` 表示取完。内部形式(`{book}-{para}`)是实现细节。
+     这跟"路径里不要放复合 id"不矛盾:复合 id 要人手写,游标是机器回传的 token。
+   - **越界不报错**:keyset 语义下"游标在范围之后"就是空结果。游标既然由服务端给,
+     再去校验它落在哪个区间就没有意义了。
+
+   **进度用的 `total` / `remaining` 只在带了收敛 filter 时给**,无 filter 时省略
+   (schema 里声明成可选),让调用方用 `next_cursor` 判断结束。无 filter 时
+   `count(distinct …)` 在 52 万行上要几百毫秒,每翻一页算一次不划算。
+
+5. **控制器只是胶水层:粘 request、service、response,不放业务逻辑。**
+
+   ```php
+   public function __invoke(TipitakaReadingRequest $request, string $channel)
+   {
+       $page = $this->reading->page($channel, $request->validated());
+
+       return ReadParagraphResource::collection($page['items'])
+           ->additional(['meta' => $page['meta']]);
+   }
+   ```
+
+   控制器里**只允许**出现这四件事:取 `validated()`、调 Service、包 Resource、
+   `abort()` / `throw`。查询构造、权限判定、分页切块、聚合统计、领域规则一律进 Service。
+
+   **「少量业务逻辑写在控制器里也还行」是个陷阱。** Laravel 官方说控制器保持在
+   10 行以内即可,那是以项目静态为前提的。本仓库六年下来的经验正相反:项目刚开始
+   业务逻辑很少,后来逐渐增加,等到不可控时已经散落在一百多个控制器里,既没法复用
+   也没法单测。所以这里的规则比官方更硬——**无论多少,业务逻辑一律进 Service,
+   没有"少量可以接受"这条缝**。有缝就会被撑开。
+
+   **自检:控制器文件里除了入口方法,还有别的方法吗?** 有就是漏了。
+   (`Concerns\` 下的共用胶水 trait 不算——它提供的是取当前用户这类跨控制器的
+   粘合动作,不是业务逻辑。)
+   反面教材(本仓库真实发生过):`TipitakaReadingController` 一度 314 行,带着
+   `scopedQuery` / `chapterRange` / `applyCursor` / `sliceByBytes` / `paragraphLengths`
+   / `progressMeta` / `filterInclude` 七个业务方法——那是个穿着控制器外衣的 Service。
+
+   **v3 自己的 Service 放 `App\Services\V3\`**,与控制器的 `V3\` 对齐:
+
+   | 放哪 | 什么 |
+   | --- | --- |
+   | `App\Services\`(根) | 纯能力,v2/v3 共享:`PaliContentService`、`OpenSearchService`、`UserService` |
+   | `App\Services\V3\` | v3 契约相关:查询构造、权限判定、分页游标、领域规则 |
+
+   这跟「不为共享逻辑去改 v2」不冲突:那条说的是**不要和 v2 共用**,不是说写进控制器。
+   v3 重写出来的逻辑,落点是 `App\Services\V3\`。
+
+   Service 返回**普通数组 + `@return array{...}` 声明**(仓库现有风格,见
+   `PaliContentService::readParagraph`),不引入 DTO 层。
+
+   好处不只是好看:业务逻辑在 Service 里可以直接单元测,不必走 HTTP。本仓库的
+   `TipitakaReadingSliceTest` 为了测一个纯函数,得 `new class(app(PaliContentService::class))
+   extends Controller` 造匿名子类——这就是逻辑放错地方的味道。
+
+6. **时间一律 ISO 8601 UTC;ID 一律字符串。** 不要让同一字段在不同端点类型摇摆。
+7. **认证走 `auth.v3` 中间件,只认 bearer。**
+
+   ```php
+   // 整组挂
+   Route::prefix('me')->middleware('auth.v3')->group(...);
+
+   // 或按方法挂——读公开、写要登录,不必为此改 URL
+   Route::apiResource('channels', ChannelController::class)
+       ->middlewareFor(['store', 'update', 'destroy'], 'auth.v3');
+   ```
+
+   `App\Http\Middleware\V3\Authenticate`(别名 `auth.v3`)做三件事:挡掉没带
+   bearer 的请求、校验 token、把解出的用户放进 request attributes。控制器用
+   `Concerns\ResolvesCurrentUser` 的 `currentUser($request)` 取,不重复解 JWT;
+   可选登录的端点(如 tally)不挂中间件,用 `currentUserUid($request)`,没登录返回 null。
+
+   **别在控制器第一行写 `if (! $user) throw`。** 中间件跑在 FormRequest 之前,
+   所以「未登录 + 参数非法」得到的是 401 而不是 422——这是语义上正确的顺序,
+   写在控制器里就拿不到。
+
+   **只认 bearer 这件事在中间件里自己挡**:`AuthService::current()` 取不到 bearer
+   时会回落读 `$_COOKIE['user_uid']`,那是 v2 的旁路。不要去改 `AuthService`——
+   它是 v2 也在用的共享代码(铁律 2)。
+
+   **鉴权和 URL 形状是两件事**:要让某些动作需要登录,挂中间件就行,不需要把它们
+   塞进 `/v3/me/` 前缀。而且中间件只管得了 401,「钥匙对不对」的 403 得靠 Policy
+   或 Service 里抛 `AuthorizationException`。
+8. **面向用户的文案一律走 `__()`,不准硬编码。**
 
    翻译文件在 **`api-v13/resources/lang/{locale}/`**(注意不是 Laravel 11+ 默认的
    `lang/`,本项目把 langPath 指到了 resources 下)。支持 8 个语言:
@@ -318,7 +433,85 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    > 所以目前 API 响应永远是 `en`,`Accept-Language` 与 `?lang=` 均无效。
    > 文案仍然必须外置到翻译文件(否则将来想修也修不了),但别指望它现在会变中文。
 
-## 路由怎么设计(Laravel 官方形态)
+## 路由怎么设计
+
+### 先过四道判据
+
+**这四条是本项目的约定,不是行业标准。** REST 从没规定过 URL 长什么样——Fielding
+本人写过 "A REST API **must not** define fixed resource names or hierarchies",
+RFC 3986 也把 path 与 query 都定义成「标识资源」,只区分层级 / 非层级。所以别去找
+"正确答案",照下面四条走:**一致比正确值钱**。
+
+**① 路径开头是「领域-功能」,不是通用坐标。**
+
+`channel + book + para` 在 wikipali 里是**通用坐标**——sentences、wbw、批注、进度
+全都用它。把坐标放路径开头,每个新能力都要来抢同一个前缀,而且看不出这条 API 干什么:
+
+```
+✗  /v3/channels/{channel}/books/{book}/paragraphs/{para}
+       坐标开头。看不出是"读三藏"还是别的;还会跟将来真正的 /v3/channels 资源混
+       (生成器按路径第一段打 tag,这两条会被归到 channels 标签下)
+
+✓  /v3/tipitaka-reading/{channel}?book=&para=
+       领域-功能开头,坐标降成 filter
+```
+
+「领域-功能」写成**一个段**(`tipitaka-reading`),不要写成 `tipitaka/reading` 两段:
+裸命名空间段不能解引用(`GET /v3/tipitaka` 是 404),而一个段后面跟 `{id}` 是正常的
+集合形态。
+
+**② 必填进路径,可选留查询串。**
+
+| 方向 | 规则 | 依据 |
+| --- | --- | --- |
+| 必填 → 路径 | 它在指认"是哪一个",属层级数据 | RFC 3986 §3.3 |
+| 路径 → 必填 | 路径参数**没有"可选"这回事** | OpenAPI 强制 `required: true` |
+
+**检验方法:把问号后面整段砍掉,请求还成立吗?** 成立就对了。
+
+副作用是好的:这条逼你给每个可选参数定默认值。
+
+**例外——本质非层级的参数,即使必填也留查询串**:自由文本(`/v3/search?key=`,
+检索词里一个斜杠就把路径劈开)、多值集合(`?ids=a,b,c`)。判断方法:这个值能不能
+安全地当一个路径段?值域是 id / slug / 数字就能,是用户输入的任意文本就不能。
+
+**③ 路径变量最多两个,最好只有一个。**
+
+```
+✓  /v3/channels/{channel}
+✓  /v3/studios/{studio}/articles/{article}
+✗  /v3/channels/{channel}/books/{book}/paragraphs/{para}      三个,太多
+```
+
+超过两个就回头想:**是不是有参数其实是 filter?是不是业务逻辑该重新定义?**
+`tipitaka-reading` 就是这么从三个降到一个的——重新想清楚「这个 API 是为阅读和下载
+设计的,只有 channel 必填,book / para 是过滤条件,不给就是取整个 channel」。
+
+**④ 需要校验归属关系 → 路径。**
+
+`->scoped()` 只对**路径参数**生效,框架自动验证 channel 确实属于那个 studio;
+写成 `?studio=x` 就得自己写一遍权限判断。
+
+### `/v3/me/` 什么时候用
+
+问一句:**`{owner}` 这个位置,将来会不会出现 `me` 以外的值?**
+
+- **会** → **magic ID**:`GET /v3/studios/me/channels`。一个路由模板、一份权限逻辑,
+  `me` 只是 `{studio}` 的取值之一(Gmail 的 `users/me`,Google AIP-122 明确认可)。
+  配套要守 AIP-122 那条 must:**响应里回真实 id,不能回 `"me"`**。
+- **不会** → **独立端点**:`/v3/me/reactions`。它与 `/v3/reactions` 必填参数相反
+  (那边 `target_id` 必填,这边不能有)、权限相反,是两个契约,不是别名。
+
+**别把鉴权和 URL 形状绑在一起。** 想用中间件挡 401 不需要路径前缀:
+`Route::apiResource(...)->middlewareFor(['store','update'], 'auth.v3')`,或者控制器
+实现 `HasMiddleware`,都能按方法挂。而且中间件只管得了 401,「钥匙对不对」的 403
+它做不了——前缀买到的东西比看上去少。
+
+反面:`me` 定义成"我有钥匙的资源"会让同一个资源有两个 URL(我协作的 channel 同时
+出现在 `/v3/me/channels` 和 `/v3/studios/{g}/channels`),这正是 AIP-122 的
+canonical name 要防的,也是 GitHub `/user/repos` 被诟病的形态。
+
+### Laravel 官方的四种形态
 
 官方对这件事只有一句话,在 Supplementing Resource Controllers 一节:
 
@@ -345,6 +538,47 @@ Route::apiResource('studios.channels', StudioChannelController::class)->scoped()
 Route::apiSingleton('courses.members.channel', MemberChannelController::class);
 ```
 
+**路由约束必须挂在每条路由上,不能挂在 prefix 组上链式调用。**
+`RouteRegistrar` 的 `where` 是**整体替换而不是合并**,后一个会把前一个顶掉,
+而且不报错——约束静默失效,非法参数一路打到数据库才炸:
+
+```php
+// ✗ whereUuid('channel') 被 whereNumber('book') 顶掉了,channel 没有约束
+Route::prefix('channels/{channel}/books/{book}')
+    ->whereUuid('channel')->whereNumber('book')->group(...);
+
+// ✓ 挂在 Route 对象上,Route::where() 是合并
+Route::get('...', Xxx::class)->whereUuid('channel')->whereNumber(['book', 'para']);
+```
+
+顺带一条契约后果:**路径参数格式不对是 404 不是 422**(匹配不上任何路由),
+查询参数格式不对才是 422(走 FormRequest)。写测试时别搞反。
+
+### 路由文件按领域拆
+
+`routes/api.php` 里 v2 有 156 条声明、134 行 import,v3 铺开后还要翻倍。
+**按领域拆成 `routes/v3/<领域>.php`**,与 `dashboard-v6/src/components/` 的领域对齐,
+找东西只有一个心智模型。
+
+```php
+// routes/api.php —— 前缀和名字前缀只在这里加一次
+Route::prefix('v3')->as('v3.')->group(function () {
+    require __DIR__.'/v3/system.php';
+    require __DIR__.'/v3/tipitaka.php';
+    require __DIR__.'/v3/channel.php';
+    require __DIR__.'/v3/interaction.php';
+});
+```
+
+三条注意:
+
+- **子文件里不要再包一层 group**,漏写会让路由落到 `/api/` 根下**而且不报错**
+- **不要用 `glob()`**:跨文件的注册顺序由 require 顺序决定,glob 会变成按字母排且看不见
+- **按需建文件,不要一次建十几个空的**——空文件会让人以为那个域已经在迁了
+
+`withRouting(api: [...])` 也接受文件数组,但那样每个文件都得重复 `prefix('v3')`
+的包装,漏一个就静默出错,所以用单入口 require。
+
 动词化端点的统一处理:
 
 | v2 模式 | v3 |
@@ -357,7 +591,7 @@ Route::apiSingleton('courses.members.channel', MemberChannelController::class);
 
 控制器内部:每个原 view 分支变成 Model 的 local scope(`scopePublic`、`scopeEditableBy`),
 index 用 `spatie/laravel-query-builder` 把 scope 暴露成 `filter[]`,
-控制器方法回到 10 行以内(官方 Keep Controllers Thin)。
+scope 和 filter 的组装写在 Service 里(硬规范第 5 条),控制器只负责调它。
 
 ## 迁移七步
 
@@ -419,6 +653,39 @@ public function toArray($request): array
 `key?:` 表示只在部分口径下返回;`|null` 会生成 `nullable: true`。
 范例见 `api-v13/app/Http/Resources/ChannelResource.php`。
 
+**Resource 不准查库。** v2 到处是 `UserApi::getByUuid($this->editor_id)` 这种逐行
+查询(仓库里约 30 处),一页 15 行就是 15~30 次查询。v3 的做法:
+
+```php
+// 模型:uuid 列对 user_infos.userid,三参数 belongsTo(同款见 Channel::owner())
+public function user(): BelongsTo     { return $this->belongsTo(UserInfo::class, 'user_id', 'userid'); }
+public function aiModel(): BelongsTo  { return $this->belongsTo(AiModel::class, 'user_id', 'uid'); }
+
+// 控制器:列白名单收在 UserService 里,别手抄
+$query->with(UserService::eagerLoadActor());
+
+// Resource:use ResolvesActor,一行
+'user' => $this->actor($this->user, $this->aiModel),
+```
+
+三个要知道的点:
+
+- **受限列必须含匹配列**(`userid` / `uid`),漏了关系会**静默对不上、整列变 null**,
+  不报错。所以列白名单收在 `UserService::eagerLoadActor()`,不要在控制器里手抄。
+- 顺带的安全收益:`->first()` 是 `SELECT *`,会把 `user_infos.password`、
+  `ai_models.key`(API 密钥)逐行捞进内存;受限列挡住了。
+- **关系的 ownerKey 是非主键列时,目标模型要声明 `$keyType`**。`AiModel` 漏了这行,
+  Eloquent 按默认 int 主键走 `whereIntegerInRaw`,把 uuid 全转成 `0`。
+
+**配一条 N+1 守卫测试**,断言的是**不变量**而不是具体数字:
+
+```php
+// 12 行与 2 行的查询数必须相同;硬编码 4 会被 Laravel 内部变动误伤
+expect($countQueriesFor(12))->toBe($countQueriesFor(2));
+```
+
+(`DB::enableQueryLog()` **不清空历史**,两次测量之间要 `DB::flushQueryLog()`。)
+
 ### 3. 测试(契约测试尚未落地,这是已知缺口)
 
 **现状:`hotmeteor/spectator` 未安装,项目里也没有可用的 JSON Schema 校验器,
@@ -469,10 +736,19 @@ lint 必须 0 error。不要手改 `resources/auto/` 下的任何文件。
 
 生成器对 v3 路由(`api/v3/*`)用的是另一套信封,不用你操心:
 
-- 成功响应是 `{data: ...}`(Laravel Resource 的默认包装),
-  `index` 额外带 `meta{page, per_page, total}`
+- 成功响应是 `{data: ...}`(Laravel Resource 的默认包装);
+  **返回 `::collection()` 的就是列表**(带 `meta`),返回 `::make()` 的是单个——
+  按返回值判断,不看方法名叫不叫 `index`,所以单动作控制器也对
 - 错误响应引用 `ProblemDetails`(RFC 9457),不是 v2 的 `{ok, message, data}`
 - 默认挂 401;**有入参时**才挂 422
+- **单动作控制器(`__invoke`)支持**
+- **子命名空间支持**:短类名按控制器的 `use` 语句解析,所以
+  `App\Http\Resources\V3\XxxResource` 能正确找到
+- `Default:` / `Example:` 的字面量会按参数声明的类型转换(整数参数不会配字符串示例)
+
+> 这几条都是 2026-09 补的。**生成器静默失败是这套方案最危险的地方**:找不到 Resource
+> 就返回空 schema、认不出路由就整条漏掉,lint 全过、没有任何报错。改完规格养成
+> 抽查一眼的习惯——`grep -c "type: string" resources/auto/<你的端点>.yaml`。
 
 两个标签控制这部分,写在控制器方法的文档注释里:
 
@@ -482,7 +758,11 @@ lint 必须 0 error。不要手改 `resources/auto/` 下的任何文件。
  *
  * @unauthenticated                       // 该端点无需登录,不要挂 401
  *
- * @responseStatus 503 服务已进入停机维护状态   // 声明额外状态码,响应体是 Problem Details
+ * @responseStatus 503 服务已进入停机维护状态   // 额外状态码。2xx 视为另一种成功口径
+ *                                            //(响应体与 200 同形),非 2xx 是 Problem Details
+ *
+ * @meta next_cursor string 下一块的游标      // 手写 meta 的端点用它声明 meta 字段,
+ *                                            // 不写就按框架的 PaginationMeta 出
  */
 ```
 
@@ -583,11 +863,12 @@ cd dashboard-v6 && npm run build                                  # 前端类型
 - 不要保留 `view=` 开关,不要保留 `ok:false` + HTTP 200(**仅指新建的 v3 端点**;
   v2 那份原样不动)
 - **不要删 B 类和 C 类的 v2 路由**,v4 在线上用着
-- 不要把逻辑在 v2 和 v3 各写一遍,抽到 Service 共用
+- **不要为了共享逻辑去改 v2**:现成的纯能力 Service 直接用,其余 v3 自己写一份
 - 不要凭字段名猜类型,一律查库核实
 - 改完留在工作区,**不要自动 git commit**
 
 ## 收尾报告
 
 用一句话说清:迁了哪个资源、v2 的哪几个端点/view 映射成了 v3 的哪几个端点、
-补了几条契约测试、三道闸的结果。
+补了几条契约测试、三道闸的结果。**改了名的话,提醒用户往 CLAUDE.md 的
+「v2 → v3 对照表」加一行。**

+ 75 - 11
CLAUDE.md

@@ -165,10 +165,38 @@ grep -rn "/api/v2/channel" dashboard-v6/src
 ### 铁律
 
 1. **不得改变 v2 的响应形状、字段名、状态码。** 加字段是安全的,改/删不是。
-2. **修 bug 修在 Service 层**,v2 控制器只当适配层,不再新增业务逻辑。
-   这样同一处修复能同时惠及 v2 与 v3——这是 v2 改动成为 v3 资产的唯一途径。
-3. **碰之前先录快照测试**(characterization test)。不必一次给 161 条都补,
-   但要动哪条就先把它当前的真实响应录下来,改完形状没变才算修对。
+2. **改 v2 只有两个理由,其余一律迁 v3。** 常规做法是「bug 报告 = 迁移触发器」:
+   与其在一份马上要删的代码里修,不如把这一刀花在往前推进上。
+
+   ```
+   发现 bug
+   ├─ ① 必须 1 小时内修好,或
+   │  ② 会污染 / 丢失数据库里的数据(哪怕不急)
+   │     └─ 直接改 v2。这是唯一允许改 v2 的两个理由。
+   └─ 其余一律 → 迁 v3、在 v3 修、v6 同 commit 切过去。v2 那份不碰、不管。
+                 C 类(只有 v4 用)没有 v6 可切,等同于不管。
+   ```
+
+   第 ② 条不是为了照顾 v4 用户——**v4 和 v6 共用同一个数据库**,v2 的写入端点
+   污染的是 v6 也在读的那批行。这一条即使 v4 明天下线也仍然成立。
+   (v4 是公示过的冻结旧版,首页入口指向 v6,角落才是「旧版入口」。普通 bug
+   不回补到旧版,与 Ubuntu LTS / Windows ESU 只修安全和数据问题是同一个口径。)
+
+   **不要为了共享逻辑去改 v2。** 规则按代码种类分:
+
+   | 种类 | 怎么办 | 放哪 |
+   | --- | --- | --- |
+   | 契约相关:查询构造、权限判定、响应组装、view 分支 | **不共享,v3 重写** | `App\Services\V3\` |
+   | 纯能力:与请求/响应形状无关,进去模型出来数据 | **共享现成的** | `App\Services\`(根):`PaliContentService`、`OpenSearchService`、`UserService` |
+
+   现成的纯能力 Service 直接注入用;v3 需要而它没有的,v3 自己写一份,
+   v2 里那份同名逻辑原地烂着,等 v4 下线一起删。**两个都照顾,两个都照顾不好。**
+
+   **「v3 重写」是重写进 `App\Services\V3\`,不是写进控制器。**
+   控制器只是胶水层:取 `validated()`、调 Service、包 Resource、`abort()`,
+   除入口方法外不该有第二个 `protected function`。详见 `v3-resource` skill 硬规范第 5 条。
+3. **改 v2 之前先录快照测试**(characterization test)。只有上面那两条分支会走到
+   这一步——常规路径永远不改 v2,也就不需要给 161 条都补。
 4. **migration 只准加,不准改删**:加列必须 nullable 或带默认值;
    禁止删列、改列名、改类型、加 NOT NULL。v4 的代码在读这些列,而你不会去改 v4。
 5. **删除任何 v2 路由前**,必须先枚举 `dashboard-v4/dashboard/src` 里两处动态调用
@@ -213,10 +241,10 @@ v2 用 `src/request.ts` 的 `HttpError`。等 v2 调用点全部消失,`reques
 列表 `{data: [...], meta: {...}}`,失败 `{type, title, status, detail, instance, errors?}`
 + `application/problem+json`。**判断成败只看 HTTP 状态码。**
 
-控制器里**没有任何响应 helper**:成功直接 `return XxxV3Resource::make()/::collection()`,
+控制器里**没有任何响应 helper**:成功直接 `return XxxResource::make()/::collection()`,
 失败 `abort()` / `throw ValidationException` / `throw BusinessException`,
 由 `bootstrap/app.php` 统一渲染(只接管 `api/v3/*`)。
-`*V3Resource` 继承 `App\Http\Resources\V3Resource`,它负责裁掉分页里的绝对 URL。
+v3 的 Resource 继承 `App\Http\Resources\V3\BaseResource`,它负责裁掉分页里的绝对 URL。
 **v2 的 `ok()` / `error()` 不准出现在 v3 控制器里。**
 
 前端对应(**只管 v3**,v2 的 `request.ts` 错误语义不同,迁移期内不碰):
@@ -229,11 +257,47 @@ v2 用 `src/request.ts` 的 `HttpError`。等 v2 调用点全部消失,`reques
   `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 的类全部放在 `V3\` 子命名空间里,名字里不再带 `V3`:**
+
+```
+app/Http/Controllers/V3/HeartbeatController.php   → App\Http\Controllers\V3\HeartbeatController
+app/Http/Resources/V3/ReactionResource.php        → App\Http\Resources\V3\ReactionResource
+app/Http/Requests/V3/IndexReactionRequest.php     → App\Http\Requests\V3\IndexReactionRequest
+```
+
+命名空间已经把版本说清楚了,类名再带一次是冗余;而且跟 v2 同名也不冲突——
+`App\Http\Controllers\HeartbeatController`(v2)与 `…\V3\HeartbeatController` 并存。
+目录名用 StudlyCase 的 `V3`(PSR-4 要求目录与命名空间段一致,仓库里的先例是 `Library/`)。
+**判据:`/v3/*` 路由指向的控制器,全都应该在 `Controllers/V3/` 下。**
+
+基类在 `App\Http\Resources\V3\BaseResource` / `BaseResourceCollection`
+(原来叫 `V3Resource` / `V3ResourceCollection`)。测试文件保持 `tests/Feature/` 扁平。
+
+**默认沿用 v2 的资源名**:`HeartbeatController` → `V3\HeartbeatController`,
+Resource / FormRequest / 测试同理;URL 只换版本前缀
+(`/v2/heartbeat` → `/v3/heartbeat`),集合复数化(`/v2/channel` → `/v3/channels`)。
+名字对得上,排查时两边能互相找到。
+
+**但改名是允许的,不需要审批。** v3 是破坏性重构,v2 的名字本来就有很多名不副实的;
+遇到该改的就改,输入输出也可以重新设计——反正前后端一起改,没改到的资源继续走 v2 老代码。
+
+**改了就往下面这张表加一行。** 这张表不是门禁,是**对照表**:改名越多,v2↔v3 对不上号
+的排查成本越高,所以每迁一个改了名的资源都要留下映射。
+
+### v2 → v3 对照表
+
+| v2 | v3 | 说明 |
+| --- | --- | --- |
+| `like` / `LikeController` | `reactions` / `V3\ReactionController` 等 | `likes` 表早已不只存点赞:`type` 的实际取值有 `like`、`dislike`、`favorite`、`watch`、`bookmark`、`download` 六种 |
+| (无对应 v2 端点) | `tipitaka-reading/{channel}` / `V3\TipitakaReadingController` | 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 `tipitaka-read-para` / `tipitaka-read-chapter`(两条),也短暂叫过 `channels/{c}/books/{b}/paragraphs\|chapters`,2026-09-24 并成一条 |
+
+**注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
+那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;
+v3 这条只服务单 channel 的只读渲染。前端 `dashboard-v6/src/api/pali-text.ts` 仍在调 v2,
+要真正取代它们还得先把多 channel 与 edit 口径补上。
+
+补偿手段要写进代码:v3 控制器的类注释写明「取代 `GET /v2/xxx`」,模型注释写明底层表名
+(如 `Reaction` 写明仍是 `likes` 表),这样从任一端都能找到另一端。
 
 **做迁移时使用 `v3-resource` skill**,里面有完整的七步链路与模板。
 一次只迁一个资源。试点顺序:先 A 类 3 条(零线上风险,用来打磨样板与 CI 三道闸),