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

Merge pull request #2473 from visuddhinanda/development

Development
visuddhinanda 3 недель назад
Родитель
Сommit
48f354d646
100 измененных файлов с 4908 добавлено и 1647 удалено
  1. 553 33
      .claude/skills/v3-resource/SKILL.md
  2. 103 13
      CLAUDE.md
  3. 13 52
      api-v13/app/Http/Api/AiAssistantApi.php
  4. 36 68
      api-v13/app/Http/Api/UserApi.php
  5. 55 0
      api-v13/app/Http/Controllers/Concerns/ResolvesCurrentUser.php
  6. 0 20
      api-v13/app/Http/Controllers/LikeController.php
  7. 9 2
      api-v13/app/Http/Controllers/ProgressChapterController.php
  8. 0 109
      api-v13/app/Http/Controllers/ProgressController.php
  9. 7 1
      api-v13/app/Http/Controllers/RecentController.php
  10. 9 5
      api-v13/app/Http/Controllers/SentenceController.php
  11. 0 311
      api-v13/app/Http/Controllers/TipitakaReadChapterController.php
  12. 0 125
      api-v13/app/Http/Controllers/TipitakaReadParaController.php
  13. 0 56
      api-v13/app/Http/Controllers/UpgradeController.php
  14. 21 5
      api-v13/app/Http/Controllers/UserDictController.php
  15. 6 5
      api-v13/app/Http/Controllers/V3/HeartbeatController.php
  16. 99 0
      api-v13/app/Http/Controllers/V3/MeReactionController.php
  17. 39 0
      api-v13/app/Http/Controllers/V3/ProgressController.php
  18. 45 0
      api-v13/app/Http/Controllers/V3/ReactionController.php
  19. 52 0
      api-v13/app/Http/Controllers/V3/ReactionTallyController.php
  20. 4 25
      api-v13/app/Http/Controllers/V3/SearchPlusController.php
  21. 2 1
      api-v13/app/Http/Controllers/V3/SearchSuggestController.php
  22. 77 0
      api-v13/app/Http/Controllers/V3/TipitakaReadingController.php
  23. 22 0
      api-v13/app/Http/Controllers/V3/UpgradeController.php
  24. 56 0
      api-v13/app/Http/Middleware/V3/Authenticate.php
  25. 37 0
      api-v13/app/Http/Requests/V3/IndexMeReactionRequest.php
  26. 52 0
      api-v13/app/Http/Requests/V3/IndexProgressRequest.php
  27. 38 0
      api-v13/app/Http/Requests/V3/IndexReactionRequest.php
  28. 33 0
      api-v13/app/Http/Requests/V3/IndexReactionTallyRequest.php
  29. 39 0
      api-v13/app/Http/Requests/V3/StoreMeReactionRequest.php
  30. 47 0
      api-v13/app/Http/Requests/V3/TipitakaReadingRequest.php
  31. 15 2
      api-v13/app/Http/Resources/BookTitleResource.php
  32. 39 0
      api-v13/app/Http/Resources/Concerns/ResolvesActor.php
  33. 11 2
      api-v13/app/Http/Resources/RecentResource.php
  34. 7 7
      api-v13/app/Http/Resources/V3/BaseResource.php
  35. 2 2
      api-v13/app/Http/Resources/V3/BaseResourceCollection.php
  36. 2 2
      api-v13/app/Http/Resources/V3/HeartbeatResource.php
  37. 2 2
      api-v13/app/Http/Resources/V3/ProgressResource.php
  38. 49 0
      api-v13/app/Http/Resources/V3/ReactionResource.php
  39. 34 0
      api-v13/app/Http/Resources/V3/ReactionTallyResource.php
  40. 28 0
      api-v13/app/Http/Resources/V3/ReadParagraphResource.php
  41. 11 0
      api-v13/app/Models/AiModel.php
  42. 80 0
      api-v13/app/Models/Reaction.php
  43. 13 0
      api-v13/app/Providers/AppServiceProvider.php
  44. 13 7
      api-v13/app/Services/AuthService.php
  45. 382 0
      api-v13/app/Services/UserService.php
  46. 69 0
      api-v13/app/Services/V3/ProgressService.php
  47. 138 0
      api-v13/app/Services/V3/ReactionService.php
  48. 269 0
      api-v13/app/Services/V3/TipitakaReadingService.php
  49. 4 0
      api-v13/bootstrap/app.php
  50. 1 0
      api-v13/resources/lang/en/site.php
  51. 1 0
      api-v13/resources/lang/zh-Hans/site.php
  52. 21 22
      api-v13/routes/api.php
  53. 19 0
      api-v13/routes/v3/channel.php
  54. 32 0
      api-v13/routes/v3/interaction.php
  55. 18 0
      api-v13/routes/v3/search.php
  56. 20 0
      api-v13/routes/v3/system.php
  57. 27 0
      api-v13/routes/v3/tipitaka.php
  58. 80 0
      api-v13/tests/Feature/AuthServiceCookieTest.php
  59. 17 4
      api-v13/tests/Feature/ExampleTest.php
  60. 103 0
      api-v13/tests/Feature/ProgressV3Test.php
  61. 316 0
      api-v13/tests/Feature/ReactionV3Test.php
  62. 8 8
      api-v13/tests/Feature/ReadingAnnotationsTest.php
  63. 0 251
      api-v13/tests/Feature/TipitakaReadChapterTest.php
  64. 0 142
      api-v13/tests/Feature/TipitakaReadParaTest.php
  65. 234 0
      api-v13/tests/Feature/TipitakaReadingChunkTest.php
  66. 180 0
      api-v13/tests/Feature/TipitakaReadingRenderTest.php
  67. 234 0
      api-v13/tests/Feature/UserServiceTest.php
  68. 94 0
      api-v13/tests/Feature/V1CookieRemovalTest.php
  69. 0 62
      api-v13/tests/Unit/TipitakaReadChapterSliceTest.php
  70. 50 0
      api-v13/tests/Unit/TipitakaReadingSliceTest.php
  71. 19 0
      dashboard-v6/src/api/client.ts
  72. 488 224
      dashboard-v6/src/api/schema.d.ts
  73. 11 9
      openapi/public/assets/protocol/main.yaml
  74. 23 0
      openapi/public/assets/protocol/overrides/v2-auth-current.yaml
  75. 38 0
      openapi/public/assets/protocol/overrides/v2-channel-channel.yaml
  76. 31 0
      openapi/public/assets/protocol/overrides/v2-chapter.yaml
  77. 20 0
      openapi/public/assets/protocol/overrides/v2-progress-progress.yaml
  78. 77 0
      openapi/public/assets/protocol/overrides/v2-progress.yaml
  79. 11 0
      openapi/public/assets/protocol/overrides/v2-sign-in.yaml
  80. 19 0
      openapi/public/assets/protocol/overrides/v3-tipitaka-reading-channel.yaml
  81. 2 2
      openapi/public/assets/protocol/resources/auto/v2-article-article.yaml
  82. 2 2
      openapi/public/assets/protocol/resources/auto/v2-article.yaml
  83. 13 0
      openapi/public/assets/protocol/resources/auto/v2-auth-current.yaml
  84. 1 0
      openapi/public/assets/protocol/resources/auto/v2-book-title.yaml
  85. 22 0
      openapi/public/assets/protocol/resources/auto/v2-channel-channel.yaml
  86. 1 0
      openapi/public/assets/protocol/resources/auto/v2-chapter.yaml
  87. 2 2
      openapi/public/assets/protocol/resources/auto/v2-course-member.yaml
  88. 3 3
      openapi/public/assets/protocol/resources/auto/v2-discussion.yaml
  89. 0 40
      openapi/public/assets/protocol/resources/auto/v2-like.yaml
  90. 9 0
      openapi/public/assets/protocol/resources/auto/v2-progress-progress.yaml
  91. 1 1
      openapi/public/assets/protocol/resources/auto/v2-progress.yaml
  92. 2 2
      openapi/public/assets/protocol/resources/auto/v2-project.yaml
  93. 4 3
      openapi/public/assets/protocol/resources/auto/v2-recent-recent.yaml
  94. 21 5
      openapi/public/assets/protocol/resources/auto/v2-recent.yaml
  95. 2 1
      openapi/public/assets/protocol/resources/auto/v2-sign-in.yaml
  96. 2 2
      openapi/public/assets/protocol/resources/auto/v2-task.yaml
  97. 2 2
      openapi/public/assets/protocol/resources/auto/v2-terms.yaml
  98. 2 2
      openapi/public/assets/protocol/resources/auto/v2-user.yaml
  99. 2 2
      openapi/public/assets/protocol/resources/auto/v2-userdict.yaml
  100. 1 1
      openapi/public/assets/protocol/resources/auto/v3-heartbeat.yaml

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

@@ -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=&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 +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 对照表」加一行。**

+ 103 - 13
CLAUDE.md

@@ -14,6 +14,12 @@ wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。
 | `docker/` | 容器工具链与开发环境 | |
 | `magnolia/`、`ai-translate/`、`langchain-server/`、`open-ai-server/`、`sutta-pitaka-importer/`、`scripts/` | 周边服务与工具 | |
 
+**同一后端的第三个前端(不在本仓库):**
+
+- `/home/deploy/workspace/wikipali-mobile` —— 移动端,独立 git 仓库,开发阶段未上线。
+  用 `openapi-fetch` 调 `/api/v3/*`,有自己的 `src/api/schema.d.ts`。
+  **改 v3 端点前要 grep 它**,否则会漏掉消费者(2026-09 就漏过一次)。
+
 **冻结但仍在线上运行(代码不要改,但它的依赖决定了后端能动什么):**
 
 - `dashboard-v4/` —— 线上生产前端,依赖 161 条 `/api/v2` 路径。
@@ -61,13 +67,19 @@ wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。
 
 ### 接口类型生成(agent 自动执行,不用问)
 
-`dashboard-v6/src/api/schema.d.ts` 由 OpenAPI 规格生成,**已提交进版本库**,
-不要加进 `.gitignore`。生成命令(在仓库根目录执行):
+`schema.d.ts` 由 OpenAPI 规格生成,**已提交进版本库**,不要加进 `.gitignore`。
+**有两个目标,每次都要生成两份**(第二个在另一个仓库,别漏):
 
 ```bash
+# 在 mint 仓库根目录执行
 npx openapi-typescript openapi/public/assets/protocol/main.yaml -o dashboard-v6/src/api/schema.d.ts
+npx openapi-typescript openapi/public/assets/protocol/main.yaml -o /home/deploy/workspace/wikipali-mobile/src/api/schema.d.ts
 ```
 
+两份内容完全相同,只是分发到两个消费者。mobile 那份的改动留在它自己的工作区,
+由用户决定何时提交。**它也是一道闸**:`cd /home/deploy/workspace/wikipali-mobile && npx tsc --noEmit`
+会把被后端改动打断的调用点精确报出来。
+
 **何时必须重跑(满足任一条就直接跑,不必询问用户):**
 
 - 跑过 `openapi/scripts/generate.php` 之后 —— 规格变了,类型就得跟着变
