|
|
@@ -39,6 +39,17 @@ grep -rn "/v2/channel" dashboard-v4/dashboard/src # 命中 = v4 在用
|
|
|
grep -rn "/api/v2/channel" dashboard-v6/src
|
|
|
```
|
|
|
|
|
|
+> **动 v3 端点时这张表不适用。** v3 有第三个消费者:独立仓库
|
|
|
+> `/home/deploy/workspace/wikipali-mobile`(移动端,开发阶段未上线,用
|
|
|
+> openapi-fetch 调 /api/v3/*)。改 v3 之前三个前端都要 grep:
|
|
|
+>
|
|
|
+> ```bash
|
|
|
+> grep -rn "v3/xxx" dashboard-v4/dashboard/src dashboard-v6/src \
|
|
|
+> /home/deploy/workspace/wikipali-mobile/src
|
|
|
+> ```
|
|
|
+>
|
|
|
+> 2026-09 漏过一次,把 mobile 正在用的 tipitaka-read-para / -chapter 删掉了。
|
|
|
+
|
|
|
| 分区 | 谁在用 | 条数 | 迁移策略 |
|
|
|
| ----- | ------- | ---- | ----------------------------------------------------------- |
|
|
|
| **A** | 只有 v6 | 3 | 自由迁。`heartbeat`、`chapter-content`、`paragraph-content` |
|
|
|
@@ -89,19 +100,33 @@ nginx 伺服;无论改代码、改 `.env` 还是改 ops 的配置,都要**
|
|
|
v2 用 `src/request.ts` 的 `HttpError`,两边不共用类型。等 v2 调用点全部消失,
|
|
|
`request.ts` 整个删掉,不牵连 v3。
|
|
|
|
|
|
-### 逻辑下沉到 Service
|
|
|
+### Service 怎么用:不为共享去改 v2
|
|
|
+
|
|
|
+**不要把 v2 控制器里的逻辑抽到 Service 再让两边共用。** 抽取动作本身就要改 v2,
|
|
|
+而一个 Service 同时服务两套契约,最后一定会长出开关和分支——正是本 skill 禁止的兼容代码。
|
|
|
+**两个都照顾,两个都照顾不好。**
|
|
|
+
|
|
|
+按代码种类分:
|
|
|
|
|
|
-不要把逻辑在 v2 和 v3 各写一遍,那样 v2 的紧急修复成不了 v3 的资产,反而双倍维护。
|
|
|
+| 种类 | 怎么办 |
|
|
|
+| --- | --- |
|
|
|
+| **契约相关**:查询构造、权限判定、响应组装、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 +191,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 +204,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,24 +238,82 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
|
|
|
|
|
|
它自带 `render()`,优先级高于所有处理器。**5xx 的 detail 在生产环境会被兜底
|
|
|
处理器抹掉**(防泄露),所以「预期的 5xx、文案必须送达」的场景必须用它,
|
|
|
- 不能用 `abort(503)`——见 `HeartbeatV3Controller` 的停机分支。
|
|
|
+ 不能用 `abort(503)`——见 `V3\HeartbeatController` 的停机分支。
|
|
|
|
|
|
**v2 的 `ok()` / `error()` 不准出现在 v3 控制器里。**
|
|
|
|
|
|
+ ### 写操作各自返回什么
|
|
|
+
|
|
|
+ | 动作 | 状态码 | 响应体 |
|
|
|
+ | --- | --- | --- |
|
|
|
+ | `store` | 201 | **新建的资源本身**(调用方需要 id 才能后续引用) |
|
|
|
+ | `update` | 200 | 更新后的资源 |
|
|
|
+ | `destroy` | **204** | **空**。硬删就是删了,没有资源可回 |
|
|
|
+
|
|
|
+ 三条依据都指向 204:RFC 9110 §9.3.5 说动作已执行且「no further information is
|
|
|
+ to be supplied」就用 204;GitHub 的 star/unstar 与 reactions 删除都是 204 空体;
|
|
|
+ Laravel 的 `assertNoContent()` 默认就是 204 **且断言响应体为空**——框架把这个
|
|
|
+ 组合当成了默认预期。
|
|
|
+
|
|
|
+ ```php
|
|
|
+ public function destroy(Request $request, Reaction $reaction)
|
|
|
+ {
|
|
|
+ $this->reactions->remove($reaction, $this->currentUser($request)['user_uid']);
|
|
|
+
|
|
|
+ return response()->noContent();
|
|
|
+ }
|
|
|
+ ```
|
|
|
+
|
|
|
+ **mutation 不返回共享聚合值**——计数、总数、排名、排行位次。理由不是洁癖:
|
|
|
+
|
|
|
+ - 它当场就可能是错的(你删的同时别人在点,返回的数字发出去就过期了)
|
|
|
+ - 它给了假的权威感,前端会拿它当真值缓存
|
|
|
+ - 它把「改一次状态」和「读一次聚合」耦在一起,将来想给聚合加缓存都不好加
|
|
|
+
|
|
|
+ X(Twitter) 的 like / unlike 只回 `{"data":{"liked":false}}`,连计数都不给;
|
|
|
+ GitHub 干脆 204。前端用乐观更新 ±1,下次读聚合端点时自然校正——这是
|
|
|
+ TanStack Query 文档里的标准套路(`onMutate` 快照 + 失败回滚 +
|
|
|
+ `onSettled` invalidate)。聚合值有自己的读端点,mutation 不该再抄一份。
|
|
|
+
|
|
|
+ **判据:这个值客户端自己算得出来吗?**
|
|
|
+
|
|
|
+ - 算得出来(计数 ±1、列表里去掉一项)→ 不回,前端乐观更新
|
|
|
+ - 算不出来且当场就要用(服务端分配的 id、剩余配额、余额)→ 回
|
|
|
+
|
|
|
+ > **软删是例外**:行还在、只是标了 `deleted_at`,资源并没有消失。按 AIP-164
|
|
|
+ > 这种情况应该返回**更新后的资源**(200),不是 204。测试也要跟着换成
|
|
|
+ > `assertSoftDeleted()`——软删的模型用 `assertModelMissing()` 会失败,因为行还在表里。
|
|
|
+
|
|
|
+ **destroy 的测试两条都要写**,它们抓的失败不一样:
|
|
|
+
|
|
|
+ ```php
|
|
|
+ $this->deleteJson("/api/v3/me/reactions/{$mine->id}", [], authHeader($uid))
|
|
|
+ ->assertNoContent(); // 只有它:可能返回 204 但压根没删
|
|
|
+ $this->assertModelMissing($mine); // 只有它:删对了但多吐了不该有的 body
|
|
|
+ ```
|
|
|
+
|
|
|
+ **前端注意**:`openapi-fetch` 对 204 返回 `{ data: undefined }`,而
|
|
|
+ `client.ts` 的 `unwrap()` 见到 `data === undefined` 会当失败、弹提示再抛异常。
|
|
|
+ 所以 204 端点要用 `unwrapVoid()`(只判状态码,不取 body)。
|
|
|
+
|
|
|
3. **资源名用复数名词**:`/v3/channels/{uid}`。不要 `/v2/channel-my-number` 这种
|
|
|
动词化端点。(注意:现有 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 +324,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 +352,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 +370,276 @@ 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. **用户建立的实质内容:软删除 + 乐观锁。**
|
|
|
+
|
|
|
+ **哪些算**:用户亲手产出、删错了会心疼的东西——`article`、译文(`sentence`)、
|
|
|
+ 术语(`dhamma_terms` / `user_dicts`)、`collection`、`channel`。
|
|
|
+ **哪些不算**:操作记录、日志、聚合、reactions、view、recent——可重建,硬删就好。
|
|
|
+
|
|
|
+ 判据一句话:**删错了用户会心疼吗?**
|
|
|
+
|
|
|
+ > **缺列时停下来问用户,不要自己加 migration。** 加列是不可逆的库结构变更,
|
|
|
+ > 由用户决定。这跟「不要擅自安装依赖」是同一条规矩。
|
|
|
+
|
|
|
+ ### 软删除
|
|
|
+
|
|
|
+ 现状(2026-09 实测):**16 张表已经有 `deleted_at` 列**(articles / channels /
|
|
|
+ collections / dhamma_terms / user_dicts / sentences / attachments / chats …),
|
|
|
+ 但**只有 `Sentence` 挂了 `SoftDeletes` trait**。所以多数情况下不需要 migration,
|
|
|
+ 缺的是代码。
|
|
|
+
|
|
|
+ **加 trait 之前必须查三件事**——模型是 v2/v3 共享的,加 trait 会同时改变 v2 的行为:
|
|
|
+
|
|
|
+ 1. **业务唯一索引**。软删的行仍占着唯一键,用户删了再建会撞。实测目标表的唯一
|
|
|
+ 索引只有主键,安全;但 `likes` 的 `UNIQUE(type, target_id, user_id)` 会中招——
|
|
|
+ 这类表不要软删(reactions 正因如此是硬删)。
|
|
|
+ 2. **裸 `DB::table()` 查询绕过全局作用域**,会把已删的行算进来。实测
|
|
|
+ `dhamma_terms` 3 处、`sentences` 2 处,其余 0。逐个看过再动。
|
|
|
+ 3. **v4 没有回收站 UI**。软删对它等同于删除,可接受;但别指望 v4 能恢复。
|
|
|
+
|
|
|
+ API 形态按 AIP-164:
|
|
|
+
|
|
|
+ - `destroy` → **200 + 更新后的资源**,不是 204。资源没消失,只是标了 `deleted_at`,
|
|
|
+ 204「没有内容可给」的前提不成立。
|
|
|
+ - 恢复 → `POST /v3/xxx/{id}/restore`(补充路由 + 单动作控制器)。
|
|
|
+ AIP 写成 `:undelete`,但冒号动作不符合本项目的路由判据。
|
|
|
+ - 测试用 `assertSoftDeleted()`,**不能用 `assertModelMissing()`**——行还在表里,会失败。
|
|
|
+
|
|
|
+ ### 乐观锁
|
|
|
+
|
|
|
+ 为什么值得做:两个人同时编辑同一条译文,后保存的会**静默覆盖**前一个人的工作,
|
|
|
+ 而且没有人会发现。这是这个项目最值得防的一类数据丢失。
|
|
|
+
|
|
|
+ **机制用 ETag + `If-Match` → 412,不要在 body 里塞 `version` + 409。**
|
|
|
+ RFC 9110 把 412 Precondition Failed 定义成「条件请求的前置条件不成立」,这正是
|
|
|
+ 丢失更新的语义;409 Conflict 说的是「请求与资源当前状态冲突」(用户名已占用那种)。
|
|
|
+ 混用会让客户端和缓存分不清。412 的教科书用例就是乐观并发控制。
|
|
|
+
|
|
|
+ ```
|
|
|
+ GET /v3/articles/{id} → 200, ETag: "7"
|
|
|
+ PUT /v3/articles/{id} If-Match: "7"
|
|
|
+ version 仍是 7 → 写入,version + 1,返回新 ETag
|
|
|
+ version 已变 → 412 Precondition Failed
|
|
|
+ PUT 不带 If-Match → 428 Precondition Required
|
|
|
+ ```
|
|
|
+
|
|
|
+ **ETag 不能用 `updated_at` 生成。** 实测所有目标表的 `updated_at` 是
|
|
|
+ `timestamp(0)`,只有秒精度——同一秒内的两次更新分辨不出来,而那恰恰是乐观锁
|
|
|
+ 要防的场景。需要一个自增的 version 列,**缺就问用户**。
|
|
|
+ (`sentences.version` 与 `sentences.ver` 已存在且全仓库无引用,是死列,可以直接用。)
|
|
|
+
|
|
|
+ **前端:412 不能静默重试**——那等于把别人的改动覆盖掉,比不做乐观锁还糟。
|
|
|
+ 必须提示「这条在你编辑期间被改过」,给出重新加载或查看差异的入口。
|
|
|
+
|
|
|
+ **生成器目前不支持声明请求/响应头**(只有 `@queryParam` / `@bodyParam` /
|
|
|
+ `@urlParam`)。做第一个乐观锁资源时要先给它加 header 支持,否则 ETag 与
|
|
|
+ If-Match 不会进规格,前端类型也拿不到。
|
|
|
+
|
|
|
+9. **批量操作:路由和控制器都要带 `batch` 字样。**
|
|
|
+
|
|
|
+ ```php
|
|
|
+ // 字面量段必须排在 apiResource 之前,否则 /terms/{term} 会把 batch 当成 id
|
|
|
+ Route::post('terms/batch', BatchTermController::class); // 批量创建
|
|
|
+ Route::post('terms/batch-delete', BatchDeleteTermController::class); // 批量删除
|
|
|
+ Route::apiResource('terms', TermController::class);
|
|
|
+ ```
|
|
|
+
|
|
|
+ **不要把批量伪装成单条端点。** 反面教材就在本仓库:`DELETE /v2/userdict` 的
|
|
|
+ `id` 参数收的是 JSON 数组字符串,从 URL 和方法名完全看不出它一次能删一批。
|
|
|
+ 调用方读文档才知道,审计日志里也分不清。
|
|
|
+
|
|
|
+ **批量删除用 POST,不用 DELETE。** RFC 9110 §9.3.5:「A client SHOULD NOT
|
|
|
+ generate content in a DELETE request. Content received in a DELETE request has
|
|
|
+ no generally defined semantics, cannot alter the meaning or target of the
|
|
|
+ request, and might lead some implementations to reject it.」——要删哪些只能靠
|
|
|
+ 请求体传,而 DELETE 带 body 既无定义语义,也可能被中间件拒掉。Google 的
|
|
|
+ AIP-235 同理用 `POST …:batchDelete`(冒号动作不合本项目的路由判据,用
|
|
|
+ `/batch-delete` 这个段代替)。
|
|
|
+
|
|
|
+ **默认整体事务:全成功或全失败。** 别默默部分成功——调用方拿到 200 却不知道
|
|
|
+ 有三条没进去,是最难排查的一类 bug。AIP-233 / 235 也是原子为默认,部分成功
|
|
|
+ 必须由 `return_partial_success` 这类显式开关打开,并在响应里逐条给出失败原因。
|
|
|
+ 真要支持部分成功,就得设计一个逐条结果的响应体,那是另一个契约,别顺手做。
|
|
|
+
|
|
|
+ **必须有条数上限,写进 FormRequest。** 不设上限的话一次请求就能打垮库:
|
|
|
+
|
|
|
+ ```php
|
|
|
+ 'ids' => ['required', 'array', 'min:1', 'max:200'],
|
|
|
+ 'ids.*' => ['uuid'],
|
|
|
+ ```
|
|
|
+
|
|
|
+ 响应:
|
|
|
+
|
|
|
+ | 动作 | 状态码 | 响应体 |
|
|
|
+ | --- | --- | --- |
|
|
|
+ | 批量创建 | 201 | 创建出来的资源列表(调用方要 id) |
|
|
|
+ | 批量删除(硬删) | **204** | 空 |
|
|
|
+ | 批量删除(软删) | 200 | 更新后的资源列表(同第 8 条) |
|
|
|
+
|
|
|
+ 别返回「成功了几条」这种计数——原子事务下它恒等于请求的条数,是废话;
|
|
|
+ 非原子才需要,而非原子本来就不该是默认。
|
|
|
+
|
|
|
+10. **写操作的入参:`store` 按业务必填,`update` 只做 PATCH。**
|
|
|
+
|
|
|
+ ### 只注册 PATCH,不注册 PUT
|
|
|
+
|
|
|
+ Laravel 的 `apiResource` 把两个动词路由到**同一个** `update()`
|
|
|
+ (`ResourceRegistrar:402`:`$this->router->match(['PUT', 'PATCH'], $uri, $action)`)——
|
|
|
+ 框架只给一个方法承载两种语义。注册了 PUT 却做局部更新,是在契约上撒谎;
|
|
|
+ 真做全量替换,客户端漏传一个字段就误删。所以只暴露实现了的那一个:
|
|
|
+
|
|
|
+ ```php
|
|
|
+ Route::apiResource('channels', ChannelController::class)->except(['update']);
|
|
|
+ Route::patch('channels/{channel}', [ChannelController::class, 'update'])
|
|
|
+ ->whereUuid('channel');
|
|
|
+ ```
|
|
|
+
|
|
|
+ PUT 随之返回 405,这是对的——它本来就没实现。
|
|
|
+
|
|
|
+ ### FormRequest:store 分必填,update 一律 sometimes
|
|
|
+
|
|
|
+ ```php
|
|
|
+ // StoreChannelRequest —— 业务上真的必填才写 required
|
|
|
+ 'name' => ['required', 'string', 'max:255'],
|
|
|
+ 'lang' => ['required', 'string', 'size:7'],
|
|
|
+ 'summary' => ['sometimes', 'nullable', 'string'],
|
|
|
+
|
|
|
+ // UpdateChannelRequest —— 一个 required 都不要有
|
|
|
+ 'name' => ['sometimes', 'string', 'max:255'],
|
|
|
+ 'lang' => ['sometimes', 'string', 'size:7'],
|
|
|
+ 'summary' => ['sometimes', 'nullable', 'string'],
|
|
|
+ ```
|
|
|
+
|
|
|
+ ### 控制器只用 `validated()`,绝不用 `$request->input('x')`
|
|
|
+
|
|
|
+ ```php
|
|
|
+ $channel->fill($request->validated())->save(); // 只有传了的字段会被改
|
|
|
+ ```
|
|
|
+
|
|
|
+ **反面教材在 v2 的 `ChannelController@update`:**
|
|
|
+
|
|
|
+ ```php
|
|
|
+ $channel->summary = $request->input('summary'); // 没传 → null → 清空
|
|
|
+ ```
|
|
|
+
|
|
|
+ 用户只想改个名字,`summary` 没传就被写成 null,**没有任何报错**。
|
|
|
+ `input()` 分不清「没传」和「传了 null」,`validated()` 分得清——这是两者的
|
|
|
+ 根本差别,不是风格问题。
|
|
|
+
|
|
|
+ ### 这条修的是一个现在就存在的矛盾
|
|
|
+
|
|
|
+ 实测 v2 生成的 `v2-channel-channel.yaml` 里,**PATCH 也把 5 个字段声明成必填**
|
|
|
+ (因为 PUT / PATCH 共用同一份 docblock)。前端从这份规格生成类型,结果是
|
|
|
+ 「局部更新」必须传全部字段。只保留 PATCH 之后,required 列表为空,规格与实现
|
|
|
+ 才对得上。
|
|
|
+
|
|
|
+ ### PATCH 涵盖不了什么
|
|
|
+
|
|
|
+ 「传全部字段的 PATCH」**不等于** PUT:PUT 的语义是「没传的字段重置为默认/空」,
|
|
|
+ PATCH 永远只认你传了什么。真需要「把这条记录整个重置」,用
|
|
|
+ `POST /v3/xxx/{id}/reset` 这种补充路由明确表达——靠 PUT 顺带表达重置,
|
|
|
+ 客户端漏传一个字段就变成误删,正是上面那个毛病。
|
|
|
+
|
|
|
+ 社区口径一致:GitHub 用 PATCH,Stripe 用 POST 做局部更新,Google AIP-134
|
|
|
+ 只用 PATCH + `update_mask`(`*` 才是全量替换)。**本项目不需要 `update_mask`**——
|
|
|
+ protobuf 要它是因为分不清「没传」和「传了零值」,PHP 的 `validated()` 分得清。
|
|
|
+
|
|
|
+11. **面向用户的文案一律走 `__()`,不准硬编码。**
|
|
|
|
|
|
翻译文件在 **`api-v13/resources/lang/{locale}/`**(注意不是 Laravel 11+ 默认的
|
|
|
`lang/`,本项目把 langPath 指到了 resources 下)。支持 8 个语言:
|
|
|
@@ -318,7 +672,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=¶=
|
|
|
+ 领域-功能开头,坐标降成 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 +777,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 +830,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 +892,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 +975,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 +997,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 +1102,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 对照表」加一行。**
|