@@ -159,16 +171,55 @@ grep -rn "/v2/channel" dashboard-v4/dashboard/src   # 命中 = v4 在用,动
 grep -rn "/api/v2/channel" dashboard-v6/src
 ```
 
+> ⚠️ **这张分区表只盘点了 v2 路径的消费者。改 v3 端点时它帮不上忙——
+> v3 另有第三个消费者 `wikipali-mobile`(见上面「目录状态」)。**
+> 动 v3 之前三个都要 grep:
+>
+> ```bash
+> grep -rn "v3/tipitaka" dashboard-v4/dashboard/src dashboard-v6/src \
+>     /home/deploy/workspace/wikipali-mobile/src
+> ```
+>
+> 2026-09 就是漏了第三个,把 mobile 正在用的两个端点删掉了。
+
 **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 条都补,
-   但要动哪条就先把它当前的真实响应录下来,改完形状没变才算修对。
+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 +264,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 +280,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 三道闸),
@@ -305,3 +392,6 @@ Resource / FormRequest / 测试同理。新旧长期共存,名字带上 v2 资
 - 文档文件只在用户明确要求时创建。
 - **不要擅自安装或升级任何依赖**(npm、composer 都一样)。需要新依赖就说明理由,
   等用户自己装完通知你。
+- **不要自动新建 migration。** 加列是不可逆的库结构变更,由用户决定。发现缺列
+  (如软删除的 `deleted_at`、乐观锁的 version)就停下来说明用途,等用户确认再写。
+  改动本身仍受铁律 4 约束:只准加,加列必须 nullable 或带默认值。

+ 13 - 52
api-v13/app/Http/Api/AiAssistantApi.php

@@ -2,66 +2,27 @@
 
 namespace App\Http\Api;
 
-use App\Models\AiModel;
-use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Storage;
+use App\Services\UserService;
 
+/**
+ * **冻结的转发壳,不要往里写逻辑。**
+ *
+ * 实现在 {@see UserService}——AI 助手与人类用户拼的是同一种摘要,原先分成两个类
+ * 只是历史结果,两份代码已经漂出了差异(头像在 testing 环境的处理不一致)。
+ *
+ * @deprecated 用 App\Services\UserService
+ */
 class AiAssistantApi
 {
+    /** @deprecated 用 UserService::assistantByUuid() */
     public static function getByUuid($id)
     {
-        $user = AiModel::where('uid', $id)->first();
-
-        return self::userInfo($user);
+        return app(UserService::class)->assistantByUuid($id);
     }
 
+    /** @deprecated 用 UserService::assistantProfile() */
     public static function userInfo($user)
     {
-        if (! $user) {
-            return [
-                'id' => 0,
-                'nickName' => 'unknown',
-                'userName' => 'unknown',
-                'realName' => 'unknown',
-                'avatar' => '',
-            ];
-        }
-        $data = [
-            'id' => $user->uid,
-            'nickName' => $user->name,
-            'userName' => $user->real_name,
-            'realName' => $user->real_name,
-            'roles' => ['ai'],
-            'sn' => 0,
-        ];
-
-        if ($user->avatar) {
-            $img = str_replace('.jpg', '_s.jpg', $user->avatar);
-            if (App::environment('local')) {
-                $data['avatar'] = Storage::url($img);
-            } else {
-                $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
-            }
-        } else {
-            $logo = null;
-            foreach (config('mint.ai.logo') as $key => $value) {
-                // model / url 均可为 null(新建模型时未必填写),须转字符串避免 strpos 弃用告警
-                if (strpos((string) $user->model, $key) !== false) {
-                    $logo = $value;
-                    break;
-                } elseif (strpos((string) $user->url, $key) !== false) {
-                    $logo = $value;
-                    break;
-                }
-            }
-            $base = config('app.url').'/assets/images/avatar/';
-            if ($logo === null) {
-                $data['avatar'] = $base.'ai-assistant.png';
-            } else {
-                $data['avatar'] = $base.$logo;
-            }
-        }
-
-        return $data;
+        return app(UserService::class)->assistantProfile($user);
     }
 }

+ 36 - 68
api-v13/app/Http/Api/UserApi.php

@@ -2,110 +2,78 @@
 
 namespace App\Http\Api;
 
-use App\Models\AiModel;
-use App\Models\UserInfo;
-use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Storage;
-
+use App\Services\UserService;
+
+/**
+ * **冻结的转发壳,不要往里写逻辑。**
+ *
+ * 实现全部在 {@see UserService}。保留这个类只为一件事:让 v2 的六十多个调用点
+ * 不必改动——改它们要配快照测试,而 dashboard-v4 在线上跑着且无人维护。
+ *
+ * 新代码一律注入 `UserService`。等本类的调用点清零,连同 `app/Http/Api/` 一起删。
+ *
+ * 方法名沿用 v2 的旧名,其中几个名不副实(`getIdByName` 返回的是 uuid,
+ * `getIdByUuid` 返回的是自增主键),`UserService` 那边已改成诚实的名字。
+ *
+ * @deprecated 用 App\Services\UserService
+ */
 class UserApi
 {
+    /** @deprecated 用 UserService::uuidByName() */
     public static function getIdByName($name)
     {
-        return UserInfo::where('username', $name)->value('userid');
+        return self::service()->uuidByName($name);
     }
 
+    /** @deprecated 用 UserService::intIdByUuid() */
     public static function getIdByUuid($uuid)
     {
-        return UserInfo::where('userid', $uuid)->value('id');
+        return self::service()->intIdByUuid($uuid);
     }
 
+    /** @deprecated 用 UserService::intIdByName() */
     public static function getIntIdByName($name)
     {
-        return UserInfo::where('username', $name)->value('id');
+        return self::service()->intIdByName($name);
     }
 
+    /** @deprecated 用 UserService::byId() */
     public static function getById($id)
     {
-        $user = UserInfo::where('id', $id)->first();
-
-        return UserApi::userInfo($user);
+        return self::service()->byId($id);
     }
 
+    /** @deprecated 用 UserService::byName() */
     public static function getByName($name)
     {
-        $user = UserInfo::where('username', $name)->first();
-
-        return UserApi::userInfo($user);
+        return self::service()->byName($name);
     }
 
+    /** @deprecated 用 UserService::byUuid();列表场景用 byUuids(),别循环调这个 */
     public static function getByUuid($id)
     {
-        $user = UserInfo::where('userid', $id)->first();
-        if (! $user) {
-            return AiAssistantApi::getByUuid($id);
-        }
-
-        return UserApi::userInfo($user);
+        return self::service()->byUuid($id);
     }
 
+    /** @deprecated 用 UserService::byUuids(),它返回 uuid => 摘要的映射,不会丢项 */
     public static function getListByUuid($uuid)
     {
+        // 非数组返回 null 是 v2 的既有口径,留在适配层,不要带进 Service
         if (! $uuid || ! is_array($uuid)) {
             return null;
         }
-        $users = UserInfo::whereIn('userid', $uuid)->get();
-        $assistants = AiModel::whereIn('uid', $uuid)->get();
-        $output = [];
-        foreach ($uuid as $key => $id) {
-            foreach ($users as $user) {
-                if ($user->userid === $id) {
-                    $output[] = UserApi::userInfo($user);
-
-                    continue;
-                }
-            }
-            foreach ($assistants as $assistant) {
-                if ($assistant->uid === $id) {
-                    $output[] = AiAssistantApi::userInfo($assistant);
 
-                    continue;
-                }
-            }
-        }
-
-        return $output;
+        return self::service()->listByUuids($uuid);
     }
 
+    /** @deprecated 用 UserService::profile() */
     public static function userInfo($user)
     {
-        if (! $user) {
-            return [
-                'id' => 0,
-                'nickName' => 'unknown',
-                'userName' => 'unknown',
-                'realName' => 'unknown',
-                'avatar' => '',
-            ];
-        }
-        $data = [
-            'id' => $user->userid,
-            'nickName' => $user->nickname,
-            'userName' => $user->username,
-            'realName' => $user->username,
-            'sn' => $user->id,
-        ];
-        if (! empty($user->role)) {
-            $data['roles'] = json_decode($user->role);
-        }
-        if ($user->avatar) {
-            $img = str_replace('.jpg', '_s.jpg', $user->avatar);
-            if (App::environment(['local', 'testing'])) {
-                $data['avatar'] = Storage::url($img);
-            } else {
-                $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
-            }
-        }
+        return self::service()->profile($user);
+    }
 
-        return $data;
+    private static function service(): UserService
+    {
+        return app(UserService::class);
     }
 }

+ 55 - 0
api-v13/app/Http/Controllers/Concerns/ResolvesCurrentUser.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Http\Controllers\Concerns;
+
+use App\Http\Middleware\V3\Authenticate;
+use App\Services\AuthService;
+use Illuminate\Auth\AuthenticationException;
+use Illuminate\Http\Request;
+
+/**
+ * 取当前登录用户——控制器的**胶水**动作,不是业务逻辑。
+ *
+ * 放在 trait 里而不是每个控制器各写一个 private 方法:控制器文件里除了入口方法
+ * 不该再有第二个方法(见 v3-resource skill 硬规范第 5 条)。
+ *
+ * 挂了 `auth.v3` 中间件的路由,用户已经解好放在 request attributes 里,这里直接取,
+ * 不重复解 JWT;没挂中间件的路由(如 tally 那种可选登录)走 {@see self::currentUserUid()}。
+ */
+trait ResolvesCurrentUser
+{
+    /**
+     * 必须登录。挂了 `auth.v3` 的路由上它不可能抛——中间件已经挡过一道了。
+     *
+     * @return array{user_uid: string, user_id: int}
+     *
+     * @throws AuthenticationException 未登录(只会发生在忘挂中间件的路由上)
+     */
+    protected function currentUser(Request $request): array
+    {
+        $user = $request->attributes->get(Authenticate::USER);
+        if (is_array($user)) {
+            return $user;
+        }
+
+        throw new AuthenticationException;
+    }
+
+    /**
+     * 可选登录:取到就返回 uuid,没登录返回 null,不抛异常。
+     *
+     * 与中间件一致只认 bearer——`AuthService::current()` 会回落去读 cookie,
+     * 那是 v2 的旁路,v3 不走。
+     */
+    protected function currentUserUid(Request $request): ?string
+    {
+        if ($user = $request->attributes->get(Authenticate::USER)) {
+            return $user['user_uid'];
+        }
+        if (! $request->bearerToken()) {
+            return null;
+        }
+
+        return AuthService::current($request)['user_uid'] ?? null;
+    }
+}

+ 0 - 20
api-v13/app/Http/Controllers/LikeController.php

@@ -157,24 +157,4 @@ class LikeController extends Controller
             return $this->error(_('auth.failed'), 403, 403);
         }
     }
-
-    public function delete(Request $request)
-    {
-        if (! isset($_COOKIE['user_uid'])) {
-            return $this->error('no login');
-        }
-        $param = [
-            'id' => $request->input('id'),
-            'user_id' => $_COOKIE['user_uid'],
-        ];
-        $del = Like::where($param)->delete();
-        $count = Like::where('target_id', $request->input('target_id'))
-            ->where('type', $request->input('type'))
-            ->count();
-
-        return $this->ok([
-            'deleted' => $del,
-            'count' => $count,
-        ]);
-    }
 }

+ 9 - 2
api-v13/app/Http/Controllers/ProgressChapterController.php

@@ -10,6 +10,7 @@ use App\Models\ProgressChapter;
 use App\Models\Tag;
 use App\Models\TagMap;
 use App\Models\View;
+use App\Services\AuthService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Cache;
@@ -208,6 +209,8 @@ class ProgressChapterController extends Controller
                     ->where('para', $request->input('par'))
                     ->orderBy('progress', 'desc')
                     ->get();
+                // 整个循环共用一次登录判定,不必每行都解一次 JWT
+                $currentUser = AuthService::current($request);
                 foreach ($chapters as $key => $value) {
                     // code...
                     $chapters[$key]->views = View::where('target_id', $value->uid)->count();
@@ -217,13 +220,17 @@ class ProgressChapterController extends Controller
                         ->select('type')
                         ->selectRaw('count(*)')
                         ->get();
-                    if (isset($_COOKIE['user_uid'])) {
+                    // 原先凭据取自 $_COOKIE['user_uid'](v1 遗留的认证绕过)。这段只
+                    // 决定 selected 这个可选字段出不出现,改走 AuthService 后带 bearer
+                    // 的登录用户才拿得到——以前只有揣着 v1 cookie 的浏览器有。
+                    // FIXME: 这里是 N+1,每个 chapter × 每个 type 各查一次。
+                    if ($currentUser) {
                         foreach ($likes as $key1 => $like) {
                             // 查看这些点赞里有没有我点的
                             $myLikeId = Like::where([
                                 'target_id' => $value->uid,
                                 'type' => $like->type,
-                                'user_id' => $_COOKIE['user_uid'],
+                                'user_id' => $currentUser['user_uid'],
                             ])->value('id');
                             if ($myLikeId) {
                                 $likes[$key1]->selected = $myLikeId;

+ 0 - 109
api-v13/app/Http/Controllers/ProgressController.php

@@ -1,109 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Http\Resources\ProgressV3Resource;
-use App\Models\ProgressChapter;
-use Illuminate\Http\Request;
-use Illuminate\Validation\ValidationException;
-
-class ProgressController extends Controller
-{
-    /**
-     * 列出章节翻译进度
-     *
-     * 按 channel 查询各章节的翻译完成度。view 目前只支持 channel 一种口径,
-     * 传其它值返回 422。
-     *
-     * @unauthenticated
-     *
-     * @queryParam view string required 查询口径,目前只有 channel。Enum: channel
-     * @queryParam channels string required channel uid 列表,**下划线分隔**(不是逗号)
-     * @queryParam level integer 只返回该层级及以上的章节,需联查 pali_texts
-     * @queryParam lang string 按译文语言过滤。Example: zh-Hans
-     * @queryParam book integer 按典籍 id 过滤
-     * @queryParam order string 排序字段(progress_chapters 的列)。Default: completed_at
-     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
-     * @queryParam page integer 页码。Default: 1
-     * @queryParam per_page integer 每页数量。Default: 10
-     */
-    public function index(Request $request)
-    {
-        //
-        $select = [
-            'progress_chapters.book',
-            'progress_chapters.para',
-            'progress_chapters.lang',
-            'progress_chapters.progress',
-            'progress_chapters.channel_id',
-            'progress_chapters.title',
-            'progress_chapters.last_chapter_completed_at',
-            'progress_chapters.completed_at',
-            'progress_chapters.updated_at',
-        ];
-        switch ($request->input('view')) {
-            case 'channel':
-                $table = ProgressChapter::select($select)
-                    ->whereIn('progress_chapters.channel_id', explode('_', $request->input('channels', '')));
-                break;
-            default:
-                throw ValidationException::withMessages(['view' => __('site.invalid_parameter')]);
-                break;
-        }
-
-        if ($request->filled('level')) {
-            $table = $table->join('pali_texts', function ($join) {
-                $join->on('progress_chapters.book', '=', 'pali_texts.book')
-                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
-            })->where('pali_texts.level', '<=', (int) $request->input('level'));
-        }
-
-        if ($request->has('lang')) {
-            $table = $table->where('progress_chapters.lang', $request->input('lang'));
-        }
-        if ($request->has('book')) {
-            $table = $table->where('progress_chapters.book', $request->input('book'));
-        }
-        $table = $table->orderBy(
-            'progress_chapters.'.$request->input('order', 'completed_at'),
-            $request->input('dir', 'desc')
-        );
-
-        // 分页与 meta 全由框架算;page 参数 paginate() 自己会读
-        return ProgressV3Resource::collection(
-            $table->paginate($request->integer('per_page', 10))
-        );
-    }
-
-    /**
-     * Store a newly created resource in storage.
-     */
-    public function store(Request $request)
-    {
-        //
-    }
-
-    /**
-     * Display the specified resource.
-     */
-    public function show(string $id)
-    {
-        //
-    }
-
-    /**
-     * Update the specified resource in storage.
-     */
-    public function update(Request $request, string $id)
-    {
-        //
-    }
-
-    /**
-     * Remove the specified resource from storage.
-     */
-    public function destroy(string $id)
-    {
-        //
-    }
-}

+ 7 - 1
api-v13/app/Http/Controllers/RecentController.php

@@ -12,7 +12,13 @@ use Illuminate\Support\Str;
 class RecentController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出 recent
+     *
+     * 按用户拉取阅读记录,view 目前只有 user 一种口径。
+     *
+     * @queryParam view string required 查询口径。Enum: user
+     * @queryParam id string view=user 时的用户 uid,必须是 uuid
+     * @queryParam type string 按记录类型过滤。Example: chapter
      *
      * @return Response
      */

+ 9 - 5
api-v13/app/Http/Controllers/SentenceController.php

@@ -77,16 +77,20 @@ class SentenceController extends Controller
                     ->where('updated_at', '>', $request->input('updated_after', '1970-1-1'));
                 break;
             case 'fulltext':
-                if (isset($_COOKIE['user_uid'])) {
-                    $userUid = $_COOKIE['user_uid'];
+                // 原先凭据取自 $_COOKIE['user_uid'](v1 遗留的认证绕过),而且没有
+                // cookie 时 $userUid 根本没被赋值——未登录访问这个 view 会拿未定义
+                // 变量去查询。改走 AuthService,未登录明确报错。
+                $user = AuthService::current($request);
+                if (! $user) {
+                    return $this->error(__('auth.failed'));
                 }
                 $key = $request->input('key');
                 if (empty($key)) {
                     return $this->error('没有关键词');
                 }
                 $table = Sentence::select($indexCol)
-                    ->where('content', 'like', '%' . $key . '%')
-                    ->where('editor_uid', $userUid);
+                    ->where('content', 'like', '%'.$key.'%')
+                    ->where('editor_uid', $user['user_uid']);
 
                 break;
             case 'channel':
@@ -203,7 +207,7 @@ class SentenceController extends Controller
                 break;
         }
         if (! empty($request->input('key'))) {
-            $table = $table->where('content', 'like', '%' . $request->input('key') . '%');
+            $table = $table->where('content', 'like', '%'.$request->input('key').'%');
         }
 
         $count = $table->count();

+ 0 - 311
api-v13/app/Http/Controllers/TipitakaReadChapterController.php

@@ -1,311 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Http\Resources\V3Resource;
-use App\Models\PaliText;
-use App\Models\Sentence;
-use App\Services\PaliContentService;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use Illuminate\Validation\ValidationException;
-
-class TipitakaReadChapterController extends Controller
-{
-    /**
-     * 字节模式下单块的字节上限。请求超过这个数按这个数算,不报错。
-     */
-    protected const MAX_BYTES_PER_PAGE = 5000;
-
-    /**
-     * 单块最多返回的段落数,两种单位都受它约束。
-     *
-     * 每段要单独渲染一次,块的成本跟段落数成正比而不是跟字节数成正比。段落模式
-     * 直接把 pagesize 压到这个数;字节模式下偈颂体的书平均每段只有 20~30 字节,
-     * 光靠字节上限仍可能切出几百段,所以累加时两个上限谁先到就在哪断块。
-     */
-    protected const MAX_PARAGRAPHS_PER_PAGE = 200;
-
-    /**
-     * 按章节读取译文
-     *
-     * `para` 定位章节(它是章节的起始段落号,章节长度取那一行的 `chapter_len`),
-     * `from` 是本次取数的起点游标,从它开始取 `pagesize` 大小的一块。章节不存在、
-     * 或该 channel 在这个章节里没有任何译文,返回 404。
-     *
-     * 取数的段落集合来自 sentences 表里该 channel 实际有译文的段落,不是章节的
-     * 全部段落——多数译文是残缺的,按 pali_texts 的整段区间切块会切出空块。
-     * 所以 `pagesize` 数的永远是有内容的段落。
-     *
-     * 游标式分块是为了前端的下载进度条与断点续传,meta 里给三个数:
-     * `total_para` 是分母(该 channel 在本章节有多少段),`remaining_para` 是
-     * `last_para` 之后本章节内还剩多少段,进度即
-     * `(total_para - remaining_para) / total_para`;续传时把上次的
-     * `last_para + 1` 当作下次的 `from` 传回来,`remaining_para` 为 0 即取完。
-     *
-     * `first_para` / `last_para` 是本块实际覆盖的段落闭区间,取自切块结果而不是
-     * `data`:渲染为空的段落会被剔出 `data` 但仍算在块内,所以 `data` 可能比这个
-     * 区间短,而续传必须按 `last_para` 推进,否则会卡在那一段上原地打转。
-     * `first_para` 是游标顺延后的落点,可能大于请求的 `from`(即 `current_para`)。
-     *
-     * @unauthenticated
-     *
-     * @queryParam book integer required 典籍 id
-     * @queryParam para integer required 章节的起始段落号,决定本次取数的段落区间
-     * @queryParam from integer 起点游标,从这一段开始取;不传则等于 para,即从章节
-     *             开头取起。可以是章节区间内的任意段落号,落在没有译文的段落上就
-     *             顺延到它之后的第一段。超出章节区间、或它之后已经没有译文,
-     *             返回 422。
-     * @queryParam channel string required 译文 channel 的 uuid
-     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
-     * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
-     *             Enum: display,sentences,all Default: display
-     * @queryParam pagesize integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
-     *             unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际
-     *             生效的值。Default: 10
-     * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
-     *             两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
-     */
-    public function index(Request $request, PaliContentService $paliService)
-    {
-        $data = $request->validate([
-            'book' => 'required|integer',
-            'para' => 'required|integer',
-            'from' => 'integer|min:1',
-            'channel' => 'required|uuid',
-            'format' => 'string|in:html,markdown,react,text',
-            'view' => 'string|in:display,sentences,all',
-            'pagesize' => 'integer|min:1',
-            'unit' => 'string|in:para,byte',
-        ]);
-
-        return $this->chapter(
-            (int) $data['book'],
-            (int) $data['para'],
-            $data['channel'],
-            $data,
-            $paliService
-        );
-    }
-
-    /**
-     * 按 id 读取章节
-     *
-     * 与 index 等价,只是把 book 与 para 合成一个路径参数;游标 `from` 仍走
-     * 查询参数。格式不对或 channel 不是 uuid 返回 422。
-     *
-     * @unauthenticated
-     *
-     * @urlParam tipitaka_read_chapter string required 章节 id,格式 {book}-{para}。Example: 9002-1
-     *
-     * @queryParam from integer 起点游标,从这一段开始取;不传则从章节开头取起。
-     *             超出章节区间返回 422。
-     * @queryParam channel string required 译文 channel 的 uuid
-     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
-     * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
-     *             Enum: display,sentences,all Default: display
-     * @queryParam pagesize integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
-     *             unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际
-     *             生效的值。Default: 10
-     * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
-     *             两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
-     */
-    public function show(Request $request, string $id, PaliContentService $paliService)
-    {
-        $arrId = explode('-', $id);
-        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
-        }
-        $channel = $request->input('channel');
-        if (! Str::isUuid($channel)) {
-            throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
-        }
-
-        $data = $request->validate([
-            'from' => 'integer|min:1',
-            'format' => 'string|in:html,markdown,react,text',
-            'view' => 'string|in:display,sentences,all',
-            'pagesize' => 'integer|min:1',
-            'unit' => 'string|in:para,byte',
-        ]);
-
-        return $this->chapter(
-            (int) $arrId[0],
-            (int) $arrId[1],
-            $channel,
-            $data,
-            $paliService
-        );
-    }
-
-    /**
-     * @param  array{from?: int|string, format?: string, view?: string, pagesize?: int|string, unit?: string}  $param
-     */
-    protected function chapter(
-        int $book,
-        int $para,
-        string $channel,
-        array $param,
-        PaliContentService $paliService
-    ) {
-        $format = $param['format'] ?? 'html';
-        $view = $param['view'] ?? 'display';
-        $unit = $param['unit'] ?? 'para';
-        // 超上限不报错,按上限算;meta.page_size 回的是实际生效的值
-        $pageSize = min(
-            max(1, (int) ($param['pagesize'] ?? 10)),
-            $unit === 'byte' ? self::MAX_BYTES_PER_PAGE : self::MAX_PARAGRAPHS_PER_PAGE
-        );
-        // 游标默认落在章节开头,即第一次调用不必传 para
-        $cursor = max(1, (int) ($param['from'] ?? $para));
-
-        $head = PaliText::where('book', $book)->where('paragraph', $para)->first();
-        if (! $head) {
-            abort(404, __('site.not_found'));
-        }
-        $to = $para + max(1, (int) $head->chapter_len) - 1;
-
-        // 游标必须落在本章节的段落区间里,越界是调用方算错了,直接报错而不是
-        // 悄悄钳到边界——断点续传时静默纠偏会把 bug 藏起来
-        if ($cursor < $para || $cursor > $to) {
-            throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]);
-        }
-
-        // 只取段落号,不取句子内容——最大的章节有一万五千段,整章的句子拉回来没有意义。
-        // 整章都要查,因为 total_para 是进度条的分母,不能只算游标之后的部分。
-        /** @var array<int, int> $translated 升序段落号 */
-        $translated = Sentence::where('book_id', $book)
-            ->whereBetween('paragraph', [$para, $to])
-            ->where('channel_uid', $channel)
-            ->where('strlen', '>', 0)
-            ->distinct()
-            ->orderBy('paragraph')
-            ->pluck('paragraph')
-            ->map(fn ($paragraph) => (int) $paragraph)
-            ->all();
-
-        $total = count($translated);
-        if ($total === 0) {
-            abort(404, __('site.not_found'));
-        }
-
-        $offset = $this->cursorOffset($translated, $cursor);
-        if ($offset === null) {
-            throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]);
-        }
-
-        // 只有按字节切块才需要原文长度,按段落切块完全不必碰 pali_texts
-        $lengths = $unit === 'byte' ? $this->paragraphLengths($book, $translated) : [];
-        $slice = $this->slice($translated, $lengths, $pageSize, $unit, $offset);
-
-        $items = [];
-        foreach ($slice as $paragraphNumber) {
-            $paragraph = $paliService->readParagraph($book, $paragraphNumber, $channel, $format);
-            if (empty($paragraph['display'])) {
-                continue;
-            }
-            $items[] = $this->filterView($paragraph, $view);
-        }
-
-        return V3Resource::collection($items)->additional(['meta' => [
-            // 请求的游标 from 原样回显;实际取到的第一段看 first_para(游标
-            // 落在没有译文的段落上时两者不同)
-            'current_para' => $cursor,
-            // 该 channel 在本章节内有译文的段落总数,进度条的分母
-            'total_para' => $total,
-            // 每块大小带单位(段落数或字节数),不是 Laravel 的 per_page,
-            // 所以单列 page_size + page_size_unit 两个键
-            'page_size' => $pageSize,
-            'page_size_unit' => $unit,
-            'book' => $book,
-            'chapter' => $para,
-            // 本块实际覆盖的段落区间,两端都是闭区间、都是真实存在的段落号。
-            // 取自切出来的这一块,不是取自 data:段落渲染出来是空的会被剔出
-            // data,但仍算在本块之内,last_para 照样要跨过它——否则续传会卡在
-            // 那一段上原地打转。所以 data 可能比 first_para..last_para 短。
-            // first_para 是游标顺延后的落点,可能大于请求的 current_para;
-            // last_para + 1 就是下一块要传的 from。
-            'first_para' => $slice[0],
-            'last_para' => $slice[count($slice) - 1],
-            // last_para 之后本章节内还剩多少段,0 即取完;续传把 last_para + 1 当 from 传回
-            'remaining_para' => $total - $offset - count($slice),
-        ]]);
-    }
-
-    /**
-     * 游标 $cursor 落在升序段落号里的下标:它本身有译文就是它,否则顺延到之后的
-     * 第一段。$cursor 已在章节区间内(调用方先校验过),但可能落在最后一段译文
-     * 之后,那时返回 null。
-     *
-     * @param  array<int, int>  $paragraphs  升序段落号
-     */
-    protected function cursorOffset(array $paragraphs, int $cursor): ?int
-    {
-        foreach ($paragraphs as $offset => $paragraph) {
-            if ($paragraph >= $cursor) {
-                return $offset;
-            }
-        }
-
-        return null;
-    }
-
-    /**
-     * 取这些段落的原文字节数。按首尾段落号一次查回整段区间,避免几千个值的
-     * IN 列表;区间里没有译文的段落多查回来几行,比拼 IN 便宜得多。
-     *
-     * @param  array<int, int>  $paragraphs  升序段落号,至少一个
-     * @return array<int, int> 段落号 => 原文字节数
-     */
-    protected function paragraphLengths(int $book, array $paragraphs): array
-    {
-        return PaliText::where('book', $book)
-            ->whereBetween('paragraph', [$paragraphs[0], $paragraphs[count($paragraphs) - 1]])
-            ->pluck('lenght', 'paragraph')
-            ->all();
-    }
-
-    /**
-     * 从 $offset 起取一块段落号。按段落数时直接切片,按字节时累加原文长度,
-     * 字节数或段落数任一到顶即断块,每块至少一段。
-     *
-     * @param  array<int, int>  $paragraphs  升序段落号
-     * @param  array<int, int>  $lengths  段落号 => 原文字节数,只在 byte 模式用得上
-     * @param  string  $unit  para 按段落数,byte 按字节数
-     * @param  int  $offset  起点下标,调用方保证在范围内
-     * @return array<int, int> 非空
-     */
-    protected function slice(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array
-    {
-        if ($unit !== 'byte') {
-            return array_slice($paragraphs, $offset, max(1, min($limit, self::MAX_PARAGRAPHS_PER_PAGE)));
-        }
-
-        $bytes = 0;
-        $take = 0;
-        $count = count($paragraphs);
-        while ($offset + $take < $count) {
-            $bytes += (int) ($lengths[$paragraphs[$offset + $take]] ?? 0);
-            $take++;
-            if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) {
-                break;
-            }
-        }
-
-        return array_slice($paragraphs, $offset, $take);
-    }
-
-    /**
-     * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
-     *
-     * @param  array{para: int, display: string, sentences: array}  $paragraph
-     */
-    protected function filterView(array $paragraph, string $view): array
-    {
-        return match ($view) {
-            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
-            'all' => $paragraph,
-            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
-        };
-    }
-}

+ 0 - 125
api-v13/app/Http/Controllers/TipitakaReadParaController.php

@@ -1,125 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Http\Resources\V3Resource;
-use App\Services\PaliContentService;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use Illuminate\Validation\ValidationException;
-
-class TipitakaReadParaController extends Controller
-{
-    /**
-     * 按段落区间读取译文
-     *
-     * 阅读模式下按 book + 段落区间取内容,空段落会被跳过。区间由 para 到 to,
-     * 不传 to 则只取一段;to 小于 para 返回 422。
-     *
-     * 这个接口不走 Eloquent 分页,meta 是手工给的:一次请求即一页。
-     *
-     * @unauthenticated
-     *
-     * @queryParam book integer required 典籍 id
-     * @queryParam para integer required 起始段落号
-     * @queryParam to integer 结束段落号(含)。不传则等于 para
-     * @queryParam channel string required 译文 channel 的 uuid
-     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
-     * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
-     *             Enum: display,sentences,all Default: display
-     */
-    public function index(Request $request, PaliContentService $paliService)
-    {
-        $data = $request->validate([
-            'book' => 'required|integer',
-            'para' => 'required|integer',
-            'to' => 'integer',
-            'channel' => 'required|uuid',
-            'format' => 'string|in:html,markdown,react,text',
-            'view' => 'string|in:display,sentences,all', // display:整段合并 sentences:逐句
-        ]);
-
-        $from = $data['para'];
-        $to = $data['to'] ?? $from;
-        if ($to < $from) {
-            throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
-        }
-        $format = $data['format'] ?? 'html';
-        $view = $data['view'] ?? 'display';
-
-        $items = [];
-        foreach (range($from, $to) as $para) {
-            $paragraph = $paliService->readParagraph(
-                (int) $data['book'],
-                (int) $para,
-                $data['channel'],
-                $format
-            );
-            if (empty($paragraph['display'])) {
-                continue;
-            }
-            $items[] = $this->filterView($paragraph, $view);
-        }
-
-        return V3Resource::collection($items)->additional(['meta' => [
-            'current_page' => 1,
-            'per_page' => $to - $from + 1,
-            'total' => count($items),
-            'last_page' => 1,
-        ]]);
-    }
-
-    /**
-     * 读取单个段落
-     *
-     * id 是 `{book}-{para}` 复合形式,例如 `9001-1`。格式不对或 channel 不是
-     * uuid 返回 422;段落无内容返回 404。
-     *
-     * @unauthenticated
-     *
-     * @urlParam tipitaka_read_para string required 段落 id,格式 {book}-{para}。Example: 9001-1
-     *
-     * @queryParam channel string required 译文 channel 的 uuid
-     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
-     * @queryParam view string 输出口径。Enum: display,sentences,all Default: display
-     */
-    public function show(Request $request, string $id, PaliContentService $paliService)
-    {
-        $arrId = explode('-', $id);
-        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
-        }
-        $channel = $request->input('channel');
-        if (! Str::isUuid($channel)) {
-            throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
-        }
-        $book = (int) $arrId[0];
-        $para = (int) $arrId[1];
-
-        $paragraph = $paliService->readParagraph(
-            $book,
-            $para,
-            $channel,
-            $request->input('format', 'html')
-        );
-        if (empty($paragraph['display'])) {
-            abort(404, __('site.not_found'));
-        }
-
-        return V3Resource::make($this->filterView($paragraph, $request->input('view', 'display')));
-    }
-
-    /**
-     * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
-     *
-     * @param  array{para: int, display: string, sentences: array}  $paragraph
-     */
-    protected function filterView(array $paragraph, string $view): array
-    {
-        return match ($view) {
-            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
-            'all' => $paragraph,
-            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
-        };
-    }
-}

+ 0 - 56
api-v13/app/Http/Controllers/UpgradeController.php

@@ -1,56 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Http\Requests\StoreUpgradeRequest;
-use App\Http\Requests\UpdateUpgradeRequest;
-use App\Http\Resources\V3Resource;
-use App\Models\Upgrade;
-
-class UpgradeController extends Controller
-{
-    /**
-     * 客户端升级检查
-     *
-     * 目前只回报服务可用,尚未接入版本比对逻辑。
-     *
-     * @unauthenticated
-     */
-    public function index()
-    {
-        //
-        return V3Resource::make(['status' => 'ok']);
-    }
-
-    /**
-     * Store a newly created resource in storage.
-     */
-    public function store(StoreUpgradeRequest $request)
-    {
-        //
-    }
-
-    /**
-     * Display the specified resource.
-     */
-    public function show(Upgrade $upgrade)
-    {
-        //
-    }
-
-    /**
-     * Update the specified resource in storage.
-     */
-    public function update(UpdateUpgradeRequest $request, Upgrade $upgrade)
-    {
-        //
-    }
-
-    /**
-     * Remove the specified resource from storage.
-     */
-    public function destroy(Upgrade $upgrade)
-    {
-        //
-    }
-}

+ 21 - 5
api-v13/app/Http/Controllers/UserDictController.php

@@ -78,9 +78,15 @@ class UserDictController extends Controller
                     ->whereIn('source', ['_USER_WBW_', '_USER_DICT_']);
                 break;
             case 'user':
-                // code...
+                // 原先凭据取自 $_COOKIE['user_id'](v1 遗留的认证绕过)。同一个 switch
+                // 上面的 studio 分支早就用的是 $user['user_id'],这条是漏改的。
+                // $user 是各分支各自解析的(case 之间不共享),这里要自己解一次。
+                $user = AuthService::current($request);
+                if (! $user) {
+                    return $this->error(__('auth.failed'));
+                }
                 $table = UserDict::select($indexCol)
-                    ->where('creator_id', $_COOKIE['user_id'])
+                    ->where('creator_id', $user['user_id'])
                     ->where('source', '<>', '_SYS_USER_WBW_');
                 break;
             case 'word':
@@ -365,8 +371,18 @@ class UserDictController extends Controller
      */
     public function delete(Request $request)
     {
-        // FIXME: 本方法不走 AuthService,凭据直接取自 $_COOKIE['user_id'](见下方 $param),
-        // 未登录时会 undefined array key。应改用 AuthService::current($request) 并在未登录时返回 401。
+        // 原先凭据取自 $_COOKIE['user_id'](v1 遗留的认证绕过),未登录时还会
+        // undefined array key。前端的删除走的是 DELETE /v2/userdict/{id}(destroy),
+        // 没人调这条,但既然留着就得让它是安全的。
+        //
+        // FIXME: 这条端点是死的(两个前端都无调用),而且成功路径本来就是坏的——
+        // 2026-09-24 第一次真跑它,update_sys_wbw() 往 user_dicts 插入时
+        // dict_id / editor_id(uuid 列)拿到 "0",直接 500。修它超出「清除 cookie
+        // 绕过」的范围,没有读懂那段汇总逻辑之前不动。要么补对那两列,要么整条删掉。
+        $user = AuthService::current($request);
+        if (! $user) {
+            return $this->error(__('auth.failed'));
+        }
         $arrId = json_decode($request->input('id'), true);
         $count = 0;
         $updateOk = false;
@@ -376,7 +392,7 @@ class UserDictController extends Controller
                 // 找到对应数据
                 $param = [
                     'id' => $id,
-                    'creator_id' => $_COOKIE['user_id'],
+                    'creator_id' => $user['user_id'],
                 ];
                 $del = UserDict::where($param)->delete();
                 $count += $del;

+ 6 - 5
api-v13/app/Http/Controllers/HeartbeatV3Controller.php → api-v13/app/Http/Controllers/V3/HeartbeatController.php

@@ -1,11 +1,12 @@
 <?php
 
-namespace App\Http\Controllers;
+namespace App\Http\Controllers\V3;
 
 use App\Exceptions\BusinessException;
-use App\Http\Resources\HeartbeatV3Resource;
+use App\Http\Controllers\Controller;
+use App\Http\Resources\V3\HeartbeatResource;
 
-class HeartbeatV3Controller extends Controller
+class HeartbeatController extends Controller
 {
     /**
      * 心跳
@@ -27,7 +28,7 @@ class HeartbeatV3Controller extends Controller
      *
      * @responseStatus 503 服务已进入停机维护状态
      */
-    public function show(): HeartbeatV3Resource
+    public function show(): HeartbeatResource
     {
         if (file_exists(base_path('.stop'))) {
             // 用 BusinessException 而不是 abort(503):兜底处理器会屏蔽 5xx 的 detail
@@ -35,7 +36,7 @@ class HeartbeatV3Controller extends Controller
             throw new BusinessException(__('site.maintenance'), 503, 'maintenance');
         }
 
-        return HeartbeatV3Resource::make([
+        return HeartbeatResource::make([
             'status' => 'ok',
             'checked_at' => now(),
         ]);

+ 99 - 0
api-v13/app/Http/Controllers/V3/MeReactionController.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexMeReactionRequest;
+use App\Http\Requests\V3\StoreMeReactionRequest;
+use App\Http\Resources\V3\ReactionResource;
+use App\Models\Reaction;
+use App\Services\V3\ReactionService;
+use Illuminate\Http\Request;
+
+/**
+ * 当前登录用户自己的 reactions(点赞/收藏/书签/关注/下载记录)。
+ *
+ * 与 `/v3/reactions`(公共只读)分属两个控制器:这个控制器全线要求登录,
+ * user_id 一律取当前登录用户,不接受客户端指定——替别人加关注是 target 的
+ * 关注者管理,暂留在 v2,不属于这里。
+ *
+ * 取代 `POST /v2/like` 与 `DELETE /v2/like/{like}`。资源改名(v2 的 `like` →
+ * v3 的 `reactions`)是根目录 CLAUDE.md「已登记的改名例外」里的唯一一条,
+ * 底层仍是 `likes` 表。
+ */
+class MeReactionController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly ReactionService $reactions) {}
+
+    /**
+     * 列出我自己的 reactions
+     *
+     * @queryParam type string 操作类型。Enum: like,dislike,favorite,watch,bookmark,download
+     * @queryParam target_type string target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语)。Enum: task,collection,progress_chapter,article,terms
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function index(IndexMeReactionRequest $request)
+    {
+        $user = $this->currentUser($request);
+
+        return ReactionResource::collection($this->reactions->listForUser(
+            $user['user_uid'],
+            $request->validated(),
+            $request->integer('per_page', 15),
+        ));
+    }
+
+    /**
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * 唯一键是 (type, target_id, user_id):重复提交不会产生第二条记录,返回同一条。
+     * 首次创建返回 201,命中已存在的记录返回 200。
+     *
+     * 201 不是手写的状态码:Laravel 的 ResourceResponse::calculateStatus() 看模型的
+     * wasRecentlyCreated,新建时自动给 201。这里不要改成固定 200——幂等端点区分
+     * 「真的建了」与「早就有了」对调用方有用。
+     *
+     * 返回的是 reaction 资源本身(调用方需要 id 才能后续删除),**不含计数**。
+     * 计数是共享聚合值,归 `GET /v3/reactions/tally` 管;前端用乐观更新 ±1,
+     * 下次读 tally 时校正。见 v3-resource skill 硬规范第 2 条。
+     *
+     * @responseStatus 201 首次创建。响应体与 200 同形,区别只在这一条是本次新建的
+     *
+     * @bodyParam type string required 操作类型。Enum: like,dislike,favorite,watch,bookmark,download
+     * @bodyParam target_id string required target 的 uuid
+     * @bodyParam target_type string required target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语)。Enum: task,collection,progress_chapter,article,terms
+     * @bodyParam context string 附加上下文,最长 128 字符
+     */
+    public function store(StoreMeReactionRequest $request)
+    {
+        $user = $this->currentUser($request);
+
+        return new ReactionResource(
+            $this->reactions->add($user['user_uid'], $request->validated())
+        );
+    }
+
+    /**
+     * 删除我自己的 reaction
+     *
+     * 只能删自己那条(user_id 与当前用户不符返回 403)。
+     *
+     * 成功返回 **204 空体**:硬删就是删了,没有资源可回,计数也不在这里给
+     * (见 v3-resource skill 硬规范第 2 条)。
+     *
+     * @urlParam reaction string required reaction 的 uuid
+     *
+     * @responseStatus 204 删除成功,无响应体
+     * @responseStatus 403 只能删除自己的 reaction
+     */
+    public function destroy(Request $request, Reaction $reaction)
+    {
+        $this->reactions->remove($reaction, $this->currentUser($request)['user_uid']);
+
+        return response()->noContent();
+    }
+}

+ 39 - 0
api-v13/app/Http/Controllers/V3/ProgressController.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexProgressRequest;
+use App\Http\Resources\V3\ProgressResource;
+use App\Services\V3\ProgressService;
+
+class ProgressController extends Controller
+{
+    public function __construct(private readonly ProgressService $progress) {}
+
+    /**
+     * 列出章节翻译进度
+     *
+     * 按 channel 查询各章节的翻译完成度。分页与 meta 全由框架算。
+     *
+     * 原来有个 `view` 参数,但它只有 `channel` 一个合法值、传别的直接 422——
+     * 那不是业务路径开关而是噪音,已去掉(硬规范第 1 条)。
+     *
+     * @unauthenticated
+     *
+     * @queryParam channels string required channel uid 列表,**下划线分隔**(不是逗号)
+     * @queryParam level integer 只返回该层级及以上的章节,需联查 pali_texts
+     * @queryParam lang string 按译文语言过滤。Example: zh-Hans
+     * @queryParam book integer 按典籍 id 过滤
+     * @queryParam order string 排序字段,白名单内。Enum: book,para,lang,progress,title,last_chapter_completed_at,completed_at,updated_at Default: completed_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 10
+     */
+    public function index(IndexProgressRequest $request)
+    {
+        return ProgressResource::collection(
+            $this->progress->chapters($request->validated())
+        );
+    }
+}

+ 45 - 0
api-v13/app/Http/Controllers/V3/ReactionController.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexReactionRequest;
+use App\Http\Resources\V3\ReactionResource;
+use App\Services\V3\ReactionService;
+
+/**
+ * 某个 target 下的 reactions(点赞/收藏/书签/关注/下载记录)——公共只读。
+ *
+ * 取代 `GET /v2/like?view=target`。与 `/v3/me/reactions`(当前用户自己的 reactions)
+ * 分属两个控制器:那边全线要求登录,这边全线公开。
+ *
+ * 资源改名(v2 的 `like` → v3 的 `reactions`)是根目录 CLAUDE.md
+ * 「已登记的改名例外」里的唯一一条,底层仍是 `likes` 表。
+ */
+class ReactionController extends Controller
+{
+    public function __construct(private readonly ReactionService $reactions) {}
+
+    /**
+     * 列出某个 target 下的 reactions
+     *
+     * 按 target 聚合的公共列表,不要求登录。分页由 Laravel paginator 提供。
+     *
+     * @unauthenticated
+     *
+     * @queryParam target_id string required target 的 uuid。Example: 5b4511c1-c99f-47e7-9798-97b9e5229e18
+     * @queryParam type string 操作类型。Enum: like,dislike,favorite,watch,bookmark,download
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function index(IndexReactionRequest $request)
+    {
+        $data = $request->validated();
+
+        return ReactionResource::collection($this->reactions->listForTarget(
+            $data['target_id'],
+            $data['type'] ?? null,
+            $request->integer('per_page', 15),
+        ));
+    }
+}

+ 52 - 0
api-v13/app/Http/Controllers/V3/ReactionTallyController.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexReactionTallyRequest;
+use App\Http\Resources\V3\ReactionTallyResource;
+use App\Services\V3\ReactionService;
+
+/**
+ * 某个 target 下各 type 的计数与「当前用户是否已操作」的摘要——公共只读。
+ *
+ * 取代 `GET /v2/like?view=count`。登录时额外富化 selected / id 两个字段,
+ * 不登录也能拿到全局计数。
+ *
+ * 资源改名(v2 的 `like` → v3 的 `reactions`)是根目录 CLAUDE.md
+ * 「已登记的改名例外」里的唯一一条,底层仍是 `likes` 表。
+ */
+class ReactionTallyController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly ReactionService $reactions) {}
+
+    /**
+     * 统计某个 target 下各 type 的计数
+     *
+     * 一次聚合查询出各 type 的 count;登录时再查一次当前用户的选择,补 selected / id。
+     *
+     * @unauthenticated
+     *
+     * @queryParam target_id string required target 的 uuid。Example: 5b4511c1-c99f-47e7-9798-97b9e5229e18
+     */
+    public function index(IndexReactionTallyRequest $request)
+    {
+        $items = $this->reactions->tally(
+            $request->validated('target_id'),
+            $this->currentUserUid($request),
+        );
+
+        // 非分页列表:手工给 meta,使响应形状与规格里的 {data, meta} 一致
+        // FIXME: current_page / per_page / last_page 在不分页的聚合上是假值,
+        // 只是为了套上生成器写死的 PaginationMeta,待定夺
+        return ReactionTallyResource::collection($items)->additional(['meta' => [
+            'current_page' => 1,
+            'per_page' => count($items),
+            'total' => count($items),
+            'last_page' => 1,
+        ]]);
+    }
+}

+ 4 - 25
api-v13/app/Http/Controllers/SearchPlusController.php → api-v13/app/Http/Controllers/V3/SearchPlusController.php

@@ -1,12 +1,12 @@
 <?php
 
-namespace App\Http\Controllers;
+namespace App\Http\Controllers\V3;
 
 use App\DTO\Search\HitItemDTO;
-use App\Http\Resources\V3Resource;
+use App\Http\Controllers\Controller;
+use App\Http\Resources\V3\BaseResource;
 use App\Services\OpenSearchService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 
 class SearchPlusController extends Controller
 {
@@ -145,30 +145,9 @@ class SearchPlusController extends Controller
     {
         //
         try {
-            return V3Resource::make(HitItemDTO::fromArray($this->searchService->get($id)));
+            return BaseResource::make(HitItemDTO::fromArray($this->searchService->get($id)));
         } catch (\Throwable $th) {
             abort(404, __('site.not_found'));
         }
     }
-
-    /**
-     * 更新资源
-     *
-     * @route PUT /api/search/{uid}
-     *
-     * @param JSON: OpenSearch 格式数据
-     * @return Response
-     */
-    public function update(Request $request, $uid) {}
-
-    /**
-     * Remove the specified resource from storage.
-     *
-     * 删除资源
-     *
-     * @route DELETE /api/search/{uid}
-     *
-     * @return Response
-     */
-    public function destroy($uid) {}
 }

+ 2 - 1
api-v13/app/Http/Controllers/SearchSuggestController.php → api-v13/app/Http/Controllers/V3/SearchSuggestController.php

@@ -2,8 +2,9 @@
 
 // api-v8/app/Http/Controllers/SearchSuggestController.php
 
-namespace App\Http\Controllers;
+namespace App\Http\Controllers\V3;
 
+use App\Http\Controllers\Controller;
 use App\Services\OpenSearchService;
 use Illuminate\Http\Request;
 

+ 77 - 0
api-v13/app/Http/Controllers/V3/TipitakaReadingController.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\TipitakaReadingRequest;
+use App\Http\Resources\V3\ReadParagraphResource;
+use App\Services\V3\TipitakaReadingService;
+
+/**
+ * 三藏译文的阅读与下载:按 channel 取已翻译的段落,游标式分块。
+ *
+ * 路径开头是「领域-功能」而不是资源坐标:`channel + book + para` 在 wikipali 里是
+ * 通用坐标,sentences、wbw、批注、进度全都用它,放在路径开头会让每个新能力来抢
+ * 同一个前缀,也看不出这条 API 到底干什么。所以路径是 `/v3/tipitaka-reading/{channel}`,
+ * 坐标降成 filter。
+ *
+ * 曾经叫 `/v3/tipitaka-read-para` 与 `/v3/tipitaka-read-chapter`,中间短暂叫过
+ * `/v3/channels/{channel}/books/{book}/…`,见 CLAUDE.md 的「v2 → v3 对照表」。
+ *
+ * **这不是 v2 `chapter-content` / `paragraph-content` 的替代品**:那两个支持多
+ * channel 与 edit 模式、返回 sentenceIds 给编辑器;这里只做单 channel 的只读渲染。
+ */
+class TipitakaReadingController extends Controller
+{
+    public function __construct(private readonly TipitakaReadingService $reading) {}
+
+    /**
+     * 读取译文
+     *
+     * 取这个 channel 已经翻译的段落,按 (book, para) 升序,用游标分块。
+     * 不带任何查询串就是从头取整个 channel——下载场景要的就是这个;
+     * 阅读场景用 `book` + `chapter` 或 `book` + `para`/`to` 把范围收窄。
+     *
+     * 续传把 `meta.next_cursor` 原样传回 `after` 即可,它为 null 表示取完了。
+     * **不要自己拼游标**:它的内部形式(`{book}-{para}`)是实现细节,会变。
+     *
+     * `total` / `remaining` 只在带了 `book` 过滤时才给:不带过滤时结果集是整个
+     * channel(实测最大的有 52 万段、跨 217 本书),每翻一页都去 count 一次不划算。
+     * 无过滤时用 `next_cursor` 是否为 null 判断结束,不要指望百分比。
+     *
+     * @unauthenticated
+     *
+     * @urlParam channel string required 译文 channel 的 uuid
+     *
+     * @queryParam book integer 典籍 id。与 chapter / para / to 一起用时必填。Example: 9002
+     * @queryParam chapter integer 章节起始段落号,服务端按 chapter_len 展开成段落区间。
+     *             与 para / to 互斥
+     * @queryParam para integer 起始段落号
+     * @queryParam to integer 结束段落号(含)。不传则等于 para,即只取一段
+     * @queryParam after string 游标,取 meta.next_cursor 原样回传。Example: 9002-15
+     * @queryParam page_size integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
+     *             unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值。
+     *             Default: 10
+     * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
+     *             两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
+     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
+     * @queryParam include string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
+     *             不叫 view 是因为 v2 的 `view=` 是必填互斥的业务路径开关,名字撞上会误导。
+     *             Enum: display,sentences,all Default: display
+     *
+     * @responseStatus 404 指定的 chapter 在 pali_texts 里不存在
+     *
+     * @meta page_size integer 每块大小(按 unit 计)
+     * @meta page_size_unit string 每块大小的单位:para 或 byte
+     * @meta next_cursor string 下一块的游标,原样传回 after;为 null 表示取完
+     * @meta total integer 过滤范围内已翻译的段落总数。仅在带了 book 过滤时出现
+     * @meta remaining integer 本块之后过滤范围内还剩多少段。仅在带了 book 过滤时出现
+     */
+    public function __invoke(TipitakaReadingRequest $request, string $channel)
+    {
+        $page = $this->reading->page($channel, $request->validated());
+
+        return ReadParagraphResource::collection($page['items'])
+            ->additional(['meta' => $page['meta']]);
+    }
+}

+ 22 - 0
api-v13/app/Http/Controllers/V3/UpgradeController.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Resources\V3\BaseResource;
+
+class UpgradeController extends Controller
+{
+    /**
+     * 客户端升级检查
+     *
+     * 目前只回报服务可用,尚未接入版本比对逻辑。
+     *
+     * @unauthenticated
+     */
+    public function index()
+    {
+        //
+        return BaseResource::make(['status' => 'ok']);
+    }
+}

+ 56 - 0
api-v13/app/Http/Middleware/V3/Authenticate.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Http\Middleware\V3;
+
+use App\Services\AuthService;
+use Closure;
+use Illuminate\Auth\AuthenticationException;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * v3 的登录闸:没带有效 bearer token 就在进控制器之前 401。
+ *
+ * 挂法见 routes/v3/*.php,别名 `auth.v3`(在 bootstrap/app.php 注册)。
+ * 可以挂在路由组上,也可以按方法挂:
+ *
+ *     Route::apiResource('channels', ChannelController::class)
+ *         ->middlewareFor(['store', 'update', 'destroy'], 'auth.v3');
+ *
+ * **鉴权和 URL 形状是两件事**:要让某些动作需要登录,挂中间件就行,
+ * 不需要把它们塞进 `/v3/me/` 前缀。
+ *
+ * 两个设计点:
+ *
+ * 1. **只认 bearer。** `AuthService::current()` 拿不到 bearer 时会回落去读
+ *    `$_COOKIE['user_uid']`,那是 v2 的旁路;v3 硬规范要求统一 bearer,所以这里
+ *    先自己挡一道。不改 `AuthService`——它是 v2 也在用的共享代码。
+ * 2. **解出来的用户存进 request attributes**,控制器直接取,不必再解一次 JWT。
+ */
+class Authenticate
+{
+    /**
+     * 解析出的用户存在 request attributes 的哪个键下。
+     */
+    public const USER = 'v3.user';
+
+    /**
+     * @throws AuthenticationException 没带 bearer,或 token 无效 / 过期
+     */
+    public function handle(Request $request, Closure $next): Response
+    {
+        // 不给 AuthenticationException 传消息:bootstrap/app.php 的处理器只按状态码
+        // 渲染,detail 留空,面向人的文案就是 Problem 的 title(RFC 9457 要求它稳定)
+        if (! $request->bearerToken()) {
+            throw new AuthenticationException;
+        }
+        $user = AuthService::current($request);
+        if (! $user) {
+            throw new AuthenticationException;
+        }
+
+        $request->attributes->set(self::USER, $user);
+
+        return $next($request);
+    }
+}

+ 37 - 0
api-v13/app/Http/Requests/V3/IndexMeReactionRequest.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/me/reactions` 的入参:当前用户自己的 reaction 列表。
+ *
+ * 这里没有 user_id——结果集永远由登录态决定,不接受客户端指定。
+ */
+class IndexMeReactionRequest extends FormRequest
+{
+    /**
+     * 登录检查在控制器里做:authorize() 返回 false 会渲染成 403,
+     * 而「没登录」应该是 401。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'type' => ['nullable', 'string', Rule::in(Reaction::TYPES)],
+            'target_type' => ['nullable', 'string', Rule::in(Reaction::TARGET_TYPES)],
+            'page' => ['integer', 'min:1'],
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 52 - 0
api-v13/app/Http/Requests/V3/IndexProgressRequest.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/progress` 的入参。
+ *
+ * 原来有个 `view` 参数,但它只有 `channel` 一个合法值,传别的直接 422——
+ * 那不是业务路径开关,是噪音,已去掉(硬规范第 1 条)。
+ */
+class IndexProgressRequest extends FormRequest
+{
+    /**
+     * 排序可用的列。**必须白名单**:原来是 `'progress_chapters.'.$request->input('order')`
+     * 直接拼进 orderBy,传个不存在的列名就是 QueryException → 500。
+     *
+     * @var list<string>
+     */
+    public const SORTABLE = [
+        'book', 'para', 'lang', 'progress', 'title',
+        'last_chapter_completed_at', 'completed_at', 'updated_at',
+    ];
+
+    /**
+     * 公开的只读接口。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            // channel uid 列表,**下划线分隔**(不是逗号)——既有契约,不要改
+            'channels' => ['required', 'string'],
+            'level' => ['sometimes', 'integer', 'min:1'],
+            'lang' => ['sometimes', 'string', 'max:20'],
+            'book' => ['sometimes', 'integer', 'min:1'],
+            'order' => ['sometimes', 'string', Rule::in(self::SORTABLE)],
+            'dir' => ['sometimes', 'string', 'in:asc,desc'],
+            'page' => ['sometimes', 'integer', 'min:1'],
+            'per_page' => ['sometimes', 'integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 38 - 0
api-v13/app/Http/Requests/V3/IndexReactionRequest.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/reactions` 的入参:某个 target 下的公共 reaction 列表。
+ *
+ * `target_id` 必填不是随口加的:likes 表没有全表可读的用例,
+ * 不带条件就能拉全表的列表端点是设计错误。
+ */
+class IndexReactionRequest extends FormRequest
+{
+    /**
+     * 公共只读端点,不需要鉴权。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'target_id' => ['required', 'uuid'],
+            'type' => ['nullable', 'string', Rule::in(Reaction::TYPES)],
+            'page' => ['integer', 'min:1'],
+            // 上限 200 与 v3 其它列表一致:没有上限时 ?per_page=100000 能一次拉空一个 target
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 33 - 0
api-v13/app/Http/Requests/V3/IndexReactionTallyRequest.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/reactions/tally` 的入参:某个 target 的各 type 计数摘要。
+ *
+ * 聚合结果每个 type 一行,总行数不超过 {@see Reaction::TYPES} 的长度,
+ * 所以不分页,也就没有 page / per_page。
+ */
+class IndexReactionTallyRequest extends FormRequest
+{
+    /**
+     * 公共只读端点,不需要鉴权;登录时才额外富化 selected / id。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'target_id' => ['required', 'uuid'],
+        ];
+    }
+}

+ 39 - 0
api-v13/app/Http/Requests/V3/StoreMeReactionRequest.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `POST /v3/me/reactions` 的入参:添加一条自己的 reaction。
+ *
+ * 不收 user_id:v2 允许 `user_id` 覆盖操作者(替别人加关注),那是 target 的
+ * 关注者管理,不属于 `me` 这组端点,留在 v2。
+ */
+class StoreMeReactionRequest extends FormRequest
+{
+    /**
+     * 登录检查在控制器里做:authorize() 返回 false 会渲染成 403,
+     * 而「没登录」应该是 401。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'type' => ['required', 'string', Rule::in(Reaction::TYPES)],
+            'target_id' => ['required', 'uuid'],
+            'target_type' => ['required', 'string', Rule::in(Reaction::TARGET_TYPES)],
+            // context 列是 varchar(128)
+            'context' => ['nullable', 'string', 'max:128'],
+        ];
+    }
+}

+ 47 - 0
api-v13/app/Http/Requests/V3/TipitakaReadingRequest.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/tipitaka-reading/{channel}` 的入参。
+ *
+ * **只有 channel 是必填的,而且它在路径上。** 这里全是可选的过滤与分页参数,
+ * 所以不带任何查询串就能出结果——那就是「取这个 channel 的全部译文」,
+ * 下载场景要的正是这个。
+ *
+ * `book` 只在跟 `chapter` / `para` 一起用时才必填:那两个是书内的段落号,
+ * 没有 book 就没有意义。这是唯一一条参数间依赖,不是 v2 那种 `view=` 开关。
+ */
+class TipitakaReadingRequest extends FormRequest
+{
+    /**
+     * 公开的阅读接口,不需要登录。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'book' => ['integer', 'min:1', 'required_with:chapter,para,to'],
+            // 章节起始段落号;服务端按 pali_texts 的 chapter_len 展开成段落区间
+            'chapter' => ['integer', 'min:1', 'prohibits:para,to'],
+            'para' => ['integer', 'min:1'],
+            'to' => ['integer', 'min:1'],
+            // 游标,形如 "9002-15"(book-para)。由 meta.next_cursor 给出,
+            // 客户端原样回传即可,不要自己拼
+            'after' => ['string', 'regex:/^\d+-\d+$/'],
+            'page_size' => ['integer', 'min:1'],
+            'unit' => ['string', 'in:para,byte'],
+            'format' => ['string', 'in:html,markdown,react,text'],
+            'include' => ['string', 'in:display,sentences,all'],
+        ];
+    }
+}

+ 15 - 2
api-v13/app/Http/Resources/BookTitleResource.php

@@ -2,7 +2,7 @@
 
 namespace App\Http\Resources;
 
-use Illuminate\Contracts\Support\Arrayable;
+use App\Http\Controllers\BookTitleController;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 
@@ -11,8 +11,21 @@ class BookTitleResource extends JsonResource
     /**
      * Transform the resource into an array.
      *
+     * book / paragraph / sn / title 是 book_titles 表列,toc / tags / related_name
+     * 由 {@see BookTitleController::index} 在取数时附加,
+     * language 来自目录数据(接口可能不返回)。
+     *
      * @param  Request  $request
-     * @return array|Arrayable|\JsonSerializable
+     * @return array{
+     *     book: int,
+     *     paragraph: int,
+     *     sn: int,
+     *     title: string,
+     *     toc: string|null,
+     *     tags: string[],
+     *     related_name: string|null,
+     *     language?: string|null
+     * }
      */
     public function toArray($request)
     {

+ 39 - 0
api-v13/app/Http/Resources/Concerns/ResolvesActor.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Resources\Concerns;
+
+use App\Services\UserService;
+
+/**
+ * 给 Resource 用的操作者解析。
+ *
+ * 「操作者」在这个系统里可能是人类(`user_infos`)也可能是 AI 助手(`ai_models`),
+ * 共用一个 uuid 空间且没有类型判别列,所以每个模型都要挂两条 belongsTo,
+ * Resource 再从两者里择一。这个 trait 把「择一」那步收成一行:
+ *
+ *     use ResolvesActor;
+ *
+ *     'user' => $this->actor($this->user, $this->aiModel),
+ *
+ * 配套的预加载声明用 {@see UserService::eagerLoadActor()},两边都别手抄列名。
+ *
+ * **前提是关系已经预加载。** 没 `->with()` 就每行懒加载,又回到 N+1;
+ * `ReactionV3Test` 里那条「查询数与行数无关」的断言就是守这个的。
+ */
+trait ResolvesActor
+{
+    /**
+     * 从两条已预加载的关系里定出操作者摘要;都为空时给 unknown 占位。
+     *
+     * Resource 的构造签名由 JsonResource 定死,注入不进来,只能在这里解析容器。
+     * UserService 注册为 singleton,反复解析拿到的是同一个实例,不会丢身份映射。
+     *
+     * @param  mixed  $human  一行 `user_infos`,没有传 null
+     * @param  mixed  $assistant  一行 `ai_models`,没有传 null
+     * @return array<string, mixed>
+     */
+    protected function actor($human, $assistant = null): array
+    {
+        return app(UserService::class)->actor($human, $assistant);
+    }
+}

+ 11 - 2
api-v13/app/Http/Resources/RecentResource.php

@@ -6,7 +6,6 @@ use App\Http\Api\ChannelApi;
 use App\Models\Article;
 use App\Models\DhammaTerm;
 use App\Models\Sentence;
-use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 
@@ -15,8 +14,18 @@ class RecentResource extends JsonResource
     /**
      * Transform the resource into an array.
      *
+     * id 是 uuid、type 是 article/chapter/term 之一、article_id 是
+     * `"{book}-{para}"` 之类的字符串(不是数字)。
+     *
      * @param  Request  $request
-     * @return array|Arrayable|\JsonSerializable
+     * @return array{
+     *     id: string,
+     *     type: string,
+     *     article_id: string,
+     *     param: string|null,
+     *     updated_at: string,
+     *     title: string
+     * }
      */
     public function toArray($request)
     {

+ 7 - 7
api-v13/app/Http/Resources/V3Resource.php → api-v13/app/Http/Resources/V3/BaseResource.php

@@ -1,13 +1,13 @@
 <?php
 
-namespace App\Http\Resources;
+namespace App\Http\Resources\V3;
 
 use Illuminate\Http\Resources\Json\JsonResource;
 
 /**
- * v3 资源的基类。所有 `*V3Resource` 都继承它。
+ * v3 资源的基类。所有 `*BaseResource` 都继承它。
  *
- * 作用是让 `XxxV3Resource::collection()` 产出 {@see V3ResourceCollection},
+ * 作用是让 `XxxV3Resource::collection()` 产出 {@see BaseResourceCollection},
  * 从而裁掉分页里的绝对 URL。除此之外与普通 JsonResource 完全一致——
  * 响应形状由框架决定:
  *
@@ -19,15 +19,15 @@ use Illuminate\Http\Resources\Json\JsonResource;
  *
  * 载荷是普通数组(不是 Eloquent 模型)时可以直接用本类:
  *
- *     return V3Resource::collection($items)->additional(['meta' => [...]]);
+ *     return BaseResource::collection($items)->additional(['meta' => [...]]);
  */
-class V3Resource extends JsonResource
+class BaseResource extends JsonResource
 {
     /**
      * @param  mixed  $resource
      */
-    protected static function newCollection($resource): V3ResourceCollection
+    protected static function newCollection($resource): BaseResourceCollection
     {
-        return new V3ResourceCollection($resource, static::class);
+        return new BaseResourceCollection($resource, static::class);
     }
 }

+ 2 - 2
api-v13/app/Http/Resources/V3ResourceCollection.php → api-v13/app/Http/Resources/V3/BaseResourceCollection.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace App\Http\Resources;
+namespace App\Http\Resources\V3;
 
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
 use Illuminate\Support\Arr;
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
  * 其余一概交给框架:`meta` 里的 current_page / per_page / total / last_page /
  * from / to 都是 paginator 自己算的。
  */
-class V3ResourceCollection extends AnonymousResourceCollection
+class BaseResourceCollection extends AnonymousResourceCollection
 {
     /**
      * @param  array{links: array, meta: array}  $default

+ 2 - 2
api-v13/app/Http/Resources/HeartbeatV3Resource.php → api-v13/app/Http/Resources/V3/HeartbeatResource.php

@@ -1,10 +1,10 @@
 <?php
 
-namespace App\Http\Resources;
+namespace App\Http\Resources\V3;
 
 use Illuminate\Http\Request;
 
-class HeartbeatV3Resource extends V3Resource
+class HeartbeatResource extends BaseResource
 {
     /**
      * 心跳结果。

+ 2 - 2
api-v13/app/Http/Resources/ProgressV3Resource.php → api-v13/app/Http/Resources/V3/ProgressResource.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace App\Http\Resources;
+namespace App\Http\Resources\V3;
 
 use Illuminate\Http\Request;
 
@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
  * 类型取自 progress_chapters 表:progress 是 double,book / para 是整数,
  * 时间字段可能为 null(尚未完成)。
  */
-class ProgressV3Resource extends V3Resource
+class ProgressResource extends BaseResource
 {
     /**
      * @param  Request  $request

+ 49 - 0
api-v13/app/Http/Resources/V3/ReactionResource.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Http\Resources\Concerns\ResolvesActor;
+use App\Models\Reaction;
+use Illuminate\Http\Request;
+
+/**
+ * 一条 reaction 记录。
+ *
+ * user 是操作者的公开摘要。操作者可能是人类用户(`user_infos`)也可能是 AI 助手
+ * (`ai_models`),两张表的形状不同,这里只承诺稳定的核心字段。
+ *
+ * **本类不查库。** 操作者从 `user` / `aiModel` 两条已预加载的关系上取——
+ * 控制器负责 `->with([...])`,见 {@see Reaction::user()}。
+ * {@see ResolvesActor::actor()} 只把已加载的模型拼成摘要,不碰数据库。
+ */
+class ReactionResource extends BaseResource
+{
+    use ResolvesActor;
+
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     type: string,
+     *     target_id: string,
+     *     target_type: string,
+     *     context: string|null,
+     *     created_at: string,
+     *     updated_at: string,
+     *     user: array{id: string, nickName: string, userName: string, realName: string, sn: int, avatar: string|null, roles: array|null}|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => $this->id,
+            'type' => $this->type,
+            'target_id' => $this->target_id,
+            'target_type' => $this->target_type,
+            'context' => $this->context,
+            'created_at' => $this->created_at,
+            'updated_at' => $this->updated_at,
+            'user' => $this->actor($this->user, $this->aiModel),
+        ];
+    }
+}

+ 34 - 0
api-v13/app/Http/Resources/V3/ReactionTallyResource.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 某个 type 的计数与「当前用户是否已操作」的摘要(tally 专用)。
+ *
+ * 输入是控制器已拼好的聚合行 {type, count, selected, id},这里只负责把类型转对。
+ * 计数只在这里出现。写端点(store / destroy)不返回聚合值——那是共享的、会被别人
+ * 改的,mutation 的响应发出去就可能过期。见 v3-resource skill 硬规范第 2 条。
+ */
+class ReactionTallyResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     type: string,
+     *     count: int,
+     *     selected: bool,
+     *     id: string|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'type' => $this->resource['type'],
+            'count' => (int) $this->resource['count'],
+            'selected' => (bool) $this->resource['selected'],
+            'id' => $this->resource['id'] ?? null,
+        ];
+    }
+}

+ 28 - 0
api-v13/app/Http/Resources/V3/ReadParagraphResource.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 阅读接口逐段返回的正文条目,即 `/v3/tipitaka-reading/{channel}` 的 `data[]` 元素。
+ *
+ * 结果可以跨书,所以每条都带 `book`——只给 `para` 在跨书时不唯一。
+ *
+ * 载荷是普通数组而非 Eloquent 模型,`toArray` 直接透传;`@return` 声明的是
+ * `view=display` 口径下的形状(para + display)。
+ */
+class ReadParagraphResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     para: int,
+     *     display: string
+     * }
+     */
+    public function toArray($request)
+    {
+        return parent::toArray($request);
+    }
+}

+ 11 - 0
api-v13/app/Models/AiModel.php

@@ -11,6 +11,17 @@ class AiModel extends Model
 
     protected $primaryKey = 'uid';
 
+    /**
+     * 主键 `uid` 是 uuid 字符串。
+     *
+     * 不声明这一行,Eloquent 按默认的 int 主键处理,关系预加载会走
+     * `whereIntegerInRaw`,把 uuid 全部强转成 0(`where "uid" in (0)`)。
+     * 下面的 casts 本来就写着 string,这里只是把两处声明对齐。
+     *
+     * 只影响关系预加载的取值方式;`$incrementing` 未动,写入行为不变。
+     */
+    protected $keyType = 'string';
+
     protected $casts = [
         'uid' => 'string',
     ];

+ 80 - 0
api-v13/app/Models/Reaction.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Models;
+
+use App\Http\Resources\V3\ReactionResource;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * 用户对某个锚点(target)的单向操作记录。
+ *
+ * 点赞、收藏、书签、关注、下载记录共用这张表:type 区分操作种类,
+ * 唯一键 (type, target_id, user_id) 保证「一个用户对一个锚点的同种操作只有一条」。
+ *
+ * 底层仍是历史表 `likes`(表名不迁移,v2 的 Like 模型继续指向同一张表)。
+ */
+class Reaction extends Model
+{
+    use HasFactory;
+
+    /**
+     * `type` 列的合法取值。
+     *
+     * 列本身是 varchar(32) 没有约束,这里是 v3 入口处收紧的白名单——
+     * v2 的 `LikeController` 不做校验,历史数据里出现过的只有这几种。
+     *
+     * @var list<string>
+     */
+    public const TYPES = ['like', 'dislike', 'favorite', 'watch', 'bookmark', 'download'];
+
+    /**
+     * `target_type` 列的合法取值:锚点是哪一类对象。
+     *
+     * task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语。
+     *
+     * @var list<string>
+     */
+    public const TARGET_TYPES = ['task', 'collection', 'progress_chapter', 'article', 'terms'];
+
+    protected $table = 'likes';
+
+    protected $primaryKey = 'id';
+
+    public $incrementing = false;
+
+    protected $keyType = 'string';
+
+    protected $casts = [
+        'id' => 'string',
+    ];
+
+    protected $fillable = ['type', 'target_id', 'target_type', 'user_id', 'context'];
+
+    /**
+     * 操作者是人类用户时的那一行。
+     *
+     * `user_id` 存的是 uuid,对应 `user_infos.userid`(不是主键 `id`),所以要写
+     * 三参数形式。同款写法见 {@see Channel::owner()}。
+     *
+     * 关系没有数据库外键约束——`likes` 建表时就没有,加不得(migration 只准加
+     * 不准改)。匹配列 `user_infos.userid` 上有唯一索引,whereIn 走得动。
+     */
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(UserInfo::class, 'user_id', 'userid');
+    }
+
+    /**
+     * 操作者是 AI 助手时的那一行。
+     *
+     * 同一个 `user_id` 落在 `user_infos` 或 `ai_models` 两张表之一,没有类型判别列,
+     * 所以不是 morphTo,只能两条关系并存、取到哪个算哪个。
+     * 见 {@see ReactionResource::toArray()}。
+     */
+    public function aiModel(): BelongsTo
+    {
+        return $this->belongsTo(AiModel::class, 'user_id', 'uid');
+    }
+}

+ 13 - 0
api-v13/app/Providers/AppServiceProvider.php

@@ -4,6 +4,7 @@ namespace App\Providers;
 
 use App\Services\PaliSeriesesService;
 use App\Services\RomanizeService;
+use App\Services\UserService;
 use App\Tools\QueryBuilderMacro;
 use App\View\Composers\BlogViewComposer;
 use Carbon\CarbonImmutable;
@@ -56,6 +57,18 @@ class AppServiceProvider extends ServiceProvider
         |--------------------------------------------------------------------------
         */
         $this->app->singleton(PaliSeriesesService::class);
+
+        /*
+        |--------------------------------------------------------------------------
+        | User Service
+        |--------------------------------------------------------------------------
+        |
+        | 必须是 singleton:它在请求内维护一张 uuid => 摘要的身份映射,同一个用户
+        | 在一次请求里被解析多次时只查一次库、只签一次头像 URL。每次 make 新实例
+        | 那张映射就永远是空的。
+        |
+        */
+        $this->app->singleton(UserService::class);
     }
 
     /**

+ 13 - 7
api-v13/app/Services/AuthService.php

@@ -88,14 +88,20 @@ class AuthService
 
             // 有效的token
             return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
-        } elseif (isset($_COOKIE['user_uid'])) {
-            return [
-                'user_uid' => $_COOKIE['user_uid'],
-                'user_id' => $_COOKIE['user_id'],
-            ];
-        } else {
-            return false;
         }
+
+        // 这里曾经有一条 cookie 回落:拿不到 bearer 就读 $_COOKIE['user_uid'] 并
+        // 直接当成已登录用户返回。那是 v1(api-v8)时代的遗留,而且是个认证绕过——
+        // 它不验签名、不验过期、不查库,浏览器里手工种一个 user_uid 就能冒充任何人。
+        // api-v13 与两个前端都已不再种这个 cookie(只种 timezone 和 language),
+        // 所以这条分支是只读不写的孤儿路径,2026-09-24 删除。
+        //
+        // 仍有少数 v2 控制器绕开本方法直接读 $_COOKIE,删它们是各自独立的工作:
+        //   SentenceController@index(view=fulltext)
+        //   ProgressChapterController@index
+        //   LikeController@delete
+        //   UserDictController@index(view=user)与 @delete
+        return false;
     }
 
     /**

+ 382 - 0
api-v13/app/Services/UserService.php

@@ -0,0 +1,382 @@
+<?php
+
+namespace App\Services;
+
+use App\Http\Resources\Concerns\ResolvesActor;
+use App\Models\AiModel;
+use App\Models\UserInfo;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Storage;
+
+/**
+ * 操作者摘要(昵称、头像、角色)的唯一实现。
+ *
+ * 取代 `App\Http\Api\UserApi` 与 `App\Http\Api\AiAssistantApi`——那两个类已经改成
+ * 零逻辑的转发壳,v2 的六十多个调用点一个都不用动。等调用点全部换成注入本类,
+ * `app/Http/Api/` 才可以删。**不要再往那两个壳里写逻辑。**
+ *
+ * 「操作者」有两种:人类存 `user_infos`,AI 助手存 `ai_models`。两者共用一个 uuid
+ * 空间,且没有类型判别列,所以解析顺序恒定为「先人、后 AI、都没有就给占位」。
+ *
+ * 相对原先两个静态类的三点改动:
+ *
+ * 1. **单点取值是批量取值的特例。** 原先单条走一条路、列表走另一条路,两处各自
+ *    拼形状。现在只有 {@see self::load()} 一个出口。
+ * 2. **请求内身份映射。** 同一个 uuid 在一次请求里往往要解析很多次(列表里同一个
+ *    编辑者、反复出现的当前用户)。本类注册为 singleton,解析过的记在 `$resolved`
+ *    里,含「确认查不到」的否定结果,所以重复查询和重复的头像签名都只发生一次。
+ * 3. **只取用得到的列。** 原先是 `->first()`(`SELECT *`),每行把 `password`、
+ *    `reset_password_token`、AI 的 `key` 一起捞进内存。
+ */
+class UserService
+{
+    /**
+     * 查不到人时的占位摘要。
+     *
+     * 键集合比真实摘要少(没有 `sn` / `roles`),这是 v2 既有口径,前端在依赖,
+     * 不要「顺手补齐」。
+     */
+    private const UNKNOWN = [
+        'id' => 0,
+        'nickName' => 'unknown',
+        'userName' => 'unknown',
+        'realName' => 'unknown',
+        'avatar' => '',
+    ];
+
+    /** 拼人类摘要用得到的列;`userid` 是匹配列,不能省。 */
+    private const USER_COLUMNS = ['userid', 'id', 'nickname', 'username', 'role', 'avatar'];
+
+    /** 拼 AI 摘要用得到的列;`uid` 是匹配列,不能省。 */
+    private const ASSISTANT_COLUMNS = ['uid', 'name', 'real_name', 'avatar', 'model', 'url'];
+
+    /**
+     * 请求内的身份映射:uuid => 摘要,或 null 表示「已确认查不到」。
+     *
+     * 记否定结果是为了让查不到的 uuid 也只查一次。进程常驻(octane / 长跑命令)
+     * 时它是陈旧数据的来源,改过用户资料又要重读的场合调 {@see self::flush()}。
+     *
+     * @var array<string, array<string, mixed>|null>
+     */
+    private array $resolved = [];
+
+    /**
+     * 按 uuid 取一个操作者的摘要;查不到给占位。
+     *
+     * @param  string|null  $uuid  允许为 null:调用方常直接把可空的 editor_id 传进来
+     * @return array<string, mixed>
+     */
+    public function byUuid(?string $uuid): array
+    {
+        return $this->byUuids([$uuid])[$uuid] ?? self::UNKNOWN;
+    }
+
+    /**
+     * 批量取摘要,返回 **uuid => 摘要** 的映射。
+     *
+     * 最多两次查询,与 uuid 个数无关;`ai_models` 只在人类表还没解析完时才查。
+     * 已经在身份映射里的 uuid 不参与查询。
+     *
+     * 解析不到的 uuid **不出现在结果里**——由调用方决定省略还是补占位,比塞一个
+     * unknown 进去更诚实。列表渲染请用这个方法,不要循环调 {@see self::byUuid()}。
+     *
+     * @param  array<int, string|null>  $uuids
+     * @return array<string, array<string, mixed>>
+     */
+    public function byUuids(array $uuids): array
+    {
+        $wanted = array_values(array_unique(array_filter(
+            $uuids,
+            static fn ($uuid): bool => is_string($uuid) && $uuid !== '',
+        )));
+        if (! $wanted) {
+            return [];
+        }
+
+        $unseen = array_values(array_diff($wanted, array_keys($this->resolved)));
+        if ($unseen) {
+            $loaded = $this->load($unseen);
+            foreach ($unseen as $uuid) {
+                $this->resolved[$uuid] = $loaded[$uuid] ?? null;
+            }
+        }
+
+        $output = [];
+        foreach ($wanted as $uuid) {
+            if ($this->resolved[$uuid] !== null) {
+                $output[$uuid] = $this->resolved[$uuid];
+            }
+        }
+
+        return $output;
+    }
+
+    /**
+     * 从「人类行」与「AI 行」两个候选里定出操作者摘要。
+     *
+     * 这是解析顺序(先人、后 AI、都没有给占位)的唯一实现。传进来的应当是**已经
+     * 预加载好的模型**,本方法不查库——它只负责拼形状。列表渲染时每行调用它是安全的。
+     *
+     * Resource 里直接用 {@see ResolvesActor::actor()},
+     * 不必自己解析本服务。
+     *
+     * @param  mixed  $human  一行 `user_infos`,没有传 null
+     * @param  mixed  $assistant  一行 `ai_models`,没有传 null
+     * @return array<string, mixed>
+     */
+    public function actor($human, $assistant = null): array
+    {
+        return $human ? $this->profile($human) : $this->assistantProfile($assistant);
+    }
+
+    /**
+     * 操作者两条关系的预加载声明,直接喂给 `->with()`。
+     *
+     * 把列白名单收在这里,而不是让每个控制器各抄一份长字符串——那串东西一旦漏掉
+     * 匹配列(`userid` / `uid`),关系会静默对不上、整列变 null,不报错。
+     *
+     *     Reaction::query()->with(UserService::eagerLoadActor())
+     *     Sentence::query()->with(UserService::eagerLoadActor('editor', 'editorAssistant'))
+     *
+     * @param  string  $human  人类那条关系的名字
+     * @param  string  $assistant  AI 那条关系的名字
+     * @return array<int, string>
+     */
+    public static function eagerLoadActor(string $human = 'user', string $assistant = 'aiModel'): array
+    {
+        return [
+            $human.':'.implode(',', self::USER_COLUMNS),
+            $assistant.':'.implode(',', self::ASSISTANT_COLUMNS),
+        ];
+    }
+
+    /**
+     * 批量取摘要,返回**位置数组**:保持传入顺序,解析不到的整条丢弃。
+     *
+     * 这是 `UserApi::getListByUuid()` 的既有口径,只为兼容旧调用点而存在。
+     * 新代码用 {@see self::byUuids()}——位置数组一旦丢项就和输入对不上号了。
+     *
+     * @param  array<int, string|null>  $uuids
+     * @return array<int, array<string, mixed>>
+     */
+    public function listByUuids(array $uuids): array
+    {
+        $map = $this->byUuids($uuids);
+
+        $output = [];
+        foreach ($uuids as $uuid) {
+            if (is_string($uuid) && isset($map[$uuid])) {
+                $output[] = $map[$uuid];
+            }
+        }
+
+        return $output;
+    }
+
+    /**
+     * 按 uuid 取 AI 助手的摘要,跳过人类表;查不到给占位。
+     *
+     * @return array<string, mixed>
+     */
+    public function assistantByUuid(?string $uuid): array
+    {
+        $assistant = $this->query(AiModel::class, 'uid', [$uuid], self::ASSISTANT_COLUMNS)->first();
+
+        return $assistant ? $this->assistantProfile($assistant) : self::UNKNOWN;
+    }
+
+    /**
+     * 按自增主键取人类用户的摘要。
+     *
+     * @return array<string, mixed>
+     */
+    public function byId(int|string|null $id): array
+    {
+        $user = UserInfo::query()->where('id', $id)->first(self::USER_COLUMNS);
+
+        return $user ? $this->profile($user) : self::UNKNOWN;
+    }
+
+    /**
+     * 按用户名取人类用户的摘要。
+     *
+     * @return array<string, mixed>
+     */
+    public function byName(?string $name): array
+    {
+        $user = UserInfo::query()->where('username', $name)->first(self::USER_COLUMNS);
+
+        return $user ? $this->profile($user) : self::UNKNOWN;
+    }
+
+    /** 用户名 → uuid(`user_infos.userid`)。 */
+    public function uuidByName(?string $name): ?string
+    {
+        return UserInfo::query()->where('username', $name)->value('userid');
+    }
+
+    /** uuid → 自增主键(`user_infos.id`)。 */
+    public function intIdByUuid(?string $uuid): ?int
+    {
+        return UserInfo::query()->where('userid', $uuid)->value('id');
+    }
+
+    /** 用户名 → 自增主键(`user_infos.id`)。 */
+    public function intIdByName(?string $name): ?int
+    {
+        return UserInfo::query()->where('username', $name)->value('id');
+    }
+
+    /**
+     * 把一行 `user_infos` 拼成公开摘要。
+     *
+     * 参数刻意不加类型:传进来的既有 UserInfo 模型,也有包着模型的 Resource。
+     *
+     * @return array<string, mixed>
+     */
+    public function profile($user): array
+    {
+        if (! $user) {
+            return self::UNKNOWN;
+        }
+
+        $profile = [
+            'id' => $user->userid,
+            'nickName' => $user->nickname,
+            'userName' => $user->username,
+            'realName' => $user->username,
+            'sn' => $user->id,
+        ];
+
+        // roles 与 avatar 是可选键:没有就整个键不出现,不是给个空值。
+        // 这是 v2 的既有口径,前端用 `in` 判断,别改成恒定存在。
+        if (! empty($user->role)) {
+            $profile['roles'] = json_decode($user->role);
+        }
+        if ($avatar = $this->avatarUrl($user->avatar)) {
+            $profile['avatar'] = $avatar;
+        }
+
+        return $profile;
+    }
+
+    /**
+     * 把一行 `ai_models` 拼成公开摘要。
+     *
+     * 参数刻意不加类型:`AiModelResource` 传进来的是 Resource 而不是模型。
+     *
+     * @return array<string, mixed>
+     */
+    public function assistantProfile($assistant): array
+    {
+        if (! $assistant) {
+            return self::UNKNOWN;
+        }
+
+        return [
+            'id' => $assistant->uid,
+            'nickName' => $assistant->name,
+            'userName' => $assistant->real_name,
+            'realName' => $assistant->real_name,
+            'roles' => ['ai'],
+            'sn' => 0,
+            'avatar' => $this->avatarUrl($assistant->avatar)
+                ?? $this->assistantLogo($assistant->model, $assistant->url),
+        ];
+    }
+
+    /**
+     * 清空请求内的身份映射。
+     *
+     * 只有「同一进程里改过用户资料又要重新读出来」的场合才需要,例如长跑的
+     * artisan 命令。普通 HTTP 请求不必调用。
+     */
+    public function flush(): void
+    {
+        $this->resolved = [];
+    }
+
+    /**
+     * 一批 uuid 的实际装载:先查人类表,剩下的才查 AI 表。
+     *
+     * 两次简单查询而不是一次 union/join:两张表各自的 uuid 列上都有唯一索引,
+     * 分开查各走各的索引,第二次还常常因为没有剩余 uuid 而根本不发生。
+     *
+     * @param  array<int, string>  $uuids
+     * @return array<string, array<string, mixed>>
+     */
+    private function load(array $uuids): array
+    {
+        $found = [];
+        foreach ($this->query(UserInfo::class, 'userid', $uuids, self::USER_COLUMNS)->get() as $user) {
+            $found[$user->userid] = $this->profile($user);
+        }
+
+        $rest = array_values(array_diff($uuids, array_keys($found)));
+        if (! $rest) {
+            return $found;
+        }
+
+        foreach ($this->query(AiModel::class, 'uid', $rest, self::ASSISTANT_COLUMNS)->get() as $assistant) {
+            $found[$assistant->uid] = $this->assistantProfile($assistant);
+        }
+
+        return $found;
+    }
+
+    /**
+     * 按 uuid 列表查某张表,只取指定列。
+     *
+     * @param  class-string<Model>  $model
+     * @param  array<int, string|null>  $uuids
+     * @param  array<int, string>  $columns
+     * @return Builder<covariant \Illuminate\Database\Eloquent\Model>
+     */
+    private function query(string $model, string $column, array $uuids, array $columns)
+    {
+        return $model::query()->whereIn($column, $uuids)->select($columns);
+    }
+
+    /**
+     * 头像缩略图地址;没有头像返回 null。
+     *
+     * 生产环境给 6 天有效期的签名 URL,本地与测试环境给普通 URL——签名要连对象
+     * 存储,测试里会直接炸。
+     *
+     * 原先人类路径认 `local` + `testing`、AI 路径只认 `local`,是两份副本漂移的
+     * 结果,这里统一成前者。差异只在 testing 环境显现,生产行为不变。
+     */
+    private function avatarUrl(?string $avatar): ?string
+    {
+        if (! $avatar) {
+            return null;
+        }
+        $img = str_replace('.jpg', '_s.jpg', $avatar);
+
+        return App::environment(['local', 'testing'])
+            ? Storage::url($img)
+            : Storage::temporaryUrl($img, now()->addDays(6));
+    }
+
+    /**
+     * AI 助手没有自定义头像时的内置 logo:按 model / url 里的关键字挑,都不匹配
+     * 就用通用图标。
+     */
+    private function assistantLogo(?string $model, ?string $url): string
+    {
+        $logo = 'ai-assistant.png';
+        // model 与 url 分别匹配,不要拼成一个串:关键字可能跨越拼接处产生假匹配。
+        // 两者都可为 null(新建模型时未必填写),转字符串避免 str_contains 报类型错。
+        foreach (config('mint.ai.logo', []) as $keyword => $file) {
+            if (str_contains((string) $model, (string) $keyword)
+                || str_contains((string) $url, (string) $keyword)) {
+                $logo = $file;
+                break;
+            }
+        }
+
+        return config('app.url').'/assets/images/avatar/'.$logo;
+    }
+}

+ 69 - 0
api-v13/app/Services/V3/ProgressService.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\ProgressChapter;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+
+/**
+ * 章节翻译进度的查询。
+ *
+ * v2 的 `ProgressChapterController` 另有一套实现(还带着 view= 开关),两边不共用——
+ * 见 CLAUDE.md 铁律 2:契约相关的逻辑 v3 重写,不为共享去改 v2。
+ */
+class ProgressService
+{
+    /**
+     * 取出的列。progress_chapters 还有 summary / uid / completed_chapters 等,
+     * 接口不返回就不查。
+     *
+     * @var list<string>
+     */
+    private const COLUMNS = [
+        'progress_chapters.book',
+        'progress_chapters.para',
+        'progress_chapters.lang',
+        'progress_chapters.progress',
+        'progress_chapters.channel_id',
+        'progress_chapters.title',
+        'progress_chapters.last_chapter_completed_at',
+        'progress_chapters.completed_at',
+        'progress_chapters.updated_at',
+    ];
+
+    /**
+     * 按 channel 列出章节进度。
+     *
+     * @param  array<string, mixed>  $filters  已经过 FormRequest 校验的查询参数
+     * @return LengthAwarePaginator<int, ProgressChapter>
+     */
+    public function chapters(array $filters): LengthAwarePaginator
+    {
+        $query = ProgressChapter::query()
+            ->select(self::COLUMNS)
+            // channels 是下划线分隔的 uid 列表,既有契约
+            ->whereIn('progress_chapters.channel_id', explode('_', (string) $filters['channels']));
+
+        if (isset($filters['level'])) {
+            // 只要该层级及以上的章节,层级在 pali_texts 上
+            $query->join('pali_texts', function ($join) {
+                $join->on('progress_chapters.book', '=', 'pali_texts.book')
+                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
+            })->where('pali_texts.level', '<=', (int) $filters['level']);
+        }
+        if (isset($filters['lang'])) {
+            $query->where('progress_chapters.lang', $filters['lang']);
+        }
+        if (isset($filters['book'])) {
+            $query->where('progress_chapters.book', $filters['book']);
+        }
+
+        // order 已由 FormRequest 的白名单校验过,拼进列名是安全的
+        $query->orderBy(
+            'progress_chapters.'.($filters['order'] ?? 'completed_at'),
+            $filters['dir'] ?? 'desc'
+        );
+
+        return $query->paginate($filters['per_page'] ?? 10);
+    }
+}

+ 138 - 0
api-v13/app/Services/V3/ReactionService.php

@@ -0,0 +1,138 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\Reaction;
+use App\Services\UserService;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
+
+/**
+ * reactions(点赞/收藏/书签/关注/下载记录)的业务逻辑。
+ *
+ * 底层是历史表 `likes`,v2 的 `LikeController` 另有一套实现,两边不共用——
+ * 见 CLAUDE.md 铁律 2:契约相关的逻辑 v3 重写,不为共享去改 v2。
+ */
+class ReactionService
+{
+    /**
+     * 某个 target 下的公共列表。
+     *
+     * @return LengthAwarePaginator<int, Reaction>
+     */
+    public function listForTarget(string $targetId, ?string $type, int $perPage): LengthAwarePaginator
+    {
+        return $this->withActor()
+            ->where('target_id', $targetId)
+            ->when($type, fn ($q, $value) => $q->where('type', $value))
+            ->orderByDesc('created_at')
+            ->paginate($perPage);
+    }
+
+    /**
+     * 某个用户自己的列表。
+     *
+     * @param  array{type?: string, target_type?: string}  $filters
+     * @return LengthAwarePaginator<int, Reaction>
+     */
+    public function listForUser(string $userUid, array $filters, int $perPage): LengthAwarePaginator
+    {
+        return $this->withActor()
+            ->where('user_id', $userUid)
+            ->when($filters['type'] ?? null, fn ($q, $value) => $q->where('type', $value))
+            ->when($filters['target_type'] ?? null, fn ($q, $value) => $q->where('target_type', $value))
+            ->orderByDesc('created_at')
+            ->paginate($perPage);
+    }
+
+    /**
+     * 添加一条 reaction,幂等。
+     *
+     * 唯一键是 (type, target_id, user_id):重复提交命中同一条,不新建。
+     * 返回的模型上 `wasRecentlyCreated` 决定响应是 201 还是 200,
+     * 由 Laravel 的 ResourceResponse::calculateStatus() 读取。
+     *
+     * @param  array{type: string, target_id: string, target_type: string, context?: string|null}  $data
+     */
+    public function add(string $userUid, array $data): Reaction
+    {
+        $reaction = Reaction::firstOrNew([
+            'type' => $data['type'],
+            'target_id' => $data['target_id'],
+            'user_id' => $userUid,
+        ]);
+
+        // id 不在 $fillable 里,且主键是 uuid(非自增),必须显式赋值,
+        // 否则 save() 后拿不到生成的 id,前端无法据此删除。
+        if (! $reaction->exists) {
+            $reaction->id = (string) Str::uuid();
+            $reaction->target_type = $data['target_type'];
+            $reaction->context = $data['context'] ?? null;
+        }
+        $reaction->save();
+
+        return $reaction;
+    }
+
+    /**
+     * 删除一条 reaction。只能删自己那条。
+     *
+     * 抛 AuthorizationException 而不是 abort(403):权限判定是业务规则,属于这一层;
+     * 转成 HTTP 响应由 bootstrap/app.php 的处理器做(prepareException 会把它
+     * 转成 403,message 进 Problem 的 detail)。
+     *
+     * @throws AuthorizationException 不是本人
+     */
+    public function remove(Reaction $reaction, string $userUid): Reaction
+    {
+        if ($reaction->user_id !== $userUid) {
+            throw new AuthorizationException(__('site.forbidden'));
+        }
+        $reaction->delete();
+
+        return $reaction;
+    }
+
+    /**
+     * 某个 target 下各 type 的计数;给了 userUid 就额外标出他选了哪些。
+     *
+     * @return Collection<int, array{type: string, count: int, selected: bool, id: string|null}>
+     */
+    public function tally(string $targetId, ?string $userUid): Collection
+    {
+        $rows = Reaction::query()
+            ->where('target_id', $targetId)
+            ->groupBy('type')
+            ->selectRaw('type, count(*) as count')
+            ->get();
+
+        $mine = $userUid === null ? [] : Reaction::query()
+            ->where('target_id', $targetId)
+            ->where('user_id', $userUid)
+            ->pluck('id', 'type')
+            ->all();
+
+        return $rows->map(fn ($row) => [
+            'type' => $row->type,
+            'count' => (int) $row->count,
+            'selected' => array_key_exists($row->type, $mine),
+            'id' => $mine[$row->type] ?? null,
+        ]);
+    }
+
+    /**
+     * 带操作者预加载的基础查询。
+     *
+     * 列白名单在 UserService 里,别在调用处手抄——抄漏匹配列(userid / uid)
+     * 会让关系静默对不上、整列变 null,不报错。
+     *
+     * @return Builder<Reaction>
+     */
+    private function withActor()
+    {
+        return Reaction::query()->with(UserService::eagerLoadActor());
+    }
+}

+ 269 - 0
api-v13/app/Services/V3/TipitakaReadingService.php

@@ -0,0 +1,269 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * 三藏译文的阅读与下载:按 channel 取已翻译的段落,游标式分块。
+ *
+ * 服务 `GET /v3/tipitaka-reading/{channel}`。控制器只负责把 validated() 传进来、
+ * 把结果包成 Resource;这里是全部业务逻辑——过滤收窄、章节展开、keyset 游标、
+ * 字节切块、进度统计、输出裁剪。
+ *
+ * 渲染本身交给 {@see PaliContentService}(v2/v3 共享的纯能力),这里不重复实现。
+ */
+class TipitakaReadingService
+{
+    /**
+     * 字节模式下单块的字节上限。请求超过这个数按这个数算,不报错。
+     */
+    public const MAX_BYTES_PER_PAGE = 5000;
+
+    /**
+     * 单块最多返回的段落数,两种单位都受它约束。
+     *
+     * 每段要单独渲染一次,块的成本跟段落数成正比而不是跟字节数成正比。段落模式
+     * 直接把 page_size 压到这个数;字节模式下偈颂体的书平均每段只有 20~30 字节,
+     * 光靠字节上限仍可能切出几百段,所以累加时两个上限谁先到就在哪断块。
+     */
+    public const MAX_PARAGRAPHS_PER_PAGE = 200;
+
+    public function __construct(private readonly PaliContentService $paliService) {}
+
+    /**
+     * 取一块译文。
+     *
+     * @param  array<string, mixed>  $filters  已经过 FormRequest 校验的查询参数
+     * @return array{items: array<int, array<string, mixed>>, meta: array<string, mixed>}
+     */
+    public function page(string $channel, array $filters): array
+    {
+        $unit = $filters['unit'] ?? 'para';
+        $pageSize = min(
+            max(1, (int) ($filters['page_size'] ?? 10)),
+            $unit === 'byte' ? self::MAX_BYTES_PER_PAGE : self::MAX_PARAGRAPHS_PER_PAGE
+        );
+
+        $scope = $this->scopedQuery($channel, $filters);
+
+        // 多取一行用来判断后面还有没有,不必再查一次
+        $want = $unit === 'byte'
+            ? self::MAX_PARAGRAPHS_PER_PAGE
+            : min($pageSize, self::MAX_PARAGRAPHS_PER_PAGE);
+
+        $candidates = (clone $scope)
+            ->when($filters['after'] ?? null, fn (Builder $q, string $after) => $this->applyCursor($q, $after))
+            ->distinct()
+            ->select(['book_id', 'paragraph'])
+            ->orderBy('book_id')
+            ->orderBy('paragraph')
+            ->limit($want + 1)
+            ->get()
+            ->map(fn ($row) => ['book' => (int) $row->book_id, 'para' => (int) $row->paragraph])
+            ->all();
+
+        $hasMoreBeyondWant = count($candidates) > $want;
+        $candidates = array_slice($candidates, 0, $want);
+
+        $slice = $unit === 'byte'
+            ? $this->sliceByBytes($candidates, $this->paragraphLengths($candidates), $pageSize)
+            : $candidates;
+        $hasMore = $hasMoreBeyondWant || count($slice) < count($candidates);
+
+        $format = $filters['format'] ?? 'html';
+        $include = $filters['include'] ?? 'display';
+
+        $items = [];
+        foreach ($slice as $coord) {
+            $paragraph = $this->paliService->readParagraph($coord['book'], $coord['para'], $channel, $format);
+            if (empty($paragraph['display'])) {
+                continue;
+            }
+            // 结果可以跨书,所以每条都要带 book——只给 para 在跨书时不唯一
+            $items[] = ['book' => $coord['book']] + $this->filterInclude($paragraph, $include);
+        }
+
+        $last = $slice === [] ? null : $slice[count($slice) - 1];
+
+        return [
+            'items' => $items,
+            'meta' => [
+                'page_size' => $pageSize,
+                'page_size_unit' => $unit,
+                // 本块覆盖的最后一段。渲染为空的段落会被剔出 data 但仍算在块内,
+                // 所以游标按 slice 推进而不是按 data 推进,否则会卡在那一段上打转
+                'next_cursor' => $hasMore && $last ? "{$last['book']}-{$last['para']}" : null,
+            ] + $this->progressMeta($scope, $filters, $last),
+        ];
+    }
+
+    /**
+     * 按 filter 收窄的**基础查询**:只有 where,不带 select / distinct / order。
+     *
+     * 分页取数和 count 两边对它的要求不一样(一个要去重的两列,一个要聚合),
+     * 混在一起会拼出 `select distinct book_id, paragraph, count(...)` 这种非法 SQL。
+     * 所以基础查询保持干净,各自 clone 之后再加自己那套。
+     *
+     * @param  array<string, mixed>  $filters
+     * @return Builder<Sentence>
+     */
+    private function scopedQuery(string $channel, array $filters): Builder
+    {
+        $query = Sentence::query()
+            ->where('channel_uid', $channel)
+            ->where('strlen', '>', 0);
+
+        if (! isset($filters['book'])) {
+            return $query;
+        }
+        $book = (int) $filters['book'];
+        $query->where('book_id', $book);
+
+        if (isset($filters['chapter'])) {
+            [$from, $to] = $this->chapterRange($book, (int) $filters['chapter']);
+
+            return $query->whereBetween('paragraph', [$from, $to]);
+        }
+        if (isset($filters['para'])) {
+            $from = (int) $filters['para'];
+            $to = (int) ($filters['to'] ?? $from);
+            if ($to < $from) {
+                throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
+            }
+
+            return $query->whereBetween('paragraph', [$from, $to]);
+        }
+
+        return $query;
+    }
+
+    /**
+     * 章节起始段落号 → 段落闭区间,长度取 pali_texts 那一行的 chapter_len。
+     *
+     * @return array{int, int}
+     */
+    private function chapterRange(int $book, int $chapter): array
+    {
+        $head = PaliText::where('book', $book)->where('paragraph', $chapter)->first();
+        if (! $head) {
+            abort(404, __('site.not_found'));
+        }
+
+        return [$chapter, $chapter + max(1, (int) $head->chapter_len) - 1];
+    }
+
+    /**
+     * 游标 `{book}-{para}`:取排在它之后的行。
+     *
+     * 用行值比较 `(book_id, paragraph) > (?, ?)` 而不是 `book > ? or (book = ? and para > ?)`,
+     * 一是短,二是 Postgres 能直接走 (book_id, paragraph, …) 那条复合索引。
+     *
+     * @param  Builder<Sentence>  $query
+     * @return Builder<Sentence>
+     */
+    private function applyCursor(Builder $query, string $after): Builder
+    {
+        [$book, $para] = array_map('intval', explode('-', $after, 2));
+
+        return $query->whereRaw('(book_id, paragraph) > (?, ?)', [$book, $para]);
+    }
+
+    /**
+     * 按原文字节数累加切块,到上限或到 200 段为止,每块至少一段。
+     *
+     * 纯函数:长度由调用方查好传进来,这里不碰数据库。段落数上限要造上千段才看得
+     * 出来,走 HTTP 会真的去渲染每一段,所以单元测试直接对着它测。
+     *
+     * @param  array<int, array{book: int, para: int}>  $candidates
+     * @param  array<int, array<int, int>>  $lengths  book => (para => 字节数)
+     * @return array<int, array{book: int, para: int}>
+     */
+    public function sliceByBytes(array $candidates, array $lengths, int $limit): array
+    {
+        $bytes = 0;
+        $take = 0;
+        foreach ($candidates as $coord) {
+            $bytes += (int) ($lengths[$coord['book']][$coord['para']] ?? 0);
+            $take++;
+            if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) {
+                break;
+            }
+        }
+
+        return array_slice($candidates, 0, $take);
+    }
+
+    /**
+     * 取这些段落的原文字节数。按书分组、每本用首尾段落号一次查回整段区间,
+     * 避免几百个值的 IN 列表;区间里多查回来几行比拼 IN 便宜。
+     *
+     * @param  array<int, array{book: int, para: int}>  $candidates
+     * @return array<int, array<int, int>> book => (para => 字节数)
+     */
+    private function paragraphLengths(array $candidates): array
+    {
+        $byBook = [];
+        foreach ($candidates as $coord) {
+            $byBook[$coord['book']][] = $coord['para'];
+        }
+
+        $lengths = [];
+        foreach ($byBook as $book => $paras) {
+            $lengths[$book] = PaliText::where('book', $book)
+                ->whereBetween('paragraph', [min($paras), max($paras)])
+                ->pluck('lenght', 'paragraph')
+                ->all();
+        }
+
+        return $lengths;
+    }
+
+    /**
+     * 进度用的 total / remaining,**只在带了 book 过滤时给**。
+     *
+     * 不带过滤时结果集是整个 channel(实测最大的 52 万段),每页 count 一次要
+     * 八百毫秒、两百多 MB,不值得。那种场景用 next_cursor 判断结束。
+     *
+     * @param  Builder<Sentence>  $scope
+     * @param  array<string, mixed>  $filters
+     * @param  array{book: int, para: int}|null  $last
+     * @return array<string, int>
+     */
+    private function progressMeta(Builder $scope, array $filters, ?array $last): array
+    {
+        if (! isset($filters['book'])) {
+            return [];
+        }
+
+        $countIn = fn (Builder $q): int => (int) (
+            $q->selectRaw('count(distinct (book_id, paragraph)) as aggregate')->first()?->aggregate ?? 0
+        );
+
+        $total = $countIn(clone $scope);
+        $remaining = $last === null
+            ? 0
+            : $countIn($this->applyCursor(clone $scope, "{$last['book']}-{$last['para']}"));
+
+        return ['total' => $total, 'remaining' => $remaining];
+    }
+
+    /**
+     * 按 include 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
+     *
+     * @param  array{para: int, display: string, sentences: array}  $paragraph
+     * @return array<string, mixed>
+     */
+    private function filterInclude(array $paragraph, string $include): array
+    {
+        return match ($include) {
+            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
+            'all' => $paragraph,
+            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
+        };
+    }
+}

+ 4 - 0
api-v13/bootstrap/app.php

@@ -5,6 +5,7 @@ use App\Http\Middleware\HandleInertiaRequests;
 use App\Http\Middleware\OpsToken;
 use App\Http\Middleware\SetLocale;
 use App\Http\Middleware\UserOperation;
+use App\Http\Middleware\V3\Authenticate as V3Authenticate;
 use Illuminate\Auth\AuthenticationException;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
@@ -39,6 +40,9 @@ return Application::configure(basePath: dirname(__DIR__))
 
         $middleware->alias([
             'ops.token' => OpsToken::class,
+            // v3 的登录闸。只认 bearer,未登录在进控制器前就 401——
+            // 所以「未登录 + 参数非法」得到的是 401 而不是 422
+            'auth.v3' => V3Authenticate::class,
         ]);
     })
     ->withExceptions(function (Exceptions $exceptions): void {

+ 1 - 0
api-v13/resources/lang/en/site.php

@@ -20,6 +20,7 @@ return [
     // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
     'maintenance' => 'The service is under scheduled maintenance.',
     'not_found' => 'The requested resource does not exist.',
+    'forbidden' => 'You are not allowed to perform this action.',
     'invalid_parameter' => 'Invalid parameter.',
     'author.name' => 'iapt',
     'author.email' => 'visuddhindand@gmail.com',

+ 1 - 0
api-v13/resources/lang/zh-Hans/site.php

@@ -20,6 +20,7 @@ return [
     // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
     'maintenance' => '服务已进入停机维护状态',
     'not_found' => '请求的资源不存在',
+    'forbidden' => '你没有执行该操作的权限',
     'invalid_parameter' => '参数不合法',
     'author.name' => 'iapt',
     'author.email' => 'visuddhindand@gmail.com',

+ 21 - 22
api-v13/routes/api.php

@@ -49,7 +49,6 @@ use App\Http\Controllers\GroupController;
 use App\Http\Controllers\GroupMemberController;
 use App\Http\Controllers\HealthCheckController;
 use App\Http\Controllers\HeartbeatController;
-use App\Http\Controllers\HeartbeatV3Controller;
 use App\Http\Controllers\InteractiveController;
 use App\Http\Controllers\InviteController;
 use App\Http\Controllers\LikeController;
@@ -70,7 +69,6 @@ use App\Http\Controllers\ParagraphContentController;
 use App\Http\Controllers\ParaInfoController;
 use App\Http\Controllers\PgPaliDictDownloadController;
 use App\Http\Controllers\ProgressChapterController;
-use App\Http\Controllers\ProgressController;
 use App\Http\Controllers\ProgressImgController;
 use App\Http\Controllers\ProjectController;
 use App\Http\Controllers\ProjectTreeController;
@@ -82,8 +80,6 @@ use App\Http\Controllers\SearchController;
 use App\Http\Controllers\SearchPageNumberController;
 use App\Http\Controllers\SearchPaliDataController;
 use App\Http\Controllers\SearchPaliWbwController;
-use App\Http\Controllers\SearchPlusController;
-use App\Http\Controllers\SearchSuggestController;
 use App\Http\Controllers\SearchTitleController;
 use App\Http\Controllers\SearchWordSliceController;
 use App\Http\Controllers\SentenceAttachmentController;
@@ -112,11 +108,8 @@ use App\Http\Controllers\TermExportController;
 use App\Http\Controllers\TermSummaryController;
 use App\Http\Controllers\TermVocabularyController;
 use App\Http\Controllers\TipitakaContentController;
-use App\Http\Controllers\TipitakaReadChapterController;
-use App\Http\Controllers\TipitakaReadParaController;
 use App\Http\Controllers\TransferController;
 use App\Http\Controllers\UpdatePaliSynonymsController;
-use App\Http\Controllers\UpgradeController;
 use App\Http\Controllers\UploadController;
 use App\Http\Controllers\UserController;
 use App\Http\Controllers\UserDictController;
@@ -171,7 +164,6 @@ Route::group([
     Route::apiResource('tag', TagController::class);
     Route::apiResource('view', ViewController::class);
 
-    Route::delete('like', [LikeController::class, 'delete']);
     Route::apiResource('like', LikeController::class);
     Route::apiResource('sent_history', SentHistoryController::class);
     Route::get('sent_history_contribution', [SentHistoryController::class, 'contribution']);
@@ -340,18 +332,25 @@ Route::group([
         ->where('version', '[A-Za-z0-9._-]+');
 });
 
-Route::group([
-    'prefix' => 'v3',
-    'as' => 'v3.',
-], function () {
-    // 只注册真正实现了的动作——空方法会在 OpenAPI 里变成幽灵端点
-    Route::apiResource('search', SearchPlusController::class)->only(['index', 'store', 'show']);
-    Route::apiResource('search-suggest', SearchSuggestController::class)->only(['index']);
-    Route::apiResource('upgrade', UpgradeController::class)->only(['index']);
-    Route::apiResource('progress', ProgressController::class)->only(['index']);
-    Route::apiResource('tipitaka-read-para', TipitakaReadParaController::class)->only(['index', 'show']);
-    Route::apiResource('tipitaka-read-chapter', TipitakaReadChapterController::class)->only(['index', 'show']);
-
-    // 存活检查是单例资源(只有一个实例),不是集合
-    Route::apiSingleton('heartbeat', HeartbeatV3Controller::class)->only(['show']);
+/*
+|--------------------------------------------------------------------------
+| v3
+|--------------------------------------------------------------------------
+|
+| 按领域拆成 routes/v3/*.php,前缀与名字前缀只在这里加一次——子文件里不要再包
+| 一层 group,漏写会让路由落到 /api/ 根下而且不报错。
+|
+| **顺序是显式的,不要改成 glob()**:跨文件的注册顺序由下面的 require 顺序决定,
+| glob 会变成按字母排且看不见。文件内部的顺序陷阱(字面量路由必须排在 apiResource
+| 之前)在各自文件里解决。
+|
+| 新增一个领域就在这里加一行 require,并在 routes/v3/ 下建同名文件。
+|
+*/
+Route::prefix('v3')->as('v3.')->group(function () {
+    require __DIR__.'/v3/system.php';
+    require __DIR__.'/v3/search.php';
+    require __DIR__.'/v3/tipitaka.php';
+    require __DIR__.'/v3/channel.php';
+    require __DIR__.'/v3/interaction.php';
 });

+ 19 - 0
api-v13/routes/v3/channel.php

@@ -0,0 +1,19 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| v3 · channel —— 译本与翻译进度
+|--------------------------------------------------------------------------
+|
+| 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
+|
+| channel 本身还没迁(见 CLAUDE.md 的 v3 重构一节),这里先只有 progress。
+|
+*/
+
+use App\Http\Controllers\V3\ProgressController;
+use Illuminate\Support\Facades\Route;
+
+// FIXME: ProgressController@index 还带着 switch($request->input('view')),
+// 不符合 v3 规范(v3 端点不准有 view 开关)。迁 channel 时一并处理。
+Route::apiResource('progress', ProgressController::class)->only(['index']);

+ 32 - 0
api-v13/routes/v3/interaction.php

@@ -0,0 +1,32 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| v3 · interaction —— 用户对内容的互动
+|--------------------------------------------------------------------------
+|
+| 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
+|
+| 将来 discussion、notification、tag 也落在这个文件。
+|
+*/
+
+use App\Http\Controllers\V3\MeReactionController;
+use App\Http\Controllers\V3\ReactionController;
+use App\Http\Controllers\V3\ReactionTallyController;
+use Illuminate\Support\Facades\Route;
+
+// reactions(点赞/收藏/书签/关注/下载记录)。资源改名是 CLAUDE.md
+// 「已登记的改名例外」里的唯一一条,底层仍是 v2 的 likes 表。
+
+// tally 要注册在 apiResource 之前:将来给 reactions 补上 show 之后,
+// {reaction} 会把 tally 当成 uid 吃掉
+Route::get('reactions/tally', [ReactionTallyController::class, 'index']);
+
+// 公共只读:某个 target 下的 reactions
+Route::apiResource('reactions', ReactionController::class)->only(['index']);
+
+// 当前用户自己的 reactions:全线登录,由 auth.v3 中间件在进控制器前挡掉
+Route::prefix('me')->as('me.')->middleware('auth.v3')->group(function () {
+    Route::apiResource('reactions', MeReactionController::class)->only(['index', 'store', 'destroy']);
+});

+ 18 - 0
api-v13/routes/v3/search.php

@@ -0,0 +1,18 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| v3 · search —— 全文检索与检索建议
+|--------------------------------------------------------------------------
+|
+| 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
+|
+*/
+
+use App\Http\Controllers\V3\SearchPlusController;
+use App\Http\Controllers\V3\SearchSuggestController;
+use Illuminate\Support\Facades\Route;
+
+// 只注册真正实现了的动作——空方法会在 OpenAPI 里变成幽灵端点
+Route::apiResource('search', SearchPlusController::class)->only(['index', 'store', 'show']);
+Route::apiResource('search-suggest', SearchSuggestController::class)->only(['index']);

+ 20 - 0
api-v13/routes/v3/system.php

@@ -0,0 +1,20 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| v3 · system —— 存活、版本、运维杂项
+|--------------------------------------------------------------------------
+|
+| 由 routes/api.php 的 v3 组 require 进来,前缀 v3 与名字前缀 v3. 已在那边加好,
+| 本文件只写路由本身。
+|
+*/
+
+use App\Http\Controllers\V3\HeartbeatController;
+use App\Http\Controllers\V3\UpgradeController;
+use Illuminate\Support\Facades\Route;
+
+// 存活检查是单例资源(只有一个实例),不是集合
+Route::apiSingleton('heartbeat', HeartbeatController::class)->only(['show']);
+
+Route::apiResource('upgrade', UpgradeController::class)->only(['index']);

+ 27 - 0
api-v13/routes/v3/tipitaka.php

@@ -0,0 +1,27 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| v3 · tipitaka —— 三藏相关的功能块
+|--------------------------------------------------------------------------
+|
+| 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
+|
+| **路径开头是「领域-功能」,不是资源坐标。** `channel + book + para` 在 wikipali
+| 里是通用坐标(sentences / wbw / 批注 / 进度全都用它),放在路径开头会让每个新
+| 能力来抢同一个前缀,而且看不出这条 API 干什么。所以是
+| `/v3/tipitaka-reading/{channel}?book=&para=`,不是 `/v3/channels/{c}/books/{b}/…`。
+|
+| 路由判据(见根目录 CLAUDE.md):
+|   - 必填参数进路径,可选参数留查询串
+|   - 路径变量最多两个,能做到一个最好;多出来的想办法降成 filter
+|
+*/
+
+use App\Http\Controllers\V3\TipitakaReadingController;
+use Illuminate\Support\Facades\Route;
+
+// 只有 channel 必填,所以路径上只有它一个变量;book / chapter / para 全是 filter。
+// 不带任何查询串 = 取这个 channel 的全部译文,下载场景要的就是这个。
+Route::get('tipitaka-reading/{channel}', TipitakaReadingController::class)
+    ->whereUuid('channel');

+ 80 - 0
api-v13/tests/Feature/AuthServiceCookieTest.php

@@ -0,0 +1,80 @@
+<?php
+
+use App\Services\AuthService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * `AuthService::current()` 不再认 cookie。
+ *
+ * 曾经的那条回落(拿不到 bearer 就读 `$_COOKIE['user_uid']` 并直接返回)是 v1
+ * 时代的遗留,不验签名、不验过期、不查库——浏览器里手工种一个就能冒充任何人。
+ *
+ * **必须直接操作 `$_COOKIE` 超全局。** Laravel 的 `withUnencryptedCookies()` 只填
+ * Symfony Request 的 cookie 袋子,不碰超全局(`Request::create()` 不调
+ * `overrideGlobals()`),所以用那个助手写出来的"cookie 测试"是空转的——
+ * 不管代码里有没有这条分支都会过。
+ */
+function withCookieSuperglobal(array $cookies, callable $fn): mixed
+{
+    $backup = $_COOKIE;
+    $_COOKIE = $cookies;
+    try {
+        return $fn();
+    } finally {
+        $_COOKIE = $backup;
+    }
+}
+
+it('authenticates a valid bearer token', function () {
+    $uid = (string) Str::uuid();
+
+    $user = AuthService::current(Request::create('/', 'GET', server: [
+        'HTTP_AUTHORIZATION' => 'Bearer '.userToken($uid, 7),
+    ]));
+
+    expect($user)->toBe(['user_uid' => $uid, 'user_id' => 7]);
+});
+
+it('rejects a cookie with no bearer token', function () {
+    $result = withCookieSuperglobal(
+        ['user_uid' => (string) Str::uuid(), 'user_id' => '1'],
+        fn () => AuthService::current(Request::create('/', 'GET'))
+    );
+
+    expect($result)->toBeFalse();
+});
+
+it('ignores the cookie even when the bearer token is invalid', function () {
+    // 旧代码是 if(bearer){...} elseif(cookie){...},bearer 无效时走的是 if 分支
+    // 里的 return false,本来就到不了 cookie;这条守的是将来别有人把顺序改回来
+    $result = withCookieSuperglobal(
+        ['user_uid' => (string) Str::uuid(), 'user_id' => '1'],
+        fn () => AuthService::current(Request::create('/', 'GET', server: [
+            'HTTP_AUTHORIZATION' => 'Bearer not-a-jwt',
+        ]))
+    );
+
+    expect($result)->toBeFalse();
+});
+
+it('rejects a request with neither bearer nor cookie', function () {
+    expect(AuthService::current(Request::create('/', 'GET')))->toBeFalse();
+});
+
+it('keeps v2 endpoints unreachable by cookie', function () {
+    // 端到端:v2 的 like 列表需要登录才会富化 selected;带着 cookie 打过去,
+    // 现在拿不到登录态了
+    $uid = makeStudio('cookie-v2');
+    $target = (string) Str::uuid();
+
+    $json = withCookieSuperglobal(
+        ['user_uid' => $uid, 'user_id' => '1'],
+        fn () => $this->getJson("/api/v2/like?view=count&target_id={$target}")->json()
+    );
+
+    expect($json['ok'])->toBeTrue();
+});

+ 17 - 4
api-v13/tests/Feature/ExampleTest.php

@@ -1,7 +1,20 @@
 <?php
 
-test('returns a successful response', function () {
-    $response = $this->get(route('home'));
+/**
+ * 冒烟测试:应用能不能起来、首页能不能渲染。
+ *
+ * 原来这条断言的是 `route('home')`,那是 Laravel 脚手架的样板——本应用没有叫
+ * `home` 的路由(首页是未命名的 `/`,library 那个叫 `library.home`),所以它
+ * 从建库起就一直红着。
+ *
+ * 页面渲染依赖 Vite manifest,CI 里 `npm run build` 排在 `pest` 之前(见
+ * .github/workflows/tests.yml),本地没构建过的话这两条会报 ViteException——
+ * 那本身就是个有用的信号。
+ */
+test('the site index renders', function () {
+    $this->get('/')->assertOk();
+});
 
-    $response->assertOk();
-});
+test('the library home renders', function () {
+    $this->get(route('library.home'))->assertOk();
+});

+ 103 - 0
api-v13/tests/Feature/ProgressV3Test.php

@@ -0,0 +1,103 @@
+<?php
+
+use App\Models\ProgressChapter;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * GET /v3/progress —— 按 channel 列章节翻译进度。
+ *
+ * 这个端点原来零测试覆盖,整改(去掉只有一个合法值的 view 开关、加 FormRequest、
+ * order 白名单、逻辑下沉 Service)时补上。
+ */
+function makeProgressChapter(string $channelUid, int $book, int $para, array $attrs = []): ProgressChapter
+{
+    $row = new ProgressChapter;
+    $row->forceFill(array_merge([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'book' => $book,
+        'para' => $para,
+        'lang' => 'zh-Hans',
+        'channel_id' => $channelUid,
+        'progress' => 0.5,
+        'title' => "book {$book} para {$para}",
+        // all_trans / public 是 NOT NULL 且无默认值
+        'all_trans' => 0,
+        'public' => 0,
+    ], $attrs))->save();
+
+    return $row;
+}
+
+it('lists chapters of a channel', function () {
+    $channel = makeChannel(makeStudio('prog-owner'), 'prog channel');
+    makeProgressChapter($channel, 9001, 1);
+    makeProgressChapter($channel, 9001, 2);
+    makeProgressChapter((string) Str::uuid(), 9001, 3);   // 别的 channel
+
+    $json = $this->getJson("/api/v3/progress?channels={$channel}")->assertOk()->json();
+
+    expect($json['data'])->toHaveCount(2)
+        ->and($json['data'][0])->toHaveKeys(['book', 'para', 'lang', 'progress', 'channel_id'])
+        ->and($json['meta']['total'])->toBe(2);
+});
+
+it('accepts an underscore separated channel list', function () {
+    $a = makeChannel(makeStudio('prog-a'), 'a');
+    $b = makeChannel(makeStudio('prog-b'), 'b');
+    makeProgressChapter($a, 9001, 1);
+    makeProgressChapter($b, 9002, 1);
+
+    // 分隔符是下划线不是逗号,这是既有契约
+    $data = $this->getJson("/api/v3/progress?channels={$a}_{$b}")->assertOk()->json('data');
+
+    expect($data)->toHaveCount(2);
+});
+
+it('filters by book and lang', function () {
+    $channel = makeChannel(makeStudio('prog-filter'), 'c');
+    makeProgressChapter($channel, 9001, 1, ['lang' => 'zh-Hans']);
+    makeProgressChapter($channel, 9002, 1, ['lang' => 'en']);
+
+    expect($this->getJson("/api/v3/progress?channels={$channel}&book=9001")->assertOk()->json('data'))
+        ->toHaveCount(1);
+    expect($this->getJson("/api/v3/progress?channels={$channel}&lang=en")->assertOk()->json('data'))
+        ->toHaveCount(1);
+});
+
+it('requires the channels filter', function () {
+    // 不带条件就能拉全表的列表端点是设计错误(硬规范第 4 条)
+    $this->getJson('/api/v3/progress')
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['channels']]);
+});
+
+it('rejects an order column outside the whitelist', function () {
+    $channel = makeChannel(makeStudio('prog-order'), 'c');
+
+    // 原来 order 未校验,直接拼进 orderBy——传个不存在的列名就是 QueryException → 500
+    $this->getJson("/api/v3/progress?channels={$channel}&order=no_such_column")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['order']]);
+
+    $this->getJson("/api/v3/progress?channels={$channel}&order=updated_at")->assertOk();
+});
+
+it('rejects a bad dir or per_page', function () {
+    $channel = makeChannel(makeStudio('prog-bad'), 'c');
+
+    $this->getJson("/api/v3/progress?channels={$channel}&dir=sideways")->assertStatus(422);
+    $this->getJson("/api/v3/progress?channels={$channel}&per_page=100000")->assertStatus(422);
+});
+
+it('no longer needs the view parameter', function () {
+    $channel = makeChannel(makeStudio('prog-view'), 'c');
+    makeProgressChapter($channel, 9001, 1);
+
+    // view 只有一个合法值,是噪音,已去掉;传了也只是被忽略
+    $this->getJson("/api/v3/progress?channels={$channel}")->assertOk();
+    $this->getJson("/api/v3/progress?channels={$channel}&view=channel")->assertOk();
+});

+ 316 - 0
api-v13/tests/Feature/ReactionV3Test.php

@@ -0,0 +1,316 @@
+<?php
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v3 reactions:点赞/收藏/书签/关注/下载记录共用一张 likes 表。
+ *
+ * 端点:
+ *   GET    /api/v3/reactions?target_id=…        公共,按 target 列记录
+ *   GET    /api/v3/reactions/tally?target_id=…   公共,各 type 计数 + 可选我的选择
+ *   GET    /api/v3/me/reactions                 我的列表(登录)
+ *   POST   /api/v3/me/reactions                 我添加(登录,幂等)
+ *   DELETE /api/v3/me/reactions/{reaction}       我删除(登录,只能删自己的)
+ */
+function makeReaction(array $attrs = []): Reaction
+{
+    return Reaction::forceCreate(array_merge([
+        'id' => (string) Str::uuid(),
+        'type' => 'like',
+        'target_id' => (string) Str::uuid(),
+        'target_type' => 'task',
+        'user_id' => makeStudio('reaction-'.Str::random(6)),
+        'context' => null,
+    ], $attrs));
+}
+
+it('lists reactions of a target without auth', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target]);
+    makeReaction(['target_id' => $target, 'type' => 'watch']);
+    makeReaction(['target_id' => (string) Str::uuid()]);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}")
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(2)
+        ->each(fn ($row) => $row->toHaveKeys(['id', 'type', 'target_id', 'target_type', 'user']));
+});
+
+it('filters reactions by type', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'favorite']);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}&type=like")
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(1)
+        ->and($data[0]['type'])->toBe('like');
+});
+
+it('rejects a non-uuid target_id', function () {
+    $this->getJson('/api/v3/reactions?target_id=not-a-uuid')->assertStatus(422);
+});
+
+it('rejects an out-of-range per_page', function () {
+    $target = (string) Str::uuid();
+
+    $this->getJson("/api/v3/reactions?target_id={$target}&per_page=100000")->assertStatus(422);
+    $this->getJson("/api/v3/reactions?target_id={$target}&per_page=0")->assertStatus(422);
+});
+
+it('honours per_page', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'watch']);
+
+    $json = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=1")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toHaveCount(1)
+        ->and($json['meta']['per_page'])->toBe(1)
+        ->and($json['meta']['total'])->toBe(2);
+});
+
+it('embeds the acting user without querying per row', function () {
+    // N+1 守卫:查询数必须与行数无关。
+    // 现在是 4 条(分页 count + 取页 + 预加载 user_infos + 预加载 ai_models);
+    // 断言的是「2 行与 12 行一样多」而不是硬编码 4——前者是真正的不变量,
+    // 后者会被 Laravel 内部实现的变动误伤。Resource 里每行再查一次就立刻红。
+    $countQueriesFor = function (int $rows): int {
+        $target = (string) Str::uuid();
+        foreach (range(1, $rows) as $i) {
+            makeReaction([
+                'target_id' => $target,
+                'type' => 'like',
+                'user_id' => makeStudio('actor-'.Str::random(8)),
+            ]);
+        }
+
+        DB::enableQueryLog();
+        DB::flushQueryLog();   // enable 不清空历史,两次测量之间必须手动冲掉
+        $data = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=200")
+            ->assertOk()
+            ->json('data');
+        $queries = count(DB::getQueryLog());
+        DB::disableQueryLog();
+
+        expect($data)->toHaveCount($rows);
+
+        return $queries;
+    };
+
+    expect($countQueriesFor(12))->toBe($countQueriesFor(2));
+});
+
+it('resolves the acting user through the constrained eager load', function () {
+    $target = (string) Str::uuid();
+    $uid = makeStudio('actor-name');
+    makeReaction(['target_id' => $target, 'user_id' => $uid]);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
+
+    // 受限列漏掉哪个,这里就会露馅:id 来自 userid、nickName 来自 nickname、sn 来自 id
+    expect($data[0]['user']['id'])->toBe($uid)
+        ->and($data[0]['user']['nickName'])->toBe('actor-name')
+        ->and($data[0]['user']['userName'])->toBe('actor-name')
+        ->and($data[0]['user']['sn'])->toBeGreaterThan(0);
+});
+
+it('falls back to the unknown placeholder for an orphan user_id', function () {
+    $target = (string) Str::uuid();
+    Reaction::forceCreate([
+        'id' => (string) Str::uuid(),
+        'type' => 'like',
+        'target_id' => $target,
+        'target_type' => 'task',
+        'user_id' => (string) Str::uuid(), // 既不在 user_infos 也不在 ai_models
+        'context' => null,
+    ]);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
+
+    expect($data[0]['user']['nickName'])->toBe('unknown');
+});
+
+it('tallies counts per type without auth', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'favorite']);
+
+    $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}")
+        ->assertOk()
+        ->json('data');
+
+    $like = collect($data)->firstWhere('type', 'like');
+    $favorite = collect($data)->firstWhere('type', 'favorite');
+
+    expect($like['count'])->toBe(2)
+        ->and($like['selected'])->toBeFalse()
+        ->and($favorite['count'])->toBe(1);
+});
+
+it('marks my selection in the tally when authenticated', function () {
+    $target = (string) Str::uuid();
+    $uid = makeStudio('tally-owner');
+    $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+
+    $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}", authHeader($uid))
+        ->assertOk()
+        ->json('data');
+
+    $like = collect($data)->firstWhere('type', 'like');
+    expect($like['selected'])->toBeTrue()
+        ->and($like['id'])->toBe($mine->id);
+});
+
+it('requires auth to list my reactions', function () {
+    $this->getJson('/api/v3/me/reactions')->assertStatus(401);
+});
+
+it('lists only my reactions', function () {
+    $uid = makeStudio('me-owner');
+    makeReaction(['user_id' => $uid, 'type' => 'like']);
+    makeReaction(['user_id' => $uid, 'type' => 'favorite']);
+    makeReaction(['user_id' => makeStudio('other'), 'type' => 'like']);
+
+    $data = $this->getJson('/api/v3/me/reactions', authHeader($uid))
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(2);
+});
+
+it('requires auth to add a reaction', function () {
+    $this->postJson('/api/v3/me/reactions', [
+        'type' => 'like',
+        'target_id' => (string) Str::uuid(),
+        'target_type' => 'task',
+    ])->assertStatus(401);
+});
+
+it('answers 401 before 422 when not logged in', function () {
+    // auth.v3 中间件在 FormRequest 之前跑,所以「未登录 + 参数非法」是 401 不是 422。
+    // 这正是把登录闸放进中间件(而不是控制器第一行)买到的东西
+    $this->postJson('/api/v3/me/reactions', ['type' => 'bogus'])
+        ->assertStatus(401)
+        ->assertJsonStructure(['type', 'title', 'status']);
+});
+
+it('does not accept the v1 cookie bypass', function () {
+    $uid = makeStudio('cookie-user');
+
+    // 必须直接改 $_COOKIE 超全局:withUnencryptedCookies() 只填 Symfony Request 的
+    // cookie 袋子,不碰超全局,用它写出来的 cookie 测试是空转的(不管代码里有没有
+    // 那条分支都会过)。helper 见 AuthServiceCookieTest.php
+    $backup = $_COOKIE;
+    $_COOKIE = ['user_uid' => $uid, 'user_id' => '1'];
+    try {
+        $this->getJson('/api/v3/me/reactions')->assertStatus(401);
+    } finally {
+        $_COOKIE = $backup;
+    }
+});
+
+it('validates required fields when adding', function () {
+    $uid = makeStudio('store-owner');
+
+    $this->postJson('/api/v3/me/reactions', ['type' => 'like'], authHeader($uid))
+        ->assertStatus(422);
+});
+
+it('rejects an unknown target_type when adding', function () {
+    $uid = makeStudio('store-owner');
+
+    $this->postJson('/api/v3/me/reactions', [
+        'type' => 'like',
+        'target_id' => (string) Str::uuid(),
+        'target_type' => 'unknown',
+    ], authHeader($uid))->assertStatus(422);
+});
+
+it('adds a reaction and forces user_id to the current user', function () {
+    $uid = makeStudio('store-owner');
+    $target = (string) Str::uuid();
+
+    $data = $this->postJson('/api/v3/me/reactions', [
+        'type' => 'like',
+        'target_id' => $target,
+        'target_type' => 'task',
+        'user_id' => (string) Str::uuid(), // 客户端塞的 user_id 应被忽略
+    ], authHeader($uid))->assertCreated()->json('data');
+
+    // 返回的是资源本身。计数不在这里给——那是 tally 的职责
+    expect($data)->toHaveKeys(['id', 'type', 'target_id', 'target_type', 'user'])
+        ->and($data)->not->toHaveKey('count')
+        ->and($data['type'])->toBe('like')
+        ->and($data['target_id'])->toBe($target);
+
+    $record = Reaction::find($data['id']);
+    expect($record->user_id)->toBe($uid);
+});
+
+it('is idempotent: repeat add returns the same record', function () {
+    $uid = makeStudio('store-owner');
+    $target = (string) Str::uuid();
+    $payload = ['type' => 'favorite', 'target_id' => $target, 'target_type' => 'task'];
+
+    // 首次为 201(新建),重复提交命中唯一键,为 200(幂等不新建)
+    $first = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertCreated()->json('data');
+    $second = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertOk()->json('data');
+
+    expect($second['id'])->toBe($first['id'])
+        ->and(Reaction::count())->toBe(1);
+});
+
+it('requires auth to delete a reaction', function () {
+    $reaction = makeReaction();
+
+    $this->deleteJson("/api/v3/me/reactions/{$reaction->id}")->assertStatus(401);
+});
+
+it('deletes my reaction with 204 and an empty body', function () {
+    $uid = makeStudio('del-owner');
+    $target = (string) Str::uuid();
+    $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
+    $others = makeReaction(['target_id' => $target, 'type' => 'like']);
+
+    // 两条都要:只有前者可能「返回 204 但压根没删」,只有后者可能「删对了但多吐了 body」
+    $this->deleteJson("/api/v3/me/reactions/{$mine->id}", [], authHeader($uid))
+        ->assertNoContent();
+    $this->assertModelMissing($mine);
+
+    // 别人那条还在;剩余计数去 tally 拿,不由 destroy 返回
+    $this->assertModelExists($others);
+    $tally = $this->getJson("/api/v3/reactions/tally?target_id={$target}")->assertOk()->json('data');
+    expect(collect($tally)->firstWhere('type', 'like')['count'])->toBe(1);
+});
+
+it('forbids deleting another user reaction', function () {
+    $other = makeReaction(['user_id' => makeStudio('del-other')]);
+
+    $detail = $this->deleteJson("/api/v3/me/reactions/{$other->id}", [], authHeader(makeStudio('del-me')))
+        ->assertStatus(403)
+        ->json('detail');
+
+    // 翻译文件缺键时 __() 会原样吐出键名,这条守住那种退化
+    expect($detail)->not->toBe('site.forbidden')->not->toBeEmpty();
+
+    expect(Reaction::find($other->id))->not->toBeNull();
+});
+
+it('rejects an unknown type filter on my reactions', function () {
+    $this->getJson('/api/v3/me/reactions?type=bogus', authHeader(makeStudio('filter-owner')))
+        ->assertStatus(422);
+});

+ 8 - 8
api-v13/tests/Feature/ReadingAnnotationsTest.php

@@ -46,9 +46,9 @@ it('injects a commentary annotation with a cite link', function () {
     makeSentence($channel, 9102, 7, 3, '义注这么说');
     makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
 
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
         ->assertOk()
-        ->json('data.display');
+        ->json('data.0.display');
 
     expect($display)
         ->toContain('义注这么说')
@@ -60,9 +60,9 @@ it('injects a plain note annotation without a cite link', function () {
     // 普通边注:content 就是注解正文本身
     makeAnnotation($sentence->uid, 'note', '这里的 dassana 指见到佛陀。', 5);
 
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
         ->assertOk()
-        ->json('data.display');
+        ->json('data.0.display');
 
     // 正文照原样进 sidenote,紧跟 </span> 收尾——没有 <cite> 出处
     expect($display)
@@ -76,16 +76,16 @@ it('injects both kinds on the same sentence, note first at the same position', f
     makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
     makeAnnotation($sentence->uid, 'note', '译者按。', 5);
 
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
         ->assertOk()
-        ->json('data.display');
+        ->json('data.0.display');
 
     expect(mb_strpos($display, '译者按。'))->toBeLessThan(mb_strpos($display, '义注这么说'));
 });
 
 it('drops the paragraph cache when either kind of annotation changes', function () {
     [$channel, $sentence] = makeAnnotatedSentence();
-    $url = "/api/v3/tipitaka-read-para/9101-1?channel={$channel}";
+    $url = "/api/v3/tipitaka-reading/{$channel}?book=9101&para=1";
     $this->getJson($url)->assertOk();
 
     $key = PaliContentService::paragraphCacheKey(9101, 1, $channel, 'html');
@@ -93,5 +93,5 @@ it('drops the paragraph cache when either kind of annotation changes', function
 
     makeAnnotation($sentence->uid, 'note', '译者按。', 5);
     expect(Cache::has($key))->toBeFalse();
-    expect($this->getJson($url)->json('data.display'))->toContain('译者按。');
+    expect($this->getJson($url)->json('data.0.display'))->toContain('译者按。');
 });

+ 0 - 251
api-v13/tests/Feature/TipitakaReadChapterTest.php

@@ -1,251 +0,0 @@
-<?php
-
-use App\Models\PaliText;
-use Illuminate\Foundation\Testing\RefreshDatabase;
-use Illuminate\Support\Str;
-
-uses(RefreshDatabase::class);
-
-/**
- * 建一个 pali_texts 段落。第一段带 chapter_len 表示章节长度
- */
-function makePaliText(int $book, int $para, int $lenght, ?int $chapterLen = null): void
-{
-    (new PaliText)->forceFill([
-        'book' => $book,
-        'paragraph' => $para,
-        'level' => $chapterLen ? 2 : 9,
-        'class' => '',
-        'toc' => '',
-        'text' => '',
-        'html' => '',
-        'lenght' => $lenght,
-        'chapter_len' => $chapterLen,
-        'pcd_book_id' => 0,
-        'uid' => (string) Str::uuid(),
-    ])->save();
-}
-
-/**
- * book 9002 一个 5 段的章节(起始段落号 1),每段 100 字节。$translated 列出
- * 哪些段落有译文,默认整章都有;传子集即模拟残缺译文。
- *
- * @param  array<int, int>  $translated
- */
-function makeChapterFixture(array $translated = [1, 2, 3, 4, 5]): string
-{
-    $channel = makeChannel(makeStudio('chapter-owner'), 'chapter channel');
-    makePaliText(9002, 1, 100, 5);
-    foreach (range(2, 5) as $para) {
-        makePaliText(9002, $para, 100);
-    }
-    foreach ($translated as $para) {
-        makeSentence($channel, 9002, $para, 1, "para {$para} text");
-    }
-
-    return $channel;
-}
-
-it('fetches a block of paragraphs from the cursor, the default unit', function () {
-    $channel = makeChapterFixture();
-    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2";
-
-    // 不传 from,游标默认落在章节开头
-    $first = $this->getJson($base)->assertOk()->json();
-    expect(array_column($first['data'], 'para'))->toBe([1, 2]);
-    expect($first['meta'])->toMatchArray([
-        'current_para' => 1,
-        'total_para' => 5,
-        'page_size' => 2,
-        'page_size_unit' => 'para',
-        'book' => 9002,
-        'chapter' => 1,
-        'first_para' => 1,
-        'last_para' => 2,
-        'remaining_para' => 3,
-    ]);
-
-    // 断点续传:把上次的 last_para + 1 当作下次的 from
-    $second = $this->getJson($base.'&from=3')->assertOk()->json();
-    expect(array_column($second['data'], 'para'))->toBe([3, 4]);
-    expect($second['meta'])->toMatchArray(['current_para' => 3, 'remaining_para' => 1]);
-
-    $last = $this->getJson($base.'&from=5')->assertOk()->json();
-    expect(array_column($last['data'], 'para'))->toBe([5]);
-    expect($last['meta']['remaining_para'])->toBe(0);
-
-    // 游标越过章节区间(本章节是 1..5)
-    $this->getJson($base.'&from=6')
-        ->assertStatus(422)
-        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
-});
-
-it('rejects a cursor outside the chapter range', function () {
-    $channel = makeChapterFixture();
-
-    // 章节区间是 1..5,超出两端都不行
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&from=99")
-        ->assertStatus(422)
-        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
-
-    // para=3 那一行没有 chapter_len,区间只有它自己;从第 1 段起就在区间之前
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=3&channel={$channel}&from=1")
-        ->assertStatus(422)
-        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
-
-    // 落在区间内就正常取
-    expect($this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=3&channel={$channel}&from=3")
-        ->assertOk()->json('meta'))
-        ->toMatchArray(['chapter' => 3, 'total_para' => 1, 'first_para' => 3, 'remaining_para' => 0]);
-});
-
-it('rejects a cursor that is inside the chapter but past the last translation', function () {
-    // 章节区间 1..5,只译了 1、2、3;游标 5 在区间内但之后没有译文了
-    $channel = makeChapterFixture([1, 2, 3]);
-
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&from=5")
-        ->assertStatus(422)
-        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
-});
-
-it('advances the cursor to the next translated paragraph', function () {
-    // 只译了 1、3、5;游标落在没有译文的第 2 段,顺延到第 3 段
-    $channel = makeChapterFixture([1, 3, 5]);
-
-    $block = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2&from=2")
-        ->assertOk()
-        ->json();
-
-    expect(array_column($block['data'], 'para'))->toBe([3, 5]);
-    expect($block['meta'])->toMatchArray([
-        'current_para' => 2,   // 请求的游标原样回显
-        'first_para' => 3,     // 实际取到的第一段
-        'last_para' => 5,
-        'total_para' => 3,
-        'remaining_para' => 0,
-    ]);
-});
-
-it('counts only the paragraphs this channel has actually translated', function () {
-    // 5 段的章节里只译了 1、3、5,分块按这 3 段算,不会切出空块
-    $channel = makeChapterFixture([1, 3, 5]);
-    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2";
-
-    $first = $this->getJson($base)->assertOk()->json();
-    expect(array_column($first['data'], 'para'))->toBe([1, 3]);
-    expect($first['meta'])->toMatchArray([
-        'total_para' => 3,
-        'first_para' => 1,
-        'last_para' => 3,
-        'remaining_para' => 1,
-    ]);
-
-    $second = $this->getJson($base.'&from=4')->assertOk()->json();
-    expect(array_column($second['data'], 'para'))->toBe([5]);
-    expect($second['meta']['remaining_para'])->toBe(0);
-});
-
-it('fetches a block by byte size using the lenght column', function () {
-    $channel = makeChapterFixture();
-    // 每段 100 字节,250 字节累加到第 3 段才超过上限
-    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250&unit=byte";
-
-    $first = $this->getJson($base)->assertOk()->json();
-    expect(array_column($first['data'], 'para'))->toBe([1, 2, 3]);
-    expect($first['meta'])->toMatchArray([
-        'page_size' => 250,
-        'page_size_unit' => 'byte',
-        'remaining_para' => 2,
-    ]);
-
-    $second = $this->getJson($base.'&from=4')->assertOk()->json();
-    expect(array_column($second['data'], 'para'))->toBe([4, 5]);
-    expect($second['meta']['remaining_para'])->toBe(0);
-});
-
-it('counts bytes from pali_texts for the translated paragraphs only', function () {
-    // 只译了 1、3、5;每段原文 100 字节,250b 累加到第三段(para 5)才超限
-    $channel = makeChapterFixture([1, 3, 5]);
-
-    $first = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250&unit=byte")
-        ->assertOk()
-        ->json();
-
-    expect(array_column($first['data'], 'para'))->toBe([1, 3, 5]);
-    expect($first['meta']['remaining_para'])->toBe(0);
-});
-
-it('returns at least one paragraph when it alone exceeds the byte size', function () {
-    $channel = makeChapterFixture();
-
-    $items = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=1&unit=byte")
-        ->assertOk()
-        ->json('data');
-
-    expect(array_column($items, 'para'))->toBe([1]);
-});
-
-it('returns 404 when the channel has nothing translated in this chapter', function () {
-    $channel = makeChapterFixture([]);
-
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}")
-        ->assertStatus(404)
-        ->assertJsonPath('title', 'Resource not found.');
-});
-
-it('clamps pagesize to the per-block ceiling and reports the effective value', function () {
-    $channel = makeChapterFixture();
-    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}";
-
-    // 段落模式上限 200
-    expect($this->getJson($base.'&pagesize=1000')->assertOk()->json('meta'))
-        ->toMatchArray(['page_size' => 200, 'page_size_unit' => 'para']);
-
-    // 字节模式上限 5000
-    expect($this->getJson($base.'&pagesize=20000&unit=byte')->assertOk()->json('meta'))
-        ->toMatchArray(['page_size' => 5000, 'page_size_unit' => 'byte']);
-
-    // 没到上限的照常返回原值
-    expect($this->getJson($base.'&pagesize=3')->assertOk()->json('meta.page_size'))->toBe(3);
-});
-
-it('accepts the id form, the cursor and the view parameter', function () {
-    $channel = makeChapterFixture();
-
-    $block = $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&pagesize=1&from=2&view=all")
-        ->assertOk()
-        ->json();
-
-    expect($block['data'])->toHaveCount(1);
-    expect($block['data'][0])->toHaveKeys(['para', 'display', 'sentences']);
-    expect($block['meta'])->toMatchArray(['chapter' => 1, 'first_para' => 2, 'remaining_para' => 3]);
-});
-
-it('rejects a bad id, channel, pagesize, unit, cursor or unknown chapter', function () {
-    $channel = makeChapterFixture();
-    // v3 用真实状态码 + RFC 9457 Problem Details,不再看 ok 字段
-    $this->getJson("/api/v3/tipitaka-read-chapter/bad-id?channel={$channel}")
-        ->assertStatus(422)
-        ->assertJsonStructure(['type', 'title', 'status', 'detail']);
-    $this->getJson('/api/v3/tipitaka-read-chapter/9002-1?channel=not-a-uuid')
-        ->assertStatus(422)
-        ->assertJsonPath('errors.channel.0', __('site.invalid_parameter'));
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&channel={$channel}")
-        ->assertStatus(422)
-        ->assertJsonStructure(['errors' => ['para']]);
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=99&channel={$channel}")
-        ->assertStatus(404)
-        ->assertJsonPath('title', 'Resource not found.');
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=0")
-        ->assertStatus(422)
-        ->assertJsonStructure(['errors' => ['pagesize']]);
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=10p")
-        ->assertStatus(422)
-        ->assertJsonStructure(['errors' => ['pagesize']]);
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&unit=bytes")
-        ->assertStatus(422)
-        ->assertJsonStructure(['errors' => ['unit']]);
-    $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&from=0")
-        ->assertStatus(422)
-        ->assertJsonStructure(['errors' => ['from']]);
-});

+ 0 - 142
api-v13/tests/Feature/TipitakaReadParaTest.php

@@ -1,142 +0,0 @@
-<?php
-
-use App\Models\PaliText;
-use App\Models\Sentence;
-use App\Services\PaliContentService;
-use Illuminate\Foundation\Testing\RefreshDatabase;
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Str;
-
-uses(RefreshDatabase::class);
-
-/**
- * 建测试用的 channel 和句子:book 9001 的第 1 段两句,第 2 段一句。返回 channel uid
- */
-function makeParagraphFixture(): string
-{
-    $channel = makeChannel(makeStudio('para-owner'), 'para channel');
-    makeSentence($channel, 9001, 1, 1, 'first sentence');
-    makeSentence($channel, 9001, 1, 2, 'second sentence');
-    makeSentence($channel, 9001, 2, 1, 'other paragraph');
-
-    return $channel;
-}
-
-it('renders every sentence of a paragraph wrapped in divs', function () {
-    $channel = makeParagraphFixture();
-    $data = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}")
-        ->assertOk()
-        ->json('data');
-
-    expect($data['para'])->toBe(1);
-    // 默认只输出 display
-    expect($data)->not->toHaveKey('sentences');
-    expect($data['display'])
-        ->toContain("<div id='para-1' class='translation' data-para='1'>")
-        ->toContain("<div class='sentence' data-sid='9001-1-1-1'>")
-        ->toContain("<div class='sentence' data-sid='9001-1-2-2'>")
-        ->toContain("<div class='para-block'>");
-});
-
-it('wraps a chapter title paragraph in a heading', function () {
-    $channel = makeParagraphFixture();
-    (new PaliText)->forceFill([
-        'book' => 9001,
-        'paragraph' => 1,
-        'level' => 2,
-        'class' => '',
-        'toc' => '',
-        'text' => '',
-        'html' => '',
-        'pcd_book_id' => 0,
-        'uid' => (string) Str::uuid(),
-    ])->save();
-
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}")
-        ->assertOk()
-        ->json('data.display');
-
-    expect($display)->toContain('<h2>')->not->toContain('para-block');
-});
-
-it('can output only the sentences or both', function () {
-    $channel = makeParagraphFixture();
-    $sentences = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}&view=sentences")
-        ->assertOk()
-        ->json('data');
-    expect($sentences)->not->toHaveKey('display');
-    expect($sentences['sentences'])->toHaveCount(2);
-    expect($sentences['sentences'][0])->toHaveKeys(['sid', 'html']);
-
-    $all = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}&view=all")
-        ->assertOk()
-        ->json('data');
-    expect($all)->toHaveKeys(['para', 'display', 'sentences']);
-
-    $items = $this->getJson("/api/v3/tipitaka-read-para?book=9001&para=1&channel={$channel}&view=sentences")
-        ->assertOk()
-        ->json('data');
-    expect($items[0])->not->toHaveKey('display');
-    expect($items[0]['sentences'])->toHaveCount(2);
-});
-
-it('lists the paragraphs of a range and skips empty ones', function () {
-    $channel = makeParagraphFixture();
-    $items = $this->getJson("/api/v3/tipitaka-read-para?book=9001&para=1&to=3&channel={$channel}")
-        ->assertOk()
-        ->json('data');
-
-    expect(array_column($items, 'para'))->toBe([1, 2]);
-});
-
-it('outputs one line per sentence for non html formats', function () {
-    $channel = makeParagraphFixture();
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}&format=text")
-        ->assertOk()
-        ->json('data.display');
-
-    expect($display)->toBe("first sentence\nsecond sentence");
-});
-
-it('rejects an invalid id or channel', function () {
-    $channel = makeParagraphFixture();
-    // v3 用真实状态码 + RFC 9457 Problem Details,不再看 ok 字段
-    $this->getJson("/api/v3/tipitaka-read-para/bad-id?channel={$channel}")
-        ->assertStatus(422)
-        ->assertJsonStructure(['type', 'title', 'status', 'detail'])
-        ->assertJsonPath('errors.id.0', __('site.invalid_parameter'));
-    $this->getJson('/api/v3/tipitaka-read-para/9001-1?channel=not-a-uuid')
-        ->assertStatus(422)
-        ->assertJsonPath('errors.channel.0', __('site.invalid_parameter'));
-    $this->getJson("/api/v3/tipitaka-read-para/9001-9?channel={$channel}")
-        ->assertStatus(404)
-        ->assertJsonPath('title', 'Resource not found.');
-});
-
-it('caches the paragraph and drops the cache when a sentence changes', function () {
-    $channel = makeParagraphFixture();
-    $url = "/api/v3/tipitaka-read-para/9001-1?channel={$channel}";
-    $this->getJson($url)->assertOk();
-
-    $key = PaliContentService::paragraphCacheKey(9001, 1, $channel, 'html');
-    expect(Cache::has($key))->toBeTrue();
-
-    $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first();
-    $sentence->content = 'changed sentence';
-    $sentence->save();
-
-    expect(Cache::has($key))->toBeFalse();
-    expect($this->getJson($url)->json('data.display'))->toContain('changed sentence');
-});
-
-it('drops the cache when a sentence is added or deleted', function () {
-    $channel = makeParagraphFixture();
-    $url = "/api/v3/tipitaka-read-para/9001-1?channel={$channel}";
-    $this->getJson($url)->assertOk();
-
-    makeSentence($channel, 9001, 1, 3, 'third sentence');
-    expect($this->getJson($url.'&view=sentences')->json('data.sentences'))->toHaveCount(3);
-
-    Sentence::where('book_id', 9001)->where('paragraph', 1)->where('word_start', 3)->first()->delete();
-    expect($this->getJson($url.'&view=sentences')->json('data.sentences'))->toHaveCount(2);
-});

+ 234 - 0
api-v13/tests/Feature/TipitakaReadingChunkTest.php

@@ -0,0 +1,234 @@
+<?php
+
+use App\Models\PaliText;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 建一个 pali_texts 段落。第一段带 chapter_len 表示章节长度
+ */
+function makePaliText(int $book, int $para, int $lenght, ?int $chapterLen = null): void
+{
+    (new PaliText)->forceFill([
+        'book' => $book,
+        'paragraph' => $para,
+        'level' => $chapterLen ? 2 : 9,
+        'class' => '',
+        'toc' => '',
+        'text' => '',
+        'html' => '',
+        'lenght' => $lenght,
+        'chapter_len' => $chapterLen,
+        'pcd_book_id' => 0,
+        'uid' => (string) Str::uuid(),
+    ])->save();
+}
+
+/**
+ * book 9002 一个 5 段的章节(起始段落号 1),每段 100 字节。$translated 列出
+ * 哪些段落有译文,默认整章都有;传子集即模拟残缺译文。
+ *
+ * @param  array<int, int>  $translated
+ */
+function makeChapterFixture(array $translated = [1, 2, 3, 4, 5]): string
+{
+    $channel = makeChannel(makeStudio('chapter-owner'), 'chapter channel');
+    makePaliText(9002, 1, 100, 5);
+    foreach (range(2, 5) as $para) {
+        makePaliText(9002, $para, 100);
+    }
+    foreach ($translated as $para) {
+        makeSentence($channel, 9002, $para, 1, "para {$para} text");
+    }
+
+    return $channel;
+}
+
+it('fetches a block from the start and follows next_cursor', function () {
+    $channel = makeChapterFixture();
+    $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2";
+
+    // 不带 after,从章节开头取起
+    $first = $this->getJson($base)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 2])
+        ->and($first['meta'])->toMatchArray([
+            'page_size' => 2,
+            'page_size_unit' => 'para',
+            'total' => 5,
+            'remaining' => 3,
+            'next_cursor' => '9002-2',
+        ]);
+
+    // 断点续传:把 next_cursor 原样传回 after
+    $second = $this->getJson($base.'&after='.$first['meta']['next_cursor'])->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([3, 4])
+        ->and($second['meta'])->toMatchArray(['remaining' => 1, 'next_cursor' => '9002-4']);
+
+    // 最后一块:next_cursor 为 null 即取完
+    $last = $this->getJson($base.'&after='.$second['meta']['next_cursor'])->assertOk()->json();
+    expect(array_column($last['data'], 'para'))->toBe([5])
+        ->and($last['meta']['remaining'])->toBe(0)
+        ->and($last['meta']['next_cursor'])->toBeNull();
+});
+
+it('returns an empty block when the cursor is past the end', function () {
+    $channel = makeChapterFixture();
+
+    // keyset 语义:游标在范围之后就是空结果,不是错误。游标由服务端给出,
+    // 客户端不自己拼,所以不必再校验它落在哪个区间
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&after=9002-5")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toBe([])
+        ->and($json['meta']['next_cursor'])->toBeNull()
+        ->and($json['meta']['remaining'])->toBe(0);
+});
+
+it('scopes the chapter by chapter_len', function () {
+    $channel = makeChapterFixture();
+
+    // para=3 那一行没有 chapter_len,区间只有它自己
+    expect($this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=3")
+        ->assertOk()->json('meta'))
+        ->toMatchArray(['total' => 1, 'remaining' => 0, 'next_cursor' => null]);
+});
+
+it('skips paragraphs that have no translation', function () {
+    // 只译了 1、3、5;游标停在第 1 段之后,下一块直接跳到 3 和 5
+    $channel = makeChapterFixture([1, 3, 5]);
+
+    $block = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2&after=9002-1")
+        ->assertOk()
+        ->json();
+
+    expect(array_column($block['data'], 'para'))->toBe([3, 5])
+        ->and($block['meta'])->toMatchArray([
+            'total' => 3,
+            'remaining' => 0,
+            'next_cursor' => null,
+        ]);
+});
+
+it('counts only the paragraphs this channel has actually translated', function () {
+    // 5 段的章节里只译了 1、3、5,分块按这 3 段算,不会切出空块
+    $channel = makeChapterFixture([1, 3, 5]);
+    $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2";
+
+    $first = $this->getJson($base)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 3])
+        ->and($first['meta'])->toMatchArray(['total' => 3, 'remaining' => 1, 'next_cursor' => '9002-3']);
+
+    $second = $this->getJson($base.'&after=9002-3')->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([5])
+        ->and($second['meta']['remaining'])->toBe(0);
+});
+
+it('fetches a block by byte size using the lenght column', function () {
+    $channel = makeChapterFixture();
+    // 每段 100 字节,250 字节累加到第 3 段才超过上限
+    $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=250&unit=byte";
+
+    $first = $this->getJson($base)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 2, 3])
+        ->and($first['meta'])->toMatchArray([
+            'page_size' => 250,
+            'page_size_unit' => 'byte',
+            'remaining' => 2,
+        ]);
+
+    $second = $this->getJson($base.'&after='.$first['meta']['next_cursor'])->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([4, 5])
+        ->and($second['meta']['remaining'])->toBe(0);
+});
+
+it('counts bytes from pali_texts for the translated paragraphs only', function () {
+    // 只译了 1、3、5;每段原文 100 字节,250b 累加到第三段(para 5)才超限
+    $channel = makeChapterFixture([1, 3, 5]);
+
+    $first = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=250&unit=byte")
+        ->assertOk()
+        ->json();
+
+    expect(array_column($first['data'], 'para'))->toBe([1, 3, 5])
+        ->and($first['meta']['remaining'])->toBe(0);
+});
+
+it('returns at least one paragraph when it alone exceeds the byte size', function () {
+    $channel = makeChapterFixture();
+
+    $items = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=1&unit=byte")
+        ->assertOk()
+        ->json('data');
+
+    expect(array_column($items, 'para'))->toBe([1]);
+});
+
+it('returns an empty collection when the channel has nothing translated here', function () {
+    $channel = makeChapterFixture([]);
+
+    // 集合端点,没内容就是空集合;章节本身存在,不是 404
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toBe([])
+        ->and($json['meta'])->toMatchArray(['total' => 0, 'remaining' => 0, 'next_cursor' => null]);
+});
+
+it('clamps page_size to the per-block ceiling and reports the effective value', function () {
+    $channel = makeChapterFixture();
+    $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1";
+
+    // 段落模式上限 200
+    expect($this->getJson($base.'&page_size=1000')->assertOk()->json('meta'))
+        ->toMatchArray(['page_size' => 200, 'page_size_unit' => 'para']);
+
+    // 字节模式上限 5000
+    expect($this->getJson($base.'&page_size=20000&unit=byte')->assertOk()->json('meta'))
+        ->toMatchArray(['page_size' => 5000, 'page_size_unit' => 'byte']);
+
+    // 没到上限的照常返回原值
+    expect($this->getJson($base.'&page_size=3')->assertOk()->json('meta.page_size'))->toBe(3);
+});
+
+it('accepts the cursor together with the include parameter', function () {
+    $channel = makeChapterFixture();
+
+    $block = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=1&after=9002-1&include=all")
+        ->assertOk()
+        ->json();
+
+    expect($block['data'])->toHaveCount(1)
+        ->and($block['data'][0])->toHaveKeys(['book', 'para', 'display', 'sentences'])
+        ->and($block['data'][0]['para'])->toBe(2);
+});
+
+it('rejects bad filters and an unknown chapter', function () {
+    $channel = makeChapterFixture();
+
+    // 路径上只有 channel,格式不对匹配不上路由 → 404
+    $this->getJson('/api/v3/tipitaka-reading/not-a-uuid')->assertStatus(404);
+
+    // chapter 在 pali_texts 里不存在 → 404
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=99")
+        ->assertStatus(404)
+        ->assertJsonPath('title', 'Resource not found.');
+
+    // 其余都是 filter,走 FormRequest → 422
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=0")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['page_size']]);
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=10p")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['page_size']]);
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&unit=bytes")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['unit']]);
+    // 游标格式不对
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&after=not-a-cursor")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['after']]);
+});

+ 180 - 0
api-v13/tests/Feature/TipitakaReadingRenderTest.php

@@ -0,0 +1,180 @@
+<?php
+
+use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 建测试用的 channel 和句子:book 9001 的第 1 段两句,第 2 段一句。返回 channel uid
+ */
+function makeParagraphFixture(): string
+{
+    $channel = makeChannel(makeStudio('para-owner'), 'para channel');
+    makeSentence($channel, 9001, 1, 1, 'first sentence');
+    makeSentence($channel, 9001, 1, 2, 'second sentence');
+    makeSentence($channel, 9001, 2, 1, 'other paragraph');
+
+    return $channel;
+}
+
+it('renders every sentence of a paragraph wrapped in divs', function () {
+    $channel = makeParagraphFixture();
+    $data = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1")
+        ->assertOk()
+        ->json('data.0');
+
+    expect($data['para'])->toBe(1);
+    // 默认只输出 display
+    expect($data)->not->toHaveKey('sentences');
+    expect($data['display'])
+        ->toContain("<div id='para-1' class='translation' data-para='1'>")
+        ->toContain("<div class='sentence' data-sid='9001-1-1-1'>")
+        ->toContain("<div class='sentence' data-sid='9001-1-2-2'>")
+        ->toContain("<div class='para-block'>");
+});
+
+it('wraps a chapter title paragraph in a heading', function () {
+    $channel = makeParagraphFixture();
+    (new PaliText)->forceFill([
+        'book' => 9001,
+        'paragraph' => 1,
+        'level' => 2,
+        'class' => '',
+        'toc' => '',
+        'text' => '',
+        'html' => '',
+        'pcd_book_id' => 0,
+        'uid' => (string) Str::uuid(),
+    ])->save();
+
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1")
+        ->assertOk()
+        ->json('data.0.display');
+
+    expect($display)->toContain('<h2>')->not->toContain('para-block');
+});
+
+it('can output only the sentences or both', function () {
+    $channel = makeParagraphFixture();
+    $sentences = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&include=sentences")
+        ->assertOk()
+        ->json('data.0');
+    expect($sentences)->not->toHaveKey('display');
+    expect($sentences['sentences'])->toHaveCount(2);
+    expect($sentences['sentences'][0])->toHaveKeys(['sid', 'html']);
+
+    $all = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&include=all")
+        ->assertOk()
+        ->json('data.0');
+    expect($all)->toHaveKeys(['para', 'display', 'sentences']);
+});
+
+it('lists the paragraphs of a range and skips empty ones', function () {
+    $channel = makeParagraphFixture();
+    $items = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&to=3")
+        ->assertOk()
+        ->json('data');
+
+    expect(array_column($items, 'para'))->toBe([1, 2]);
+});
+
+it('outputs one line per sentence for non html formats', function () {
+    $channel = makeParagraphFixture();
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&format=text")
+        ->assertOk()
+        ->json('data.0.display');
+
+    expect($display)->toBe("first sentence\nsecond sentence");
+});
+
+it('rejects a malformed channel in the path but validates filters as 422', function () {
+    $channel = makeParagraphFixture();
+
+    // 路径参数由路由约束把关:不是 uuid 就匹配不上任何路由 → 404
+    $this->getJson('/api/v3/tipitaka-reading/not-a-uuid')->assertStatus(404);
+
+    // filter 是查询参数,走 FormRequest → 422
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=not-a-number")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['para']]);
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=not-a-number")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['book']]);
+
+    // book 只在跟 chapter / para / to 一起用时必填
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?para=1")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['book']]);
+
+    // chapter 与 para 互斥
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&chapter=1&para=1")
+        ->assertStatus(422);
+});
+
+it('fetches the whole channel when no filter is given', function () {
+    $channel = makeParagraphFixture();
+
+    // 不带任何查询串 = 取这个 channel 的全部译文,下载场景要的就是这个
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}")->assertOk()->json();
+
+    expect(array_column($json['data'], 'para'))->toBe([1, 2])
+        ->and($json['data'][0]['book'])->toBe(9001)
+        // 无 book 过滤时不给 total / remaining,靠 next_cursor 判断结束
+        ->and($json['meta'])->not->toHaveKey('total')
+        ->and($json['meta']['next_cursor'])->toBeNull();
+});
+
+it('returns an empty collection for a paragraph with no content', function () {
+    $channel = makeParagraphFixture();
+
+    // 这里是集合端点,没内容就是空集合而不是 404——区间口径下空段落本来就被跳过,
+    // 单段只是区间长度为 1 的特例,两者口径要一致
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=9")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toBe([])
+        ->and($json['meta'])->toMatchArray(['total' => 0, 'remaining' => 0])
+        ->and($json['meta']['next_cursor'])->toBeNull();
+});
+
+it('rejects a range that ends before it starts', function () {
+    $channel = makeParagraphFixture();
+
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=3&to=1")
+        ->assertStatus(422)
+        ->assertJsonPath('errors.to.0', __('site.invalid_parameter'));
+});
+
+it('caches the paragraph and drops the cache when a sentence changes', function () {
+    $channel = makeParagraphFixture();
+    $url = "/api/v3/tipitaka-reading/{$channel}?book=9001&para=1";
+    $this->getJson($url)->assertOk();
+
+    $key = PaliContentService::paragraphCacheKey(9001, 1, $channel, 'html');
+    expect(Cache::has($key))->toBeTrue();
+
+    $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first();
+    $sentence->content = 'changed sentence';
+    $sentence->save();
+
+    expect(Cache::has($key))->toBeFalse();
+    expect($this->getJson($url)->json('data.0.display'))->toContain('changed sentence');
+});
+
+it('drops the cache when a sentence is added or deleted', function () {
+    $channel = makeParagraphFixture();
+    $url = "/api/v3/tipitaka-reading/{$channel}?book=9001&para=1";
+    $this->getJson($url)->assertOk();
+
+    makeSentence($channel, 9001, 1, 3, 'third sentence');
+    expect($this->getJson($url.'&include=sentences')->json('data.0.sentences'))->toHaveCount(3);
+
+    Sentence::where('book_id', 9001)->where('paragraph', 1)->where('word_start', 3)->first()->delete();
+    expect($this->getJson($url.'&include=sentences')->json('data.0.sentences'))->toHaveCount(2);
+});

+ 234 - 0
api-v13/tests/Feature/UserServiceTest.php

@@ -0,0 +1,234 @@
+<?php
+
+use App\Http\Api\AiAssistantApi;
+use App\Http\Api\UserApi;
+use App\Models\AiModel;
+use App\Models\UserInfo;
+use App\Services\UserService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * UserService 取代 UserApi / AiAssistantApi 两个静态类。
+ *
+ * 这里断言两件事:
+ *   1. 新实现的行为(批量、身份映射、占位口径)
+ *   2. 转发壳与 Service 返回同一结果——v2 的六十多个调用点靠这条兜底
+ */
+function makeAssistant(string $name, array $attrs = []): AiModel
+{
+    $assistant = new AiModel;
+    $assistant->forceFill(array_merge([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'name' => $name,
+        'real_name' => $name.'-real',
+        'model' => 'gpt-4',
+        'url' => null,
+        'avatar' => null,
+        // ai_models 的 owner_id / editor_id 是 NOT NULL 且无默认值
+        'owner_id' => (string) Str::uuid(),
+        'editor_id' => (string) Str::uuid(),
+    ], $attrs))->save();
+
+    return $assistant;
+}
+
+it('resolves a human user', function () {
+    $uid = makeStudio('alice');
+
+    $profile = app(UserService::class)->byUuid($uid);
+
+    expect($profile['id'])->toBe($uid)
+        ->and($profile['nickName'])->toBe('alice')
+        ->and($profile['userName'])->toBe('alice')
+        ->and($profile['sn'])->toBeGreaterThan(0);
+});
+
+it('falls back to an ai assistant when the uuid is not a human', function () {
+    $assistant = makeAssistant('claude');
+
+    $profile = app(UserService::class)->byUuid($assistant->uid);
+
+    expect($profile['id'])->toBe($assistant->uid)
+        ->and($profile['nickName'])->toBe('claude')
+        ->and($profile['roles'])->toBe(['ai'])
+        ->and($profile['sn'])->toBe(0);
+});
+
+it('returns the unknown placeholder for an unresolvable uuid', function () {
+    $profile = app(UserService::class)->byUuid((string) Str::uuid());
+
+    expect($profile)->toBe([
+        'id' => 0,
+        'nickName' => 'unknown',
+        'userName' => 'unknown',
+        'realName' => 'unknown',
+        'avatar' => '',
+    ]);
+});
+
+it('costs no query for a null uuid', function () {
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $profile = app(UserService::class)->byUuid(null);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    // 旧实现会为 null 白查两张表
+    expect($queries)->toBe(0)
+        ->and($profile['nickName'])->toBe('unknown');
+});
+
+it('resolves a mixed batch in two queries regardless of size', function () {
+    $humans = collect(range(1, 6))->map(fn ($i) => makeStudio("batch-user-{$i}"))->all();
+    $assistants = collect(range(1, 3))->map(fn ($i) => makeAssistant("batch-ai-{$i}")->uid)->all();
+    $orphan = (string) Str::uuid();
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $map = app(UserService::class)->byUuids([...$humans, ...$assistants, $orphan]);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    // user_infos 一次 + ai_models 一次,与 uuid 个数无关
+    expect($queries)->toBe(2)
+        ->and($map)->toHaveCount(9)          // 解析不到的 orphan 不出现在结果里
+        ->and($map)->not->toHaveKey($orphan)
+        ->and($map[$humans[0]]['nickName'])->toBe('batch-user-1')
+        ->and($map[$assistants[0]]['roles'])->toBe(['ai']);
+});
+
+it('skips ai_models entirely when every uuid is a human', function () {
+    $humans = collect(range(1, 4))->map(fn ($i) => makeStudio("human-only-{$i}"))->all();
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    app(UserService::class)->byUuids($humans);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBe(1);
+});
+
+it('remembers resolved identities within the request', function () {
+    $uid = makeStudio('memoized');
+    $service = app(UserService::class);
+    $service->byUuid($uid);
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $again = $service->byUuid($uid);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBe(0)
+        ->and($again['nickName'])->toBe('memoized');
+});
+
+it('remembers negative results too', function () {
+    $orphan = (string) Str::uuid();
+    $service = app(UserService::class);
+    $service->byUuid($orphan);
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $service->byUuid($orphan);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBe(0);
+});
+
+it('re-reads after flush', function () {
+    $uid = makeStudio('flushed');
+    $service = app(UserService::class);
+    $service->byUuid($uid);
+    $service->flush();
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $service->byUuid($uid);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBeGreaterThan(0);
+});
+
+it('keeps input order and drops misses in the positional list', function () {
+    $a = makeStudio('list-a');
+    $b = makeStudio('list-b');
+    $orphan = (string) Str::uuid();
+
+    $list = app(UserService::class)->listByUuids([$b, $orphan, $a]);
+
+    expect($list)->toHaveCount(2)
+        ->and($list[0]['nickName'])->toBe('list-b')
+        ->and($list[1]['nickName'])->toBe('list-a');
+});
+
+it('resolves either candidate through actor()', function () {
+    $service = app(UserService::class);
+    $uid = makeStudio('either-human');
+    $human = UserInfo::where('userid', $uid)->first();
+    $assistant = makeAssistant('either-ai');
+
+    // 人类优先,AI 兜底,都没有给占位
+    expect($service->actor($human, $assistant)['nickName'])->toBe('either-human')
+        ->and($service->actor(null, $assistant)['nickName'])->toBe('either-ai')
+        ->and($service->actor(null, null)['nickName'])->toBe('unknown');
+});
+
+it('always includes the matching column in the eager load spec', function () {
+    [$human, $assistant] = UserService::eagerLoadActor();
+
+    // 漏掉匹配列不会报错,只会让整列静默变 null——这条守的就是那个
+    expect($human)->toStartWith('user:')
+        ->and(explode(',', explode(':', $human)[1]))->toContain('userid')
+        ->and($assistant)->toStartWith('aiModel:')
+        ->and(explode(',', explode(':', $assistant)[1]))->toContain('uid');
+
+    // 关系名可换,列白名单不跟着走样
+    [$editor] = UserService::eagerLoadActor('editor', 'editorAssistant');
+    expect($editor)->toStartWith('editor:')
+        ->and(explode(',', explode(':', $editor)[1]))->toContain('userid');
+});
+
+it('forwards UserApi to the service unchanged', function () {
+    $uid = makeStudio('forwarded');
+    $service = app(UserService::class);
+
+    expect(UserApi::getByUuid($uid))->toBe($service->byUuid($uid))
+        ->and(UserApi::getIdByName('forwarded'))->toBe($uid)
+        ->and(UserApi::getIdByUuid($uid))->toBe($service->intIdByUuid($uid))
+        ->and(UserApi::getIntIdByName('forwarded'))->toBe($service->intIdByName('forwarded'))
+        ->and(UserApi::getByName('forwarded'))->toBe($service->byName('forwarded'))
+        ->and(UserApi::userInfo(null))->toBe($service->profile(null));
+});
+
+it('keeps the v2 quirk of returning null for a non-array list', function () {
+    expect(UserApi::getListByUuid(null))->toBeNull()
+        ->and(UserApi::getListByUuid('not-an-array'))->toBeNull()
+        ->and(UserApi::getListByUuid([]))->toBeNull();
+});
+
+it('forwards AiAssistantApi to the service unchanged', function () {
+    $assistant = makeAssistant('forwarded-ai');
+    $service = app(UserService::class);
+
+    expect(AiAssistantApi::getByUuid($assistant->uid))->toBe($service->assistantByUuid($assistant->uid))
+        ->and(AiAssistantApi::userInfo($assistant))->toBe($service->assistantProfile($assistant))
+        ->and(AiAssistantApi::userInfo(null))->toBe($service->assistantProfile(null));
+});
+
+it('picks a built-in logo for an assistant without an avatar', function () {
+    $assistant = makeAssistant('logo-test', ['model' => 'gpt-4', 'url' => null]);
+
+    $avatar = app(UserService::class)->assistantProfile($assistant)['avatar'];
+
+    expect($avatar)->toStartWith(config('app.url').'/assets/images/avatar/')
+        ->and($avatar)->toEndWith('.png');
+});

+ 94 - 0
api-v13/tests/Feature/V1CookieRemovalTest.php

@@ -0,0 +1,94 @@
+<?php
+
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v1 时代的 cookie 认证绕过已从 v2 控制器里清干净。
+ *
+ * 这几条是 characterization test:改动的是冻结中的 v2,必须钉住「未登录时明确
+ * 被拒」和「带 bearer 时照常工作」两件事,而不是靠 cookie 悄悄放行。
+ *
+ * 带 cookie 的用例必须直接操作 `$_COOKIE` 超全局——`withUnencryptedCookies()`
+ * 只填 Symfony Request 的 cookie 袋子,不碰超全局,用它写出来的测试是空转的。
+ */
+function withV1Cookie(array $cookies, callable $fn): mixed
+{
+    $backup = $_COOKIE;
+    $_COOKIE = $cookies;
+    try {
+        return $fn();
+    } finally {
+        $_COOKIE = $backup;
+    }
+}
+
+it('removed the dead DELETE /v2/like route', function () {
+    // 前端只用 POST /v2/like 与 DELETE /v2/like/{id};这条无 id 的 DELETE 没人调,
+    // 而且 cookie 是它唯一的认证方式,已整条删除
+    $this->deleteJson('/api/v2/like')->assertStatus(405);
+});
+
+it('keeps the like routes the frontends actually use', function () {
+    $target = (string) Str::uuid();
+
+    $this->getJson("/api/v2/like?view=count&target_id={$target}")
+        ->assertOk()
+        ->assertJsonPath('ok', true);
+});
+
+it('rejects sentence fulltext without a bearer token', function () {
+    // 这个 view 以前靠 cookie,且未登录时 $userUid 是未定义变量
+    // $this->error() 默认 400,这是 v2 既有的失败口径,不是这次改出来的
+    $json = withV1Cookie(['user_uid' => (string) Str::uuid()], fn () => $this
+        ->getJson('/api/v2/sentence?view=fulltext&key=abc')
+        ->assertStatus(400)
+        ->json());
+
+    expect($json['ok'])->toBeFalse()
+        ->and($json['message'])->toBe(__('auth.failed'));
+});
+
+it('serves sentence fulltext to a bearer-authenticated user', function () {
+    $uid = makeStudio('fulltext-user');
+
+    $json = $this->getJson('/api/v2/sentence?view=fulltext&key=abc', authHeader($uid))
+        ->assertOk()
+        ->json();
+
+    expect($json['ok'])->toBeTrue()
+        ->and($json['data'])->toHaveKey('rows');
+});
+
+it('rejects userdict view=user without a bearer token', function () {
+    $json = withV1Cookie(['user_id' => '1'], fn () => $this
+        ->getJson('/api/v2/userdict?view=user')
+        ->assertStatus(400)
+        ->json());
+
+    expect($json['ok'])->toBeFalse()
+        ->and($json['message'])->toBe(__('auth.failed'));
+});
+
+it('serves userdict view=user to a bearer-authenticated user', function () {
+    $uid = makeStudio('userdict-user');
+
+    $json = $this->getJson('/api/v2/userdict?view=user', authHeader($uid))
+        ->assertOk()
+        ->json();
+
+    expect($json['ok'])->toBeTrue()
+        ->and($json['data'])->toHaveKey('rows');
+});
+
+it('rejects DELETE /v2/userdict without a bearer token', function () {
+    $json = withV1Cookie(['user_id' => '1'], fn () => $this
+        ->deleteJson('/api/v2/userdict', ['id' => json_encode(['1'])])
+        ->assertStatus(400)
+        ->json());
+
+    expect($json['ok'])->toBeFalse()
+        ->and($json['message'])->toBe(__('auth.failed'));
+});

+ 0 - 62
api-v13/tests/Unit/TipitakaReadChapterSliceTest.php

@@ -1,62 +0,0 @@
-<?php
-
-use App\Http\Controllers\TipitakaReadChapterController;
-
-/**
- * slice() 是纯函数,不碰数据库。段落数上限要造上千段才看得出来,走 HTTP 会真的
- * 去渲染每一段,所以这里直接对着方法本身测。
- *
- * @param  array<int, int>  $paragraphs
- * @param  array<int, int>  $lengths
- * @return array<int, int>
- */
-function sliceParagraphs(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array
-{
-    $controller = new class extends TipitakaReadChapterController
-    {
-        /**
-         * @param  array<int, int>  $paragraphs
-         * @param  array<int, int>  $lengths
-         * @return array<int, int>
-         */
-        public function sliceForTest(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array
-        {
-            return $this->slice($paragraphs, $lengths, $limit, $unit, $offset);
-        }
-    };
-
-    return $controller->sliceForTest($paragraphs, $lengths, $limit, $unit, $offset);
-}
-
-it('caps a byte-mode block at 200 paragraphs however short the paragraphs are', function () {
-    // 1000 段,每段 1 字节;不设段落上限的话 5000b 会把整章切成一块
-    $paragraphs = range(1, 1000);
-    $lengths = array_fill_keys($paragraphs, 1);
-
-    expect(sliceParagraphs($paragraphs, $lengths, 5000, 'byte', 0))->toBe(range(1, 200))
-        ->and(sliceParagraphs($paragraphs, $lengths, 5000, 'byte', 200))->toBe(range(201, 400))
-        ->and(sliceParagraphs($paragraphs, $lengths, 5000, 'byte', 900))->toBe(range(901, 1000));
-});
-
-it('caps a paragraph-mode block at 200 paragraphs', function () {
-    $paragraphs = range(1, 1000);
-
-    expect(sliceParagraphs($paragraphs, [], 1000, 'para', 0))->toBe(range(1, 200))
-        ->and(sliceParagraphs($paragraphs, [], 1000, 'para', 950))->toBe(range(951, 1000));
-});
-
-it('still breaks on the byte limit when paragraphs are long enough', function () {
-    $paragraphs = range(1, 10);
-    $lengths = array_fill_keys($paragraphs, 100);
-
-    expect(sliceParagraphs($paragraphs, $lengths, 250, 'byte', 0))->toBe([1, 2, 3])
-        ->and(sliceParagraphs($paragraphs, $lengths, 250, 'byte', 3))->toBe([4, 5, 6]);
-});
-
-it('never returns an empty byte-mode block when lengths are missing', function () {
-    // pali_texts 里查不到长度时按 0 算,靠段落数上限收口,不会死循环也不会空块
-    $paragraphs = range(1, 1000);
-
-    expect(sliceParagraphs($paragraphs, [], 5000, 'byte', 0))->toBe(range(1, 200))
-        ->and(sliceParagraphs($paragraphs, [], 5000, 'byte', 800))->toBe(range(801, 1000));
-});

+ 50 - 0
api-v13/tests/Unit/TipitakaReadingSliceTest.php

@@ -0,0 +1,50 @@
+<?php
+
+use App\Services\V3\TipitakaReadingService;
+
+/**
+ * sliceByBytes() 是纯函数,不碰数据库。段落数上限要造上千段才看得出来,走 HTTP
+ * 会真的去渲染每一段,所以这里直接对着方法本身测。
+ *
+ * 逻辑在 Service 里,所以不必再为了拿到它去造控制器的匿名子类——
+ * 这正是「业务逻辑一律进 Service」买到的好处之一。
+ *
+ * @param  array<int, int>  $paras  段落号,统一当作 book 9000
+ * @param  array<int, int>  $lengths  段落号 => 字节数
+ * @return array<int, int> 切出来的段落号
+ */
+function sliceParagraphs(array $paras, array $lengths, int $limit): array
+{
+    $candidates = array_map(fn (int $para) => ['book' => 9000, 'para' => $para], $paras);
+    $slice = app(TipitakaReadingService::class)->sliceByBytes($candidates, [9000 => $lengths], $limit);
+
+    return array_column($slice, 'para');
+}
+
+it('caps a byte-mode block at 200 paragraphs however short the paragraphs are', function () {
+    // 1000 段,每段 1 字节;不设段落上限的话 5000b 会把整章切成一块
+    $paras = range(1, 1000);
+    $lengths = array_fill_keys($paras, 1);
+
+    expect(sliceParagraphs($paras, $lengths, 5000))->toBe(range(1, 200))
+        ->and(sliceParagraphs(range(201, 1000), $lengths, 5000))->toBe(range(201, 400))
+        ->and(sliceParagraphs(range(901, 1000), $lengths, 5000))->toBe(range(901, 1000));
+});
+
+it('still breaks on the byte limit when paragraphs are long enough', function () {
+    $paras = range(1, 10);
+    $lengths = array_fill_keys($paras, 100);
+
+    expect(sliceParagraphs($paras, $lengths, 250))->toBe([1, 2, 3])
+        ->and(sliceParagraphs(range(4, 10), $lengths, 250))->toBe([4, 5, 6]);
+});
+
+it('never returns an empty byte-mode block when lengths are missing', function () {
+    // pali_texts 里查不到长度时按 0 算,靠段落数上限收口,不会死循环也不会空块
+    expect(sliceParagraphs(range(1, 1000), [], 5000))->toBe(range(1, 200))
+        ->and(sliceParagraphs(range(801, 1000), [], 5000))->toBe(range(801, 1000));
+});
+
+it('returns an empty block for no candidates', function () {
+    expect(sliceParagraphs([], [], 5000))->toBe([]);
+});

+ 19 - 0
dashboard-v6/src/api/client.ts

@@ -83,3 +83,22 @@ export const unwrapQuiet = <T>(result: FetchResult<T>): T => {
   }
   return result.data;
 };
+
+/**
+ * 给 **204 No Content** 的端点用:只判成败,不取 body。
+ *
+ * v3 的 destroy 一律返回 204 空体(见 v3-resource skill 硬规范第 2 条)。
+ * 而 `openapi-fetch` 对 204 返回的是 `{ data: undefined }`——`unwrap()` 见到
+ * `data === undefined` 会当成失败、弹提示再抛异常,明明请求是成功的。
+ * 所以 204 端点必须走这里,不能走 `unwrap()`。
+ *
+ * 失败时的行为与 `unwrap()` 一致:弹提示并抛出。
+ */
+export const unwrapVoid = (result: FetchResult<unknown>): void => {
+  if (result.response.ok) {
+    return;
+  }
+  const error = toApiError(result);
+  reportApiError(error);
+  throw error;
+};

Разница между файлами не показана из-за своего большого размера
+ 488 - 224
dashboard-v6/src/api/schema.d.ts


+ 11 - 9
openapi/public/assets/protocol/main.yaml

@@ -7,7 +7,7 @@ info:
 
     由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。
     响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
-  version: 2026.9.21
+  version: 2026.9.24
 servers:
   -
     url: /api
@@ -677,21 +677,23 @@ paths:
     $ref: ./resources/auto/v2-webhook-webhook.yaml
   /v3/heartbeat:
     $ref: ./resources/auto/v3-heartbeat.yaml
+  /v3/me/reactions:
+    $ref: ./resources/auto/v3-me-reactions.yaml
+  '/v3/me/reactions/{reaction}':
+    $ref: ./resources/auto/v3-me-reactions-reaction.yaml
   /v3/progress:
     $ref: ./resources/auto/v3-progress.yaml
+  /v3/reactions:
+    $ref: ./resources/auto/v3-reactions.yaml
+  /v3/reactions/tally:
+    $ref: ./resources/auto/v3-reactions-tally.yaml
   /v3/search:
     $ref: ./resources/auto/v3-search.yaml
   /v3/search-suggest:
     $ref: ./resources/auto/v3-search-suggest.yaml
   '/v3/search/{search}':
     $ref: ./resources/auto/v3-search-search.yaml
-  /v3/tipitaka-read-chapter:
-    $ref: ./resources/auto/v3-tipitaka-read-chapter.yaml
-  '/v3/tipitaka-read-chapter/{tipitaka_read_chapter}':
-    $ref: ./resources/auto/v3-tipitaka-read-chapter-tipitaka-read-chapter.yaml
-  /v3/tipitaka-read-para:
-    $ref: ./resources/auto/v3-tipitaka-read-para.yaml
-  '/v3/tipitaka-read-para/{tipitaka_read_para}':
-    $ref: ./resources/auto/v3-tipitaka-read-para-tipitaka-read-para.yaml
+  '/v3/tipitaka-reading/{channel}':
+    $ref: ./resources/auto/v3-tipitaka-reading-channel.yaml
   /v3/upgrade:
     $ref: ./resources/auto/v3-upgrade.yaml

+ 23 - 0
openapi/public/assets/protocol/overrides/v2-auth-current.yaml

@@ -0,0 +1,23 @@
+# 手写补充:GET /v2/auth/current 的 data 逐字段类型(对齐移动端 AuthUser)。
+get:
+  responses:
+    200:
+      content:
+        application/json:
+          schema:
+            properties:
+              data:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  nickName:
+                    type: string
+                  realName:
+                    type: string
+                  avatar:
+                    type: string
+                  roles:
+                    type: array
+                    items:
+                      type: string

+ 38 - 0
openapi/public/assets/protocol/overrides/v2-channel-channel.yaml

@@ -0,0 +1,38 @@
+# 手写补充:GET /v2/channel/{channel} 的 data 逐字段类型(对齐移动端 ChannelInfo)。
+# 只补 get 的响应,put/patch/delete 保持自动生成结果。
+get:
+  responses:
+    200:
+      content:
+        application/json:
+          schema:
+            properties:
+              data:
+                type: object
+                properties:
+                  uid:
+                    type: string
+                  name:
+                    type: string
+                  summary:
+                    type: string
+                    nullable: true
+                  lang:
+                    type: string
+                  type:
+                    type: string
+                  updated_at:
+                    type: string
+                  studio:
+                    type: object
+                    properties:
+                      id:
+                        type: string
+                      nickName:
+                        type: string
+                      realName:
+                        type: string
+                      studioName:
+                        type: string
+                      avatar:
+                        type: string

+ 31 - 0
openapi/public/assets/protocol/overrides/v2-chapter.yaml

@@ -0,0 +1,31 @@
+# 手写补充:/v2/chapter?view=toc 的 rows 逐字段类型,与 dashboard-v6 手写的 IChapterToc 对齐
+# (src/api/pali-text.ts):text 可空,其余为 number。实际返回 PaliText 的
+# book/paragraph/level/text/chapter_len/chapter_strlen/parent。
+get:
+  responses:
+    200:
+      content:
+        application/json:
+          schema:
+            properties:
+              data:
+                properties:
+                  rows:
+                    items:
+                      type: object
+                      properties:
+                        book:
+                          type: integer
+                        paragraph:
+                          type: integer
+                        level:
+                          type: integer
+                        text:
+                          type: string
+                          nullable: true
+                        chapter_len:
+                          type: integer
+                        chapter_strlen:
+                          type: integer
+                        parent:
+                          type: integer

+ 20 - 0
openapi/public/assets/protocol/overrides/v2-progress-progress.yaml

@@ -0,0 +1,20 @@
+# 手写补充:GET /v2/progress/{progress} 的 data 逐字段类型(对齐移动端 ProgressChapterInfo)。
+# 只补 get 的响应,put/patch/delete 保持自动生成结果。
+get:
+  responses:
+    200:
+      content:
+        application/json:
+          schema:
+            properties:
+              data:
+                type: object
+                properties:
+                  book:
+                    type: integer
+                  para:
+                    type: integer
+                  channel_id:
+                    type: string
+                  title:
+                    type: string

+ 77 - 0
openapi/public/assets/protocol/overrides/v2-progress.yaml

@@ -0,0 +1,77 @@
+# 手写补充:/v2/progress 多 view 的 rows 按口径分 schema。
+# 只补 get 的响应 item(anyOf,各 view 形状有重叠字段,不能算互斥),post 与其它 view 保持自动生成结果。
+get:
+  responses:
+    200:
+      content:
+        application/json:
+          schema:
+            properties:
+              data:
+                properties:
+                  rows:
+                    items:
+                      anyOf:
+                        - type: object
+                          description: view=chapter_channels
+                          properties:
+                            book:
+                              type: integer
+                            para:
+                              type: integer
+                            uid:
+                              type: string
+                            channel_id:
+                              type: string
+                            name:
+                              type: string
+                            type:
+                              type: string
+                            progress:
+                              type: number
+                            updated_at:
+                              type: string
+                            channel:
+                              type: object
+                              properties:
+                                uid:
+                                  type: string
+                                name:
+                                  type: string
+                                type:
+                                  type: string
+                        - type: object
+                          description: view=ids
+                          properties:
+                            id:
+                              type: string
+                        - type: object
+                          description: view=channel
+                          properties:
+                            channel_id:
+                              type: string
+                            count:
+                              type: integer
+                            studio:
+                              type: object
+                              properties:
+                                id:
+                                  type: string
+                                nickName:
+                                  type: string
+                                realName:
+                                  type: string
+                                studioName:
+                                  type: string
+                                avatar:
+                                  type: string
+                            channel:
+                              type: object
+                              properties:
+                                name:
+                                  type: string
+                                summary:
+                                  type: string
+                                  nullable: true
+                                lang:
+                                  type: string

+ 11 - 0
openapi/public/assets/protocol/overrides/v2-sign-in.yaml

@@ -0,0 +1,11 @@
+# 手写补充:POST /v2/sign-in 的 data 是 token 字符串,不是对象。
+post:
+  responses:
+    200:
+      content:
+        application/json:
+          schema:
+            properties:
+              data:
+                type: string
+                description: 登录成功后返回的 JWT token

+ 19 - 0
openapi/public/assets/protocol/overrides/v3-tipitaka-reading-channel.yaml

@@ -0,0 +1,19 @@
+# 手写补充:/v3/tipitaka-reading/{channel} 的 data[] 逐字段类型。
+# meta 已由控制器 @meta 注解生成专用 schema,这里只补 data 的 item(para + display)。
+get:
+  responses:
+    200:
+      content:
+        application/json:
+          schema:
+            properties:
+              data:
+                items:
+                  type: object
+                  properties:
+                    book:
+                      type: integer
+                    para:
+                      type: integer
+                    display:
+                      type: string

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-article-article.yaml

@@ -158,7 +158,7 @@ put:
             status:
               type: integer
               description: '公开状态,30 表示公开'
-              default: '10'
+              default: 10
           required:
             - title
             - content
@@ -291,7 +291,7 @@ patch:
             status:
               type: integer
               description: '公开状态,30 表示公开'
-              default: '10'
+              default: 10
           required:
             - title
             - content

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-article.yaml

@@ -92,14 +92,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 分页起始位置
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '1000'
+        default: 1000
       description: 每页条数
   responses:
     200:

+ 13 - 0
openapi/public/assets/protocol/resources/auto/v2-auth-current.yaml

@@ -19,6 +19,19 @@ get:
                 type: string
               data:
                 type: object
+                properties:
+                  id:
+                    type: string
+                  nickName:
+                    type: string
+                  realName:
+                    type: string
+                  avatar:
+                    type: string
+                  roles:
+                    type: array
+                    items:
+                      type: string
     401:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:

+ 1 - 0
openapi/public/assets/protocol/resources/auto/v2-book-title.yaml

@@ -58,6 +58,7 @@ get:
                     type: array
                     items:
                       type: object
+                      properties: { book: { type: integer }, paragraph: { type: integer }, sn: { type: integer }, title: { type: string }, toc: { type: string, nullable: true }, tags: { type: array, items: { type: string } }, related_name: { type: string, nullable: true }, language: { type: string, nullable: true, description: 仅在部分查询口径下返回。 } }
                   count:
                     type: integer
     401:

+ 22 - 0
openapi/public/assets/protocol/resources/auto/v2-channel-channel.yaml

@@ -30,6 +30,28 @@ get:
                 type: string
               data:
                 type: object
+                properties:
+                  uid:
+                    type: string
+                  name:
+                    type: string
+                  summary:
+                    type: string
+                    nullable: true
+                  lang:
+                    type: string
+                  type:
+                    type: string
+                  updated_at:
+                    type: string
+                  studio:
+                    type: object
+                    properties:
+                      id: { type: string }
+                      nickName: { type: string }
+                      realName: { type: string }
+                      studioName: { type: string }
+                      avatar: { type: string }
     401:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:

+ 1 - 0
openapi/public/assets/protocol/resources/auto/v2-chapter.yaml

@@ -70,6 +70,7 @@ get:
                     type: array
                     items:
                       type: object
+                      properties: { book: { type: integer }, paragraph: { type: integer }, level: { type: integer }, text: { type: string, nullable: true }, chapter_len: { type: integer }, chapter_strlen: { type: integer }, parent: { type: integer } }
                   count:
                     type: integer
     401:

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-course-member.yaml

@@ -89,14 +89,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 跳过的记录数
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '1000'
+        default: 1000
       description: 返回条数上限
   responses:
     200:

+ 3 - 3
openapi/public/assets/protocol/resources/auto/v2-discussion.yaml

@@ -104,14 +104,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 跳过的记录数
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '100'
+        default: 100
       description: 返回条数上限
     -
       name: search
@@ -219,7 +219,7 @@ post:
             notification:
               type: boolean
               description: 是否推送消息队列通知
-              default: 'true'
+              default: true
           required:
             - res_id
             - res_type

+ 0 - 40
openapi/public/assets/protocol/resources/auto/v2-like.yaml

@@ -1,44 +1,4 @@
 # 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v2-like.yaml
-delete:
-  summary: 'delete like'
-  tags:
-    - like
-  operationId: delete_api_v2_like
-  description: 实现:`LikeController@delete`
-  parameters:
-    -
-      name: id
-      in: query
-      schema:
-        type: string
-    -
-      name: target_id
-      in: query
-      schema:
-        type: string
-    -
-      name: type
-      in: query
-      schema:
-        type: string
-  responses:
-    200:
-      description: '成功。业务失败时 HTTP 仍可能为 200,以 ok 字段为准。'
-      content:
-        application/json:
-          schema:
-            type: object
-            properties:
-              ok:
-                type: boolean
-              message:
-                type: string
-              data:
-                type: object
-    401:
-      $ref: '../../main.yaml#/components/responses/Unauthorized'
-    422:
-      $ref: '../../main.yaml#/components/responses/ValidationError'
 get:
   summary: '列出 like'
   tags:

+ 9 - 0
openapi/public/assets/protocol/resources/auto/v2-progress-progress.yaml

@@ -26,6 +26,15 @@ get:
                 type: string
               data:
                 type: object
+                properties:
+                  book:
+                    type: integer
+                  para:
+                    type: integer
+                  channel_id:
+                    type: string
+                  title:
+                    type: string
     401:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:

+ 1 - 1
openapi/public/assets/protocol/resources/auto/v2-progress.yaml

@@ -123,7 +123,7 @@ get:
                   rows:
                     type: array
                     items:
-                      type: object
+                      anyOf: [{ type: object, description: view=chapter_channels, properties: { book: { type: integer }, para: { type: integer }, uid: { type: string }, channel_id: { type: string }, name: { type: string }, type: { type: string }, progress: { type: number }, updated_at: { type: string }, channel: { type: object, properties: { uid: { type: string }, name: { type: string }, type: { type: string } } } } }, { type: object, description: view=ids, properties: { id: { type: string } } }, { type: object, description: view=channel, properties: { channel_id: { type: string }, count: { type: integer }, studio: { type: object, properties: { id: { type: string }, nickName: { type: string }, realName: { type: string }, studioName: { type: string }, avatar: { type: string } } }, channel: { type: object, properties: { name: { type: string }, summary: { type: string, nullable: true }, lang: { type: string } } } } }]
                   count:
                     type: integer
     401:

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-project.yaml

@@ -79,14 +79,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 分页偏移量
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '10000'
+        default: 10000
       description: 每页条数
     -
       name: search

+ 4 - 3
openapi/public/assets/protocol/resources/auto/v2-recent-recent.yaml

@@ -28,13 +28,14 @@ get:
                 type: object
                 properties:
                   id:
-                    type: integer
+                    type: string
                   type:
-                    type: integer
+                    type: string
                   article_id:
-                    type: integer
+                    type: string
                   param:
                     type: string
+                    nullable: true
                   updated_at:
                     type: string
                   title:

+ 21 - 5
openapi/public/assets/protocol/resources/auto/v2-recent.yaml

@@ -4,18 +4,24 @@ get:
   tags:
     - recent
   operationId: get_api_v2_recent
-  description: 实现:`RecentController@index`
+  description: |-
+    按用户拉取阅读记录,view 目前只有 user 一种口径。
+
+    实现:`RecentController@index`
   parameters:
     -
       name: id
       in: query
       schema:
         type: string
+      description: 'view=user 时的用户 uid,必须是 uuid'
     -
       name: type
       in: query
       schema:
         type: string
+      description: 按记录类型过滤
+      example: chapter
     -
       name: order
       in: query
@@ -38,6 +44,15 @@ get:
       in: query
       schema:
         type: string
+    -
+      name: view
+      in: query
+      schema:
+        type: string
+        enum:
+          - user
+      description: 查询口径
+      required: true
     -
       name: search
       in: query
@@ -63,7 +78,7 @@ get:
                     type: array
                     items:
                       type: object
-                      properties: { id: { type: integer }, type: { type: integer }, article_id: { type: integer }, param: { type: string }, updated_at: { type: string }, title: { type: string } }
+                      properties: { id: { type: string }, type: { type: string }, article_id: { type: string }, param: { type: string, nullable: true }, updated_at: { type: string }, title: { type: string } }
                   count:
                     type: integer
     401:
@@ -110,13 +125,14 @@ post:
                 type: object
                 properties:
                   id:
-                    type: integer
+                    type: string
                   type:
-                    type: integer
+                    type: string
                   article_id:
-                    type: integer
+                    type: string
                   param:
                     type: string
+                    nullable: true
                   updated_at:
                     type: string
                   title:

+ 2 - 1
openapi/public/assets/protocol/resources/auto/v2-sign-in.yaml

@@ -29,7 +29,8 @@ post:
               message:
                 type: string
               data:
-                type: object
+                type: string
+                description: '登录成功后返回的 JWT token'
     401:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-task.yaml

@@ -120,14 +120,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 分页偏移量
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '1000'
+        default: 1000
       description: 每页条数
     -
       name: search

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-terms.yaml

@@ -95,14 +95,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 分页偏移量
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '1000'
+        default: 1000
       description: 每页条数
     -
       name: mode

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-user.yaml

@@ -61,14 +61,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 分页起始位置
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '20'
+        default: 20
       description: 每页条数
   responses:
     200:

+ 2 - 2
openapi/public/assets/protocol/resources/auto/v2-userdict.yaml

@@ -126,14 +126,14 @@ get:
       in: query
       schema:
         type: integer
-        default: '0'
+        default: 0
       description: 跳过的记录数
     -
       name: limit
       in: query
       schema:
         type: integer
-        default: '200'
+        default: 200
       description: 返回条数上限
   responses:
     200:

+ 1 - 1
openapi/public/assets/protocol/resources/auto/v3-heartbeat.yaml

@@ -15,7 +15,7 @@ get:
     仓库根目录存在 `.stop` 文件时返回 503(运维停机开关),此时响应体是
     RFC 9457 Problem Details 而非正常结构。
 
-    实现:`HeartbeatV3Controller@show`
+    实现:`V3\HeartbeatController@show`
   responses:
     200:
       description: 成功

Некоторые файлы не были показаны из-за большого количества измененных файлов