Преглед изворни кода

Merge branch 'development' of github.com:visuddhinanda/mint into development

visuddhinanda пре 1 недеља
родитељ
комит
89e9678247
100 измењених фајлова са 27592 додато и 772 уклоњено
  1. 192 0
      .claude/skills/api-change/SKILL.md
  2. 1113 0
      .claude/skills/v3-resource/SKILL.md
  3. 398 0
      CLAUDE.md
  4. 5 0
      api-test/.gitignore
  5. 189 0
      api-test/README.md
  6. 20 0
      api-test/cases/01_system.php
  7. 44 0
      api-test/cases/02_search.php
  8. 115 0
      api-test/cases/03_reactions.php
  9. 22 0
      api-test/cases/04_progress.php
  10. 67 0
      api-test/cases/05_tipitaka.php
  11. 15 0
      api-test/client/.gitignore
  12. 23 0
      api-test/client/.openapi-generator-ignore
  13. 122 0
      api-test/client/.openapi-generator/FILES
  14. 1 0
      api-test/client/.openapi-generator/VERSION
  15. 39 0
      api-test/client/composer.json
  16. 463 0
      api-test/client/lib/Api/HeartbeatApi.php
  17. 1118 0
      api-test/client/lib/Api/MeApi.php
  18. 589 0
      api-test/client/lib/Api/ProgressApi.php
  19. 819 0
      api-test/client/lib/Api/ReactionsApi.php
  20. 1260 0
      api-test/client/lib/Api/SearchApi.php
  21. 529 0
      api-test/client/lib/Api/SearchSuggestApi.php
  22. 632 0
      api-test/client/lib/Api/TipitakaReadingApi.php
  23. 1216 0
      api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php
  24. 449 0
      api-test/client/lib/Api/UpgradeApi.php
  25. 119 0
      api-test/client/lib/ApiException.php
  26. 613 0
      api-test/client/lib/Configuration.php
  27. 246 0
      api-test/client/lib/FormDataProcessor.php
  28. 273 0
      api-test/client/lib/HeaderSelector.php
  29. 409 0
      api-test/client/lib/Model/GetApiV3Heartbeat200Response.php
  30. 443 0
      api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php
  31. 443 0
      api-test/client/lib/Model/GetApiV3MeReactions200Response.php
  32. 661 0
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php
  33. 613 0
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
  34. 443 0
      api-test/client/lib/Model/GetApiV3Progress200Response.php
  35. 702 0
      api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php
  36. 443 0
      api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php
  37. 518 0
      api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php
  38. 443 0
      api-test/client/lib/Model/GetApiV3Search200Response.php
  39. 409 0
      api-test/client/lib/Model/GetApiV3SearchSearch200Response.php
  40. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php
  41. 477 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php
  42. 545 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php
  43. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php
  44. 627 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php
  45. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php
  46. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php
  47. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php
  48. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php
  49. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
  50. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
  51. 111 0
      api-test/client/lib/Model/ModelInterface.php
  52. 600 0
      api-test/client/lib/Model/PaginationMeta.php
  53. 409 0
      api-test/client/lib/Model/PostApiV3MeReactions200Response.php
  54. 602 0
      api-test/client/lib/Model/PostApiV3MeReactionsRequest.php
  55. 858 0
      api-test/client/lib/Model/PostApiV3SearchRequest.php
  56. 589 0
      api-test/client/lib/Model/ProblemDetails.php
  57. 603 0
      api-test/client/lib/ObjectSerializer.php
  58. 47 0
      api-test/config.php
  59. 26 0
      api-test/fixtures.php
  60. 124 0
      api-test/run.php
  61. 199 0
      api-test/src/bootstrap.php
  62. 169 0
      api-test/tools/filter-v3.php
  63. 54 0
      api-test/tools/regen.sh
  64. 2 1
      api-v13/app/Console/Commands/ExportPaliSynonyms.php
  65. 2 2
      api-v13/app/Console/Commands/MqExport.php
  66. 1 1
      api-v13/app/Console/Commands/MqExportPaliChapter.php
  67. 2 2
      api-v13/app/Console/Commands/MqProgress.php
  68. 567 0
      api-v13/app/Console/Commands/TestMqProgress.php
  69. 1 1
      api-v13/app/Console/Commands/UpdateCorpus.php
  70. 1 1
      api-v13/app/Console/Commands/UpgradeWeekly.php
  71. 57 0
      api-v13/app/Exceptions/BusinessException.php
  72. 73 0
      api-v13/app/Exceptions/Problem.php
  73. 13 52
      api-v13/app/Http/Api/AiAssistantApi.php
  74. 35 4
      api-v13/app/Http/Api/MdRender.php
  75. 35 8
      api-v13/app/Http/Api/TemplateRender.php
  76. 36 68
      api-v13/app/Http/Api/UserApi.php
  77. 90 21
      api-v13/app/Http/Controllers/ArticleController.php
  78. 74 18
      api-v13/app/Http/Controllers/ChannelController.php
  79. 55 0
      api-v13/app/Http/Controllers/Concerns/ResolvesCurrentUser.php
  80. 19 0
      api-v13/app/Http/Controllers/Controller.php
  81. 7 1
      api-v13/app/Http/Controllers/CorpusController.php
  82. 83 13
      api-v13/app/Http/Controllers/CourseMemberController.php
  83. 114 21
      api-v13/app/Http/Controllers/DhammaTermController.php
  84. 119 18
      api-v13/app/Http/Controllers/DiscussionController.php
  85. 6 1
      api-v13/app/Http/Controllers/Library/BookController.php
  86. 0 20
      api-v13/app/Http/Controllers/LikeController.php
  87. 11 3
      api-v13/app/Http/Controllers/ProgressChapterController.php
  88. 0 104
      api-v13/app/Http/Controllers/ProgressController.php
  89. 64 14
      api-v13/app/Http/Controllers/ProjectController.php
  90. 7 1
      api-v13/app/Http/Controllers/RecentController.php
  91. 7 6
      api-v13/app/Http/Controllers/SentenceController.php
  92. 86 23
      api-v13/app/Http/Controllers/TaskController.php
  93. 0 183
      api-v13/app/Http/Controllers/TipitakaReadChapterController.php
  94. 0 100
      api-v13/app/Http/Controllers/TipitakaReadParaController.php
  95. 0 51
      api-v13/app/Http/Controllers/UpgradeController.php
  96. 45 17
      api-v13/app/Http/Controllers/UserController.php
  97. 98 17
      api-v13/app/Http/Controllers/UserDictController.php
  98. 44 0
      api-v13/app/Http/Controllers/V3/HeartbeatController.php
  99. 99 0
      api-v13/app/Http/Controllers/V3/MeReactionController.php
  100. 39 0
      api-v13/app/Http/Controllers/V3/ProgressController.php

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

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

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

@@ -0,0 +1,1113 @@
+---
+name: v3-resource
+description: "把 mint 的一个资源**从 /api/v2 迁移到 /api/v3**,或在 v3 下新建资源时用。按固定链路走完:FormRequest 定入参、Resource 的 @return array{} 定出参、补测试、重生成 OpenAPI、生成前端 TS 类型、把 dashboard-v6 彻底切过去(不留兼容分支)、在文档上标记 v2 旧端点。迁移期内一律不碰 v2 代码。触发:用户说「把 X 迁到 v3」「做 v3 重构」「v3 加个资源」「废掉 view 开关」「接口要符合 REST 标准」,或任务涉及 v2→v3 迁移、API 标准化、从 spec 生成前端类型。**不要用于**:在现有 v2 接口上加 view 值、加字段、修 bug 这类增量改动(那用 api-change skill,改动范围小得多);也不要用于把巴利语料、译文、术语、批注写进 WikiPali 数据库(那是 wikipali:write skill)。"
+---
+
+# v3 资源迁移
+
+## 先确认没拿错 skill
+
+| 你要做的事                                               | 用哪个           |
+| -------------------------------------------------------- | ---------------- |
+| 在现有**v2** 接口上加 view 值 / 加字段 / 改参数 / 修 bug | `api-change`     |
+| 把资源**迁到 v3**、v3 新建资源、废掉 view 开关做标准化   | `v3-resource`    |
+| 把巴利语料、译文、术语、批注写进**WikiPali 数据库**      | `wikipali:write` |
+
+判断依据是**动的是 v2 还是 v3**:在 v2 原地改用 `api-change`,迁到 v3 用 `v3-resource`。
+拿错了就停下换对的,不要将就。
+
+## 为什么这么做
+
+v2 的规格是**从代码里推断**出来的,注释一旦漂移,文档就错,agent 只能猜。
+v3 要反过来:**声明是强制的,spec 和前端类型都是生成的,CI 卡住漂移**。
+
+三件事,缺一不可:`声明 → 生成 → 契约测试`。
+
+关键不在于找到完美的声明语法,而在于**声明与实际不符时会红灯**。
+
+## 过渡期铁律:迁移 ≠ 删 v2
+
+**`dashboard-v4` 在线上跑,已冻结不再升级,依赖 161 条 v2 路径。**
+v6 还在测试上线阶段,等它稳定才会下线 v4。所以过渡期内:
+
+> **v2 原地保留,v3 是并存的新端点。迁完一个资源也不要删它的 v2 版本。**
+
+### 先分区,决定这个资源怎么迁
+
+```bash
+grep -rn "/v2/channel" dashboard-v4/dashboard/src   # 命中 = v4 在用
+grep -rn "/api/v2/channel" dashboard-v6/src
+```
+
+> **动 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` |
+| **B** | v4 + v6 | 118  | v3 建新端点,v6 灰度切;v2 原地冻结不动                     |
+| **C** | 只有 v4 | 43   | **不迁**。等 v4 下线整块删,投入任何功夫都是沉没成本        |
+
+**试点顺序:先做完 A 类 3 条**(零线上风险,用来打磨样板与 CI 三道闸),
+再从 `channel` 开始啃 B 类。不要一上来就动 B 类。
+
+### 不写兼容代码
+
+**重构是为了提升,不是为了兼容。** 后端 v2 / v3 共存,但前端要分别对待:
+
+> 灰度是**按资源**一部分一部分地做,而每一部分都要**彻底**。
+> 一个资源切到 v3,就把它的 v2 调用删干净——不留开关、不留双分支、
+> 不写把两种响应抹平的归一化层。
+
+反面教材(曾经写过,已删):
+
+```ts
+// ✗ 不要这样
+if (isV3("health")) {
+  const d = unwrapQuiet(await api.GET("/v3/heartbeat"));
+  return { status: d.data?.status ?? "ok", checkedAt: d.data?.checked_at ?? "" };
+}
+const res = await get<V2Health>("/api/v2/heartbeat");     // v2 分支
+return { status: "ok", checkedAt: (res as { createdAt?: string }).createdAt ?? "" };
+```
+
+这段有三样兼容代码:运行时开关、两条分支、一个抹平两种形状的接口。
+**这种代码以后删起来很麻烦**——没人敢确认开关还有没有人用。
+
+正确写法是切完就只剩一条路:
+
+```ts
+export const apiServerHealth = async (): Promise<Heartbeat> =>
+  unwrapQuiet(await api.GET("/v3/heartbeat")).data ?? {};
+```
+
+**回退靠 `git revert` 那个 commit**,不靠代码里的开关。一个资源一个 commit,
+这也是「一次只迁一个资源」的另一个理由。
+
+生产上本来也不存在「运行时切换」这个选项:前端是 Vite 构建出的静态产物,由
+nginx 伺服;无论改代码、改 `.env` 还是改 ops 的配置,都要**重新构建 + 重新部署**,
+没有热更新。所以代码里的开关一分钱好处都买不到,只留下删不掉的负担。
+
+同理,**v3 不要 import v2 的东西**:v3 用 `src/api/error.ts` 的 `ApiError`,
+v2 用 `src/request.ts` 的 `HttpError`,两边不共用类型。等 v2 调用点全部消失,
+`request.ts` 整个删掉,不牵连 v3。
+
+### Service 怎么用:不为共享去改 v2
+
+**不要把 v2 控制器里的逻辑抽到 Service 再让两边共用。** 抽取动作本身就要改 v2,
+而一个 Service 同时服务两套契约,最后一定会长出开关和分支——正是本 skill 禁止的兼容代码。
+**两个都照顾,两个都照顾不好。**
+
+按代码种类分:
+
+| 种类 | 怎么办 |
+| --- | --- |
+| **契约相关**:查询构造、权限判定、响应组装、view 分支 | **不共享,v3 重写** |
+| **纯能力**:与请求/响应形状无关,进去模型出来数据 | **共享现成的** |
+
+现成的纯能力 Service(已经在被两边共享,继续用):
+
+```
+PaliContentService   ← ChapterContentController(v2) / V3\TipitakaReadingController / …
+OpenSearchService    ← TipitakaContentController(v2) / V3\SearchPlusController / …
+UserService          ← 全仓库(v2 经 UserApi 转发壳,v3 直接注入)
+```
+
+v3 需要而现成 Service 没有的逻辑,**v3 自己写一份**;v2 里那份同名逻辑原地烂着,
+等 v4 下线一起删。
+
+**那 v2 的 bug 怎么办?** 常规做法是「bug 报告 = 迁移触发器」:迁 v3、在 v3 修、
+v6 同 commit 切过去,v2 那份不碰。只有两种情况直接改 v2——必须 1 小时内修好的,
+以及会污染 / 丢失数据库数据的(v4 和 v6 共用一个库)。详见根目录 CLAUDE.md 铁律 2。
+
+### migration 只准加不准删
+
+加列必须 nullable 或带默认值;禁止删列、改列名、改类型、加 NOT NULL。
+v4 的代码在读这些列,而你不会去改 v4。要改名就新列并存 + 双写,等 v4 下线再清理。
+
+v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agentApi.ts:14`)
+均不构成生产依赖,已确认。
+
+## 一次只迁一个资源
+
+不要大爆炸,不要一次迁多个资源。v2 与 v3 并存,逐个资源走完整条链,
+每次的 diff 要小到人能 review 完。**没走完全部 7 步就不算迁完。**
+
+## v3 硬规范
+
+新端点必须满足,不满足就不要写:
+
+1. **一个 index 只服务一个契约。**
+
+   契约 = 三件事:**同一套权限模型、同一套必填参数、同一种响应结构**。
+   三条里破任意一条,就说明那不是同一个端点,必须拆。三条都满足则留在同一个 index。
+
+   **过滤是允许的,而且是正常的**——同一个 index 挂十几个 `filter[]` 是常态
+   (用 `spatie/laravel-query-builder` 把 Model 的 local scope 暴露成 filter)。
+   要禁止的是 v2 那种必填、互斥、还各自带不同必填伙伴参数的 `view=` 开关。
+
+   **判断某个参数是过滤还是业务路径开关**,问一句:*去掉它,查询还成立吗?*
+   - 成立 → 可选叠加的过滤,留在 index,写成 `filter[xxx]`
+   - 不成立(必填 + 各值互斥)→ 业务路径开关,拆出去
+
+   **最可靠的机械检验是必填参数那条**:一个 FormRequest 的 `rules()` 能不能干净地
+   描述它?如果得写成下面这样,这串 `required_if` 就是在说「这里其实是三个端点」:
+
+   ```php
+   'name'    => 'required_if:view,studio,studio-all',
+   'book'    => 'required_if:view,user-in-chapter',
+   'book_id' => 'required_if:view,paragraphs',
+   ```
+
+   纯过滤永远是一串互不依赖的 `sometimes`,写起来很平。
+
+   响应结构那条有灰区:传了 `filter[book]` 才多返回一个 `progress` 字段**不算**结构
+   不同,只要 schema 里声明成可选(`progress?: float`)。结构不同指**顶层形状变了**,
+   比如有的返回 `{rows, count}`、有的直接返回数组。
+
+   拆出去的部分按 Laravel 官方形态落地(见下面「路由怎么设计」)。
+
+   > 别把这条读成「一个 index 只能走一个分支」。Laravel 官方没有这句话,
+   > index 内部有多少 `where` 条件分支都无所谓,那叫过滤。
+2. **响应信封 = Laravel Resource 的原生形状,不要自己拼**
+
+   | | 形状 | media type |
+   | --- | --- | --- |
+   | 成功·单个 | `{data: {...}}` | `application/json` |
+   | 成功·列表 | `{data: [...], meta: {...}}` | `application/json` |
+   | 失败 | `{type, title, status, detail, instance, errors?}` | `application/problem+json` |
+
+   **判断成败只看 HTTP 状态码,不看响应体字段。** 这是与 v2 最根本的区别——
+   v2 靠 `ok` 字段、业务失败也回 200;v3 靠状态码。
+
+   **成功:直接 return Resource,控制器里没有任何 helper。**
+
+   ```php
+   return ChannelResource::make($channel);                              // 单个
+   return ChannelResource::collection($query->paginate($per_page));     // 列表
+   ```
+
+   所有 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 自己算的。
+
+   载荷不是 Eloquent 模型时直接用基类,`additional()` 补 meta:
+
+   ```php
+   return BaseResource::collection($items)->additional(['meta' => [
+       'current_page' => $page, 'total' => $total, 'has_more' => $more,
+   ]]);
+   ```
+
+   meta 的键**跟框架走**(snake_case);领域特有的字段另起名字,不要占用
+   `per_page` / `from` / `to` 这些有既定含义的键(例如按字节切页的阅读接口
+   用 `page_size` / `first_para` / `last_para`)。
+
+   **失败:抛异常,不要在控制器里拼错误响应。** `bootstrap/app.php` 的处理器
+   会统一渲染成 Problem Details,**只接管 `api/v3/*`**,v2 不受影响:
+
+   ```php
+   abort(404, __('site.not_found'));                             // → 404
+   throw ValidationException::withMessages([                     // → 422,带 errors
+       'channel' => __('site.invalid_parameter'),
+   ]);
+   $this->authorize('update', $channel);                         // → 403
+   // FormRequest 校验失败自动 → 422,什么都不用写
+   ```
+
+   不必为 `ModelNotFoundException`(`findOrFail`)和 `AuthorizationException`
+   单独注册处理器:`prepareException()` 在 render 回调之前已把它们转成
+   404 / 403。`AuthenticationException` 则不在那张表里,已显式处理成 401。
+
+   **需要自定义 type / 扩展字段的业务错误**才写 `BusinessException`:
+
+   ```php
+   throw new BusinessException(__('sentence.locked'), 409, 'sentence-locked',
+       ['locked_by' => $editor->nickname]);
+   ```
+
+   它自带 `render()`,优先级高于所有处理器。**5xx 的 detail 在生产环境会被兜底
+   处理器抹掉**(防泄露),所以「预期的 5xx、文案必须送达」的场景必须用它,
+   不能用 `abort(503)`——见 `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 是早期
+   遗留的单数命名,新资源不要照抄。)
+
+   **v3 的类放在 `V3\` 子命名空间,名字里不带 `V3`**,资源名沿用 v2 的:
+
+   | v2 | v3 |
+   | --- | --- |
+   | `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` 区分。
+
+   **判据:`/v3/*` 路由指向的控制器,全都应该在 `Controllers/V3/` 下。**
+
+   理由:新旧类会长期共存(v4 下线前 v2 一直在),名字带上 v2 的资源名才能
+   一眼看出父子关系。**不要按新端点的语义另起名**——`/v3/heartbeat` 别叫
+   `HealthController`,那会和既有的 `HealthCheckController` 混淆,也切断了与
+   `HeartbeatController` 的血缘。
+
+   **URL 也沿用 v2 的资源名,只换版本前缀**,不要借迁移之机改名:
+   `/v2/heartbeat` → `/v3/heartbeat`,不要改叫 `/v3/health`。
+   改名会切断 v2↔v3 的对应关系,迁移期内两套并存,对不上号就很难排查。
+
+   **但改名是允许的,不需要审批**——v3 是破坏性重构,v2 有不少名字本来就名不副实。
+   改了就往根目录 CLAUDE.md 的「v2 → v3 对照表」加一行。那张表不是门禁是地图:
+   看到 v3 的名字与 v2 对不上时先查表,别当成错误去"修正"。
+
+   唯一允许的调整是**按 REST 规范做复数化**(集合用复数,单例保持单数):
+
+   | v2 | v3 | 说明 |
+   | --- | --- | --- |
+   | `/v2/heartbeat` | `/v3/heartbeat` | 单例资源,保持单数 |
+   | `/v2/channel` | `/v3/channels` | 集合,复数化 |
+   | `/v2/terms` | `/v3/terms` | 已经是复数,不动 |
+
+   动词化端点(`/v2/channel-my-number`)不在此列,它们本来就要按
+   「路由怎么设计」那节重新落位。
+
+   v3 控制器的文档注释里写明取代了哪个 v2 端点:
+
+   ```php
+   /**
+    * 存活检查
+    *
+    * 取代 `GET /v2/heartbeat`(`HeartbeatController@index`)。v2 那份在
+    * dashboard-v4 下线前原样保留,不要改动。
+    */
+   ```
+4. **分页交给框架。**
+
+   ```php
+   return ChannelResource::collection($query->paginate($request->integer('per_page', 20)));
+   ```
+
+   `paginate()` 自己会读 `page` 查询参数,count、页数、边界全由框架算,
+   `meta` 由 `V3ResourceCollection` 输出(已裁掉绝对 URL)。控制器里只写业务查询。
+
+   只有**不是 Eloquent 查询**的端点才手写 meta(例如 `V3\TipitakaReadingController`
+   按字节累加切页),用 `BaseResource::collection($items)->additional(['meta' => [...]])`,
+   **对外形状必须与框架一致**:`{data: [...], meta: {...}}`,键名跟 snake_case。
+
+   **列表端点必须能把结果集收敛到可读范围。** 不加条件就能拉全表的端点是设计
+   错误——`sentences` 有 414 万行,没人会翻 20 万页。设计时先问「用户实际带着
+   什么条件来」:若那条件必填且互斥,按第 1 条它就不是 filter 而是契约的一部分,
+   应该体现在路由上(`/v3/channels/{channel}/books/{book}/sentences`)。
+
+   收敛之后性能不是问题:实测 sentences 按 channel+book 过滤后 3,019 行,
+   `count(*)` 8ms,offset 5000 仍是 4ms。不需要 `cursorPaginate`。
+
+   **但"下载全量"这类端点必须用 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 个语言:
+   `en`、`zh-Hans`、`zh-Hant`、`my`、`th`、`si`、`vi`、`lo`。
+
+   **不要新建语言文件,合并进已有的那 11 个**:`site`、`labels`、`buttons`、
+   `home`、`library`、`auth`、`validation`、`passwords`、`pagination`、
+   `grammar`、`language`。(注意没有 `messages.php`。)
+   API 的服务状态类文案放 **`site.php`**,界面标签放 `labels.php`,
+   按钮文字放 `buttons.php`,各就各位。
+
+   新增文案至少补 `en` 与 `zh-Hans` 两份,其余语言缺失会自动回退到 `en`,
+   不必一次补齐。
+
+   RFC 9457 的字段分工:**`title` 保持稳定英文**(按 RFC 它应在所有场合一致,
+   便于机器识别),**`detail` 是面向人的文案,走 `__()`**。
+
+   ```php
+   'title'  => 'Service Unavailable',
+   'detail' => __('site.maintenance'),
+   ```
+
+   配一条测试守住退化——翻译文件缺失时 `__()` 会原样吐出键名:
+
+   ```php
+   expect($detail)->not->toBe('site.maintenance');
+   ```
+
+   > ⚠️ 已知问题:`SetLocale` 中间件只挂在 `web` 组,**api 组没有语言协商**,
+   > 所以目前 API 响应永远是 `en`,`Accept-Language` 与 `?lang=` 均无效。
+   > 文案仍然必须外置到翻译文件(否则将来想修也修不了),但别指望它现在会变中文。
+
+## 路由怎么设计
+
+### 先过四道判据
+
+**这四条是本项目的约定,不是行业标准。** 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 一节:
+
+> Remember to keep your controllers focused. If you find yourself routinely needing
+> methods outside of the typical set of resource actions, consider splitting your
+> controller into two, smaller controllers.
+
+拆出去的部分按下面四种形态落地,不要自创:
+
+| 形态 | 路由 | 什么时候用 |
+| --- | --- | --- |
+| **留在 index 用过滤** | `GET /v3/channels?filter[lang]=zh` | 可选叠加的筛选条件 |
+| **补充路由 + 单动作控制器** | `GET /v3/channels/editable` | 权限模型独特的集合(`--invokable` 生成) |
+| **嵌套资源** | `GET /v3/studios/{studio}/channels` | 从属关系;`->scoped()` 自动校验归属,权限代码少一半 |
+| **单例资源** | `GET /v3/courses/{c}/members/me` | 只有一个实例(当前用户的成员记录、profile、summary) |
+
+**路由顺序陷阱**(官方明确警告):补充路由必须写在 `apiResource` **之前**,
+否则 `/channels/{channel}` 会把 `/channels/editable` 吃掉,`editable` 被当成 id。
+
+```php
+Route::get('channels/editable', EditableChannelsController::class);   // 必须在前
+Route::apiResource('channels', ChannelController::class);
+Route::apiResource('studios.channels', StudioChannelController::class)->scoped()->shallow();
+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 |
+| --- | --- |
+| `*-my-number`、`*-count`(纯计数) | 读 index 的 `meta.total`,或 `GET /v3/studios/{s}/summary` 单例 |
+| `*-export` | `GET /v3/terms` + `Accept: text/csv`(内容协商) |
+| `*-import` | `POST /v3/terms/imports`(导入作业资源化) |
+| `*-tree` | `GET /v3/projects/{p}/tree` 或 `?include=descendants` |
+| `sign-in` / `sign-up` / `auth/current` | `POST /v3/sessions` / `POST /v3/users` / `GET /v3/me` |
+
+控制器内部:每个原 view 分支变成 Model 的 local scope(`scopePublic`、`scopeEditableBy`),
+index 用 `spatie/laravel-query-builder` 把 scope 暴露成 `filter[]`,
+scope 和 filter 的组装写在 Service 里(硬规范第 5 条),控制器只负责调它。
+
+## 迁移七步
+
+### 0. 先盘点,列给用户确认
+
+```bash
+grep -rn "channel" api-v13/routes/api.php | grep v2
+grep -rn "/api/v2/channel" dashboard-v6/src
+cd api-v13 && php artisan tinker --execute 'print_r(\Illuminate\Support\Facades\Schema::getColumnListing("channels"));'
+```
+
+产出三份清单:这个资源在 v2 有哪些端点(含 view 值)、前端哪些文件在调、
+表有哪些列及其**真实类型**。列给用户确认后再动手。
+
+### 1. FormRequest 定入参(有入参才需要)
+
+**有入参就必须用 FormRequest,禁止 inline `validate()`;零入参的端点不要建
+空 FormRequest**——那只是仪式,没有任何声明价值。
+
+`rules()` 既是运行时强制,又是 requestBody 的真声明——它不可能和实际行为不符,
+因为它就是实际行为。
+
+```php
+class IndexChannelRequest extends FormRequest
+{
+    /** @return array<string, string> */
+    public function rules(): array
+    {
+        return [
+            'filter.owner' => 'string|in:me,shared,system',
+            'filter.lang' => 'string|size:7',
+            'page' => 'integer|min:1',
+            'per_page' => 'integer|min:1|max:200',
+        ];
+    }
+}
+```
+
+### 2. Resource 定出参
+
+必须写 `@return array{...}` 声明。**类型去查 `information_schema` 核实**,
+不要照字段名想当然——`type`、`source_id` 这类常是 varchar 而非数字。
+
+```php
+/**
+ * @return array{
+ *     uid: string,
+ *     name: string,
+ *     summary: string|null,
+ *     type: string,
+ *     status: int,
+ *     created_at: string,
+ *     progress?: float
+ * }
+ */
+public function toArray($request): array
+```
+
+`key?:` 表示只在部分口径下返回;`|null` 会生成 `nullable: true`。
+范例见 `api-v13/app/Http/Resources/ChannelResource.php`。
+
+**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 校验器,
+所以目前写不了真正的契约测试。**这意味着**规格与实现漂移不会被自动拦截**——
+这是整套方案里唯一没落地的一环,写测试时要清楚自己没有这层保护。
+
+在装上之前,写普通的 Pest 形状断言:状态码、关键字段存在性与类型、权限分支。
+每个端点至少三条:正常返回、无权限被拒、参数非法被拒(无入参的端点可省最后一条)。
+测试文件开头用注释写明「这里断言的是形状而非契约」,装上 spectator 后好定位替换。
+
+装上之后,把形状断言换成 `assertValidRequest()` / `assertValidResponse()`:
+
+
+
+```php
+it('lists channels', function () {
+    $me = makeStudio('me');
+    makeChannel($me, 'my channel');
+
+    $this->getJson('/api/v3/channels?filter[owner]=me', authHeader($me))
+        ->assertValidRequest()      // 请求符合 spec
+        ->assertValidResponse(200); // 响应符合 spec,字段类型对不上就失败
+});
+
+it('rejects anonymous access', function () {
+    $this->getJson('/api/v3/channels')->assertStatus(401);
+});
+
+it('rejects invalid filter', function () {
+    $this->getJson('/api/v3/channels?filter[owner]=bogus', authHeader(makeStudio('me')))
+        ->assertStatus(422);
+});
+```
+
+`makeStudio` / `makeChannel` / `authHeader` 是现成 helper,见
+`tests/Feature/ChannelUserEditListTest.php`。
+
+**每个 view 值拆出来的新端点都要单独测**——这是业务路径分叉,新写的查询与权限
+逻辑没人跑过,最容易藏 bug。
+
+### 4. 重新生成规格
+
+```bash
+cd openapi && php scripts/generate.php && npm run lint
+```
+
+lint 必须 0 error。不要手改 `resources/auto/` 下的任何文件。
+
+生成器对 v3 路由(`api/v3/*`)用的是另一套信封,不用你操心:
+
+- 成功响应是 `{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`。
+
+两个标签控制这部分,写在控制器方法的文档注释里:
+
+```php
+/**
+ * 存活检查
+ *
+ * @unauthenticated                       // 该端点无需登录,不要挂 401
+ *
+ * @responseStatus 503 服务已进入停机维护状态   // 额外状态码。2xx 视为另一种成功口径
+ *                                            //(响应体与 200 同形),非 2xx 是 Problem Details
+ *
+ * @meta next_cursor string 下一块的游标      // 手写 meta 的端点用它声明 meta 字段,
+ *                                            // 不写就按框架的 PaginationMeta 出
+ */
+```
+
+范例见 `api-v13/app/Http/Controllers/HealthController.php`。
+
+### 5. 生成前端类型
+
+```bash
+npx openapi-typescript openapi/public/assets/protocol/main.yaml -o dashboard-v6/src/api/schema.d.ts
+```
+
+**跑过 `generate.php` 就必须跑这条**,不用问用户。`schema.d.ts` 已提交进版本库,
+它的改动是本次改动的一部分,要一并留在工作区。
+
+**类型一律从生成产物取,不要手写字段**:
+
+```ts
+import type { paths } from "./schema";   // 不写 .d.ts 后缀
+
+type ChannelListResponse =
+  paths["/v3/channels"]["get"]["responses"][200]["content"]["application/json"];
+type ChannelListQuery =
+  paths["/v3/channels"]["get"]["parameters"]["query"];
+```
+
+后端改了字段而前端没跟 → `npm run build` 直接报错。这是整条链路的最后一道闸。
+
+### 6. 前端切过去
+
+v3 的调用**一律走类型化客户端** `src/api/client.ts`(基于 `openapi-fetch`,已安装)。
+v2 的老调用继续用 `src/request.ts`,不要动。
+
+```ts
+import { api, unwrap } from "./client";
+
+// 路径写 OpenAPI 规格里的 path,client 的 baseUrl 已经是 "/api"
+const data = unwrap(await api.GET("/v3/channels", {
+  params: { query: { "filter[lang]": "zh-Hans" } },
+}));
+```
+
+这样写的收益:**路径字面量、HTTP 方法、路径参数名、响应字段类型全部编译期检查**。
+路径拼错、对只有 GET 的端点用 POST,`npm run build` 直接报错——手写 URL 拿不到这层保护。
+
+两个必须知道的点:
+
+- **`openapi-fetch` 非 2xx 不抛异常**,返回 `{ data, error }`。一律用
+  `client.ts` 的 `unwrap()`,不要绕过它直接读 `data`。
+- **错误不用在调用点处理**:`unwrap()` 失败时弹提示(消息来自后端已本地化的
+  `detail`)并抛出,全局兜底收尾。**不要写 try/catch 只为弹窗**,也不要再写
+  `if (!json.ok)`。
+- 自己渲染失败状态的调用(轮询、状态条)用 `unwrapQuiet()`,只抛不弹——
+  否则网络一断就每隔几秒弹一次。见 `api-health.ts`。
+- 表单要字段级错误时才自己 catch,用 `fieldErrorsOf(e)` 取 422 的 `errors`。
+- **token 注入已在 `client.ts` 的中间件里做掉**,不要在调用处再拼 Authorization。
+
+还要做的:
+
+- 把资源名加进 `src/api/v3-migration.ts` 的 `V3_RESOURCES`,两条分支都保留
+- 改 `src/features/`、`src/pages/` 里的调用点
+- 不要引入 RTK Query / react-query 这类数据层
+
+### 7. 标记 v2 旧路由(不碰 v2 代码)
+
+**过渡期内一律不动 v2**——不删路由、不改控制器,连加 `@deprecated` 注释都不行。
+修改 v2 的唯一理由是紧急 bug 修复。v4 在线上依赖它们,任何改动都是风险。
+删除留到 v4 下线后统一处理。
+
+要在文档上标记某个 v2 端点已被取代,走 **overrides**,这样不碰一行 PHP:
+
+```yaml
+# openapi/public/assets/protocol/overrides/v2-heartbeat.yaml
+get:
+  deprecated: true
+  description: |
+    已被 `GET /v3/heartbeat` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+```
+
+同时在 v3 控制器的文档注释里写明它取代了哪个 v2 端点,让两边都能追溯。
+
+## 验收:CI 三道闸
+
+```bash
+cd api-v13 && vendor/bin/pint --dirty --format agent && php artisan test --compact
+cd openapi && php scripts/generate.php && git diff --exit-code   # spec 没重生成就失败
+cd dashboard-v6 && npm run build                                  # 前端类型对不上就失败
+```
+
+三条都要过。**没有这三道闸,上面所有规范半年内都会腐化**——这比任何文档都重要。
+(`tests/Feature/ExampleTest.php` 是历史遗留失败,可忽略。)
+
+## 禁止事项
+
+- 不要一次迁多个资源
+- 不要在迁移里夹带无关重构(看到问题写 `// FIXME:`)
+- 不要手写前端类型,不要手改 `resources/auto/`
+- **不要擅自安装依赖**。需要就说明理由,等用户装完通知
+- 不要保留 `view=` 开关,不要保留 `ok:false` + HTTP 200(**仅指新建的 v3 端点**;
+  v2 那份原样不动)
+- **不要删 B 类和 C 类的 v2 路由**,v4 在线上用着
+- **不要为了共享逻辑去改 v2**:现成的纯能力 Service 直接用,其余 v3 自己写一份
+- 不要凭字段名猜类型,一律查库核实
+- 改完留在工作区,**不要自动 git commit**
+
+## 收尾报告
+
+用一句话说清:迁了哪个资源、v2 的哪几个端点/view 映射成了 v3 的哪几个端点、
+补了几条契约测试、三道闸的结果。**改了名的话,提醒用户往 CLAUDE.md 的
+「v2 → v3 对照表」加一行。**

+ 398 - 0
CLAUDE.md

@@ -0,0 +1,398 @@
+# CLAUDE.md
+
+wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。本仓库是 monorepo。
+
+## 目录状态(重要)
+
+**活跃维护:**
+
+| 目录 | 是什么 | 技术栈 |
+| --- | --- | --- |
+| `api-v13/` | 后端,唯一在维护的 API | Laravel 13 / PHP 8.5 / Pest 4 / Inertia 3 |
+| `dashboard-v6/` | 前端主站 | React 19 / Vite / antd 6 / Redux Toolkit |
+| `openapi/` | API 规格与在线文档(Swagger UI) | 规格由脚本从 `api-v13` 源码生成 |
+| `docker/` | 容器工具链与开发环境 | |
+| `magnolia/`、`ai-translate/`、`langchain-server/`、`open-ai-server/`、`sutta-pitaka-importer/`、`scripts/` | 周边服务与工具 | |
+
+**同一后端的第三个前端(不在本仓库):**
+
+- `/home/deploy/workspace/wikipali-mobile` —— 移动端,独立 git 仓库,开发阶段未上线。
+  用 `openapi-fetch` 调 `/api/v3/*`,有自己的 `src/api/schema.d.ts`。
+  **改 v3 端点前要 grep 它**,否则会漏掉消费者(2026-09 就漏过一次)。
+
+**冻结但仍在线上运行(代码不要改,但它的依赖决定了后端能动什么):**
+
+- `dashboard-v4/` —— 线上生产前端,依赖 161 条 `/api/v2` 路径。
+  **破坏 v2 就是线上事故**,详见下面「过渡期」一节。真实代码在
+  `dashboard-v4/dashboard/src`,不是 `dashboard-v4/src`。
+
+**已冻结,不再维护,不要改动、不要参照其写法、不要当作现状依据:**
+
+- `api-v8/`
+- `coconut/`
+- `tulip/`
+
+用户说“后端”指 `api-v13`,说“前端”指 `dashboard-v6`。在这两个目录之外动手前先确认。
+
+## 后端 api-v13
+
+- 详细的 Laravel/Pest/Pint 约定见 `api-v13/CLAUDE.md`(Laravel Boost 生成),在该目录工作时必须遵守。
+- 路由集中在 `routes/api.php`:`/api/v2/*` 是主力业务接口(约 140 个 `apiResource`),
+  `/api/v3/*` 是新版检索与阅读接口,`/api/ops/*` 是运维接口,需
+  `Authorization: Bearer <APP_OPS_TOKEN>`。
+- 响应统一封装 `{ok, message, data}`(见 `app/Http/Controllers/Controller.php` 的
+  `ok()` / `error()`)。**业务失败时 HTTP 状态码仍可能是 200,以 `ok` 字段为准**,
+  新代码沿用这个约定。
+- 列表接口的通用查询参数:`search`、`order`、`dir`、`limit`、`offset`。
+- 响应结构尽量走 `app/Http/Resources/*Resource`。
+- 改完 PHP 跑 `vendor/bin/pint --dirty --format agent`;测试 `php artisan test --compact`,
+  测试连独立的 `mint_test` 库(不要让 `RefreshDatabase` 打到开发库)。
+- `storage/resources` 是 git submodule(clove)。
+- 面向用户的文案一律走 `__()`。翻译文件在 **`resources/lang/{locale}/`**(不是
+  `lang/`),共 8 个语言。**不要新建语言文件,合并进已有的 11 个**
+  (`site`/`labels`/`buttons`/`home`/`library`/`auth`/… 没有 `messages.php`);
+  服务状态类文案放 `site.php`。至少补 `en` 与 `zh-Hans`,其余自动回退 en。
+
+## 前端 dashboard-v6
+
+- `src/request.ts` 是唯一的请求封装(原生 fetch,导出 `get/post/put/patch/delete_/upload/download/graphql`)。
+  **没有 baseURL**:每个调用点硬编码完整路径 `/api/v2/...`,开发期由 `vite.config.ts` 的
+  proxy 转发到 `http://127.0.0.1:8000`。新增调用请沿用这个写法。
+- `src/api/index.ts` 里的 `api_url()` 和 `VITE_API_BASE` 是死代码,没有任何地方使用。
+- 目录:`features/`(业务模块)、`pages/`、`components/`、`reducers/` + `store.ts`(RTK)、
+  `locales/`(react-intl 多语言)。
+- 命令:`npm run dev` / `npm run build` / `npm run lint`。
+- **接口类型不要手写**,从 OpenAPI 生成(见下)。RTK 只用了普通 slice,
+  没有 RTK Query / react-query / SWR,不要擅自引入数据层。
+
+### 接口类型生成(agent 自动执行,不用问)
+
+`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` 之后 —— 规格变了,类型就得跟着变
+- 改了 api-v13 的路由、控制器 docblock、FormRequest 规则、或 Resource 的
+  `@return array{...}` 声明
+- 前端要用某个接口或字段,但 `schema.d.ts` 里查不到它
+- 前端构建报接口类型相关的错误,怀疑类型是旧的
+
+跑完记得把 `schema.d.ts` 的改动一并留在工作区,它是这次改动的一部分。
+
+`src/api/<资源>.ts` 里的类型从它引用,不要再手写字段:
+
+```ts
+import type { paths } from "./schema";   // 注意:不写 .d.ts 后缀
+
+type ChannelResponse =
+  paths["/v2/channel-name/{name}"]["get"]["responses"][200]["content"]["application/json"];
+type ChannelListQuery = paths["/v2/channel"]["get"]["parameters"]["query"];
+```
+
+后端改了字段而前端没跟 → `npm run build` 直接报错。
+验证类型时用 `npx tsc -b --force`(`tsc -b` 有增量缓存,不加 `--force` 可能不重新检查)。
+
+**v3 的调用走 `src/api/client.ts`**(基于 `openapi-fetch`,已安装):
+
+```ts
+import { api, unwrap } from "./client";
+const data = unwrap(await api.GET("/v3/heartbeat"));
+```
+
+路径字面量、HTTP 方法、路径参数名、响应字段类型全部编译期检查。client 的
+baseUrl 是 `/api`,所以路径写规格里的 path;token 由中间件注入;非 2xx 时
+`openapi-fetch` 不抛异常,用 `unwrap()` 转成 `HttpError`。
+
+**v2 的调用继续用 `src/request.ts`,不要动。**
+
+## OpenAPI 规格
+
+`openapi/public/assets/protocol/` 下的规格**全部自动生成,不要手改**。
+
+```bash
+cd openapi
+php scripts/generate.php   # 从 api-v13 的 route:list + 控制器 AST 重新生成
+npm run lint               # redocly 校验,应为 0 error
+```
+
+- 生成器解析控制器的文档注释、`$request->input()/query()`、`switch($request->input('view'))`
+  的 case、`validate()` 规则、以及返回的 `*Resource::toArray()` 键。
+- **接口参数说明写在控制器的文档注释里**,用 `@queryParam` / `@bodyParam` / `@urlParam`
+  标签(`@标签 名字 类型 [required] 描述`,描述里可写 `Enum:` / `Default:` / `Example:`),
+  生成器会解析成精确的 OpenAPI 参数。示例见 `ChannelController@index`。
+- **响应字段的类型来自 Resource 的 `@return array{...}` 声明**;没写声明就只能按
+  字段名猜(会猜错,如 `channels.type` 是 varchar 却被猜成 integer)。
+  范例见 `api-v13/app/Http/Resources/ChannelResource.php`。
+- 不适合写进注释的长篇补充才放 `openapi/public/assets/protocol/overrides/<同名 slug>.yaml`,
+  深度合并到自动结果之上(注意数组是整体替换)。
+- **后端路由或控制器有改动,就重跑生成器**,否则规格立刻过时。
+- 细节见 `openapi/README.md`。
+
+### 为什么是自研脚本,什么时候该换掉
+
+社区有成熟方案(Scribe、swagger-php / L5-Swagger、Dedoc Scramble),当初没用是因为
+v2 有三个它们处理不了的形态:`switch ($request->input('view'))` 抽枚举、
+`{ok, message, data}` 信封、业务失败也返回 HTTP 200。
+
+v3 的规范正在消除这三点。**等 v3 资源铺开,就评估换成 Scramble**(零注解、自动推断
+FormRequest 与 Resource),自研脚本退化为只服务 v2 遗留部分;v4 下线、v2 删完后
+一并删掉。在那之前不要提议换工具,也不要在 v3 成熟后还抱着自研脚本不放。
+
+## 过渡期:v2 是线上冻结面(最高优先级,先读这节)
+
+**`dashboard-v4` 正在线上运行,已冻结不再升级,它依赖 161 条 v2 路径。**
+`dashboard-v6` 还在测试上线阶段。等 v6 稳定后才会下线 v4。在那之前:
+
+> **破坏 v2 = 线上崩,而且不会有人发现** —— v4 无人维护、没有测试、改了也没人跑。
+
+### 调用面分区(2026-09 盘点)
+
+| 分区 | 谁在用 | 条数 | 策略 |
+| --- | --- | --- | --- |
+| **A** | 只有 v6 | 3 | 可自由迁 v3。`heartbeat`、`chapter-content`、`paragraph-content` |
+| **B** | v4 + v6 | 118 | v2 **原地冻结**,在 v3 另建新端点,v6 灰度切过去 |
+| **C** | 只有 v4 | 43 | **什么都不做**。不补注释、不写测试、不迁移,等 v4 下线整块删 |
+
+判断某条路径属于哪区:
+
+```bash
+grep -rn "/v2/channel" dashboard-v4/dashboard/src   # 命中 = v4 在用,动不得
+grep -rn "/api/v2/channel" dashboard-v6/src
+```
+
+> ⚠️ **这张分区表只盘点了 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. **改 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` 里两处动态调用
+   (`components/corpus/BookTreeWithTags.tsx:44`、`components/dict/SearchVocabulary.tsx:102`
+   的 `/v2/${api}`)可能传入的资源名。在枚举完成前,v2 路由一律不准删。
+
+### v4 与 v3 的关系(已澄清)
+
+v4 有两处引用 v3,**均不构成生产依赖**:
+
+- `components/chat/ChatInput.tsx:127` 调 `/v3/search-suggest`——所在的三个页面
+  线上不可达,用户会彻底封闭该组件。
+- `services/agentApi.ts:14` 硬编码 `http://localhost:8000/api/v3`,是未完成的开发残留,
+  生产环境必然请求失败。
+
+所以 **v3 端点可以自由改**。仍建议改 v3 时顺手 grep 一下 v4,成本近乎为零。
+
+### 退出条件
+
+v6 全量稳定 → 下线 v4 → 删掉 C 类与 B 类的 v2 残留 → v2 前缀整体下线。
+
+## v3 重构
+
+正在把资源从 `/api/v2` 逐个迁到 `/api/v3`。原则是 `声明 → 生成 → 契约测试`:
+FormRequest 定入参、Resource 的 `@return array{...}` 定出参、契约测试卡住漂移、
+spec 与前端类型全部生成。v3 端点不准有 `view=` 开关、不准 `ok:false` + HTTP 200。
+
+**后端 v2 原地保留,前端切干净。** v2 与 v3 在后端共存到 v4 下线;但前端每迁
+一个资源就要**彻底**切过去——删掉该资源的 v2 调用,不留开关、不留双分支、
+不写抹平两种响应的归一化层。**重构是为了提升,不是为了兼容**;兼容代码以后
+删起来很麻烦,没人敢确认还有没有人在用。
+
+回退靠 `git revert` 那个 commit,不靠代码里的开关——这也是「一次只迁一个资源、
+一个资源一个 commit」的理由。生产上本来也没有「运行时切换」这回事:前端是静态
+构建产物,改代码、改 `.env`、改 ops 配置都得重新构建部署,没有热更新。
+
+v3 前端代码不要 import v2 的东西:v3 用 `src/api/error.ts` 的 `ApiError`,
+v2 用 `src/request.ts` 的 `HttpError`。等 v2 调用点全部消失,`request.ts`
+整个删掉,不牵连 v3。
+
+**v3 响应信封(已定死)**:就是 Laravel Resource 的原生形状——单个 `{data: {...}}`,
+列表 `{data: [...], meta: {...}}`,失败 `{type, title, status, detail, instance, errors?}`
++ `application/problem+json`。**判断成败只看 HTTP 状态码。**
+
+控制器里**没有任何响应 helper**:成功直接 `return XxxResource::make()/::collection()`,
+失败 `abort()` / `throw ValidationException` / `throw BusinessException`,
+由 `bootstrap/app.php` 统一渲染(只接管 `api/v3/*`)。
+v3 的 Resource 继承 `App\Http\Resources\V3\BaseResource`,它负责裁掉分页里的绝对 URL。
+**v2 的 `ok()` / `error()` 不准出现在 v3 控制器里。**
+
+前端对应(**只管 v3**,v2 的 `request.ts` 错误语义不同,迁移期内不碰):
+
+- `unwrap()` —— 失败时弹提示并抛出。业务代码只写成功路径,**不用 try/catch、
+  不用判断 `ok` 字段**(v2 那套在全仓库重复了 221 处)
+- `unwrapQuiet()` —— 只抛不弹,给自己渲染失败状态的调用用(如心跳轮询)
+- `ApiError` —— v3 专用错误类型,与 v2 的 `HttpError` 互不依赖
+- `src/api/error.ts` —— 提示的唯一出口,将来换 Notification 只改这里;
+  `installApiErrorHandler()` 在 `main.tsx` 装全局兜底,消掉未捕获 promise 的噪音
+- 表单要字段级错误时自己 catch,用 `fieldErrorsOf(e)` 取 422 的 `errors`
+
+**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 并成一条 |
+| `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
+
+**注意**:`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 三道闸),
+再从 `channel` 开始啃 B 类。
+
+## 渐进式还债
+
+这个代码库有六年历史,欠债很多。规则是**碰到什么还什么**,但必须让每次的还债量
+小到人能 review 完。三条硬约束:
+
+**1. 只还你这次真正改到的那个方法的债,不扩散。**
+改 `ChannelController@index` 就只清理 `index`,同文件的 `store`、`destroy` 一律不动,
+哪怕一眼就看到问题。看到了就加 `// FIXME:`,留给下次碰到它的人。
+
+**2. 功能改动和还债拆成两个 commit。**
+先 `feat`/`fix` 提交功能本身,再 `refactor` 提交清理。这样 review 时能分开看,
+清理动作出了问题也能单独回滚。
+
+**3. 每次改动结尾,用一句话列出还了哪些债。**
+比如「顺带:`index` 补了 docblock、switch 加了 default 保护」。没列出来的清理动作
+等于没做过 review。
+
+碰到旧方法时按这个顺序还(做得到就做,做不到就 FIXME):
+
+- 补中文 docblock 与 `@queryParam` / `@bodyParam` 标签(规格直接受益)
+- 返回值走 `app/Http/Resources/` 的 Resource,而不是 `return $this->ok($model)` 吐原始模型
+- 给碰到的 Resource 的 `toArray()` 补 `@return array{...}` 声明(响应类型直接受益)
+- `$this->error()` 的参数位置写对:`error(string $message, mixed $data, int $status)`
+- `switch` 补 `default` 分支,避免 `$table` 未定义直接 500
+- `$this->error(...)` 后面漏掉的 `return` 补上
+- 给你这次碰到的 view 值补测试
+
+**不要做的事:** 不要为了"顺手"重写整个控制器、不要改动你没读懂的逻辑、
+不要在功能改动里夹带大范围重构。拿不准就写 FIXME,成本最低。
+
+现有的 FIXME 清单:`grep -rn FIXME api-v13/app/Http/Controllers/`
+
+## 测试约定
+
+**每个 view 值都要有测试覆盖。** 列表接口的 `view` 参数是业务路径分叉,每个值背后
+是一段独立的查询与权限逻辑,新增的那段代码没人跑过,最可能藏 bug。
+
+- **新增 view 值 → 必须补 Pest 测试**,至少两条:一条验证它返回预期数据,
+  一条验证权限不足时被拒。模板见 `tests/Feature/ChannelUserEditListTest.php`,
+  `makeStudio` / `makeChannel` / `authHeader` 是现成 helper。
+- **新增字段 → 不用改测试**。项目里的断言都是 `toHaveKeys(...)` 这种「包含即可」
+  的写法,没有 `assertExactJson` / `assertJsonStructure`,多返回一个字段不会打破谁。
+- 需要改已有测试的只有:改了现有字段的名字或类型、改了权限规则、改了现有 view 的返回口径。
+- 测试连独立的 `mint_test` 库,`RefreshDatabase` 不会清开发库。
+
+## 分支与发布(先读这条)
+
+**线上生产跑的就是本仓库的 `development` 分支。** 没有单独的发布分支,
+也没有 CI 部署流程(`api-v13/.github/workflows/` 只有 lint 和 tests)。
+
+所以:**提交到 `development` 就等于把代码推向生产路径**。这让下面两条约定
+不只是习惯问题,而是安全边界:
+
+- 改完留在工作区,**永远不要自动 `git commit`**,等用户 review
+- **永远不要 `git push`**,除非用户明确要求
+
+`dashboard-v4/deploy/` 里那套 ansible 是 v4 时代的遗留:它指向
+`iapt-platform/mint` 的 `laravel` 分支,而本仓库的 remote 是
+`visuddhinanda/mint`。**不要把它当作现在的发布流程依据。**
+
+## 工作约定
+
+- 改完代码留在工作区,等用户 review,不要自动 `git commit`。
+- 文档文件只在用户明确要求时创建。
+- **不要擅自安装或升级任何依赖**(npm、composer 都一样)。需要新依赖就说明理由,
+  等用户自己装完通知你。
+- **不要自动新建 migration。** 加列是不可逆的库结构变更,由用户决定。发现缺列
+  (如软删除的 `deleted_at`、乐观锁的 version)就停下来说明用途,等用户确认再写。
+  改动本身仍受铁律 4 约束:只准加,加列必须 nullable 或带默认值。

+ 5 - 0
api-test/.gitignore

@@ -0,0 +1,5 @@
+# 依赖与可再生的构建产物(tools/regen.sh 会重生成),不入库
+/client/vendor/
+/client/composer.lock
+/spec/openapi.json
+/spec/openapi-v3.json

+ 189 - 0
api-test/README.md

@@ -0,0 +1,189 @@
+# api-test —— api-v13 的 `/api/v3` 契约测试
+
+用 openapi-generator 从 `openapi/public/assets/protocol/main.yaml` 生成 PHP(Guzzle)客户端,
+测试脚本是**纯 PHP**,用它来对 `/api/v3/*` 做契约冒烟 + 负向测试。**目前只测 v3 路由。**
+
+## 从零开始(全新环境)
+
+全新克隆后按顺序执行,到能跑出结果为止。
+
+### 0. 环境要求
+
+| 依赖 | 用途 | 版本 |
+| --- | --- | --- |
+| PHP | 跑测试脚本 | 8.1+(需 `ext-curl` / `ext-json` / `ext-mbstring`) |
+| Composer | 装 Guzzle 依赖 | 2.x |
+| Java | 仅「重新生成客户端」时用(openapi-generator 是 jar) | 17+ |
+| Node / npx | 仅「重新生成客户端」时用(redocly bundle) | 18+ |
+
+> 只跑测试、不重新生成客户端的话,Java / Node 不是必须的。
+
+### 1. 安装运行依赖(只需一次)
+
+生成客户端 `client/` 里只有代码,`vendor/` 被 gitignore 了,要先装 Guzzle:
+
+```bash
+cd api-test/client
+composer install --no-dev
+cd ../..
+```
+
+### 2. 运行测试
+
+```bash
+cd api-test
+php run.php --username=test161 --password=12345            # 调 /v2/sign-in 换 token,跑全量
+php run.php --username=test161 --password=12345 --server=staging
+php run.php --token=<bearer>                                # 或直接给 token
+```
+
+> 如需**重新生成客户端**(后端/规格改过之后),见下文「OpenAPI 修改后如何升级测试」。
+
+## 运行
+
+```bash
+cd api-test
+
+php run.php                          # 默认 local(http://127.0.0.1:8000/api)
+php run.php --server=staging         # 切服务器(alias)
+php run.php --server=https://foo/api # 任意完整 base URL
+php run.php --filter=search          # 只跑名字含 search 的用例
+php run.php --list                   # 列出可用服务器
+
+# 跑 /v3/me/*(需登录)用例 —— 两种给 token 的方式:
+php run.php --token=<bearer>                                   # 直接给 token
+php run.php --username=alice --password=secret                 # 调 /v2/sign-in 现换 token
+```
+
+### 可用 `--server` 一览
+
+| alias | URL | 说明 |
+| --- | --- | --- |
+| `local` | `http://127.0.0.1:8000/api` | 本地开发(默认) |
+| `staging` | `https://staging.wikipali.org/api` | 测试 |
+| `next` | `https://next.wikipali.org/api` | 生产预览 |
+| `prod` | `https://www.wikipali.org/api` | 生产 |
+| `next-cn` | `https://next.wikipali.cc/api` | 生产预览(中国) |
+| `prod-cn` | `https://www.wikipali.cc/api` | 生产(中国) |
+
+> 也可以 `--server=<完整 base URL>` 直连任意服务器(含 /api 前缀)。
+
+- 服务器切换优先级:`--server=` / `API_TEST_BASE` > `config.php` 的 `default_server`。
+- **鉴权优先级**:`--token=` > 环境变量 `API_TEST_TOKEN` > `--username/--password`
+  (或 `API_TEST_USERNAME`/`API_TEST_PASSWORD`)调 `POST /v2/sign-in` 登录换 token。
+  v3 只认 bearer token,**不用 username/password**——那两样只用于登录换 token。
+- **测试逻辑**:需要登录的 `/v3/me/reactions`(GET/POST/DELETE)各测两种情况——
+  无 token → 期望 401;带 token → 正常。带 token 的用例**缺 token 就 FAIL,不 SKIP**,
+  所以跑全量前要带 `--token=` 或 `--username/--password`。
+- **写操作在所有服务器上都真实执行**(POST 建 / DELETE 删,用垃圾 target_id,测完即删),
+  包括 prod。会产生少量垃圾数据,由测试账号定期清理。
+- 登录接口 `POST /v2/sign-in`:`username` 可以是用户名或邮箱;响应是 v2 信封
+  `{ok,message,data}`,**成败看 `ok` 字段**(失败时 HTTP 可能是 200 也可能是 400),
+  `data` 即 JWT。
+- 退出码:`0` 全绿;`1` 有失败/错误;`2` 参数/加载错误。
+
+## 目录结构
+
+```
+api-test/
+├── run.php               # 入口 + 运行器
+├── config.php            # 服务器 alias 与 token
+├── fixtures.php          # 种子数据(channel/target_id/book 等,按环境可能要改)
+├── src/bootstrap.php     # 断言/注册 helper(test/request/assert_*)
+├── cases/*.php           # 用例,按领域分文件
+├── spec/
+│   ├── openapi.json      # 全量 spec 的 bundle(构建输入)
+│   └── openapi-v3.json   # 切出的 v3 + 补丁(生成客户端的输入)
+├── tools/
+│   ├── filter-v3.php     # 切 /v3 + 修数组 items + 修 3 个漂移端点的响应 schema
+│   └── regen.sh          # 一键重新生成客户端
+└── client/               # openapi-generator 生成的 Guzzle 客户端(vendor/ 已 gitignore)
+```
+
+## OpenAPI 修改后如何升级测试
+
+后端改了路由 / 控制器 docblock / FormRequest / Resource 之后,spec 会过时,测试要跟着升
+两级:**先重生 spec,再重生客户端,最后人工核对用例**。客户端是自动生成的,但用例断言
+不会自动跟着变。
+
+### 1. 重新生成 OpenAPI spec(openapi 侧)
+
+```bash
+cd openapi
+php scripts/generate.php   # 从 api-v13 的 route:list + 控制器 AST 重新生成 main.yaml
+npm run lint               # redocly 校验,应为 0 error
+cd ..
+```
+
+> 前端类型也跟着变了的话,还要按根目录 CLAUDE.md 重跑
+> `npx openapi-typescript … -o dashboard-v6/src/api/schema.d.ts`(两份,mobile 那份别漏)。
+
+### 2. 重新生成测试客户端(api-test 侧)
+
+```bash
+# 首次先下载 openapi-generator-cli jar(7.25.0)
+mkdir -p /tmp/og
+curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.25.0/openapi-generator-cli-7.25.0.jar \
+  -o /tmp/og/openapi-generator-cli.jar
+
+# 一键:bundle main.yaml → 切 /v3 + 打补丁 → 生成 Guzzle 客户端 → composer install
+bash api-test/tools/regen.sh
+```
+
+`regen.sh` 内部四步,都对应 spec 的某一部分:
+
+| 步骤 | 做什么 | 产物 |
+| --- | --- | --- |
+| bundle | redocly 把 `main.yaml` 及所有 `$ref` 合并成单文件 | `spec/openapi.json` |
+| filter | `filter-v3.php` 切出 `/v3/*` + 修数组 items + 修 3 个漂移端点 schema | `spec/openapi-v3.json` |
+| generate | openapi-generator 生成 PHP(Guzzle) 客户端 | `client/`(会先 `rm -rf` 重来) |
+| install | composer 装 Guzzle | `client/vendor/` |
+
+> ⚠️ `client/` 是纯生成物,**不要手改**——`regen.sh` 会整个删掉重生成,手改会丢。
+
+### 3. 人工核对 / 更新用例(不会自动)
+
+客户端自动更新了,但 `cases/*.php`、`fixtures.php` 要人看:
+
+- **新增端点** → 在 `cases/` 加对应 `test()`(参照现有文件按领域放)。
+- **参数变了**(新增 required、改名、枚举增删)→ 改对应用例的入参。
+- **响应字段变了**(Resource 的 `@return` 改了形状)→ 改对应断言。
+- **种子数据失效** → `fixtures.php` 里的 `channel` / `target_id` / `search_doc_id` 换新值。
+
+### 4. 跑全量验证
+
+```bash
+php run.php --username=test161 --password=12345          # 默认 local
+php run.php --username=test161 --password=12345 --server=staging
+```
+
+- 如果新 spec 又冒出「spec 与实现不一致」导致客户端反序列化报错(如之前的
+  `Invalid array 'object[]'`),在 `filter-v3.php` 的 `applyDriftPatches()` 补,并同步更新
+  下面「已知漂移」表。
+- 退出码 `0` 才算绿;`1` 看每条 `❌ FAIL` 的详情。
+
+## 已知的 spec ↔ 实现漂移(记录在案,不是测试脚本的 bug)
+
+| 端点 | spec 说 | 实际 | 处理 |
+| --- | --- | --- | --- |
+| `GET/POST /v3/search` | 200 `{data:数组, meta}` + 非法 `search_mode` 422 | 200 `{success, data:{took,hits}}`;`search_mode` 不校验 | `filter-v3.php` 把 200 schema 修为 `{success,data:对象}`;用例只断言状态码 + `data` 键 |
+| `GET /v3/search-suggest` | 200 `{data:数组, meta}`;空 q 422 | 200 `{success, data:{query,suggestions}}`;空 q 400 `{success:false,error}` | 同上修 schema;空 q 用例按实际 400 断言 |
+| `GET /v3/upgrade` | 200 `{data:数组, meta}` | 200 `{data:{status:"ok"}}` | 修 schema 为 `{data:对象}` |
+
+根因是这三个控制器还没迁到 v3 的 Resource 信封(`{data}` 原生形状、错误体 RFC 9457
+Problem Details、成败只看状态码)。正确的长期修法在 api-v13 侧,这里只让测试客户端能用、
+把漂移显式记录。`filter-v3.php` 里的 `applyDriftPatches()` 就是这三处补丁,改对齐后可删。
+
+## 环境注意
+
+- **pspell / xdebug 启动警告**:本机 PHP CLI 的 pspell 扩展与 PHP 版本不匹配,每次 `php`
+  都会在 stderr 打警告,与测试无关,可忽略。
+- **staging 的 OpenSearch**:`/v3/search`、`/v3/search-suggest` 在 staging 会 500
+  (索引 mapping 与代码不一致:`resource_type` 缺 fielddata、`content.suggest.pali` 没映射)。
+  这是 staging 环境问题,本地跑这几条是绿的。
+- **种子数据按环境不同**:`fixtures.php` 里的 channel/target_id/doc_id 取自本地开发库,
+  换环境后可能需要更新。跨环境跑时,数据不存在通常只是返回空集(仍 200),不会假红,
+  但 `search/{id}` 的 200 用例依赖真实 doc id。
+- **写操作用垃圾数据 + 测完即删**:`/v3/me/reactions` 的增删查用例用固定垃圾
+  `target_id`(uuid 格式),DELETE 会清理;若中途失败留下残留,下次跑时幂等逻辑会
+  兜住(POST 返回 200 命中已有),最后由测试账号定期清理。

+ 20 - 0
api-test/cases/01_system.php

@@ -0,0 +1,20 @@
+<?php
+
+use \WikipaliApi\Api\HeartbeatApi;
+use \WikipaliApi\Api\UpgradeApi;
+
+test('GET /v3/heartbeat → 200 {data:{status,checked_at}}', function () {
+    $res = request(fn () => api(HeartbeatApi::class)->getApiV3HeartbeatWithHttpInfo());
+    assert_status(200, $res, 'heartbeat');
+    $data = $res['body']['data'] ?? null;
+    assert_has_key('status', $data, 'heartbeat.data');
+    assert_eq('ok', $data['status'], 'heartbeat.data.status');
+    assert_has_key('checked_at', $data, 'heartbeat.data');
+});
+
+test('GET /v3/upgrade → 200 {data:{status}}', function () {
+    // 注意:spec 把 data 写成 array,实际返回 {data:{status:"ok"}}(见 README 漂移说明)。
+    $res = request(fn () => api(UpgradeApi::class)->getApiV3UpgradeWithHttpInfo());
+    assert_status(200, $res, 'upgrade');
+    assert_has_key('data', $res['body'], 'upgrade');
+});

+ 44 - 0
api-test/cases/02_search.php

@@ -0,0 +1,44 @@
+<?php
+
+use \WikipaliApi\Api\SearchApi;
+use \WikipaliApi\Api\SearchSuggestApi;
+use \WikipaliApi\Model\PostApiV3SearchRequest;
+
+test('GET /v3/search?q=dukkha → 200', function () {
+    $res = request(fn () => api(SearchApi::class)->getApiV3SearchWithHttpInfo(fixtures()['search_q']));
+    assert_status(200, $res, 'search GET');
+    // spec 说 data 是数组,实际是 {success,data:{took,hits}}(漂移,见 README)
+    assert_has_key('data', $res['body'], 'search GET');
+});
+
+test('POST /v3/search → 200(与 GET 等价,参数在 body)', function () {
+    $body = new PostApiV3SearchRequest();
+    $body->setQ(fixtures()['search_q']);
+    $res = request(fn () => api(SearchApi::class)->postApiV3SearchWithHttpInfo($body));
+    assert_status(200, $res, 'search POST');
+    assert_has_key('data', $res['body'], 'search POST');
+});
+
+test('GET /v3/search/{id} → 200(真实文档 id)', function () {
+    $res = request(fn () => api(SearchApi::class)->getApiV3SearchSearchWithHttpInfo(fixtures()['search_doc_id']));
+    assert_status(200, $res, 'search show');
+    assert_has_key('data', $res['body'], 'search show');
+});
+
+test('GET /v3/search/{id} → 404(不存在的文档)', function () {
+    $res = request(fn () => api(SearchApi::class)->getApiV3SearchSearchWithHttpInfo('no_such_doc_xyz'));
+    assert_status(404, $res, 'search show 404');
+    assert_problem($res, 'search show 404');
+});
+
+test('GET /v3/search-suggest?q=dukkha → 200', function () {
+    $res = request(fn () => api(SearchSuggestApi::class)->getApiV3SearchSuggestWithHttpInfo(fixtures()['search_q']));
+    assert_status(200, $res, 'search-suggest');
+    assert_has_key('data', $res['body'], 'search-suggest');
+});
+
+test('GET /v3/search-suggest?q= → 400(空查询)', function () {
+    // 描述里写明「q 为空返回 400」;spec 只登记了 422,这里按实际 400 断言。
+    $res = raw('GET', '/v3/search-suggest', ['q' => '']);
+    assert_status(400, $res, 'search-suggest 空 q');
+});

+ 115 - 0
api-test/cases/03_reactions.php

@@ -0,0 +1,115 @@
+<?php
+
+use \WikipaliApi\Api\MeApi;
+use \WikipaliApi\Api\ReactionsApi;
+use \WikipaliApi\Model\PostApiV3MeReactionsRequest;
+
+/* ---- 公共 reactions / tally(只读,无需 token)---- */
+
+test('GET /v3/reactions?target_id=… → 200', function () {
+    $res = request(fn () => api(ReactionsApi::class)->getApiV3ReactionsWithHttpInfo(fixtures()['target_id']));
+    assert_status(200, $res, 'reactions');
+    assert_has_key('data', $res['body'], 'reactions');
+    assert_has_key('meta', $res['body'], 'reactions');
+});
+
+test('GET /v3/reactions(缺 target_id)→ 422', function () {
+    $res = raw('GET', '/v3/reactions');
+    assert_status(422, $res, 'reactions 缺 target_id');
+    assert_problem($res, 'reactions 缺 target_id');
+});
+
+test('GET /v3/reactions/tally?target_id=… → 200', function () {
+    $res = request(fn () => api(ReactionsApi::class)->getApiV3ReactionsTallyWithHttpInfo(fixtures()['target_id']));
+    assert_status(200, $res, 'tally');
+    assert_has_key('data', $res['body'], 'tally');
+    $first = $res['body']['data'][0] ?? null;
+    assert_has_key('type', $first, 'tally.data[0]');
+    assert_has_key('count', $first, 'tally.data[0]');
+});
+
+test('GET /v3/reactions/tally(缺 target_id)→ 422', function () {
+    $res = raw('GET', '/v3/reactions/tally');
+    assert_status(422, $res, 'tally 缺 target_id');
+    assert_problem($res, 'tally 缺 target_id');
+});
+
+/* ---- /v3/me/reactions:无 token → 401(每个动作各一条)---- */
+
+test('GET /v3/me/reactions(无 token)→ 401', function () {
+    $res = request(fn () => api(MeApi::class)->getApiV3MeReactionsWithHttpInfo());
+    assert_status(401, $res, 'me/reactions 无 token');
+    assert_problem($res, 'me/reactions 无 token');
+});
+
+test('POST /v3/me/reactions(无 token)→ 401', function () {
+    $body = new PostApiV3MeReactionsRequest();
+    $body->setType('watch');
+    $body->setTargetId('11111111-1111-4111-8111-111111111111');
+    $body->setTargetType('collection');
+    $res = request(fn () => api(MeApi::class)->postApiV3MeReactionsWithHttpInfo($body));
+    assert_status(401, $res, 'me/reactions POST 无 token');
+    assert_problem($res, 'me/reactions POST 无 token');
+});
+
+test('GET /v3/me/reactions(非法 token)→ 401', function () {
+    $res = request(fn () => api(MeApi::class, 'garbage-token')->getApiV3MeReactionsWithHttpInfo());
+    assert_status(401, $res, 'me/reactions 非法 token');
+    assert_problem($res, 'me/reactions 非法 token');
+});
+
+/* ---- /v3/me/reactions:带 token → 实际增删查(幂等 + 无 token 删 401 + 删后 404)---- */
+
+test('me/reactions 增删查:POST 建 → GET 查到 → 再 POST 幂等 → 无 token DELETE 401 → DELETE 204 → 再 DELETE 404', function () {
+    $token = required_token();
+
+    // 垃圾 target(uuid 格式即可,不要求真实存在),测完删掉
+    $targetId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
+    $body = new PostApiV3MeReactionsRequest();
+    $body->setType('watch');
+    $body->setTargetId($targetId);
+    $body->setTargetType('collection');
+
+    // 增:POST
+    $create = request(fn () => api(MeApi::class, $token)->postApiV3MeReactionsWithHttpInfo($body));
+    if (! in_array($create['status'], [200, 201], true)) {
+        fail('POST 创建:期望 201(新建)或 200(已存在),实际 ' . $create['status']);
+    }
+    $id = $create['body']['data']['id'] ?? null;
+    if (! $id) {
+        fail('POST 创建:应返回 reaction id');
+    }
+
+    // 查:GET 按 type 过滤,能查到刚建的(用 target_id 定位,因为列表没有 target_id 过滤参数)
+    $list = request(fn () => api(MeApi::class, $token)->getApiV3MeReactionsWithHttpInfo(200, 'watch'));
+    assert_status(200, $list, 'GET 列表');
+    $found = false;
+    foreach (($list['body']['data'] ?? []) as $row) {
+        if (($row['target_id'] ?? null) === $targetId) {
+            $found = true;
+            break;
+        }
+    }
+    if (! $found) {
+        fail('GET 列表:查不到刚建的 reaction(target_id=' . $targetId . ')');
+    }
+
+    // 幂等:再 POST 同一条 → 200,id 不变
+    $again = request(fn () => api(MeApi::class, $token)->postApiV3MeReactionsWithHttpInfo($body));
+    assert_status(200, $again, '再 POST 幂等');
+    assert_eq($id, $again['body']['data']['id'] ?? null, '幂等:两次 id 一致');
+
+    // 无 token DELETE → 401(此时该 reaction 存在,路由绑定成功 → 鉴权 401)
+    $noTokenDel = raw('DELETE', '/v3/me/reactions/' . $id);
+    assert_status(401, $noTokenDel, '无 token DELETE');
+    assert_problem($noTokenDel, '无 token DELETE');
+
+    // 删:DELETE → 204
+    $del = request(fn () => api(MeApi::class, $token)->deleteApiV3MeReactionsReactionWithHttpInfo($id));
+    assert_status(204, $del, 'DELETE');
+
+    // 再删:已删除 → 404
+    $delAgain = request(fn () => api(MeApi::class, $token)->deleteApiV3MeReactionsReactionWithHttpInfo($id));
+    assert_status(404, $delAgain, '再 DELETE');
+    assert_problem($delAgain, '再 DELETE');
+});

+ 22 - 0
api-test/cases/04_progress.php

@@ -0,0 +1,22 @@
+<?php
+
+use \WikipaliApi\Api\ProgressApi;
+
+test('GET /v3/progress?channels=… → 200', function () {
+    $res = request(fn () => api(ProgressApi::class)->getApiV3ProgressWithHttpInfo(fixtures()['channel']));
+    assert_status(200, $res, 'progress');
+    assert_has_key('data', $res['body'], 'progress');
+    assert_has_key('meta', $res['body'], 'progress');
+});
+
+test('GET /v3/progress(缺 channels)→ 422', function () {
+    $res = raw('GET', '/v3/progress');
+    assert_status(422, $res, 'progress 缺 channels');
+    assert_problem($res, 'progress 缺 channels');
+});
+
+test('GET /v3/progress?order=非法值 → 422', function () {
+    $res = raw('GET', '/v3/progress', ['channels' => fixtures()['channel'], 'order' => 'bogus']);
+    assert_status(422, $res, 'progress 非法 order');
+    assert_problem($res, 'progress 非法 order');
+});

+ 67 - 0
api-test/cases/05_tipitaka.php

@@ -0,0 +1,67 @@
+<?php
+
+use \WikipaliApi\Api\TipitakaReadingApi;
+use \WikipaliApi\Api\TipitakaRelatedParagraphsApi;
+
+test('GET /v3/tipitaka-reading/{channel} → 200(无过滤,取首块)', function () {
+    $res = request(fn () => api(TipitakaReadingApi::class)->getApiV3TipitakaReadingChannelWithHttpInfo(fixtures()['channel'], null, null, null, null, null, 2));
+    assert_status(200, $res, 'tipitaka-reading');
+    assert_has_key('data', $res['body'], 'tipitaka-reading');
+    assert_has_key('meta', $res['body'], 'tipitaka-reading');
+    // next_cursor 为 null 时(取完)生成模型会把它丢掉,这里只看非空字段。
+    assert_eq('para', $res['body']['meta']['page_size_unit'] ?? null, 'tipitaka-reading.meta.page_size_unit');
+});
+
+test('GET /v3/tipitaka-reading/{channel}?book=… → 200(带 total)', function () {
+    $res = request(fn () => api(TipitakaReadingApi::class)->getApiV3TipitakaReadingChannelWithHttpInfo(fixtures()['channel'], 65, null, null, null, null, 2));
+    assert_status(200, $res, 'tipitaka-reading book 过滤');
+    assert_has_key('total', $res['body']['meta'] ?? [], 'tipitaka-reading.meta.total');
+});
+
+test('GET /v3/tipitaka-reading/{非 uuid} → 404', function () {
+    $res = raw('GET', '/v3/tipitaka-reading/not-a-uuid');
+    assert_status(404, $res, 'tipitaka-reading 非 uuid');
+    assert_problem($res, 'tipitaka-reading 非 uuid');
+});
+
+test('GET /v3/tipitaka-related-paragraphs?book_name=…&cs_para=… → 200', function () {
+    $res = request(fn () => api(TipitakaRelatedParagraphsApi::class)->getApiV3TipitakaRelatedParagraphsWithHttpInfo(
+        null, null, fixtures()['book_name'], fixtures()['cs_para']
+    ));
+    assert_status(200, $res, 'related-paragraphs');
+    assert_has_key('data', $res['body'], 'related-paragraphs');
+    assert_has_key('count', $res['body']['meta'] ?? [], 'related-paragraphs.meta.count');
+});
+
+test('GET /v3/tipitaka-related-paragraphs(两组坐标互斥)→ 422', function () {
+    $res = raw('GET', '/v3/tipitaka-related-paragraphs', [
+        'book' => fixtures()['book'],
+        'para' => fixtures()['para'],
+        'book_name' => fixtures()['book_name'],
+        'cs_para' => fixtures()['cs_para'],
+    ]);
+    assert_status(422, $res, 'related-paragraphs 互斥');
+    assert_problem($res, 'related-paragraphs 互斥');
+});
+
+test('GET /v3/tipitaka-related-paragraphs(两组都没给)→ 422', function () {
+    $res = raw('GET', '/v3/tipitaka-related-paragraphs');
+    assert_status(422, $res, 'related-paragraphs 缺坐标');
+    assert_problem($res, 'related-paragraphs 缺坐标');
+});
+
+test('GET /v3/tipitaka-related-paragraphs/aggregate → 200', function () {
+    $res = request(fn () => api(TipitakaRelatedParagraphsApi::class)->getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo());
+    assert_status(200, $res, 'aggregate');
+    assert_has_key('data', $res['body'], 'aggregate');
+    $first = $res['body']['data'][0] ?? null;
+    assert_has_key('book_name', $first, 'aggregate.data[0]');
+});
+
+test('GET /v3/tipitaka-related-paragraphs/aggregate/{book_name} → 200', function () {
+    $res = request(fn () => api(TipitakaRelatedParagraphsApi::class)->getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo(fixtures()['book_name']));
+    assert_status(200, $res, 'aggregate/{book_name}');
+    assert_has_key('data', $res['body'], 'aggregate/{book_name}');
+    $first = $res['body']['data'][0] ?? null;
+    assert_has_key('cs_para', $first, 'aggregate.data[0]');
+});

+ 15 - 0
api-test/client/.gitignore

@@ -0,0 +1,15 @@
+# ref: https://github.com/github/gitignore/blob/master/Composer.gitignore
+
+composer.phar
+/vendor/
+
+# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
+# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
+# composer.lock
+
+# php-cs-fixer cache
+.php_cs.cache
+.php-cs-fixer.cache
+
+# PHPUnit cache
+.phpunit.result.cache

+ 23 - 0
api-test/client/.openapi-generator-ignore

@@ -0,0 +1,23 @@
+# OpenAPI Generator Ignore
+# Generated by openapi-generator https://github.com/openapitools/openapi-generator
+
+# Use this file to prevent files from being overwritten by the generator.
+# The patterns follow closely to .gitignore or .dockerignore.
+
+# As an example, the C# client generator defines ApiClient.cs.
+# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
+#ApiClient.cs
+
+# You can match any string of characters against a directory, file or extension with a single asterisk (*):
+#foo/*/qux
+# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
+
+# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
+#foo/**/qux
+# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
+
+# You can also negate patterns with an exclamation (!).
+# For example, you can ignore all files in a docs folder with the file extension .md:
+#docs/*.md
+# Then explicitly reverse the ignore rule for a single file:
+#!docs/README.md

+ 122 - 0
api-test/client/.openapi-generator/FILES

@@ -0,0 +1,122 @@
+.gitignore
+.openapi-generator-ignore
+.php-cs-fixer.dist.php
+.travis.yml
+README.md
+composer.json
+docs/Api/HeartbeatApi.md
+docs/Api/MeApi.md
+docs/Api/ProgressApi.md
+docs/Api/ReactionsApi.md
+docs/Api/SearchApi.md
+docs/Api/SearchSuggestApi.md
+docs/Api/TipitakaReadingApi.md
+docs/Api/TipitakaRelatedParagraphsApi.md
+docs/Api/UpgradeApi.md
+docs/Model/GetApiV3Heartbeat200Response.md
+docs/Model/GetApiV3Heartbeat200ResponseData.md
+docs/Model/GetApiV3MeReactions200Response.md
+docs/Model/GetApiV3MeReactions200ResponseDataInner.md
+docs/Model/GetApiV3MeReactions200ResponseDataInnerUser.md
+docs/Model/GetApiV3Progress200Response.md
+docs/Model/GetApiV3Progress200ResponseDataInner.md
+docs/Model/GetApiV3ReactionsTally200Response.md
+docs/Model/GetApiV3ReactionsTally200ResponseDataInner.md
+docs/Model/GetApiV3Search200Response.md
+docs/Model/GetApiV3SearchSearch200Response.md
+docs/Model/GetApiV3TipitakaReadingChannel200Response.md
+docs/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.md
+docs/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200Response.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.md
+docs/Model/PaginationMeta.md
+docs/Model/PostApiV3MeReactions200Response.md
+docs/Model/PostApiV3MeReactionsRequest.md
+docs/Model/PostApiV3SearchRequest.md
+docs/Model/ProblemDetails.md
+git_push.sh
+lib/Api/HeartbeatApi.php
+lib/Api/MeApi.php
+lib/Api/ProgressApi.php
+lib/Api/ReactionsApi.php
+lib/Api/SearchApi.php
+lib/Api/SearchSuggestApi.php
+lib/Api/TipitakaReadingApi.php
+lib/Api/TipitakaRelatedParagraphsApi.php
+lib/Api/UpgradeApi.php
+lib/ApiException.php
+lib/Configuration.php
+lib/FormDataProcessor.php
+lib/HeaderSelector.php
+lib/Model/GetApiV3Heartbeat200Response.php
+lib/Model/GetApiV3Heartbeat200ResponseData.php
+lib/Model/GetApiV3MeReactions200Response.php
+lib/Model/GetApiV3MeReactions200ResponseDataInner.php
+lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
+lib/Model/GetApiV3Progress200Response.php
+lib/Model/GetApiV3Progress200ResponseDataInner.php
+lib/Model/GetApiV3ReactionsTally200Response.php
+lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php
+lib/Model/GetApiV3Search200Response.php
+lib/Model/GetApiV3SearchSearch200Response.php
+lib/Model/GetApiV3TipitakaReadingChannel200Response.php
+lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php
+lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
+lib/Model/ModelInterface.php
+lib/Model/PaginationMeta.php
+lib/Model/PostApiV3MeReactions200Response.php
+lib/Model/PostApiV3MeReactionsRequest.php
+lib/Model/PostApiV3SearchRequest.php
+lib/Model/ProblemDetails.php
+lib/ObjectSerializer.php
+phpunit.xml.dist
+test/Api/HeartbeatApiTest.php
+test/Api/MeApiTest.php
+test/Api/ProgressApiTest.php
+test/Api/ReactionsApiTest.php
+test/Api/SearchApiTest.php
+test/Api/SearchSuggestApiTest.php
+test/Api/TipitakaReadingApiTest.php
+test/Api/TipitakaRelatedParagraphsApiTest.php
+test/Api/UpgradeApiTest.php
+test/Model/GetApiV3Heartbeat200ResponseDataTest.php
+test/Model/GetApiV3Heartbeat200ResponseTest.php
+test/Model/GetApiV3MeReactions200ResponseDataInnerTest.php
+test/Model/GetApiV3MeReactions200ResponseDataInnerUserTest.php
+test/Model/GetApiV3MeReactions200ResponseTest.php
+test/Model/GetApiV3Progress200ResponseDataInnerTest.php
+test/Model/GetApiV3Progress200ResponseTest.php
+test/Model/GetApiV3ReactionsTally200ResponseDataInnerTest.php
+test/Model/GetApiV3ReactionsTally200ResponseTest.php
+test/Model/GetApiV3Search200ResponseTest.php
+test/Model/GetApiV3SearchSearch200ResponseTest.php
+test/Model/GetApiV3TipitakaReadingChannel200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaReadingChannel200ResponseMetaTest.php
+test/Model/GetApiV3TipitakaReadingChannel200ResponseTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMetaTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseTest.php
+test/Model/PaginationMetaTest.php
+test/Model/PostApiV3MeReactions200ResponseTest.php
+test/Model/PostApiV3MeReactionsRequestTest.php
+test/Model/PostApiV3SearchRequestTest.php
+test/Model/ProblemDetailsTest.php

+ 1 - 0
api-test/client/.openapi-generator/VERSION

@@ -0,0 +1 @@
+7.25.0

+ 39 - 0
api-test/client/composer.json

@@ -0,0 +1,39 @@
+{
+    "version": "1.0.0",
+    "description": "International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。",
+    "keywords": [
+        "openapitools",
+        "openapi-generator",
+        "openapi",
+        "php",
+        "sdk",
+        "rest",
+        "api"
+    ],
+    "homepage": "https://openapi-generator.tech",
+    "license": "unlicense",
+    "authors": [
+        {
+            "name": "OpenAPI",
+            "homepage": "https://openapi-generator.tech"
+        }
+    ],
+    "require": {
+        "php": "^8.1",
+        "ext-curl": "*",
+        "ext-json": "*",
+        "ext-mbstring": "*",
+        "guzzlehttp/guzzle": "^7.3",
+        "guzzlehttp/psr7": "^1.7 || ^2.0"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^8.0 || ^9.0",
+        "friendsofphp/php-cs-fixer": "^3.5"
+    },
+    "autoload": {
+        "psr-4": { "WikipaliApi\\" : "lib/" }
+    },
+    "autoload-dev": {
+        "psr-4": { "WikipaliApi\\Test\\" : "test/" }
+    }
+}

+ 463 - 0
api-test/client/lib/Api/HeartbeatApi.php

@@ -0,0 +1,463 @@
+<?php
+/**
+ * HeartbeatApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * HeartbeatApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class HeartbeatApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Heartbeat' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Heartbeat
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Heartbeat200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Heartbeat(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        list($response) = $this->getApiV3HeartbeatWithHttpInfo($contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3HeartbeatWithHttpInfo
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Heartbeat200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3HeartbeatWithHttpInfo(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        $request = $this->getApiV3HeartbeatRequest($contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Heartbeat200Response',
+                        $request,
+                        $response,
+                    );
+                case 503:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Heartbeat200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Heartbeat200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 503:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3HeartbeatAsync
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3HeartbeatAsync(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        return $this->getApiV3HeartbeatAsyncWithHttpInfo($contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3HeartbeatAsyncWithHttpInfo
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3HeartbeatAsyncWithHttpInfo(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Heartbeat200Response';
+        $request = $this->getApiV3HeartbeatRequest($contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Heartbeat'
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3HeartbeatRequest(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+
+
+        $resourcePath = '/v3/heartbeat';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 1118 - 0
api-test/client/lib/Api/MeApi.php

@@ -0,0 +1,1118 @@
+<?php
+/**
+ * MeApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * MeApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class MeApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'deleteApiV3MeReactionsReaction' => [
+            'application/json',
+        ],
+        'getApiV3MeReactions' => [
+            'application/json',
+        ],
+        'postApiV3MeReactions' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReaction
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return void
+     */
+    public function deleteApiV3MeReactionsReaction($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        $this->deleteApiV3MeReactionsReactionWithHttpInfo($reaction, $contentType);
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReactionWithHttpInfo
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of null, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function deleteApiV3MeReactionsReactionWithHttpInfo($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        $request = $this->deleteApiV3MeReactionsReactionRequest($reaction, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            return [null, $statusCode, $response->getHeaders()];
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 401:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 403:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReactionAsync
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function deleteApiV3MeReactionsReactionAsync($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        return $this->deleteApiV3MeReactionsReactionAsyncWithHttpInfo($reaction, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReactionAsyncWithHttpInfo
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function deleteApiV3MeReactionsReactionAsyncWithHttpInfo($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        $returnType = '';
+        $request = $this->deleteApiV3MeReactionsReactionRequest($reaction, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    return [null, $response->getStatusCode(), $response->getHeaders()];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'deleteApiV3MeReactionsReaction'
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function deleteApiV3MeReactionsReactionRequest($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+
+        // verify the required parameter 'reaction' is set
+        if ($reaction === null || (is_array($reaction) && count($reaction) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $reaction when calling deleteApiV3MeReactionsReaction'
+            );
+        }
+
+
+        $resourcePath = '/v3/me/reactions/{reaction}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+        // path params
+        if ($reaction !== null) {
+            $resourcePath = str_replace(
+                '{reaction}',
+                ObjectSerializer::toPathValue($reaction),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'DELETE',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3MeReactions
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3MeReactions($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        list($response) = $this->getApiV3MeReactionsWithHttpInfo($per_page, $type, $target_type, $page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3MeReactionsWithHttpInfo
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3MeReactionsWithHttpInfo($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        $request = $this->getApiV3MeReactionsRequest($per_page, $type, $target_type, $page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 401:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 401:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3MeReactionsAsync
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3MeReactionsAsync($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        return $this->getApiV3MeReactionsAsyncWithHttpInfo($per_page, $type, $target_type, $page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3MeReactionsAsyncWithHttpInfo
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3MeReactionsAsyncWithHttpInfo($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3MeReactions200Response';
+        $request = $this->getApiV3MeReactionsRequest($per_page, $type, $target_type, $page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3MeReactions'
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3MeReactionsRequest($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+
+
+
+
+
+
+        $resourcePath = '/v3/me/reactions';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $type,
+            'type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $target_type,
+            'target_type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation postApiV3MeReactions
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function postApiV3MeReactions($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        list($response) = $this->postApiV3MeReactionsWithHttpInfo($post_api_v3_me_reactions_request, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation postApiV3MeReactionsWithHttpInfo
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function postApiV3MeReactionsWithHttpInfo($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        $request = $this->postApiV3MeReactionsRequest($post_api_v3_me_reactions_request, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 201:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 401:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 201:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 401:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation postApiV3MeReactionsAsync
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3MeReactionsAsync($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        return $this->postApiV3MeReactionsAsyncWithHttpInfo($post_api_v3_me_reactions_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3MeReactionsAsyncWithHttpInfo
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3MeReactionsAsyncWithHttpInfo($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        $returnType = '\WikipaliApi\Model\PostApiV3MeReactions200Response';
+        $request = $this->postApiV3MeReactionsRequest($post_api_v3_me_reactions_request, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'postApiV3MeReactions'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3MeReactionsRequest($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_me_reactions_request' is set
+        if ($post_api_v3_me_reactions_request === null || (is_array($post_api_v3_me_reactions_request) && count($post_api_v3_me_reactions_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_me_reactions_request when calling postApiV3MeReactions'
+            );
+        }
+
+
+        $resourcePath = '/v3/me/reactions';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (isset($post_api_v3_me_reactions_request)) {
+            if (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the body
+                try {
+                    $httpBody = json_encode(ObjectSerializer::sanitizeForSerialization($post_api_v3_me_reactions_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_me_reactions_request;
+            }
+        } elseif (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'POST',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 589 - 0
api-test/client/lib/Api/ProgressApi.php

@@ -0,0 +1,589 @@
+<?php
+/**
+ * ProgressApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * ProgressApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ProgressApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Progress' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Progress
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Progress200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Progress($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        list($response) = $this->getApiV3ProgressWithHttpInfo($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3ProgressWithHttpInfo
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Progress200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3ProgressWithHttpInfo($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        $request = $this->getApiV3ProgressRequest($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Progress200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Progress200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Progress200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3ProgressAsync
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ProgressAsync($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        return $this->getApiV3ProgressAsyncWithHttpInfo($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3ProgressAsyncWithHttpInfo
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ProgressAsyncWithHttpInfo($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Progress200Response';
+        $request = $this->getApiV3ProgressRequest($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Progress'
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3ProgressRequest($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+
+        // verify the required parameter 'channels' is set
+        if ($channels === null || (is_array($channels) && count($channels) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $channels when calling getApiV3Progress'
+            );
+        }
+
+
+
+
+
+
+
+
+
+        $resourcePath = '/v3/progress';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $channels,
+            'channels', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $level,
+            'level', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $lang,
+            'lang', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $order,
+            'order', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $dir,
+            'dir', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 819 - 0
api-test/client/lib/Api/ReactionsApi.php

@@ -0,0 +1,819 @@
+<?php
+/**
+ * ReactionsApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * ReactionsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ReactionsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Reactions' => [
+            'application/json',
+        ],
+        'getApiV3ReactionsTally' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Reactions
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Reactions($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        list($response) = $this->getApiV3ReactionsWithHttpInfo($target_id, $per_page, $type, $page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3ReactionsWithHttpInfo
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3ReactionsWithHttpInfo($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        $request = $this->getApiV3ReactionsRequest($target_id, $per_page, $type, $page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3ReactionsAsync
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsAsync($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        return $this->getApiV3ReactionsAsyncWithHttpInfo($target_id, $per_page, $type, $page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3ReactionsAsyncWithHttpInfo
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsAsyncWithHttpInfo($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3MeReactions200Response';
+        $request = $this->getApiV3ReactionsRequest($target_id, $per_page, $type, $page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Reactions'
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3ReactionsRequest($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+
+        // verify the required parameter 'target_id' is set
+        if ($target_id === null || (is_array($target_id) && count($target_id) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $target_id when calling getApiV3Reactions'
+            );
+        }
+
+
+
+
+
+        $resourcePath = '/v3/reactions';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $target_id,
+            'target_id', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $type,
+            'type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3ReactionsTally
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3ReactionsTally200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3ReactionsTally($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        list($response) = $this->getApiV3ReactionsTallyWithHttpInfo($target_id, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3ReactionsTallyWithHttpInfo
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3ReactionsTally200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3ReactionsTallyWithHttpInfo($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        $request = $this->getApiV3ReactionsTallyRequest($target_id, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3ReactionsTally200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3ReactionsTally200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3ReactionsTally200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3ReactionsTallyAsync
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsTallyAsync($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        return $this->getApiV3ReactionsTallyAsyncWithHttpInfo($target_id, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3ReactionsTallyAsyncWithHttpInfo
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsTallyAsyncWithHttpInfo($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3ReactionsTally200Response';
+        $request = $this->getApiV3ReactionsTallyRequest($target_id, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3ReactionsTally'
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3ReactionsTallyRequest($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+
+        // verify the required parameter 'target_id' is set
+        if ($target_id === null || (is_array($target_id) && count($target_id) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $target_id when calling getApiV3ReactionsTally'
+            );
+        }
+
+
+        $resourcePath = '/v3/reactions/tally';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $target_id,
+            'target_id', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 1260 - 0
api-test/client/lib/Api/SearchApi.php

@@ -0,0 +1,1260 @@
+<?php
+/**
+ * SearchApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * SearchApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class SearchApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Search' => [
+            'application/json',
+        ],
+        'getApiV3SearchSearch' => [
+            'application/json',
+        ],
+        'postApiV3Search' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Search
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Search($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        list($response) = $this->getApiV3SearchWithHttpInfo($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3SearchWithHttpInfo
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3SearchWithHttpInfo($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        $request = $this->getApiV3SearchRequest($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Search200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3SearchAsync
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchAsync($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        return $this->getApiV3SearchAsyncWithHttpInfo($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3SearchAsyncWithHttpInfo
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchAsyncWithHttpInfo($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Search200Response';
+        $request = $this->getApiV3SearchRequest($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Search'
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3SearchRequest($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+
+        // verify the required parameter 'q' is set
+        if ($q === null || (is_array($q) && count($q) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $q when calling getApiV3Search'
+            );
+        }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+        $resourcePath = '/v3/search';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $q,
+            'q', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page_size,
+            'page_size', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $search_mode,
+            'search_mode', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $resource_type,
+            'resource_type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $resource_id,
+            'resource_id', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $granularity,
+            'granularity', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $language,
+            'language', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $category,
+            'category', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $tags,
+            'tags', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page_refs,
+            'page_refs', // param base name
+            'array', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $related_id,
+            'related_id', // param base name
+            'array', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $author,
+            'author', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $channel,
+            'channel', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3SearchSearch
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3SearchSearch200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3SearchSearch($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        list($response) = $this->getApiV3SearchSearchWithHttpInfo($search, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3SearchSearchWithHttpInfo
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3SearchSearch200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3SearchSearchWithHttpInfo($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        $request = $this->getApiV3SearchSearchRequest($search, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3SearchSearchAsync
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSearchAsync($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        return $this->getApiV3SearchSearchAsyncWithHttpInfo($search, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3SearchSearchAsyncWithHttpInfo
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSearchAsyncWithHttpInfo($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3SearchSearch200Response';
+        $request = $this->getApiV3SearchSearchRequest($search, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3SearchSearch'
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3SearchSearchRequest($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+
+        // verify the required parameter 'search' is set
+        if ($search === null || (is_array($search) && count($search) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $search when calling getApiV3SearchSearch'
+            );
+        }
+
+
+        $resourcePath = '/v3/search/{search}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+        // path params
+        if ($search !== null) {
+            $resourcePath = str_replace(
+                '{search}',
+                ObjectSerializer::toPathValue($search),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation postApiV3Search
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function postApiV3Search($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        list($response) = $this->postApiV3SearchWithHttpInfo($post_api_v3_search_request, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation postApiV3SearchWithHttpInfo
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function postApiV3SearchWithHttpInfo($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        $request = $this->postApiV3SearchRequest($post_api_v3_search_request, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Search200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation postApiV3SearchAsync
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3SearchAsync($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        return $this->postApiV3SearchAsyncWithHttpInfo($post_api_v3_search_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3SearchAsyncWithHttpInfo
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3SearchAsyncWithHttpInfo($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Search200Response';
+        $request = $this->postApiV3SearchRequest($post_api_v3_search_request, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'postApiV3Search'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3SearchRequest($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_search_request' is set
+        if ($post_api_v3_search_request === null || (is_array($post_api_v3_search_request) && count($post_api_v3_search_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_search_request when calling postApiV3Search'
+            );
+        }
+
+
+        $resourcePath = '/v3/search';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (isset($post_api_v3_search_request)) {
+            if (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the body
+                try {
+                    $httpBody = json_encode(ObjectSerializer::sanitizeForSerialization($post_api_v3_search_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_search_request;
+            }
+        } elseif (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'POST',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 529 - 0
api-test/client/lib/Api/SearchSuggestApi.php

@@ -0,0 +1,529 @@
+<?php
+/**
+ * SearchSuggestApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * SearchSuggestApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class SearchSuggestApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3SearchSuggest' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3SearchSuggest
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3SearchSuggest($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        list($response) = $this->getApiV3SearchSuggestWithHttpInfo($q, $fields, $language, $limit, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3SearchSuggestWithHttpInfo
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3SearchSuggestWithHttpInfo($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        $request = $this->getApiV3SearchSuggestRequest($q, $fields, $language, $limit, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Search200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3SearchSuggestAsync
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSuggestAsync($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        return $this->getApiV3SearchSuggestAsyncWithHttpInfo($q, $fields, $language, $limit, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3SearchSuggestAsyncWithHttpInfo
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSuggestAsyncWithHttpInfo($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Search200Response';
+        $request = $this->getApiV3SearchSuggestRequest($q, $fields, $language, $limit, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3SearchSuggest'
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3SearchSuggestRequest($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+
+        // verify the required parameter 'q' is set
+        if ($q === null || (is_array($q) && count($q) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $q when calling getApiV3SearchSuggest'
+            );
+        }
+
+
+
+
+
+        $resourcePath = '/v3/search-suggest';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $q,
+            'q', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $fields,
+            'fields', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $language,
+            'language', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $limit,
+            'limit', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 632 - 0
api-test/client/lib/Api/TipitakaReadingApi.php

@@ -0,0 +1,632 @@
+<?php
+/**
+ * TipitakaReadingApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * TipitakaReadingApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class TipitakaReadingApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3TipitakaReadingChannel' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannel
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaReadingChannel($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        list($response) = $this->getApiV3TipitakaReadingChannelWithHttpInfo($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannelWithHttpInfo
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaReadingChannelWithHttpInfo($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        $request = $this->getApiV3TipitakaReadingChannelRequest($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response',
+                        $request,
+                        $response,
+                    );
+                case 404:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 404:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannelAsync
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaReadingChannelAsync($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        return $this->getApiV3TipitakaReadingChannelAsyncWithHttpInfo($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannelAsyncWithHttpInfo
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaReadingChannelAsyncWithHttpInfo($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response';
+        $request = $this->getApiV3TipitakaReadingChannelRequest($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaReadingChannel'
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaReadingChannelRequest($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+
+        // verify the required parameter 'channel' is set
+        if ($channel === null || (is_array($channel) && count($channel) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $channel when calling getApiV3TipitakaReadingChannel'
+            );
+        }
+
+
+
+
+
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-reading/{channel}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $chapter,
+            'chapter', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $para,
+            'para', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $to,
+            'to', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $after,
+            'after', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page_size,
+            'page_size', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $unit,
+            'unit', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $format,
+            'format', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $include,
+            'include', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+        // path params
+        if ($channel !== null) {
+            $resourcePath = str_replace(
+                '{channel}',
+                ObjectSerializer::toPathValue($channel),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 1216 - 0
api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php

@@ -0,0 +1,1216 @@
+<?php
+/**
+ * TipitakaRelatedParagraphsApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * TipitakaRelatedParagraphsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class TipitakaRelatedParagraphsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3TipitakaRelatedParagraphs' => [
+            'application/json',
+        ],
+        'getApiV3TipitakaRelatedParagraphsAggregate' => [
+            'application/json',
+        ],
+        'getApiV3TipitakaRelatedParagraphsAggregateBookName' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphs
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaRelatedParagraphs($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        list($response) = $this->getApiV3TipitakaRelatedParagraphsWithHttpInfo($book, $para, $book_name, $cs_para, $book_id, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsWithHttpInfo
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaRelatedParagraphsWithHttpInfo($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        $request = $this->getApiV3TipitakaRelatedParagraphsRequest($book, $para, $book_name, $cs_para, $book_id, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAsync
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAsync($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        return $this->getApiV3TipitakaRelatedParagraphsAsyncWithHttpInfo($book, $para, $book_name, $cs_para, $book_id, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAsyncWithHttpInfo
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAsyncWithHttpInfo($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response';
+        $request = $this->getApiV3TipitakaRelatedParagraphsRequest($book, $para, $book_name, $cs_para, $book_id, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaRelatedParagraphs'
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaRelatedParagraphsRequest($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-related-paragraphs';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $para,
+            'para', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book_name,
+            'book_name', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $cs_para,
+            'cs_para', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book_id,
+            'book_id', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregate
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregate($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        list($response) = $this->getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo($file, $book, $page, $per_page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateRequest($file, $book, $page, $per_page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateAsync
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateAsync($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        return $this->getApiV3TipitakaRelatedParagraphsAggregateAsyncWithHttpInfo($file, $book, $page, $per_page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateAsyncWithHttpInfo
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateAsyncWithHttpInfo($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response';
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateRequest($file, $book, $page, $per_page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaRelatedParagraphsAggregate'
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateRequest($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-related-paragraphs/aggregate';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $file,
+            'file', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookName
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookName($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        list($response) = $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo($book_name, $file, $book, $page, $per_page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameRequest($book_name, $file, $book, $page, $per_page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookNameAsync
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameAsync($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        return $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameAsyncWithHttpInfo($book_name, $file, $book, $page, $per_page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookNameAsyncWithHttpInfo
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameAsyncWithHttpInfo($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response';
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameRequest($book_name, $file, $book, $page, $per_page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaRelatedParagraphsAggregateBookName'
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameRequest($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+
+        // verify the required parameter 'book_name' is set
+        if ($book_name === null || (is_array($book_name) && count($book_name) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $book_name when calling getApiV3TipitakaRelatedParagraphsAggregateBookName'
+            );
+        }
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-related-paragraphs/aggregate/{book_name}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $file,
+            'file', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+        // path params
+        if ($book_name !== null) {
+            $resourcePath = str_replace(
+                '{book_name}',
+                ObjectSerializer::toPathValue($book_name),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 449 - 0
api-test/client/lib/Api/UpgradeApi.php

@@ -0,0 +1,449 @@
+<?php
+/**
+ * UpgradeApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * UpgradeApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class UpgradeApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Upgrade' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Upgrade
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3SearchSearch200Response
+     */
+    public function getApiV3Upgrade(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        list($response) = $this->getApiV3UpgradeWithHttpInfo($contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3UpgradeWithHttpInfo
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3SearchSearch200Response, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3UpgradeWithHttpInfo(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        $request = $this->getApiV3UpgradeRequest($contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3UpgradeAsync
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3UpgradeAsync(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        return $this->getApiV3UpgradeAsyncWithHttpInfo($contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3UpgradeAsyncWithHttpInfo
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3UpgradeAsyncWithHttpInfo(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3SearchSearch200Response';
+        $request = $this->getApiV3UpgradeRequest($contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Upgrade'
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3UpgradeRequest(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+
+
+        $resourcePath = '/v3/upgrade';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 119 - 0
api-test/client/lib/ApiException.php

@@ -0,0 +1,119 @@
+<?php
+/**
+ * ApiException
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+use \Exception;
+
+/**
+ * ApiException Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ApiException extends Exception
+{
+    /**
+     * The HTTP body of the server response either as Json or string.
+     *
+     * @var \stdClass|string|null
+     */
+    protected $responseBody;
+
+    /**
+     * The HTTP header of the server response.
+     *
+     * @var string[][]|null
+     */
+    protected $responseHeaders;
+
+    /**
+     * The deserialized response object
+     *
+     * @var \stdClass|string|null
+     */
+    protected $responseObject;
+
+    /**
+     * Constructor
+     *
+     * @param string                $message         Error message
+     * @param int                   $code            HTTP status code
+     * @param string[][]|null       $responseHeaders HTTP response header
+     * @param \stdClass|string|null $responseBody    HTTP decoded body of the server response either as \stdClass or string
+     */
+    public function __construct($message = "", $code = 0, $responseHeaders = [], $responseBody = null)
+    {
+        parent::__construct($message, $code);
+        $this->responseHeaders = $responseHeaders;
+        $this->responseBody = $responseBody;
+    }
+
+    /**
+     * Gets the HTTP response header
+     *
+     * @return string[][]|null HTTP response header
+     */
+    public function getResponseHeaders()
+    {
+        return $this->responseHeaders;
+    }
+
+    /**
+     * Gets the HTTP body of the server response either as Json or string
+     *
+     * @return \stdClass|string|null HTTP body of the server response either as \stdClass or string
+     */
+    public function getResponseBody()
+    {
+        return $this->responseBody;
+    }
+
+    /**
+     * Sets the deserialized response object (during deserialization)
+     *
+     * @param mixed $obj Deserialized response object
+     *
+     * @return void
+     */
+    public function setResponseObject($obj)
+    {
+        $this->responseObject = $obj;
+    }
+
+    /**
+     * Gets the deserialized response object (during deserialization)
+     *
+     * @return mixed the deserialized response object
+     */
+    public function getResponseObject()
+    {
+        return $this->responseObject;
+    }
+}

+ 613 - 0
api-test/client/lib/Configuration.php

@@ -0,0 +1,613 @@
+<?php
+/**
+ * Configuration
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+/**
+ * Configuration Class Doc Comment
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class Configuration
+{
+    public const BOOLEAN_FORMAT_INT = 'int';
+    public const BOOLEAN_FORMAT_STRING = 'string';
+
+    /**
+     * @var Configuration
+     */
+    private static $defaultConfiguration;
+
+    /**
+     * Associate array to store API key(s)
+     *
+     * @var string[]
+     */
+    protected $apiKeys = [];
+
+    /**
+     * Associate array to store API prefix (e.g. Bearer)
+     *
+     * @var string[]
+     */
+    protected $apiKeyPrefixes = [];
+
+    /**
+     * Access token for OAuth/Bearer authentication
+     *
+     * @var string
+     */
+    protected $accessToken = '';
+
+    /**
+     * Boolean format for query string
+     *
+     * @var string
+     */
+    protected $booleanFormatForQueryString = self::BOOLEAN_FORMAT_INT;
+
+    /**
+     * Username for HTTP basic authentication
+     *
+     * @var string
+     */
+    protected $username = '';
+
+    /**
+     * Password for HTTP basic authentication
+     *
+     * @var string
+     */
+    protected $password = '';
+
+    /**
+     * The host
+     *
+     * @var string
+     */
+    protected $host = '/api';
+
+    /**
+     * User agent of the HTTP request, set to "OpenAPI-Generator/{version}/PHP" by default
+     *
+     * @var string
+     */
+    protected $userAgent = 'OpenAPI-Generator/1.0.0/PHP';
+
+    /**
+     * Debug switch (default set to false)
+     *
+     * @var bool
+     */
+    protected $debug = false;
+
+    /**
+     * Debug file location (log to STDOUT by default)
+     *
+     * @var string
+     */
+    protected $debugFile = 'php://output';
+
+    /**
+     * Debug file location (log to STDOUT by default)
+     *
+     * @var string
+     */
+    protected $tempFolderPath;
+
+    /**
+     * Path to a certificate file, for mTLS
+     *
+     * @var string
+     */
+    protected $certFile;
+
+    /**
+     * Path to a key file, for mTLS
+     *
+     * @var string
+     */
+    protected $keyFile;
+
+    /**
+     * Constructor
+     */
+    public function __construct()
+    {
+        $this->tempFolderPath = sys_get_temp_dir();
+    }
+
+    /**
+     * Sets API key
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     * @param string $key              API key or token
+     *
+     * @return $this
+     */
+    public function setApiKey($apiKeyIdentifier, $key)
+    {
+        $this->apiKeys[$apiKeyIdentifier] = $key;
+        return $this;
+    }
+
+    /**
+     * Gets API key
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     *
+     * @return null|string API key or token
+     */
+    public function getApiKey($apiKeyIdentifier)
+    {
+        return isset($this->apiKeys[$apiKeyIdentifier]) ? $this->apiKeys[$apiKeyIdentifier] : null;
+    }
+
+    /**
+     * Sets the prefix for API key (e.g. Bearer)
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     * @param string $prefix           API key prefix, e.g. Bearer
+     *
+     * @return $this
+     */
+    public function setApiKeyPrefix($apiKeyIdentifier, $prefix)
+    {
+        $this->apiKeyPrefixes[$apiKeyIdentifier] = $prefix;
+        return $this;
+    }
+
+    /**
+     * Gets API key prefix
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     *
+     * @return null|string
+     */
+    public function getApiKeyPrefix($apiKeyIdentifier)
+    {
+        return isset($this->apiKeyPrefixes[$apiKeyIdentifier]) ? $this->apiKeyPrefixes[$apiKeyIdentifier] : null;
+    }
+
+    /**
+     * Sets the access token for OAuth
+     *
+     * @param string $accessToken Token for OAuth
+     *
+     * @return $this
+     */
+    public function setAccessToken($accessToken)
+    {
+        $this->accessToken = $accessToken;
+        return $this;
+    }
+
+    /**
+     * Gets the access token for OAuth
+     *
+     * @return string Access token for OAuth
+     */
+    public function getAccessToken()
+    {
+        return $this->accessToken;
+    }
+
+    /**
+     * Sets boolean format for query string.
+     *
+     * @param string $booleanFormat Boolean format for query string
+     *
+     * @return $this
+     */
+    public function setBooleanFormatForQueryString(string $booleanFormat)
+    {
+        $this->booleanFormatForQueryString = $booleanFormat;
+
+        return $this;
+    }
+
+    /**
+     * Gets boolean format for query string.
+     *
+     * @return string Boolean format for query string
+     */
+    public function getBooleanFormatForQueryString(): string
+    {
+        return $this->booleanFormatForQueryString;
+    }
+
+    /**
+     * Sets the username for HTTP basic authentication
+     *
+     * @param string $username Username for HTTP basic authentication
+     *
+     * @return $this
+     */
+    public function setUsername($username)
+    {
+        $this->username = $username;
+        return $this;
+    }
+
+    /**
+     * Gets the username for HTTP basic authentication
+     *
+     * @return string Username for HTTP basic authentication
+     */
+    public function getUsername()
+    {
+        return $this->username;
+    }
+
+    /**
+     * Sets the password for HTTP basic authentication
+     *
+     * @param string $password Password for HTTP basic authentication
+     *
+     * @return $this
+     */
+    public function setPassword($password)
+    {
+        $this->password = $password;
+        return $this;
+    }
+
+    /**
+     * Gets the password for HTTP basic authentication
+     *
+     * @return string Password for HTTP basic authentication
+     */
+    public function getPassword()
+    {
+        return $this->password;
+    }
+
+    /**
+     * Sets the host
+     *
+     * @param string $host Host
+     *
+     * @return $this
+     */
+    public function setHost($host)
+    {
+        $this->host = $host;
+        return $this;
+    }
+
+    /**
+     * Gets the host
+     *
+     * @return string Host
+     */
+    public function getHost()
+    {
+        return $this->host;
+    }
+
+    /**
+     * Sets the user agent of the api client
+     *
+     * @param string $userAgent the user agent of the api client
+     *
+     * @throws \InvalidArgumentException
+     * @return $this
+     */
+    public function setUserAgent($userAgent)
+    {
+        if (!is_string($userAgent)) {
+            throw new \InvalidArgumentException('User-agent must be a string.');
+        }
+
+        $this->userAgent = $userAgent;
+        return $this;
+    }
+
+    /**
+     * Gets the user agent of the api client
+     *
+     * @return string user agent
+     */
+    public function getUserAgent()
+    {
+        return $this->userAgent;
+    }
+
+    /**
+     * Sets debug flag
+     *
+     * @param bool $debug Debug flag
+     *
+     * @return $this
+     */
+    public function setDebug($debug)
+    {
+        $this->debug = $debug;
+        return $this;
+    }
+
+    /**
+     * Gets the debug flag
+     *
+     * @return bool
+     */
+    public function getDebug()
+    {
+        return $this->debug;
+    }
+
+    /**
+     * Sets the debug file
+     *
+     * @param string $debugFile Debug file
+     *
+     * @return $this
+     */
+    public function setDebugFile($debugFile)
+    {
+        $this->debugFile = $debugFile;
+        return $this;
+    }
+
+    /**
+     * Gets the debug file
+     *
+     * @return string
+     */
+    public function getDebugFile()
+    {
+        return $this->debugFile;
+    }
+
+    /**
+     * Sets the temp folder path
+     *
+     * @param string $tempFolderPath Temp folder path
+     *
+     * @return $this
+     */
+    public function setTempFolderPath($tempFolderPath)
+    {
+        $this->tempFolderPath = $tempFolderPath;
+        return $this;
+    }
+
+    /**
+     * Gets the temp folder path
+     *
+     * @return string Temp folder path
+     */
+    public function getTempFolderPath()
+    {
+        return $this->tempFolderPath;
+    }
+
+    /**
+     * Sets the certificate file path, for mTLS
+     *
+     * @return $this
+     */
+     public function setCertFile($certFile)
+     {
+        $this->certFile = $certFile;
+        return $this;
+     }
+
+    /**
+     * Gets the certificate file path, for mTLS
+     *
+     * @return string Certificate file path
+     */
+    public function getCertFile()
+    {
+        return $this->certFile;
+    }
+
+    /**
+     * Sets the certificate key path, for mTLS
+     *
+     * @return $this
+     */
+     public function setKeyFile($keyFile)
+     {
+        $this->keyFile = $keyFile;
+        return $this;
+     }
+
+    /**
+     * Gets the certificate key path, for mTLS
+     *
+     * @return string Certificate key path
+     */
+    public function getKeyFile()
+    {
+        return $this->keyFile;
+    }
+    
+
+    /**
+     * Gets the default configuration instance
+     *
+     * @return Configuration
+     */
+    public static function getDefaultConfiguration()
+    {
+        if (self::$defaultConfiguration === null) {
+            self::$defaultConfiguration = new Configuration();
+        }
+
+        return self::$defaultConfiguration;
+    }
+
+    /**
+     * Sets the default configuration instance
+     *
+     * @param Configuration $config An instance of the Configuration Object
+     *
+     * @return void
+     */
+    public static function setDefaultConfiguration(Configuration $config)
+    {
+        self::$defaultConfiguration = $config;
+    }
+
+    /**
+     * Gets the essential information for debugging
+     *
+     * @return string The report for debugging
+     */
+    public static function toDebugReport()
+    {
+        $report  = 'PHP SDK (WikipaliApi) Debug Report:' . PHP_EOL;
+        $report .= '    OS: ' . php_uname() . PHP_EOL;
+        $report .= '    PHP Version: ' . PHP_VERSION . PHP_EOL;
+        $report .= '    The version of the OpenAPI document: 2026.9.28' . PHP_EOL;
+        $report .= '    SDK Package Version: 1.0.0' . PHP_EOL;
+        $report .= '    Temp Folder Path: ' . self::getDefaultConfiguration()->getTempFolderPath() . PHP_EOL;
+
+        return $report;
+    }
+
+    /**
+     * Get API key (with prefix if set)
+     *
+     * @param  string $apiKeyIdentifier name of apikey
+     *
+     * @return null|string API key with the prefix
+     */
+    public function getApiKeyWithPrefix($apiKeyIdentifier)
+    {
+        $prefix = $this->getApiKeyPrefix($apiKeyIdentifier);
+        $apiKey = $this->getApiKey($apiKeyIdentifier);
+
+        if ($apiKey === null) {
+            return null;
+        }
+
+        if ($prefix === null) {
+            $keyWithPrefix = $apiKey;
+        } else {
+            $keyWithPrefix = $prefix . ' ' . $apiKey;
+        }
+
+        return $keyWithPrefix;
+    }
+
+    /**
+     * Returns an array of host settings
+     *
+     * @return array an array of host settings
+     */
+    public function getHostSettings()
+    {
+        return [
+            [
+                "url" => "/api",
+                "description" => "同源(预览页里 Try it out 走 vite 代理,避免跨域)",
+            ],
+            [
+                "url" => "http://127.0.0.1:8000/api",
+                "description" => "本地开发(直连,浏览器里会被 CORS 挡住)",
+            ],
+            [
+                "url" => "https://staging.wikipali.org/api",
+                "description" => "测试",
+            ],
+            [
+                "url" => "https://www.wikipali.org/api",
+                "description" => "生产",
+            ],
+            [
+                "url" => "https://next.wikipali.org/api",
+                "description" => "生产预览",
+            ],
+            [
+                "url" => "https://www.wikipali.cc/api",
+                "description" => "生产(中国)",
+            ],
+            [
+                "url" => "https://next.wikipali.cc/api",
+                "description" => "生产预览(中国)",
+            ]
+        ];
+    }
+
+    /**
+    * Returns URL based on host settings, index and variables
+    *
+    * @param array      $hostSettings array of host settings, generated from getHostSettings() or equivalent from the API clients
+    * @param int        $hostIndex    index of the host settings
+    * @param array|null $variables    hash of variable and the corresponding value (optional)
+    * @return string URL based on host settings
+    */
+    public static function getHostString(array $hostSettings, $hostIndex, ?array $variables = null)
+    {
+        if (null === $variables) {
+            $variables = [];
+        }
+
+        // check array index out of bound
+        if ($hostIndex < 0 || $hostIndex >= count($hostSettings)) {
+            throw new \InvalidArgumentException("Invalid index $hostIndex when selecting the host. Must be less than ".count($hostSettings));
+        }
+
+        $host = $hostSettings[$hostIndex];
+        $url = $host["url"];
+
+        // go through variable and assign a value
+        foreach ($host["variables"] ?? [] as $name => $variable) {
+            if (array_key_exists($name, $variables)) { // check to see if it's in the variables provided by the user
+                if (!isset($variable['enum_values']) || in_array($variables[$name], $variable["enum_values"], true)) { // check to see if the value is in the enum
+                    $url = str_replace("{".$name."}", $variables[$name], $url);
+                } else {
+                    throw new \InvalidArgumentException("The variable `$name` in the host URL has invalid value ".$variables[$name].". Must be ".join(',', $variable["enum_values"]).".");
+                }
+            } else {
+                // use default value
+                $url = str_replace("{".$name."}", $variable["default_value"], $url);
+            }
+        }
+
+        return $url;
+    }
+
+    /**
+     * Returns URL based on the index and variables
+     *
+     * @param int        $index     index of the host settings
+     * @param array|null $variables hash of variable and the corresponding value (optional)
+     * @return string URL based on host settings
+     */
+    public function getHostFromSettings($index, $variables = null)
+    {
+        return self::getHostString($this->getHostSettings(), $index, $variables);
+    }
+}

+ 246 - 0
api-test/client/lib/FormDataProcessor.php

@@ -0,0 +1,246 @@
+<?php
+/**
+ * FormDataProcessor
+ * PHP version 7.4
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+use ArrayAccess;
+use DateTime;
+use GuzzleHttp\Psr7\Utils;
+use Psr\Http\Message\StreamInterface;
+use SplFileObject;
+use WikipaliApi\Model\ModelInterface;
+
+/**
+ * FormDataProcessor Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class FormDataProcessor
+{
+    /**
+     * Tags whether payload passed to ::prepare() contains one or more
+     * SplFileObject or stream values.
+     */
+    public bool $has_file = false;
+
+    /**
+     * Take value and turn it into an array suitable for inclusion in
+     * the http body (form parameter). If it's a string, pass through unchanged
+     * If it's a datetime object, format it in ISO8601
+     *
+     * @param array<string|bool|array|DateTime|ArrayAccess|SplFileObject> $values the value of the form parameter
+     *
+     * @return array [key => value] of formdata
+     */
+    public function prepare(array $values): array
+    {
+        $this->has_file = false;
+        $result = [];
+
+        foreach ($values as $k => $v) {
+            if ($v === null) {
+                continue;
+            }
+
+            $result[$k] = $this->makeFormSafe($v);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Flattens a multi-level array of data and generates a single-level array
+     * compatible with formdata - a single-level array where the keys use bracket
+     * notation to signify nested data.
+     *
+     * credit: https://github.com/FranBar1966/FlatPHP
+     */
+    public static function flatten(array $source, string $start = ''): array
+    {
+        $opt = [
+            'prefix'          => '[',
+            'suffix'          => ']',
+            'suffix-end'      => true,
+            'prefix-list'     => '[',
+            'suffix-list'     => ']',
+            'suffix-list-end' => true,
+        ];
+
+        if ($start === '') {
+            $currentPrefix    = '';
+            $currentSuffix    = '';
+            $currentSuffixEnd = false;
+        } elseif (array_is_list($source)) {
+            $currentPrefix    = $opt['prefix-list'];
+            $currentSuffix    = $opt['suffix-list'];
+            $currentSuffixEnd = $opt['suffix-list-end'];
+        } else {
+            $currentPrefix    = $opt['prefix'];
+            $currentSuffix    = $opt['suffix'];
+            $currentSuffixEnd = $opt['suffix-end'];
+        }
+
+        $currentName = $start;
+        $result = [];
+
+        foreach ($source as $key => $val) {
+            $currentName .= $currentPrefix.$key;
+
+            if (is_array($val) && !empty($val)) {
+                $currentName .= $currentSuffix;
+                $result += self::flatten($val, $currentName);
+            } else {
+                if ($currentSuffixEnd) {
+                    $currentName .= $currentSuffix;
+                }
+
+                if (is_resource($val)) {
+                    $result[$currentName] = $val;
+                } else {
+                    $result[$currentName] = ObjectSerializer::toString($val);
+                }
+            }
+
+            $currentName = $start;
+        }
+
+        return $result;
+    }
+
+    /**
+     * formdata must be limited to scalars or arrays of scalar values,
+     * or a resource for a file upload. Here we iterate through all available
+     * data and identify how to handle each scenario
+     */
+    protected function makeFormSafe($value)
+    {
+        if ($value instanceof SplFileObject) {
+            return $this->processFiles([$value])[0];
+        }
+
+        if (is_resource($value)) {
+            $this->has_file = true;
+
+            return $value;
+        }
+
+        if ($value instanceof ModelInterface) {
+            return $this->processModel($value);
+        }
+
+        if (is_array($value) || (is_object($value) && !$value instanceof \DateTimeInterface)) {
+            $data = [];
+
+            foreach ($value as $k => $v) {
+                $data[$k] = $this->makeFormSafe($v);
+            }
+
+            return $data;
+        }
+
+        return ObjectSerializer::toString($value);
+    }
+
+    /**
+     * We are able to handle nested ModelInterface. We do not simply call
+     * json_decode(json_encode()) because any given model may have binary data
+     * or other data that cannot be serialized to a JSON string
+     */
+    protected function processModel(ModelInterface $model): array
+    {
+        $result = [];
+
+        foreach ($model::openAPITypes() as $name => $type) {
+            $value = $model->offsetGet($name);
+
+            if ($value === null) {
+                continue;
+            }
+
+            if (strpos($type, '\SplFileObject') !== false) {
+                $file = is_array($value) ? $value : [$value];
+                $result[$name] = $this->processFiles($file);
+
+                continue;
+            }
+
+            if ($value instanceof ModelInterface) {
+                $result[$name] = $this->processModel($value);
+
+                continue;
+            }
+
+            if (is_array($value) || is_object($value)) {
+                $result[$name] = $this->makeFormSafe($value);
+
+                continue;
+            }
+
+            $result[$name] = ObjectSerializer::toString($value);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Handle file data
+     */
+    protected function processFiles(array $files): array
+    {
+        $this->has_file = true;
+
+        $result = [];
+
+        foreach ($files as $i => $file) {
+            if (is_array($file)) {
+                $result[$i] = $this->processFiles($file);
+
+                continue;
+            }
+
+            if ($file instanceof StreamInterface) {
+                $result[$i] = $file;
+
+                continue;
+            }
+
+            if ($file instanceof SplFileObject) {
+                $result[$i] = $this->tryFopen($file);
+            }
+        }
+
+        return $result;
+    }
+
+    private function tryFopen(SplFileObject $file)
+    {
+        return Utils::tryFopen($file->getRealPath(), 'rb');
+    }
+}

+ 273 - 0
api-test/client/lib/HeaderSelector.php

@@ -0,0 +1,273 @@
+<?php
+/**
+ * HeaderSelector
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+/**
+ * HeaderSelector Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class HeaderSelector
+{
+    /**
+     * @param string[] $accept
+     * @param string   $contentType
+     * @param bool     $isMultipart
+     * @return string[]
+     */
+    public function selectHeaders(array $accept, string $contentType, bool $isMultipart): array
+    {
+        $headers = [];
+
+        $accept = $this->selectAcceptHeader($accept);
+        if ($accept !== null) {
+            $headers['Accept'] = $accept;
+        }
+
+        if (!$isMultipart) {
+            if($contentType === '') {
+                $contentType = 'application/json';
+            }
+
+            $headers['Content-Type'] = $contentType;
+        }
+
+        return $headers;
+    }
+
+    /**
+     * Return the header 'Accept' based on an array of Accept provided.
+     *
+     * @param string[] $accept Array of header
+     *
+     * @return null|string Accept (e.g. application/json)
+     */
+    private function selectAcceptHeader(array $accept): ?string
+    {
+        # filter out empty entries
+        $accept = array_filter($accept);
+
+        if (count($accept) === 0) {
+            return null;
+        }
+
+        # If there's only one Accept header, just use it
+        if (count($accept) === 1) {
+            return reset($accept);
+        }
+
+        # If none of the available Accept headers is of type "json", then just use all them
+        $headersWithJson = $this->selectJsonMimeList($accept);
+        if (count($headersWithJson) === 0) {
+            return implode(',', $accept);
+        }
+
+        # If we got here, then we need add quality values (weight), as described in IETF RFC 9110, Items 12.4.2/12.5.1,
+        # to give the highest priority to json-like headers - recalculating the existing ones, if needed
+        return $this->getAcceptHeaderWithAdjustedWeight($accept, $headersWithJson);
+    }
+
+    /**
+    * Detects whether a string contains a valid JSON mime type
+    *
+    * @param string $searchString
+    * @return bool
+    */
+    public function isJsonMime(string $searchString): bool
+    {
+        return preg_match('~^application/(json|[\w!#$&.+-^_]+\+json)\s*(;|$)~', $searchString) === 1;
+    }
+    
+    /**
+    * Select all items from a list containing a JSON mime type
+    *
+    * @param array $mimeList
+    * @return array
+    */
+    private function selectJsonMimeList(array $mimeList): array {
+        $jsonMimeList = [];
+        foreach ($mimeList as $mime) {
+            if($this->isJsonMime($mime)) {
+                $jsonMimeList[] = $mime;
+            }
+        }
+        return $jsonMimeList;
+    }
+
+
+    /**
+    * Create an Accept header string from the given "Accept" headers array, recalculating all weights
+    *
+    * @param string[] $accept            Array of Accept Headers
+    * @param string[] $headersWithJson   Array of Accept Headers of type "json"
+    *
+    * @return string "Accept" Header (e.g. "application/json, text/html; q=0.9")
+    */
+    private function getAcceptHeaderWithAdjustedWeight(array $accept, array $headersWithJson): string
+    {
+        $processedHeaders = [
+            'withApplicationJson' => [],
+            'withJson' => [],
+            'withoutJson' => [],
+        ];
+
+        foreach ($accept as $header) {
+
+            $headerData = $this->getHeaderAndWeight($header);
+
+            if (stripos($headerData['header'], 'application/json') === 0) {
+                $processedHeaders['withApplicationJson'][] = $headerData;
+            } elseif (in_array($header, $headersWithJson, true)) {
+                $processedHeaders['withJson'][] = $headerData;
+            } else {
+                $processedHeaders['withoutJson'][] = $headerData;
+            }
+        }
+
+        $acceptHeaders = [];
+        $currentWeight = 1000;
+
+        $hasMoreThan28Headers = count($accept) > 28;
+
+        foreach($processedHeaders as $headers) {
+            if (count($headers) > 0) {
+                $acceptHeaders[] = $this->adjustWeight($headers, $currentWeight, $hasMoreThan28Headers);
+            }
+        }
+
+        $acceptHeaders = array_merge(...$acceptHeaders);
+
+        return implode(',', $acceptHeaders);
+    }
+
+    /**
+     * Given an Accept header, returns an associative array splitting the header and its weight
+     *
+     * @param string $header "Accept" Header
+     *
+     * @return array with the header and its weight
+     */
+    private function getHeaderAndWeight(string $header): array
+    {
+        # matches headers with weight, splitting the header and the weight in $outputArray
+        if (preg_match('/(.*);\s*q=(1(?:\.0+)?|0\.\d+)$/', $header, $outputArray) === 1) {
+            $headerData = [
+                'header' => $outputArray[1],
+                'weight' => (int)($outputArray[2] * 1000),
+            ];
+        } else {
+            $headerData = [
+                'header' => trim($header),
+                'weight' => 1000,
+            ];
+        }
+
+        return $headerData;
+    }
+
+    /**
+     * @param array[] $headers
+     * @param float   $currentWeight
+     * @param bool    $hasMoreThan28Headers
+     * @return string[] array of adjusted "Accept" headers
+     */
+    private function adjustWeight(array $headers, float &$currentWeight, bool $hasMoreThan28Headers): array
+    {
+        usort($headers, function (array $a, array $b) {
+            return $b['weight'] - $a['weight'];
+        });
+
+        $acceptHeaders = [];
+        foreach ($headers as $index => $header) {
+            if($index > 0 && $headers[$index - 1]['weight'] > $header['weight'])
+            {
+                $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers);
+            }
+
+            $weight = $currentWeight;
+
+            $acceptHeaders[] = $this->buildAcceptHeader($header['header'], $weight);
+        }
+
+        $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers);
+
+        return $acceptHeaders;
+    }
+
+    /**
+     * @param string $header
+     * @param int    $weight
+     * @return string
+     */
+    private function buildAcceptHeader(string $header, int $weight): string
+    {
+        if($weight === 1000) {
+            return $header;
+        }
+
+        return trim($header, '; ') . ';q=' . rtrim(sprintf('%0.3f', $weight / 1000), '0');
+    }
+
+    /**
+     * Calculate the next weight, based on the current one.
+     *
+     * If there are less than 28 "Accept" headers, the weights will be decreased by 1 on its highest significant digit, using the
+     * following formula:
+     *
+     *    next weight = current weight - 10 ^ (floor(log(current weight - 1)))
+     *
+     *    ( current weight minus ( 10 raised to the power of ( floor of (log to the base 10 of ( current weight minus 1 ) ) ) ) )
+     *
+     * Starting from 1000, this generates the following series:
+     *
+     * 1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
+     *
+     * The resulting quality codes are closer to the average "normal" usage of them (like "q=0.9", "q=0.8" and so on), but it only works
+     * if there is a maximum of 28 "Accept" headers. If we have more than that (which is extremely unlikely), then we fall back to a 1-by-1
+     * decrement rule, which will result in quality codes like "q=0.999", "q=0.998" etc.
+     *
+     * @param int  $currentWeight varying from 1 to 1000 (will be divided by 1000 to build the quality value)
+     * @param bool $hasMoreThan28Headers
+     * @return int
+     */
+    public function getNextWeight(int $currentWeight, bool $hasMoreThan28Headers): int
+    {
+        if ($currentWeight <= 1) {
+            return 1;
+        }
+
+        if ($hasMoreThan28Headers) {
+            return $currentWeight - 1;
+        }
+
+        return $currentWeight - 10 ** floor( log10($currentWeight - 1) );
+    }
+}

+ 409 - 0
api-test/client/lib/Model/GetApiV3Heartbeat200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3Heartbeat200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Heartbeat200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Heartbeat200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_heartbeat_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3Heartbeat200ResponseData'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3Heartbeat200ResponseData|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3Heartbeat200ResponseData|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3Heartbeat200ResponseData
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Heartbeat200ResponseData Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Heartbeat200ResponseData implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_heartbeat_200_response_data';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'status' => 'string',
+        'checked_at' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'status' => null,
+        'checked_at' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'status' => false,
+        'checked_at' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'status' => 'status',
+        'checked_at' => 'checked_at'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'status' => 'setStatus',
+        'checked_at' => 'setCheckedAt'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'status' => 'getStatus',
+        'checked_at' => 'getCheckedAt'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('status', $data ?? [], null);
+        $this->setIfExists('checked_at', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets status
+     *
+     * @return string|null
+     */
+    public function getStatus()
+    {
+        return $this->container['status'];
+    }
+
+    /**
+     * Sets status
+     *
+     * @param string|null $status status
+     *
+     * @return self
+     */
+    public function setStatus($status)
+    {
+        if (is_null($status)) {
+            throw new \InvalidArgumentException('non-nullable status cannot be null');
+        }
+        $this->container['status'] = $status;
+
+        return $this;
+    }
+
+    /**
+     * Gets checked_at
+     *
+     * @return string|null
+     */
+    public function getCheckedAt()
+    {
+        return $this->container['checked_at'];
+    }
+
+    /**
+     * Sets checked_at
+     *
+     * @param string|null $checked_at checked_at
+     *
+     * @return self
+     */
+    public function setCheckedAt($checked_at)
+    {
+        if (is_null($checked_at)) {
+            throw new \InvalidArgumentException('non-nullable checked_at cannot be null');
+        }
+        $this->container['checked_at'] = $checked_at;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3MeReactions200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3MeReactions200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3MeReactions200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3MeReactions200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_reactions_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 661 - 0
api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php

@@ -0,0 +1,661 @@
+<?php
+/**
+ * GetApiV3MeReactions200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3MeReactions200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3MeReactions200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_reactions_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'type' => 'string',
+        'target_id' => 'string',
+        'target_type' => 'string',
+        'context' => 'string',
+        'created_at' => 'string',
+        'updated_at' => 'string',
+        'user' => '\WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInnerUser'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'type' => null,
+        'target_id' => null,
+        'target_type' => null,
+        'context' => null,
+        'created_at' => null,
+        'updated_at' => null,
+        'user' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'type' => false,
+        'target_id' => false,
+        'target_type' => false,
+        'context' => true,
+        'created_at' => false,
+        'updated_at' => false,
+        'user' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'type' => 'type',
+        'target_id' => 'target_id',
+        'target_type' => 'target_type',
+        'context' => 'context',
+        'created_at' => 'created_at',
+        'updated_at' => 'updated_at',
+        'user' => 'user'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'type' => 'setType',
+        'target_id' => 'setTargetId',
+        'target_type' => 'setTargetType',
+        'context' => 'setContext',
+        'created_at' => 'setCreatedAt',
+        'updated_at' => 'setUpdatedAt',
+        'user' => 'setUser'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'type' => 'getType',
+        'target_id' => 'getTargetId',
+        'target_type' => 'getTargetType',
+        'context' => 'getContext',
+        'created_at' => 'getCreatedAt',
+        'updated_at' => 'getUpdatedAt',
+        'user' => 'getUser'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('target_id', $data ?? [], null);
+        $this->setIfExists('target_type', $data ?? [], null);
+        $this->setIfExists('context', $data ?? [], null);
+        $this->setIfExists('created_at', $data ?? [], null);
+        $this->setIfExists('updated_at', $data ?? [], null);
+        $this->setIfExists('user', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets type
+     *
+     * @return string|null
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string|null $type type
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_id
+     *
+     * @return string|null
+     */
+    public function getTargetId()
+    {
+        return $this->container['target_id'];
+    }
+
+    /**
+     * Sets target_id
+     *
+     * @param string|null $target_id target_id
+     *
+     * @return self
+     */
+    public function setTargetId($target_id)
+    {
+        if (is_null($target_id)) {
+            throw new \InvalidArgumentException('non-nullable target_id cannot be null');
+        }
+        $this->container['target_id'] = $target_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_type
+     *
+     * @return string|null
+     */
+    public function getTargetType()
+    {
+        return $this->container['target_type'];
+    }
+
+    /**
+     * Sets target_type
+     *
+     * @param string|null $target_type target_type
+     *
+     * @return self
+     */
+    public function setTargetType($target_type)
+    {
+        if (is_null($target_type)) {
+            throw new \InvalidArgumentException('non-nullable target_type cannot be null');
+        }
+        $this->container['target_type'] = $target_type;
+
+        return $this;
+    }
+
+    /**
+     * Gets context
+     *
+     * @return string|null
+     */
+    public function getContext()
+    {
+        return $this->container['context'];
+    }
+
+    /**
+     * Sets context
+     *
+     * @param string|null $context context
+     *
+     * @return self
+     */
+    public function setContext($context)
+    {
+        if (is_null($context)) {
+            array_push($this->openAPINullablesSetToNull, 'context');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('context', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['context'] = $context;
+
+        return $this;
+    }
+
+    /**
+     * Gets created_at
+     *
+     * @return string|null
+     */
+    public function getCreatedAt()
+    {
+        return $this->container['created_at'];
+    }
+
+    /**
+     * Sets created_at
+     *
+     * @param string|null $created_at created_at
+     *
+     * @return self
+     */
+    public function setCreatedAt($created_at)
+    {
+        if (is_null($created_at)) {
+            throw new \InvalidArgumentException('non-nullable created_at cannot be null');
+        }
+        $this->container['created_at'] = $created_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets updated_at
+     *
+     * @return string|null
+     */
+    public function getUpdatedAt()
+    {
+        return $this->container['updated_at'];
+    }
+
+    /**
+     * Sets updated_at
+     *
+     * @param string|null $updated_at updated_at
+     *
+     * @return self
+     */
+    public function setUpdatedAt($updated_at)
+    {
+        if (is_null($updated_at)) {
+            throw new \InvalidArgumentException('non-nullable updated_at cannot be null');
+        }
+        $this->container['updated_at'] = $updated_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets user
+     *
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInnerUser|null
+     */
+    public function getUser()
+    {
+        return $this->container['user'];
+    }
+
+    /**
+     * Sets user
+     *
+     * @param \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInnerUser|null $user user
+     *
+     * @return self
+     */
+    public function setUser($user)
+    {
+        if (is_null($user)) {
+            array_push($this->openAPINullablesSetToNull, 'user');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('user', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['user'] = $user;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 613 - 0
api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php

@@ -0,0 +1,613 @@
+<?php
+/**
+ * GetApiV3MeReactions200ResponseDataInnerUser
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3MeReactions200ResponseDataInnerUser Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3MeReactions200ResponseDataInnerUser implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_reactions_200_response_data_inner_user';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'nick_name' => 'string',
+        'user_name' => 'string',
+        'real_name' => 'string',
+        'sn' => 'int',
+        'avatar' => 'string',
+        'roles' => 'string[]'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'nick_name' => null,
+        'user_name' => null,
+        'real_name' => null,
+        'sn' => null,
+        'avatar' => null,
+        'roles' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'nick_name' => false,
+        'user_name' => false,
+        'real_name' => false,
+        'sn' => false,
+        'avatar' => false,
+        'roles' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'nick_name' => 'nickName',
+        'user_name' => 'userName',
+        'real_name' => 'realName',
+        'sn' => 'sn',
+        'avatar' => 'avatar',
+        'roles' => 'roles'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'nick_name' => 'setNickName',
+        'user_name' => 'setUserName',
+        'real_name' => 'setRealName',
+        'sn' => 'setSn',
+        'avatar' => 'setAvatar',
+        'roles' => 'setRoles'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'nick_name' => 'getNickName',
+        'user_name' => 'getUserName',
+        'real_name' => 'getRealName',
+        'sn' => 'getSn',
+        'avatar' => 'getAvatar',
+        'roles' => 'getRoles'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('nick_name', $data ?? [], null);
+        $this->setIfExists('user_name', $data ?? [], null);
+        $this->setIfExists('real_name', $data ?? [], null);
+        $this->setIfExists('sn', $data ?? [], null);
+        $this->setIfExists('avatar', $data ?? [], null);
+        $this->setIfExists('roles', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets nick_name
+     *
+     * @return string|null
+     */
+    public function getNickName()
+    {
+        return $this->container['nick_name'];
+    }
+
+    /**
+     * Sets nick_name
+     *
+     * @param string|null $nick_name nick_name
+     *
+     * @return self
+     */
+    public function setNickName($nick_name)
+    {
+        if (is_null($nick_name)) {
+            throw new \InvalidArgumentException('non-nullable nick_name cannot be null');
+        }
+        $this->container['nick_name'] = $nick_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets user_name
+     *
+     * @return string|null
+     */
+    public function getUserName()
+    {
+        return $this->container['user_name'];
+    }
+
+    /**
+     * Sets user_name
+     *
+     * @param string|null $user_name user_name
+     *
+     * @return self
+     */
+    public function setUserName($user_name)
+    {
+        if (is_null($user_name)) {
+            throw new \InvalidArgumentException('non-nullable user_name cannot be null');
+        }
+        $this->container['user_name'] = $user_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets real_name
+     *
+     * @return string|null
+     */
+    public function getRealName()
+    {
+        return $this->container['real_name'];
+    }
+
+    /**
+     * Sets real_name
+     *
+     * @param string|null $real_name real_name
+     *
+     * @return self
+     */
+    public function setRealName($real_name)
+    {
+        if (is_null($real_name)) {
+            throw new \InvalidArgumentException('non-nullable real_name cannot be null');
+        }
+        $this->container['real_name'] = $real_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets sn
+     *
+     * @return int|null
+     */
+    public function getSn()
+    {
+        return $this->container['sn'];
+    }
+
+    /**
+     * Sets sn
+     *
+     * @param int|null $sn sn
+     *
+     * @return self
+     */
+    public function setSn($sn)
+    {
+        if (is_null($sn)) {
+            throw new \InvalidArgumentException('non-nullable sn cannot be null');
+        }
+        $this->container['sn'] = $sn;
+
+        return $this;
+    }
+
+    /**
+     * Gets avatar
+     *
+     * @return string|null
+     */
+    public function getAvatar()
+    {
+        return $this->container['avatar'];
+    }
+
+    /**
+     * Sets avatar
+     *
+     * @param string|null $avatar avatar
+     *
+     * @return self
+     */
+    public function setAvatar($avatar)
+    {
+        if (is_null($avatar)) {
+            throw new \InvalidArgumentException('non-nullable avatar cannot be null');
+        }
+        $this->container['avatar'] = $avatar;
+
+        return $this;
+    }
+
+    /**
+     * Gets roles
+     *
+     * @return string[]|null
+     */
+    public function getRoles()
+    {
+        return $this->container['roles'];
+    }
+
+    /**
+     * Sets roles
+     *
+     * @param string[]|null $roles roles
+     *
+     * @return self
+     */
+    public function setRoles($roles)
+    {
+        if (is_null($roles)) {
+            throw new \InvalidArgumentException('non-nullable roles cannot be null');
+        }
+        $this->container['roles'] = $roles;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3Progress200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3Progress200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Progress200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Progress200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_progress_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3Progress200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3Progress200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3Progress200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 702 - 0
api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php

@@ -0,0 +1,702 @@
+<?php
+/**
+ * GetApiV3Progress200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Progress200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Progress200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_progress_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'book' => 'int',
+        'para' => 'int',
+        'lang' => 'string',
+        'progress' => 'float',
+        'channel_id' => 'string',
+        'title' => 'string',
+        'last_chapter_completed_at' => 'string',
+        'completed_at' => 'string',
+        'updated_at' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'book' => null,
+        'para' => null,
+        'lang' => null,
+        'progress' => null,
+        'channel_id' => null,
+        'title' => null,
+        'last_chapter_completed_at' => null,
+        'completed_at' => null,
+        'updated_at' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'book' => false,
+        'para' => false,
+        'lang' => false,
+        'progress' => false,
+        'channel_id' => false,
+        'title' => true,
+        'last_chapter_completed_at' => true,
+        'completed_at' => true,
+        'updated_at' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'book' => 'book',
+        'para' => 'para',
+        'lang' => 'lang',
+        'progress' => 'progress',
+        'channel_id' => 'channel_id',
+        'title' => 'title',
+        'last_chapter_completed_at' => 'last_chapter_completed_at',
+        'completed_at' => 'completed_at',
+        'updated_at' => 'updated_at'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'book' => 'setBook',
+        'para' => 'setPara',
+        'lang' => 'setLang',
+        'progress' => 'setProgress',
+        'channel_id' => 'setChannelId',
+        'title' => 'setTitle',
+        'last_chapter_completed_at' => 'setLastChapterCompletedAt',
+        'completed_at' => 'setCompletedAt',
+        'updated_at' => 'setUpdatedAt'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'book' => 'getBook',
+        'para' => 'getPara',
+        'lang' => 'getLang',
+        'progress' => 'getProgress',
+        'channel_id' => 'getChannelId',
+        'title' => 'getTitle',
+        'last_chapter_completed_at' => 'getLastChapterCompletedAt',
+        'completed_at' => 'getCompletedAt',
+        'updated_at' => 'getUpdatedAt'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('book', $data ?? [], null);
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('lang', $data ?? [], null);
+        $this->setIfExists('progress', $data ?? [], null);
+        $this->setIfExists('channel_id', $data ?? [], null);
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('last_chapter_completed_at', $data ?? [], null);
+        $this->setIfExists('completed_at', $data ?? [], null);
+        $this->setIfExists('updated_at', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+
+    /**
+     * Gets para
+     *
+     * @return int|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets lang
+     *
+     * @return string|null
+     */
+    public function getLang()
+    {
+        return $this->container['lang'];
+    }
+
+    /**
+     * Sets lang
+     *
+     * @param string|null $lang lang
+     *
+     * @return self
+     */
+    public function setLang($lang)
+    {
+        if (is_null($lang)) {
+            throw new \InvalidArgumentException('non-nullable lang cannot be null');
+        }
+        $this->container['lang'] = $lang;
+
+        return $this;
+    }
+
+    /**
+     * Gets progress
+     *
+     * @return float|null
+     */
+    public function getProgress()
+    {
+        return $this->container['progress'];
+    }
+
+    /**
+     * Sets progress
+     *
+     * @param float|null $progress progress
+     *
+     * @return self
+     */
+    public function setProgress($progress)
+    {
+        if (is_null($progress)) {
+            throw new \InvalidArgumentException('non-nullable progress cannot be null');
+        }
+        $this->container['progress'] = $progress;
+
+        return $this;
+    }
+
+    /**
+     * Gets channel_id
+     *
+     * @return string|null
+     */
+    public function getChannelId()
+    {
+        return $this->container['channel_id'];
+    }
+
+    /**
+     * Sets channel_id
+     *
+     * @param string|null $channel_id channel_id
+     *
+     * @return self
+     */
+    public function setChannelId($channel_id)
+    {
+        if (is_null($channel_id)) {
+            throw new \InvalidArgumentException('non-nullable channel_id cannot be null');
+        }
+        $this->container['channel_id'] = $channel_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets title
+     *
+     * @return string|null
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string|null $title title
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            array_push($this->openAPINullablesSetToNull, 'title');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('title', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets last_chapter_completed_at
+     *
+     * @return string|null
+     */
+    public function getLastChapterCompletedAt()
+    {
+        return $this->container['last_chapter_completed_at'];
+    }
+
+    /**
+     * Sets last_chapter_completed_at
+     *
+     * @param string|null $last_chapter_completed_at last_chapter_completed_at
+     *
+     * @return self
+     */
+    public function setLastChapterCompletedAt($last_chapter_completed_at)
+    {
+        if (is_null($last_chapter_completed_at)) {
+            array_push($this->openAPINullablesSetToNull, 'last_chapter_completed_at');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('last_chapter_completed_at', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['last_chapter_completed_at'] = $last_chapter_completed_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets completed_at
+     *
+     * @return string|null
+     */
+    public function getCompletedAt()
+    {
+        return $this->container['completed_at'];
+    }
+
+    /**
+     * Sets completed_at
+     *
+     * @param string|null $completed_at completed_at
+     *
+     * @return self
+     */
+    public function setCompletedAt($completed_at)
+    {
+        if (is_null($completed_at)) {
+            array_push($this->openAPINullablesSetToNull, 'completed_at');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('completed_at', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['completed_at'] = $completed_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets updated_at
+     *
+     * @return string|null
+     */
+    public function getUpdatedAt()
+    {
+        return $this->container['updated_at'];
+    }
+
+    /**
+     * Sets updated_at
+     *
+     * @param string|null $updated_at updated_at
+     *
+     * @return self
+     */
+    public function setUpdatedAt($updated_at)
+    {
+        if (is_null($updated_at)) {
+            throw new \InvalidArgumentException('non-nullable updated_at cannot be null');
+        }
+        $this->container['updated_at'] = $updated_at;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3ReactionsTally200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3ReactionsTally200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3ReactionsTally200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_reactions_tally_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3ReactionsTally200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3ReactionsTally200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3ReactionsTally200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 518 - 0
api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php

@@ -0,0 +1,518 @@
+<?php
+/**
+ * GetApiV3ReactionsTally200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3ReactionsTally200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3ReactionsTally200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_reactions_tally_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'type' => 'string',
+        'count' => 'int',
+        'selected' => 'bool',
+        'id' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'type' => null,
+        'count' => null,
+        'selected' => null,
+        'id' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'type' => false,
+        'count' => false,
+        'selected' => false,
+        'id' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'type' => 'type',
+        'count' => 'count',
+        'selected' => 'selected',
+        'id' => 'id'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'type' => 'setType',
+        'count' => 'setCount',
+        'selected' => 'setSelected',
+        'id' => 'setId'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'type' => 'getType',
+        'count' => 'getCount',
+        'selected' => 'getSelected',
+        'id' => 'getId'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('count', $data ?? [], null);
+        $this->setIfExists('selected', $data ?? [], null);
+        $this->setIfExists('id', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets type
+     *
+     * @return string|null
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string|null $type type
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets count
+     *
+     * @return int|null
+     */
+    public function getCount()
+    {
+        return $this->container['count'];
+    }
+
+    /**
+     * Sets count
+     *
+     * @param int|null $count count
+     *
+     * @return self
+     */
+    public function setCount($count)
+    {
+        if (is_null($count)) {
+            throw new \InvalidArgumentException('non-nullable count cannot be null');
+        }
+        $this->container['count'] = $count;
+
+        return $this;
+    }
+
+    /**
+     * Gets selected
+     *
+     * @return bool|null
+     */
+    public function getSelected()
+    {
+        return $this->container['selected'];
+    }
+
+    /**
+     * Sets selected
+     *
+     * @param bool|null $selected selected
+     *
+     * @return self
+     */
+    public function setSelected($selected)
+    {
+        if (is_null($selected)) {
+            throw new \InvalidArgumentException('non-nullable selected cannot be null');
+        }
+        $this->container['selected'] = $selected;
+
+        return $this;
+    }
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            array_push($this->openAPINullablesSetToNull, 'id');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('id', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3Search200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3Search200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Search200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Search200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_search_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'success' => 'bool',
+        'data' => 'object'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'success' => null,
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'success' => false,
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'success' => 'success',
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'success' => 'setSuccess',
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'success' => 'getSuccess',
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('success', $data ?? [], null);
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets success
+     *
+     * @return bool|null
+     */
+    public function getSuccess()
+    {
+        return $this->container['success'];
+    }
+
+    /**
+     * Sets success
+     *
+     * @param bool|null $success success
+     *
+     * @return self
+     */
+    public function setSuccess($success)
+    {
+        if (is_null($success)) {
+            throw new \InvalidArgumentException('non-nullable success cannot be null');
+        }
+        $this->container['success'] = $success;
+
+        return $this;
+    }
+
+    /**
+     * Gets data
+     *
+     * @return object|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param object|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3SearchSearch200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3SearchSearch200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3SearchSearch200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3SearchSearch200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_search_search__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => 'object'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return object|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param object|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaReadingChannel200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaReadingChannel200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaReadingChannel200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_reading_channel__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 477 - 0
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php

@@ -0,0 +1,477 @@
+<?php
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaReadingChannel200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_reading_channel__200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'para' => 'int',
+        'display' => 'string',
+        'book' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'para' => null,
+        'display' => null,
+        'book' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'para' => false,
+        'display' => false,
+        'book' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'para' => 'para',
+        'display' => 'display',
+        'book' => 'book'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'para' => 'setPara',
+        'display' => 'setDisplay',
+        'book' => 'setBook'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'para' => 'getPara',
+        'display' => 'getDisplay',
+        'book' => 'getBook'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('display', $data ?? [], null);
+        $this->setIfExists('book', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets para
+     *
+     * @return int|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets display
+     *
+     * @return string|null
+     */
+    public function getDisplay()
+    {
+        return $this->container['display'];
+    }
+
+    /**
+     * Sets display
+     *
+     * @param string|null $display display
+     *
+     * @return self
+     */
+    public function setDisplay($display)
+    {
+        if (is_null($display)) {
+            throw new \InvalidArgumentException('non-nullable display cannot be null');
+        }
+        $this->container['display'] = $display;
+
+        return $this;
+    }
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 545 - 0
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php

@@ -0,0 +1,545 @@
+<?php
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseMeta
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseMeta Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaReadingChannel200ResponseMeta implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_reading_channel__200_response_meta';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'page_size' => 'int',
+        'page_size_unit' => 'string',
+        'next_cursor' => 'string',
+        'total' => 'int',
+        'remaining' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'page_size' => null,
+        'page_size_unit' => null,
+        'next_cursor' => null,
+        'total' => null,
+        'remaining' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'page_size' => false,
+        'page_size_unit' => false,
+        'next_cursor' => false,
+        'total' => false,
+        'remaining' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'page_size' => 'page_size',
+        'page_size_unit' => 'page_size_unit',
+        'next_cursor' => 'next_cursor',
+        'total' => 'total',
+        'remaining' => 'remaining'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'page_size' => 'setPageSize',
+        'page_size_unit' => 'setPageSizeUnit',
+        'next_cursor' => 'setNextCursor',
+        'total' => 'setTotal',
+        'remaining' => 'setRemaining'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'page_size' => 'getPageSize',
+        'page_size_unit' => 'getPageSizeUnit',
+        'next_cursor' => 'getNextCursor',
+        'total' => 'getTotal',
+        'remaining' => 'getRemaining'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('page_size', $data ?? [], null);
+        $this->setIfExists('page_size_unit', $data ?? [], null);
+        $this->setIfExists('next_cursor', $data ?? [], null);
+        $this->setIfExists('total', $data ?? [], null);
+        $this->setIfExists('remaining', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets page_size
+     *
+     * @return int|null
+     */
+    public function getPageSize()
+    {
+        return $this->container['page_size'];
+    }
+
+    /**
+     * Sets page_size
+     *
+     * @param int|null $page_size 每块大小(按 unit 计)
+     *
+     * @return self
+     */
+    public function setPageSize($page_size)
+    {
+        if (is_null($page_size)) {
+            throw new \InvalidArgumentException('non-nullable page_size cannot be null');
+        }
+        $this->container['page_size'] = $page_size;
+
+        return $this;
+    }
+
+    /**
+     * Gets page_size_unit
+     *
+     * @return string|null
+     */
+    public function getPageSizeUnit()
+    {
+        return $this->container['page_size_unit'];
+    }
+
+    /**
+     * Sets page_size_unit
+     *
+     * @param string|null $page_size_unit 每块大小的单位:para 或 byte
+     *
+     * @return self
+     */
+    public function setPageSizeUnit($page_size_unit)
+    {
+        if (is_null($page_size_unit)) {
+            throw new \InvalidArgumentException('non-nullable page_size_unit cannot be null');
+        }
+        $this->container['page_size_unit'] = $page_size_unit;
+
+        return $this;
+    }
+
+    /**
+     * Gets next_cursor
+     *
+     * @return string|null
+     */
+    public function getNextCursor()
+    {
+        return $this->container['next_cursor'];
+    }
+
+    /**
+     * Sets next_cursor
+     *
+     * @param string|null $next_cursor 下一块的游标,原样传回 after;为 null 表示取完
+     *
+     * @return self
+     */
+    public function setNextCursor($next_cursor)
+    {
+        if (is_null($next_cursor)) {
+            throw new \InvalidArgumentException('non-nullable next_cursor cannot be null');
+        }
+        $this->container['next_cursor'] = $next_cursor;
+
+        return $this;
+    }
+
+    /**
+     * Gets total
+     *
+     * @return int|null
+     */
+    public function getTotal()
+    {
+        return $this->container['total'];
+    }
+
+    /**
+     * Sets total
+     *
+     * @param int|null $total 过滤范围内已翻译的段落总数。仅在带了 book 过滤时出现
+     *
+     * @return self
+     */
+    public function setTotal($total)
+    {
+        if (is_null($total)) {
+            throw new \InvalidArgumentException('non-nullable total cannot be null');
+        }
+        $this->container['total'] = $total;
+
+        return $this;
+    }
+
+    /**
+     * Gets remaining
+     *
+     * @return int|null
+     */
+    public function getRemaining()
+    {
+        return $this->container['remaining'];
+    }
+
+    /**
+     * Sets remaining
+     *
+     * @param int|null $remaining 本块之后过滤范围内还剩多少段。仅在带了 book 过滤时出现
+     *
+     * @return self
+     */
+    public function setRemaining($remaining)
+    {
+        if (is_null($remaining)) {
+            throw new \InvalidArgumentException('non-nullable remaining cannot be null');
+        }
+        $this->container['remaining'] = $remaining;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 627 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php

@@ -0,0 +1,627 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'book' => 'int',
+        'book_id' => 'int',
+        'title' => 'string',
+        'cs_para' => 'int',
+        'para' => 'int[]',
+        'tags' => 'string[]',
+        'path' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner[]'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'book' => null,
+        'book_id' => null,
+        'title' => null,
+        'cs_para' => null,
+        'para' => null,
+        'tags' => null,
+        'path' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'book' => false,
+        'book_id' => false,
+        'title' => true,
+        'cs_para' => false,
+        'para' => false,
+        'tags' => false,
+        'path' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'book' => 'book',
+        'book_id' => 'book_id',
+        'title' => 'title',
+        'cs_para' => 'cs_para',
+        'para' => 'para',
+        'tags' => 'tags',
+        'path' => 'path'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'book' => 'setBook',
+        'book_id' => 'setBookId',
+        'title' => 'setTitle',
+        'cs_para' => 'setCsPara',
+        'para' => 'setPara',
+        'tags' => 'setTags',
+        'path' => 'setPath'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'book' => 'getBook',
+        'book_id' => 'getBookId',
+        'title' => 'getTitle',
+        'cs_para' => 'getCsPara',
+        'para' => 'getPara',
+        'tags' => 'getTags',
+        'path' => 'getPath'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('book', $data ?? [], null);
+        $this->setIfExists('book_id', $data ?? [], null);
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('cs_para', $data ?? [], null);
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('tags', $data ?? [], null);
+        $this->setIfExists('path', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+
+    /**
+     * Gets book_id
+     *
+     * @return int|null
+     */
+    public function getBookId()
+    {
+        return $this->container['book_id'];
+    }
+
+    /**
+     * Sets book_id
+     *
+     * @param int|null $book_id book_id
+     *
+     * @return self
+     */
+    public function setBookId($book_id)
+    {
+        if (is_null($book_id)) {
+            throw new \InvalidArgumentException('non-nullable book_id cannot be null');
+        }
+        $this->container['book_id'] = $book_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets title
+     *
+     * @return string|null
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string|null $title title
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            array_push($this->openAPINullablesSetToNull, 'title');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('title', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets cs_para
+     *
+     * @return int|null
+     */
+    public function getCsPara()
+    {
+        return $this->container['cs_para'];
+    }
+
+    /**
+     * Sets cs_para
+     *
+     * @param int|null $cs_para cs_para
+     *
+     * @return self
+     */
+    public function setCsPara($cs_para)
+    {
+        if (is_null($cs_para)) {
+            throw new \InvalidArgumentException('non-nullable cs_para cannot be null');
+        }
+        $this->container['cs_para'] = $cs_para;
+
+        return $this;
+    }
+
+    /**
+     * Gets para
+     *
+     * @return int[]|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int[]|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets tags
+     *
+     * @return string[]|null
+     */
+    public function getTags()
+    {
+        return $this->container['tags'];
+    }
+
+    /**
+     * Sets tags
+     *
+     * @param string[]|null $tags tags
+     *
+     * @return self
+     */
+    public function setTags($tags)
+    {
+        if (is_null($tags)) {
+            throw new \InvalidArgumentException('non-nullable tags cannot be null');
+        }
+        $this->container['tags'] = $tags;
+
+        return $this;
+    }
+
+    /**
+     * Gets path
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner[]|null
+     */
+    public function getPath()
+    {
+        return $this->container['path'];
+    }
+
+    /**
+     * Sets path
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner[]|null $path path
+     *
+     * @return self
+     */
+    public function setPath($path)
+    {
+        if (is_null($path)) {
+            array_push($this->openAPINullablesSetToNull, 'path');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('path', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['path'] = $path;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response_data_inner_path_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'title' => 'string',
+        'level' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'title' => null,
+        'level' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'title' => false,
+        'level' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'title' => 'title',
+        'level' => 'level'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'title' => 'setTitle',
+        'level' => 'setLevel'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'title' => 'getTitle',
+        'level' => 'getLevel'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('level', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets title
+     *
+     * @return string|null
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string|null $title title
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            throw new \InvalidArgumentException('non-nullable title cannot be null');
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets level
+     *
+     * @return int|null
+     */
+    public function getLevel()
+    {
+        return $this->container['level'];
+    }
+
+    /**
+     * Sets level
+     *
+     * @param int|null $level level
+     *
+     * @return self
+     */
+    public function setLevel($level)
+    {
+        if (is_null($level)) {
+            throw new \InvalidArgumentException('non-nullable level cannot be null');
+        }
+        $this->container['level'] = $level;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseMeta
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseMeta Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200ResponseMeta implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response_meta';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'count' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'count' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'count' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'count' => 'count'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'count' => 'setCount'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'count' => 'getCount'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('count', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets count
+     *
+     * @return int|null
+     */
+    public function getCount()
+    {
+        return $this->container['count'];
+    }
+
+    /**
+     * Sets count
+     *
+     * @param int|null $count 命中的书籍条数
+     *
+     * @return self
+     */
+    public function setCount($count)
+    {
+        if (is_null($count)) {
+            throw new \InvalidArgumentException('non-nullable count cannot be null');
+        }
+        $this->container['count'] = $count;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregate200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'book_name' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'book_name' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'book_name' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'book_name' => 'book_name'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'book_name' => 'setBookName'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'book_name' => 'getBookName'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('book_name', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets book_name
+     *
+     * @return string|null
+     */
+    public function getBookName()
+    {
+        return $this->container['book_name'];
+    }
+
+    /**
+     * Sets book_name
+     *
+     * @param string|null $book_name book_name
+     *
+     * @return self
+     */
+    public function setBookName($book_name)
+    {
+        if (is_null($book_name)) {
+            throw new \InvalidArgumentException('non-nullable book_name cannot be null');
+        }
+        $this->container['book_name'] = $book_name;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_book_name__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_book_name__200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'cs_para' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'cs_para' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'cs_para' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'cs_para' => 'cs_para'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'cs_para' => 'setCsPara'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'cs_para' => 'getCsPara'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('cs_para', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets cs_para
+     *
+     * @return string|null
+     */
+    public function getCsPara()
+    {
+        return $this->container['cs_para'];
+    }
+
+    /**
+     * Sets cs_para
+     *
+     * @param string|null $cs_para cs_para
+     *
+     * @return self
+     */
+    public function setCsPara($cs_para)
+    {
+        if (is_null($cs_para)) {
+            throw new \InvalidArgumentException('non-nullable cs_para cannot be null');
+        }
+        $this->container['cs_para'] = $cs_para;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 111 - 0
api-test/client/lib/Model/ModelInterface.php

@@ -0,0 +1,111 @@
+<?php
+/**
+ * ModelInterface
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi\Model
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+/**
+ * Interface abstracting model access.
+ *
+ * @package WikipaliApi\Model
+ * @author  OpenAPI Generator team
+ */
+interface ModelInterface
+{
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName();
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes();
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats();
+
+    /**
+     * Array of attributes where the key is the local name, and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap();
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters();
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters();
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array
+     */
+    public function listInvalidProperties();
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool
+     */
+    public function valid();
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool;
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool;
+}

+ 600 - 0
api-test/client/lib/Model/PaginationMeta.php

@@ -0,0 +1,600 @@
+<?php
+/**
+ * PaginationMeta
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PaginationMeta Class Doc Comment
+ *
+ * @category Class
+ * @description v3 列表接口的分页信息,由 Laravel paginator 生成。 刻意不含 links / path:那些是 APP_URL 拼出的绝对地址,反代下会拼错,前端也用不到。 手工分页的接口会附加自己的字段(如 has_more / first_para / page_size)。
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PaginationMeta implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'PaginationMeta';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'current_page' => 'int',
+        'per_page' => 'int',
+        'total' => 'int',
+        'last_page' => 'int',
+        'from' => 'int',
+        'to' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'current_page' => null,
+        'per_page' => null,
+        'total' => null,
+        'last_page' => null,
+        'from' => null,
+        'to' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'current_page' => false,
+        'per_page' => false,
+        'total' => false,
+        'last_page' => false,
+        'from' => true,
+        'to' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'current_page' => 'current_page',
+        'per_page' => 'per_page',
+        'total' => 'total',
+        'last_page' => 'last_page',
+        'from' => 'from',
+        'to' => 'to'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'current_page' => 'setCurrentPage',
+        'per_page' => 'setPerPage',
+        'total' => 'setTotal',
+        'last_page' => 'setLastPage',
+        'from' => 'setFrom',
+        'to' => 'setTo'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'current_page' => 'getCurrentPage',
+        'per_page' => 'getPerPage',
+        'total' => 'getTotal',
+        'last_page' => 'getLastPage',
+        'from' => 'getFrom',
+        'to' => 'getTo'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('current_page', $data ?? [], null);
+        $this->setIfExists('per_page', $data ?? [], null);
+        $this->setIfExists('total', $data ?? [], null);
+        $this->setIfExists('last_page', $data ?? [], null);
+        $this->setIfExists('from', $data ?? [], null);
+        $this->setIfExists('to', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['current_page'] === null) {
+            $invalidProperties[] = "'current_page' can't be null";
+        }
+        if ($this->container['total'] === null) {
+            $invalidProperties[] = "'total' can't be null";
+        }
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets current_page
+     *
+     * @return int
+     */
+    public function getCurrentPage()
+    {
+        return $this->container['current_page'];
+    }
+
+    /**
+     * Sets current_page
+     *
+     * @param int $current_page current_page
+     *
+     * @return self
+     */
+    public function setCurrentPage($current_page)
+    {
+        if (is_null($current_page)) {
+            throw new \InvalidArgumentException('non-nullable current_page cannot be null');
+        }
+        $this->container['current_page'] = $current_page;
+
+        return $this;
+    }
+
+    /**
+     * Gets per_page
+     *
+     * @return int|null
+     */
+    public function getPerPage()
+    {
+        return $this->container['per_page'];
+    }
+
+    /**
+     * Sets per_page
+     *
+     * @param int|null $per_page per_page
+     *
+     * @return self
+     */
+    public function setPerPage($per_page)
+    {
+        if (is_null($per_page)) {
+            throw new \InvalidArgumentException('non-nullable per_page cannot be null');
+        }
+        $this->container['per_page'] = $per_page;
+
+        return $this;
+    }
+
+    /**
+     * Gets total
+     *
+     * @return int
+     */
+    public function getTotal()
+    {
+        return $this->container['total'];
+    }
+
+    /**
+     * Sets total
+     *
+     * @param int $total total
+     *
+     * @return self
+     */
+    public function setTotal($total)
+    {
+        if (is_null($total)) {
+            throw new \InvalidArgumentException('non-nullable total cannot be null');
+        }
+        $this->container['total'] = $total;
+
+        return $this;
+    }
+
+    /**
+     * Gets last_page
+     *
+     * @return int|null
+     */
+    public function getLastPage()
+    {
+        return $this->container['last_page'];
+    }
+
+    /**
+     * Sets last_page
+     *
+     * @param int|null $last_page last_page
+     *
+     * @return self
+     */
+    public function setLastPage($last_page)
+    {
+        if (is_null($last_page)) {
+            throw new \InvalidArgumentException('non-nullable last_page cannot be null');
+        }
+        $this->container['last_page'] = $last_page;
+
+        return $this;
+    }
+
+    /**
+     * Gets from
+     *
+     * @return int|null
+     */
+    public function getFrom()
+    {
+        return $this->container['from'];
+    }
+
+    /**
+     * Sets from
+     *
+     * @param int|null $from from
+     *
+     * @return self
+     */
+    public function setFrom($from)
+    {
+        if (is_null($from)) {
+            array_push($this->openAPINullablesSetToNull, 'from');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('from', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['from'] = $from;
+
+        return $this;
+    }
+
+    /**
+     * Gets to
+     *
+     * @return int|null
+     */
+    public function getTo()
+    {
+        return $this->container['to'];
+    }
+
+    /**
+     * Sets to
+     *
+     * @param int|null $to to
+     *
+     * @return self
+     */
+    public function setTo($to)
+    {
+        if (is_null($to)) {
+            array_push($this->openAPINullablesSetToNull, 'to');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('to', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['to'] = $to;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/PostApiV3MeReactions200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * PostApiV3MeReactions200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PostApiV3MeReactions200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3MeReactions200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_me_reactions_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 602 - 0
api-test/client/lib/Model/PostApiV3MeReactionsRequest.php

@@ -0,0 +1,602 @@
+<?php
+/**
+ * PostApiV3MeReactionsRequest
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PostApiV3MeReactionsRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3MeReactionsRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_me_reactions_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'type' => 'string',
+        'target_id' => 'string',
+        'target_type' => 'string',
+        'context' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'type' => null,
+        'target_id' => null,
+        'target_type' => null,
+        'context' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'type' => false,
+        'target_id' => false,
+        'target_type' => false,
+        'context' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'type' => 'type',
+        'target_id' => 'target_id',
+        'target_type' => 'target_type',
+        'context' => 'context'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'type' => 'setType',
+        'target_id' => 'setTargetId',
+        'target_type' => 'setTargetType',
+        'context' => 'setContext'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'type' => 'getType',
+        'target_id' => 'getTargetId',
+        'target_type' => 'getTargetType',
+        'context' => 'getContext'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+    public const TYPE_LIKE = 'like';
+    public const TYPE_DISLIKE = 'dislike';
+    public const TYPE_FAVORITE = 'favorite';
+    public const TYPE_WATCH = 'watch';
+    public const TYPE_BOOKMARK = 'bookmark';
+    public const TYPE_DOWNLOAD = 'download';
+    public const TARGET_TYPE_TASK = 'task';
+    public const TARGET_TYPE_COLLECTION = 'collection';
+    public const TARGET_TYPE_PROGRESS_CHAPTER = 'progress_chapter';
+    public const TARGET_TYPE_ARTICLE = 'article';
+    public const TARGET_TYPE_TERMS = 'terms';
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getTypeAllowableValues()
+    {
+        return [
+            self::TYPE_LIKE,
+            self::TYPE_DISLIKE,
+            self::TYPE_FAVORITE,
+            self::TYPE_WATCH,
+            self::TYPE_BOOKMARK,
+            self::TYPE_DOWNLOAD,
+        ];
+    }
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getTargetTypeAllowableValues()
+    {
+        return [
+            self::TARGET_TYPE_TASK,
+            self::TARGET_TYPE_COLLECTION,
+            self::TARGET_TYPE_PROGRESS_CHAPTER,
+            self::TARGET_TYPE_ARTICLE,
+            self::TARGET_TYPE_TERMS,
+        ];
+    }
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('target_id', $data ?? [], null);
+        $this->setIfExists('target_type', $data ?? [], null);
+        $this->setIfExists('context', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['type'] === null) {
+            $invalidProperties[] = "'type' can't be null";
+        }
+        $allowedValues = $this->getTypeAllowableValues();
+        if (!is_null($this->container['type']) && !in_array($this->container['type'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'type', must be one of '%s'",
+                $this->container['type'],
+                implode("', '", $allowedValues)
+            );
+        }
+
+        if ($this->container['target_id'] === null) {
+            $invalidProperties[] = "'target_id' can't be null";
+        }
+        if ($this->container['target_type'] === null) {
+            $invalidProperties[] = "'target_type' can't be null";
+        }
+        $allowedValues = $this->getTargetTypeAllowableValues();
+        if (!is_null($this->container['target_type']) && !in_array($this->container['target_type'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'target_type', must be one of '%s'",
+                $this->container['target_type'],
+                implode("', '", $allowedValues)
+            );
+        }
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets type
+     *
+     * @return string
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string $type 操作类型
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $allowedValues = $this->getTypeAllowableValues();
+        if (!in_array($type, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'type', must be one of '%s'",
+                    $type,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_id
+     *
+     * @return string
+     */
+    public function getTargetId()
+    {
+        return $this->container['target_id'];
+    }
+
+    /**
+     * Sets target_id
+     *
+     * @param string $target_id target 的 uuid
+     *
+     * @return self
+     */
+    public function setTargetId($target_id)
+    {
+        if (is_null($target_id)) {
+            throw new \InvalidArgumentException('non-nullable target_id cannot be null');
+        }
+        $this->container['target_id'] = $target_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_type
+     *
+     * @return string
+     */
+    public function getTargetType()
+    {
+        return $this->container['target_type'];
+    }
+
+    /**
+     * Sets target_type
+     *
+     * @param string $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语)
+     *
+     * @return self
+     */
+    public function setTargetType($target_type)
+    {
+        if (is_null($target_type)) {
+            throw new \InvalidArgumentException('non-nullable target_type cannot be null');
+        }
+        $allowedValues = $this->getTargetTypeAllowableValues();
+        if (!in_array($target_type, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'target_type', must be one of '%s'",
+                    $target_type,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['target_type'] = $target_type;
+
+        return $this;
+    }
+
+    /**
+     * Gets context
+     *
+     * @return string|null
+     */
+    public function getContext()
+    {
+        return $this->container['context'];
+    }
+
+    /**
+     * Sets context
+     *
+     * @param string|null $context 附加上下文,最长 128 字符
+     *
+     * @return self
+     */
+    public function setContext($context)
+    {
+        if (is_null($context)) {
+            throw new \InvalidArgumentException('non-nullable context cannot be null');
+        }
+        $this->container['context'] = $context;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 858 - 0
api-test/client/lib/Model/PostApiV3SearchRequest.php

@@ -0,0 +1,858 @@
+<?php
+/**
+ * PostApiV3SearchRequest
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PostApiV3SearchRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3SearchRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_search_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'q' => 'string',
+        'search_mode' => 'string',
+        'resource_type' => 'string',
+        'granularity' => 'string',
+        'language' => 'string',
+        'category' => 'string',
+        'tags' => 'string',
+        'page_refs' => 'string[]',
+        'related_id' => 'string[]',
+        'author' => 'string',
+        'channel' => 'string',
+        'page' => 'int',
+        'page_size' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'q' => null,
+        'search_mode' => null,
+        'resource_type' => null,
+        'granularity' => null,
+        'language' => null,
+        'category' => null,
+        'tags' => null,
+        'page_refs' => null,
+        'related_id' => null,
+        'author' => null,
+        'channel' => null,
+        'page' => null,
+        'page_size' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'q' => false,
+        'search_mode' => false,
+        'resource_type' => false,
+        'granularity' => false,
+        'language' => false,
+        'category' => false,
+        'tags' => false,
+        'page_refs' => false,
+        'related_id' => false,
+        'author' => false,
+        'channel' => false,
+        'page' => false,
+        'page_size' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'q' => 'q',
+        'search_mode' => 'search_mode',
+        'resource_type' => 'resource_type',
+        'granularity' => 'granularity',
+        'language' => 'language',
+        'category' => 'category',
+        'tags' => 'tags',
+        'page_refs' => 'page_refs',
+        'related_id' => 'related_id',
+        'author' => 'author',
+        'channel' => 'channel',
+        'page' => 'page',
+        'page_size' => 'page_size'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'q' => 'setQ',
+        'search_mode' => 'setSearchMode',
+        'resource_type' => 'setResourceType',
+        'granularity' => 'setGranularity',
+        'language' => 'setLanguage',
+        'category' => 'setCategory',
+        'tags' => 'setTags',
+        'page_refs' => 'setPageRefs',
+        'related_id' => 'setRelatedId',
+        'author' => 'setAuthor',
+        'channel' => 'setChannel',
+        'page' => 'setPage',
+        'page_size' => 'setPageSize'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'q' => 'getQ',
+        'search_mode' => 'getSearchMode',
+        'resource_type' => 'getResourceType',
+        'granularity' => 'getGranularity',
+        'language' => 'getLanguage',
+        'category' => 'getCategory',
+        'tags' => 'getTags',
+        'page_refs' => 'getPageRefs',
+        'related_id' => 'getRelatedId',
+        'author' => 'getAuthor',
+        'channel' => 'getChannel',
+        'page' => 'getPage',
+        'page_size' => 'getPageSize'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+    public const SEARCH_MODE_FUZZY = 'fuzzy';
+    public const SEARCH_MODE_EXACT = 'exact';
+    public const SEARCH_MODE_SEMANTIC = 'semantic';
+    public const SEARCH_MODE_HYBRID = 'hybrid';
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getSearchModeAllowableValues()
+    {
+        return [
+            self::SEARCH_MODE_FUZZY,
+            self::SEARCH_MODE_EXACT,
+            self::SEARCH_MODE_SEMANTIC,
+            self::SEARCH_MODE_HYBRID,
+        ];
+    }
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('q', $data ?? [], null);
+        $this->setIfExists('search_mode', $data ?? [], 'fuzzy');
+        $this->setIfExists('resource_type', $data ?? [], null);
+        $this->setIfExists('granularity', $data ?? [], null);
+        $this->setIfExists('language', $data ?? [], null);
+        $this->setIfExists('category', $data ?? [], null);
+        $this->setIfExists('tags', $data ?? [], null);
+        $this->setIfExists('page_refs', $data ?? [], null);
+        $this->setIfExists('related_id', $data ?? [], null);
+        $this->setIfExists('author', $data ?? [], null);
+        $this->setIfExists('channel', $data ?? [], null);
+        $this->setIfExists('page', $data ?? [], 1);
+        $this->setIfExists('page_size', $data ?? [], 20);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['q'] === null) {
+            $invalidProperties[] = "'q' can't be null";
+        }
+        $allowedValues = $this->getSearchModeAllowableValues();
+        if (!is_null($this->container['search_mode']) && !in_array($this->container['search_mode'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'search_mode', must be one of '%s'",
+                $this->container['search_mode'],
+                implode("', '", $allowedValues)
+            );
+        }
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets q
+     *
+     * @return string
+     */
+    public function getQ()
+    {
+        return $this->container['q'];
+    }
+
+    /**
+     * Sets q
+     *
+     * @param string $q 搜索关键词
+     *
+     * @return self
+     */
+    public function setQ($q)
+    {
+        if (is_null($q)) {
+            throw new \InvalidArgumentException('non-nullable q cannot be null');
+        }
+        $this->container['q'] = $q;
+
+        return $this;
+    }
+
+    /**
+     * Gets search_mode
+     *
+     * @return string|null
+     */
+    public function getSearchMode()
+    {
+        return $this->container['search_mode'];
+    }
+
+    /**
+     * Sets search_mode
+     *
+     * @param string|null $search_mode 检索模式
+     *
+     * @return self
+     */
+    public function setSearchMode($search_mode)
+    {
+        if (is_null($search_mode)) {
+            throw new \InvalidArgumentException('non-nullable search_mode cannot be null');
+        }
+        $allowedValues = $this->getSearchModeAllowableValues();
+        if (!in_array($search_mode, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'search_mode', must be one of '%s'",
+                    $search_mode,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['search_mode'] = $search_mode;
+
+        return $this;
+    }
+
+    /**
+     * Gets resource_type
+     *
+     * @return string|null
+     */
+    public function getResourceType()
+    {
+        return $this->container['resource_type'];
+    }
+
+    /**
+     * Sets resource_type
+     *
+     * @param string|null $resource_type 资源类型
+     *
+     * @return self
+     */
+    public function setResourceType($resource_type)
+    {
+        if (is_null($resource_type)) {
+            throw new \InvalidArgumentException('non-nullable resource_type cannot be null');
+        }
+        $this->container['resource_type'] = $resource_type;
+
+        return $this;
+    }
+
+    /**
+     * Gets granularity
+     *
+     * @return string|null
+     */
+    public function getGranularity()
+    {
+        return $this->container['granularity'];
+    }
+
+    /**
+     * Sets granularity
+     *
+     * @param string|null $granularity 文档颗粒度
+     *
+     * @return self
+     */
+    public function setGranularity($granularity)
+    {
+        if (is_null($granularity)) {
+            throw new \InvalidArgumentException('non-nullable granularity cannot be null');
+        }
+        $this->container['granularity'] = $granularity;
+
+        return $this;
+    }
+
+    /**
+     * Gets language
+     *
+     * @return string|null
+     */
+    public function getLanguage()
+    {
+        return $this->container['language'];
+    }
+
+    /**
+     * Sets language
+     *
+     * @param string|null $language 语言
+     *
+     * @return self
+     */
+    public function setLanguage($language)
+    {
+        if (is_null($language)) {
+            throw new \InvalidArgumentException('non-nullable language cannot be null');
+        }
+        $this->container['language'] = $language;
+
+        return $this;
+    }
+
+    /**
+     * Gets category
+     *
+     * @return string|null
+     */
+    public function getCategory()
+    {
+        return $this->container['category'];
+    }
+
+    /**
+     * Sets category
+     *
+     * @param string|null $category 文档分类,逗号分隔
+     *
+     * @return self
+     */
+    public function setCategory($category)
+    {
+        if (is_null($category)) {
+            throw new \InvalidArgumentException('non-nullable category cannot be null');
+        }
+        $this->container['category'] = $category;
+
+        return $this;
+    }
+
+    /**
+     * Gets tags
+     *
+     * @return string|null
+     */
+    public function getTags()
+    {
+        return $this->container['tags'];
+    }
+
+    /**
+     * Sets tags
+     *
+     * @param string|null $tags 标签,逗号分隔
+     *
+     * @return self
+     */
+    public function setTags($tags)
+    {
+        if (is_null($tags)) {
+            throw new \InvalidArgumentException('non-nullable tags cannot be null');
+        }
+        $this->container['tags'] = $tags;
+
+        return $this;
+    }
+
+    /**
+     * Gets page_refs
+     *
+     * @return string[]|null
+     */
+    public function getPageRefs()
+    {
+        return $this->container['page_refs'];
+    }
+
+    /**
+     * Sets page_refs
+     *
+     * @param string[]|null $page_refs 页码标记
+     *
+     * @return self
+     */
+    public function setPageRefs($page_refs)
+    {
+        if (is_null($page_refs)) {
+            throw new \InvalidArgumentException('non-nullable page_refs cannot be null');
+        }
+        $this->container['page_refs'] = $page_refs;
+
+        return $this;
+    }
+
+    /**
+     * Gets related_id
+     *
+     * @return string[]|null
+     */
+    public function getRelatedId()
+    {
+        return $this->container['related_id'];
+    }
+
+    /**
+     * Sets related_id
+     *
+     * @param string[]|null $related_id 关联 id
+     *
+     * @return self
+     */
+    public function setRelatedId($related_id)
+    {
+        if (is_null($related_id)) {
+            throw new \InvalidArgumentException('non-nullable related_id cannot be null');
+        }
+        $this->container['related_id'] = $related_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets author
+     *
+     * @return string|null
+     */
+    public function getAuthor()
+    {
+        return $this->container['author'];
+    }
+
+    /**
+     * Sets author
+     *
+     * @param string|null $author 作者或译者
+     *
+     * @return self
+     */
+    public function setAuthor($author)
+    {
+        if (is_null($author)) {
+            throw new \InvalidArgumentException('non-nullable author cannot be null');
+        }
+        $this->container['author'] = $author;
+
+        return $this;
+    }
+
+    /**
+     * Gets channel
+     *
+     * @return string|null
+     */
+    public function getChannel()
+    {
+        return $this->container['channel'];
+    }
+
+    /**
+     * Sets channel
+     *
+     * @param string|null $channel 来源 channel
+     *
+     * @return self
+     */
+    public function setChannel($channel)
+    {
+        if (is_null($channel)) {
+            throw new \InvalidArgumentException('non-nullable channel cannot be null');
+        }
+        $this->container['channel'] = $channel;
+
+        return $this;
+    }
+
+    /**
+     * Gets page
+     *
+     * @return int|null
+     */
+    public function getPage()
+    {
+        return $this->container['page'];
+    }
+
+    /**
+     * Sets page
+     *
+     * @param int|null $page 页码
+     *
+     * @return self
+     */
+    public function setPage($page)
+    {
+        if (is_null($page)) {
+            throw new \InvalidArgumentException('non-nullable page cannot be null');
+        }
+        $this->container['page'] = $page;
+
+        return $this;
+    }
+
+    /**
+     * Gets page_size
+     *
+     * @return int|null
+     */
+    public function getPageSize()
+    {
+        return $this->container['page_size'];
+    }
+
+    /**
+     * Sets page_size
+     *
+     * @param int|null $page_size 每页数量,上限 100
+     *
+     * @return self
+     */
+    public function setPageSize($page_size)
+    {
+        if (is_null($page_size)) {
+            throw new \InvalidArgumentException('non-nullable page_size cannot be null');
+        }
+        $this->container['page_size'] = $page_size;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 589 - 0
api-test/client/lib/Model/ProblemDetails.php

@@ -0,0 +1,589 @@
+<?php
+/**
+ * ProblemDetails
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * ProblemDetails Class Doc Comment
+ *
+ * @category Class
+ * @description RFC 9457 Problem Details,v3 端点的错误响应体。 由 bootstrap/app.php 的异常处理器统一渲染,控制器不自己拼。 media type 是 application/problem+json。
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class ProblemDetails implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'ProblemDetails';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'type' => 'string',
+        'title' => 'string',
+        'status' => 'int',
+        'detail' => 'string',
+        'instance' => 'string',
+        'errors' => 'array<string,string[]>'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'type' => null,
+        'title' => null,
+        'status' => null,
+        'detail' => null,
+        'instance' => null,
+        'errors' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'type' => false,
+        'title' => false,
+        'status' => false,
+        'detail' => false,
+        'instance' => false,
+        'errors' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'type' => 'type',
+        'title' => 'title',
+        'status' => 'status',
+        'detail' => 'detail',
+        'instance' => 'instance',
+        'errors' => 'errors'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'type' => 'setType',
+        'title' => 'setTitle',
+        'status' => 'setStatus',
+        'detail' => 'setDetail',
+        'instance' => 'setInstance',
+        'errors' => 'setErrors'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'type' => 'getType',
+        'title' => 'getTitle',
+        'status' => 'getStatus',
+        'detail' => 'getDetail',
+        'instance' => 'getInstance',
+        'errors' => 'getErrors'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('status', $data ?? [], null);
+        $this->setIfExists('detail', $data ?? [], null);
+        $this->setIfExists('instance', $data ?? [], null);
+        $this->setIfExists('errors', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['type'] === null) {
+            $invalidProperties[] = "'type' can't be null";
+        }
+        if ($this->container['title'] === null) {
+            $invalidProperties[] = "'title' can't be null";
+        }
+        if ($this->container['status'] === null) {
+            $invalidProperties[] = "'status' can't be null";
+        }
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets type
+     *
+     * @return string
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string $type 问题类型标识,形如 urn:problem:not-found
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets title
+     *
+     * @return string
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string $title 稳定的英文摘要,同类问题恒定,供机器识别
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            throw new \InvalidArgumentException('non-nullable title cannot be null');
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets status
+     *
+     * @return int
+     */
+    public function getStatus()
+    {
+        return $this->container['status'];
+    }
+
+    /**
+     * Sets status
+     *
+     * @param int $status status
+     *
+     * @return self
+     */
+    public function setStatus($status)
+    {
+        if (is_null($status)) {
+            throw new \InvalidArgumentException('non-nullable status cannot be null');
+        }
+        $this->container['status'] = $status;
+
+        return $this;
+    }
+
+    /**
+     * Gets detail
+     *
+     * @return string|null
+     */
+    public function getDetail()
+    {
+        return $this->container['detail'];
+    }
+
+    /**
+     * Sets detail
+     *
+     * @param string|null $detail 面向人的文案,已本地化;5xx 在生产环境会省略以免泄露内部信息
+     *
+     * @return self
+     */
+    public function setDetail($detail)
+    {
+        if (is_null($detail)) {
+            throw new \InvalidArgumentException('non-nullable detail cannot be null');
+        }
+        $this->container['detail'] = $detail;
+
+        return $this;
+    }
+
+    /**
+     * Gets instance
+     *
+     * @return string|null
+     */
+    public function getInstance()
+    {
+        return $this->container['instance'];
+    }
+
+    /**
+     * Sets instance
+     *
+     * @param string|null $instance 出问题的请求 URI
+     *
+     * @return self
+     */
+    public function setInstance($instance)
+    {
+        if (is_null($instance)) {
+            throw new \InvalidArgumentException('non-nullable instance cannot be null');
+        }
+        $this->container['instance'] = $instance;
+
+        return $this;
+    }
+
+    /**
+     * Gets errors
+     *
+     * @return array<string,string[]>|null
+     */
+    public function getErrors()
+    {
+        return $this->container['errors'];
+    }
+
+    /**
+     * Sets errors
+     *
+     * @param array<string,string[]>|null $errors 字段级校验错误,仅 422 时出现
+     *
+     * @return self
+     */
+    public function setErrors($errors)
+    {
+        if (is_null($errors)) {
+            throw new \InvalidArgumentException('non-nullable errors cannot be null');
+        }
+        $this->container['errors'] = $errors;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 603 - 0
api-test/client/lib/ObjectSerializer.php

@@ -0,0 +1,603 @@
+<?php
+/**
+ * ObjectSerializer
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+use GuzzleHttp\Psr7\Utils;
+use WikipaliApi\Model\ModelInterface;
+
+/**
+ * ObjectSerializer Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ObjectSerializer
+{
+    /** @var string */
+    private static $dateTimeFormat = \DateTime::ATOM;
+
+    /**
+     * Change the date format
+     *
+     * @param string $format   the new date format to use
+     */
+    public static function setDateTimeFormat($format)
+    {
+        self::$dateTimeFormat = $format;
+    }
+
+    /**
+     * Serialize data
+     *
+     * @param mixed  $data   the data to serialize
+     * @param string|null $type   the OpenAPIToolsType of the data
+     * @param string|null $format the format of the OpenAPITools type of the data
+     *
+     * @return scalar|object|array|null serialized form of $data
+     */
+    public static function sanitizeForSerialization($data, $type = null, $format = null)
+    {
+        if (is_scalar($data) || null === $data) {
+            return $data;
+        }
+
+        if ($data instanceof \DateTime) {
+            return ($format === 'date') ? $data->format('Y-m-d') : $data->format(self::$dateTimeFormat);
+        }
+
+        if (is_array($data)) {
+            foreach ($data as $property => $value) {
+                $data[$property] = self::sanitizeForSerialization($value);
+            }
+            return $data;
+        }
+
+        if (is_object($data)) {
+            $values = [];
+            if ($data instanceof ModelInterface) {
+                $formats = $data::openAPIFormats();
+                foreach ($data::openAPITypes() as $property => $openAPIType) {
+                    $getter = $data::getters()[$property];
+                    $value = $data->$getter();
+                    if ($value !== null && !in_array($openAPIType, ['\DateTime', '\SplFileObject', 'array', 'bool', 'boolean', 'byte', 'float', 'int', 'integer', 'mixed', 'number', 'object', 'string', 'void'], true)) {
+                        $callable = [$openAPIType, 'getAllowableEnumValues'];
+                        if (is_callable($callable)) {
+                            /** array $callable */
+                            $allowedEnumTypes = $callable();
+                            if (!in_array($value, $allowedEnumTypes, true)) {
+                                $imploded = implode("', '", $allowedEnumTypes);
+                                throw new \InvalidArgumentException("Invalid value for enum '$openAPIType', must be one of: '$imploded'");
+                            }
+                        }
+                    }
+                    if (($data::isNullable($property) && $data->isNullableSetToNull($property)) || $value !== null) {
+                        $values[$data::attributeMap()[$property]] = self::sanitizeForSerialization($value, $openAPIType, $formats[$property]);
+                    }
+                }
+            } else {
+                foreach ($data as $property => $value) {
+                    $values[$property] = self::sanitizeForSerialization($value);
+                }
+            }
+            return (object) $values;
+        } else {
+            return (string)$data;
+        }
+    }
+
+    /**
+     * Sanitize filename by removing path.
+     * e.g. ../../sun.gif becomes sun.gif
+     *
+     * @param string $filename filename to be sanitized
+     *
+     * @return string the sanitized filename
+     */
+    public static function sanitizeFilename($filename)
+    {
+        if (preg_match("/.*[\/\\\\](.*)$/", $filename, $match)) {
+            return $match[1];
+        } else {
+            return $filename;
+        }
+    }
+
+    /**
+     * Shorter timestamp microseconds to 6 digits length.
+     *
+     * @param string $timestamp Original timestamp
+     *
+     * @return string the shorten timestamp
+     */
+    public static function sanitizeTimestamp($timestamp)
+    {
+        if (!is_string($timestamp)) {
+            return $timestamp;
+        }
+
+        return preg_replace('/(:\d{2}.\d{6})\d*/', '$1', $timestamp);
+    }
+
+    /**
+     * Take value and turn it into a string suitable for inclusion in
+     * the path, by url-encoding.
+     *
+     * @param string $value a string which will be part of the path
+     *
+     * @return string the serialized object
+     */
+    public static function toPathValue($value)
+    {
+        return rawurlencode(self::toString($value));
+    }
+
+    /**
+     * Checks if a value is empty, based on its OpenAPI type.
+     *
+     * @param mixed  $value
+     * @param string $openApiType
+     *
+     * @return bool true if $value is empty
+     */
+    private static function isEmptyValue($value, string $openApiType): bool
+    {
+        // If empty() returns false, it is not empty regardless of its type.
+        if (!empty($value)) {
+            return false;
+        }
+
+        // Null is always empty, as we cannot send a real "null" value in a query parameter.
+        if ($value === null) {
+            return true;
+        }
+
+        switch ($openApiType) {
+            // For numeric values, false and '' are considered empty.
+            // This comparison is safe for floating point values, since the previous call to empty() will
+            // filter out values that don't match 0.
+            case 'int':
+            case 'integer':
+                return $value !== 0;
+
+            case 'number':
+            case 'float':
+                return $value !== 0 && $value !== 0.0;
+
+            // For boolean values, '' is considered empty
+            case 'bool':
+            case 'boolean':
+                return !in_array($value, [false, 0], true);
+
+            // For string values, '' is considered empty.
+            case 'string':
+                return $value === '';
+
+            // For all the other types, any value at this point can be considered empty.
+            default:
+                return true;
+        }
+    }
+
+    /**
+     * Take query parameter properties and turn it into an array suitable for
+     * native http_build_query or GuzzleHttp\Psr7\Query::build.
+     *
+     * @param mixed  $value       Parameter value
+     * @param string $paramName   Parameter name
+     * @param string $openApiType OpenAPIType eg. array or object
+     * @param string $style       Parameter serialization style
+     * @param bool   $explode     Parameter explode option
+     * @param bool   $required    Whether query param is required or not
+     *
+     * @return array
+     */
+    public static function toQueryValue(
+        $value,
+        string $paramName,
+        string $openApiType = 'string',
+        string $style = 'form',
+        bool $explode = true,
+        bool $required = true
+    ): array {
+
+        // Check if we should omit this parameter from the query. This should only happen when:
+        //  - Parameter is NOT required; AND
+        //  - its value is set to a value that is equivalent to "empty", depending on its OpenAPI type. For
+        //    example, 0 as "int" or "boolean" is NOT an empty value.
+        if (self::isEmptyValue($value, $openApiType)) {
+            if ($required) {
+                return ["{$paramName}" => ''];
+            } else {
+                return [];
+            }
+        }
+
+        // Handle DateTime objects in query
+        if ($openApiType === "\\DateTime" && $value instanceof \DateTime) {
+            return ["{$paramName}" => $value->format(self::$dateTimeFormat)];
+        }
+
+        $query = [];
+        $value = (in_array($openApiType, ['object', 'array'], true)) ? (array)$value : $value;
+
+        // since \GuzzleHttp\Psr7\Query::build fails with nested arrays
+        // need to flatten array first
+        $flattenArray = function ($arr, $name, &$result = []) use (&$flattenArray, $style, $explode) {
+            if (!is_array($arr)) {
+                return $arr;
+            }
+
+            foreach ($arr as $k => $v) {
+                $prop = ($style === 'deepObject') ? "{$name}[{$k}]" : $k;
+
+                if (is_array($v)) {
+                    $flattenArray($v, $prop, $result);
+                } else {
+                    if ($style !== 'deepObject' && !$explode) {
+                        // push key itself
+                        $result[] = $prop;
+                    }
+                    $result[$prop] = $v;
+                }
+            }
+            return $result;
+        };
+
+        $value = $flattenArray($value, $paramName);
+
+        // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-values
+        if ($openApiType === 'array' && $style === 'deepObject' && $explode) {
+            return $value;
+        }
+
+        if ($openApiType === 'object' && ($style === 'deepObject' || $explode)) {
+            return $value;
+        }
+
+        if ('boolean' === $openApiType && is_bool($value)) {
+            $value = self::convertBoolToQueryStringFormat($value);
+        }
+
+        // handle style in serializeCollection
+        $query[$paramName] = ($explode) ? $value : self::serializeCollection((array)$value, $style);
+
+        return $query;
+    }
+
+    /**
+     * Convert boolean value to format for query string.
+     *
+     * @param bool $value Boolean value
+     *
+     * @return int|string Boolean value in format
+     */
+    public static function convertBoolToQueryStringFormat(bool $value)
+    {
+        if (Configuration::BOOLEAN_FORMAT_STRING == Configuration::getDefaultConfiguration()->getBooleanFormatForQueryString()) {
+            return $value ? 'true' : 'false';
+        }
+
+        return (int) $value;
+    }
+
+    /**
+     * Take value and turn it into a string suitable for inclusion in
+     * the header. If it's a string, pass through unchanged
+     * If it's a datetime object, format it in ISO8601
+     *
+     * @param string $value a string which will be part of the header
+     *
+     * @return string the header string
+     */
+    public static function toHeaderValue($value)
+    {
+        $callable = [$value, 'toHeaderValue'];
+        if (is_callable($callable)) {
+            return $callable();
+        }
+
+        return self::toString($value);
+    }
+
+    /**
+     * Take value and turn it into a string suitable for inclusion in
+     * the parameter. If it's a string, pass through unchanged
+     * If it's a datetime object, format it in ISO8601
+     * If it's a boolean, convert it to "true" or "false".
+     *
+     * @param float|int|bool|\DateTime $value the value of the parameter
+     *
+     * @return string the header string
+     */
+    public static function toString($value)
+    {
+        if ($value instanceof \DateTime) { // datetime in ISO8601 format
+            return $value->format(self::$dateTimeFormat);
+        } elseif (is_bool($value)) {
+            return $value ? 'true' : 'false';
+        } else {
+            return (string) $value;
+        }
+    }
+
+    /**
+     * Serialize an array to a string.
+     *
+     * @param array  $collection                 collection to serialize to a string
+     * @param string $style                      the format use for serialization (csv,
+     * ssv, tsv, pipes, multi)
+     * @param bool   $allowCollectionFormatMulti allow collection format to be a multidimensional array
+     *
+     * @return string
+     */
+    public static function serializeCollection(array $collection, $style, $allowCollectionFormatMulti = false)
+    {
+        if ($allowCollectionFormatMulti && ('multi' === $style)) {
+            // http_build_query() almost does the job for us. We just
+            // need to fix the result of multidimensional arrays.
+            return preg_replace('/%5B[0-9]+%5D=/', '=', http_build_query($collection, '', '&'));
+        }
+        switch ($style) {
+            case 'pipeDelimited':
+            case 'pipes':
+                return implode('|', $collection);
+
+            case 'tsv':
+                return implode("\t", $collection);
+
+            case 'spaceDelimited':
+            case 'ssv':
+                return implode(' ', $collection);
+
+            case 'simple':
+            case 'csv':
+                // Deliberate fall through. CSV is default format.
+            default:
+                return implode(',', $collection);
+        }
+    }
+
+    /**
+     * Deserialize a JSON string into an object
+     *
+     * @param mixed    $data          object or primitive to be deserialized
+     * @param string   $class         class name is passed as a string
+     * @param string[]|null $httpHeaders   HTTP headers
+     *
+     * @return object|array|null a single or an array of $class instances
+     */
+    public static function deserialize($data, $class, $httpHeaders = null)
+    {
+        if (null === $data) {
+            return null;
+        }
+
+        if (strcasecmp(substr($class, -2), '[]') === 0) {
+            $data = is_string($data) ? json_decode($data) : $data;
+
+            if (!is_array($data)) {
+                throw new \InvalidArgumentException("Invalid array '$class'");
+            }
+
+            $subClass = substr($class, 0, -2);
+            $values = [];
+            foreach ($data as $key => $value) {
+                $values[] = self::deserialize($value, $subClass, null);
+            }
+            return $values;
+        }
+
+        if (preg_match('/^(array<|map\[)/', $class)) { // for associative array e.g. array<string,int>
+            $data = is_string($data) ? json_decode($data) : $data;
+            settype($data, 'array');
+            $inner = substr($class, 4, -1);
+            $deserialized = [];
+            if (strrpos($inner, ",") !== false) {
+                $subClass_array = explode(',', $inner, 2);
+                $subClass = $subClass_array[1];
+                foreach ($data as $key => $value) {
+                    $deserialized[$key] = self::deserialize($value, $subClass, null);
+                }
+            }
+            return $deserialized;
+        }
+
+        if ($class === 'object') {
+            settype($data, 'array');
+            return $data;
+        } elseif ($class === 'mixed') {
+            settype($data, gettype($data));
+            return $data;
+        }
+
+        if ($class === '\DateTime') {
+            // Some APIs return an invalid, empty string as a
+            // date-time property. DateTime::__construct() will return
+            // the current time for empty input which is probably not
+            // what is meant. The invalid empty string is probably to
+            // be interpreted as a missing field/value. Let's handle
+            // this graceful.
+            if (!empty($data)) {
+                try {
+                    return new \DateTime($data);
+                } catch (\Exception $exception) {
+                    // Some APIs return a date-time with too high nanosecond
+                    // precision for php's DateTime to handle.
+                    // With provided regexp 6 digits of microseconds saved
+                    return new \DateTime(self::sanitizeTimestamp($data));
+                }
+            } else {
+                return null;
+            }
+        }
+
+        if ($class === '\SplFileObject') {
+            $data = Utils::streamFor($data);
+
+            /** @var \Psr\Http\Message\StreamInterface $data */
+
+            // determine file name
+            if (
+                is_array($httpHeaders)
+                && array_key_exists('Content-Disposition', $httpHeaders)
+                && preg_match('/inline; filename=[\'"]?([^\'"\s]+)[\'"]?$/i', $httpHeaders['Content-Disposition'], $match)
+            ) {
+                $filename = Configuration::getDefaultConfiguration()->getTempFolderPath() . DIRECTORY_SEPARATOR . self::sanitizeFilename($match[1]);
+            } else {
+                $filename = tempnam(Configuration::getDefaultConfiguration()->getTempFolderPath(), '');
+            }
+
+            $file = fopen($filename, 'w');
+            while ($chunk = $data->read(200)) {
+                fwrite($file, $chunk);
+            }
+            fclose($file);
+
+            return new \SplFileObject($filename, 'r');
+        }
+
+        /** @psalm-suppress ParadoxicalCondition */
+        if (in_array($class, ['\DateTime', '\SplFileObject', 'array', 'bool', 'boolean', 'byte', 'float', 'int', 'integer', 'mixed', 'number', 'object', 'string', 'void'], true)) {
+            settype($data, $class);
+            return $data;
+        }
+
+
+        if (method_exists($class, 'getAllowableEnumValues')) {
+            if (!in_array($data, $class::getAllowableEnumValues(), true)) {
+                $imploded = implode("', '", $class::getAllowableEnumValues());
+                throw new \InvalidArgumentException("Invalid value for enum '$class', must be one of: '$imploded'");
+            }
+            return $data;
+        } else {
+            $data = is_string($data) ? json_decode($data) : $data;
+
+            if (is_array($data)) {
+                $data = (object) $data;
+            }
+
+            // If a discriminator is defined and points to a valid subclass, use it.
+            $discriminator = $class::DISCRIMINATOR;
+            if (!empty($discriminator) && isset($data->{$discriminator}) && is_string($data->{$discriminator})) {
+                $subclass = '\WikipaliApi\Model\\' . $data->{$discriminator};
+                if (is_subclass_of($subclass, $class)) {
+                    $class = $subclass;
+                }
+            }
+
+            /** @var ModelInterface $instance */
+            $instance = new $class();
+            foreach ($instance::openAPITypes() as $property => $type) {
+                $propertySetter = $instance::setters()[$property];
+
+                if (!isset($propertySetter)) {
+                    continue;
+                }
+
+                if (!isset($data->{$instance::attributeMap()[$property]})) {
+                    if ($instance::isNullable($property)) {
+                        $instance->$propertySetter(null);
+                    }
+
+                    continue;
+                }
+
+                if (isset($data->{$instance::attributeMap()[$property]})) {
+                    $propertyValue = $data->{$instance::attributeMap()[$property]};
+                    $instance->$propertySetter(self::deserialize($propertyValue, $type, null));
+                }
+            }
+            return $instance;
+        }
+    }
+
+    /**
+     * Build a query string from an array of key value pairs.
+     *
+     * This function can use the return value of `parse()` to build a query
+     * string. This function does not modify the provided keys when an array is
+     * encountered (like `http_build_query()` would).
+     *
+     * The function is copied from https://github.com/guzzle/psr7/blob/a243f80a1ca7fe8ceed4deee17f12c1930efe662/src/Query.php#L59-L112
+     * with a modification which is described in https://github.com/guzzle/psr7/pull/603
+     *
+     * @param array     $params              Query string parameters.
+     * @param int|false $encoding            Set to false to not encode, PHP_QUERY_RFC3986
+     *                                       to encode using RFC3986, or PHP_QUERY_RFC1738
+     *                                       to encode using RFC1738.
+     */
+    public static function buildQuery(array $params, $encoding = PHP_QUERY_RFC3986): string
+    {
+        if (!$params) {
+            return '';
+        }
+
+        if ($encoding === false) {
+            $encoder = function (string $str): string {
+                return $str;
+            };
+        } elseif ($encoding === PHP_QUERY_RFC3986) {
+            $encoder = 'rawurlencode';
+        } elseif ($encoding === PHP_QUERY_RFC1738) {
+            $encoder = 'urlencode';
+        } else {
+            throw new \InvalidArgumentException('Invalid type');
+        }
+
+        $castBool = Configuration::BOOLEAN_FORMAT_INT == Configuration::getDefaultConfiguration()->getBooleanFormatForQueryString()
+            ? function ($v) { return (int) $v; }
+            : function ($v) { return $v ? 'true' : 'false'; };
+
+        $qs = '';
+        foreach ($params as $k => $v) {
+            $k = $encoder((string) $k);
+            if (!is_array($v)) {
+                $qs .= $k;
+                $v = is_bool($v) ? $castBool($v) : $v;
+                if ($v !== null) {
+                    $qs .= '='.$encoder((string) $v);
+                }
+                $qs .= '&';
+            } else {
+                foreach ($v as $vv) {
+                    $qs .= $k;
+                    $vv = is_bool($vv) ? $castBool($vv) : $vv;
+                    if ($vv !== null) {
+                        $qs .= '='.$encoder((string) $vv);
+                    }
+                    $qs .= '&';
+                }
+            }
+        }
+
+        return $qs ? substr($qs, 0, -1) : '';
+    }
+}

+ 47 - 0
api-test/config.php

@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * api-test 配置:测试服务器切换 + 鉴权 token。
+ *
+ * 服务器切换的优先级(高 → 低):
+ *   1. 命令行 `php run.php --server=<alias|完整 base URL>`
+ *   2. 环境变量 `API_TEST_BASE`(完整 base URL,覆盖 alias)
+ *   3. 本文件的 `default_server`
+ *
+ * alias 对齐 openapi/public/assets/protocol/main.yaml 里的 servers。
+ * 第一个 `/api` 是同源地址,CLI 客户端用不到,故略。
+ *
+ * token(测试 /v3/me/* 用)优先级:
+ *   1. `php run.php --token=...`
+ *   2. 环境变量 `API_TEST_TOKEN`
+ *   3. 都没有时,用下面的 username/password 调 `POST /v2/sign-in` 现换一个 token
+ *
+ * username/password(登录换 token 用)优先级:
+ *   1. `php run.php --username=... --password=...`
+ *   2. 环境变量 `API_TEST_USERNAME` / `API_TEST_PASSWORD`
+ *
+ * 写操作(POST/DELETE)在**所有服务器上都真实执行**,包括 prod——会产生垃圾数据,
+ * 由测试账号定期清理。
+ */
+
+return [
+    // alias => 完整 base URL(含 /api 前缀)
+    'servers' => [
+        'local'   => 'http://127.0.0.1:8000/api',
+        'staging' => 'https://staging.wikipali.org/api',
+        'next'    => 'https://next.wikipali.org/api',
+        'prod'    => 'https://www.wikipali.org/api',
+        'next-cn' => 'https://next.wikipali.cc/api',
+        'prod-cn' => 'https://www.wikipali.cc/api',
+    ],
+    'default_server' => 'local',
+
+    // bearer token;没有则 /v3/me/* 的“带 token”用例会 FAIL(不跳过)。
+    'token' => getenv('API_TEST_TOKEN') ?: null,
+
+    // 登录凭据:sign-in 用 username(也可以是 email)+ password。
+    'username' => getenv('API_TEST_USERNAME') ?: null,
+    'password' => getenv('API_TEST_PASSWORD') ?: null,
+
+    'timeout' => 20,
+];

+ 26 - 0
api-test/fixtures.php

@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * v3 用例的种子数据。
+ *
+ * 不同环境(local / staging / prod)数据不同,必要时按环境覆盖。
+ * 当前取值来自本地开发库实测 + openapi spec 里的 example。
+ */
+
+return [
+    // 一个 translation 类型、有翻译进度与译文数据的 channel
+    'channel' => '4414c96e-4903-11ef-af72-571c3fee08e6',
+
+    // reactions / reactions/tally 用的 target(collection)
+    'target_id' => '5b4511c1-c99f-47e7-9798-97b9e5229e18',
+
+    // tipitaka-related-paragraphs 的两组互斥坐标
+    'book'      => 1,
+    'para'      => 2,
+    'book_name' => 'dn1',
+    'cs_para'   => 4,
+
+    // 搜索关键词 + 一条真实 OpenSearch 文档 id(search/{id} 的 200 用例)
+    'search_q'       => 'dukkha',
+    'search_doc_id'  => 'term_f8afdcaa-ad68-4795-b3d8-d5f12bdb3a5f',
+];

+ 124 - 0
api-test/run.php

@@ -0,0 +1,124 @@
+<?php
+
+/**
+ * api-test 入口:装配配置 → 加载用例 → 运行 → 报告。
+ *
+ * 用法:
+ *   php run.php [--server=<alias|URL>] [--token=<bearer>] [--username=<u> --password=<p>] [--filter=<子串>] [--list]
+ *
+ * 例:
+ *   php run.php                          # 默认 local
+ *   php run.php --server=staging
+ *   php run.php --server=https://foo/api
+ *   php run.php --filter=search
+ *   php run.php --list                   # 列出可用服务器
+ *   php run.php --username=alice --password=secret   # 调 /v2/sign-in 换 token
+ *
+ * 退出码:0 全绿;1 有失败/错误;2 参数或加载错误。
+ */
+
+error_reporting(E_ALL & ~E_DEPRECATED);
+
+$opts = [];
+foreach (array_slice($argv, 1) as $arg) {
+    if (preg_match('/^--([^=]+)(?:=(.*))?$/', $arg, $m)) {
+        $opts[$m[1]] = $m[2] ?? true;
+    }
+}
+
+// --list:只打印服务器清单
+if (isset($opts['list'])) {
+    $cfg = require __DIR__ . '/config.php';
+    echo "可用服务器(--server=alias,默认 {$cfg['default_server']}):\n";
+    foreach ($cfg['servers'] as $alias => $url) {
+        printf("  %-8s %s\n", $alias, $url);
+    }
+    echo "\n也可直接 --server=<完整 base URL>。\n";
+    exit(0);
+}
+
+// 解析服务器
+$config = require __DIR__ . '/config.php';
+$servers = $config['servers'];
+$serverArg = is_string($opts['server'] ?? null) ? $opts['server'] : null;
+$envBase = getenv('API_TEST_BASE') ?: null;
+
+if ($envBase !== null) {
+    $config['base_url'] = rtrim($envBase, '/');
+} elseif ($serverArg !== null && isset($servers[$serverArg])) {
+    $config['base_url'] = $servers[$serverArg];
+} elseif ($serverArg !== null && preg_match('#^https?://#', $serverArg)) {
+    $config['base_url'] = rtrim($serverArg, '/');
+} else {
+    $config['base_url'] = $servers[$config['default_server']];
+}
+
+// token 覆盖
+if (is_string($opts['token'] ?? null) && $opts['token'] !== '') {
+    $config['token'] = $opts['token'];
+}
+
+// username/password 覆盖
+if (is_string($opts['username'] ?? null) && $opts['username'] !== '') {
+    $config['username'] = $opts['username'];
+}
+if (is_string($opts['password'] ?? null) && $opts['password'] !== '') {
+    $config['password'] = $opts['password'];
+}
+
+$GLOBALS['config'] = $config;
+$GLOBALS['fixtures'] = require __DIR__ . '/fixtures.php';
+
+require __DIR__ . '/src/bootstrap.php';
+
+// 没给 token 但给了账号密码 → 调 /v2/sign-in 现换一个
+if (empty($config['token']) && $config['username'] && $config['password']) {
+    try {
+        $config['token'] = login($config['username'], $config['password']);
+        $GLOBALS['config']['token'] = $config['token'];
+        echo "已用 {$config['username']} 登录(/v2/sign-in)换取 token\n\n";
+    } catch (Fail $e) {
+        echo "登录失败:{$e->getMessage()}(需登录的用例将 FAIL)\n\n";
+    }
+}
+
+// 加载用例
+$caseFiles = glob(__DIR__ . '/cases/*.php') ?: [];
+sort($caseFiles);
+foreach ($caseFiles as $file) {
+    require $file;
+}
+
+$tests = $GLOBALS['__tests'];
+$filter = is_string($opts['filter'] ?? null) ? $opts['filter'] : null;
+
+$pass = $fail = $skip = $error = 0;
+$start = microtime(true);
+
+printf("目标服务器: %s\n\n", $config['base_url']);
+
+foreach ($tests as [$name, $fn]) {
+    if ($filter !== null && ! str_contains($name, $filter)) {
+        continue;
+    }
+    try {
+        $fn();
+        $pass++;
+        echo "  ✅ PASS  {$name}\n";
+    } catch (Skip $e) {
+        $skip++;
+        echo "  ⏭️  SKIP  {$name}  ({$e->getMessage()})\n";
+    } catch (Fail $e) {
+        $fail++;
+        echo "  ❌ FAIL  {$name}\n        {$e->getMessage()}\n";
+    } catch (Throwable $e) {
+        $error++;
+        echo "  💥 ERROR {$name}\n        " . get_class($e) . ': ' . $e->getMessage() . "\n";
+    }
+}
+
+$total = $pass + $fail + $skip + $error;
+$elapsed = round(microtime(true) - $start, 2);
+printf("\n%d 用例:%d 通过 / %d 失败 / %d 跳过 / %d 错误(%.2fs)\n", $total, $pass, $fail, $skip, $error, $elapsed);
+
+exit($fail === 0 && $error === 0 ? 0 : 1);

+ 199 - 0
api-test/src/bootstrap.php

@@ -0,0 +1,199 @@
+<?php
+
+/**
+ * 测试引导:加载生成的 Guzzle 客户端 + 注册/断言 helper。
+ * 纯 PHP,无测试框架,不依赖 Laravel。
+ */
+
+require_once __DIR__ . '/../client/vendor/autoload.php';
+
+use GuzzleHttp\Client;
+use \WikipaliApi\ApiException;
+use \WikipaliApi\Configuration;
+
+final class Fail extends RuntimeException
+{
+}
+
+final class Skip extends RuntimeException
+{
+}
+
+$GLOBALS['__tests'] = [];
+
+function test(string $name, callable $fn): void
+{
+    $GLOBALS['__tests'][] = [$name, $fn];
+}
+
+function skip(string $reason): void
+{
+    throw new Skip($reason);
+}
+
+function fail(string $message): void
+{
+    throw new Fail($message);
+}
+
+function config(): array
+{
+    return $GLOBALS['config'];
+}
+
+function fixtures(): array
+{
+    return $GLOBALS['fixtures'];
+}
+
+/** 造一个已指向目标服务器的 Configuration;传 $token 时带 bearer。 */
+function makeConfig(?string $token = null): Configuration
+{
+    $c = new Configuration();
+    $c->setHost(config()['base_url']);
+    if ($token !== null) {
+        $c->setAccessToken($token);
+    }
+
+    return $c;
+}
+
+/** 实例化生成的 Api 类。$class 传完整 FQCN,如 \WikipaliApi\Api\HeartbeatApi::class */
+function api(string $class, ?string $token = null): object
+{
+    // http_errors 保持默认 true:生成的客户端靠 RequestException→ApiException 这条链
+    // 把非 2xx 的 body 塞进 ApiException(尤其 204-only 的 DELETE,它没有显式状态检查,
+    // 关掉 http_errors 会让错误 body 丢失)。
+    $http = new Client(['timeout' => config()['timeout']]);
+
+    return new $class($http, makeConfig($token));
+}
+
+/**
+ * 统一执行一次请求,返回 ['status'=>int, 'body'=>?array, 'headers'=>array]。
+ * $fn 必须返回 WithHttpInfo 的三元组 [$content, $status, $headers]。
+ * 非 2xx 时生成的客户端抛 ApiException,这里捕获并解出状态码与 body。
+ */
+function request(callable $fn): array
+{
+    try {
+        [$content, $status, $headers] = $fn();
+
+        return ['status' => $status, 'body' => normalize($content), 'headers' => $headers];
+    } catch (ApiException $e) {
+        $status = $e->getCode();
+        // 网络层错误(DNS/连接/超时)时,生成客户端把异常包成 ApiException 但 code 不是
+        // 合法 HTTP 状态(可能是 0,真实 curl 错误在 message 里);转成可读的 Fail。
+        if ($status < 100 || $status > 599) {
+            throw new Fail(sprintf('网络错误(服务器不可达):%s', $e->getMessage()));
+        }
+        $body = json_decode((string) $e->getResponseBody(), true);
+
+        return ['status' => $status, 'body' => is_array($body) ? $body : null, 'headers' => $e->getResponseHeaders()];
+    }
+}
+
+/**
+ * 原始 HTTP 请求:绕过生成客户端的必填参数校验,用于「缺参/非法组合」这类负向用例。
+ * $path 以 / 开头(如 /v3/reactions),base URL 已含 /api 前缀。
+ */
+function raw(string $method, string $path, ?array $query = null, ?array $json = null, ?string $token = null): array
+{
+    $http = new Client(['timeout' => config()['timeout'], 'http_errors' => false]);
+    $opts = ['headers' => []];
+    if ($query !== null) {
+        $opts['query'] = $query;
+    }
+    if ($json !== null) {
+        $opts['json'] = $json;
+    }
+    if ($token !== null) {
+        $opts['headers']['Authorization'] = 'Bearer ' . $token;
+    }
+    try {
+        $resp = $http->request($method, config()['base_url'] . $path, $opts);
+    } catch (\GuzzleHttp\Exception\GuzzleException $e) {
+        // 网络层错误(DNS 解析失败、连接拒绝、超时等)→ 转成 Fail,别让 Guzzle 异常
+        // 一路冒成 fatal error。消息带上目标,方便定位是哪个服务器连不上。
+        throw new Fail(sprintf('网络错误(%s %s%s):%s', strtoupper($method), config()['base_url'], $path, $e->getMessage()));
+    }
+    $body = json_decode((string) $resp->getBody(), true);
+
+    return ['status' => $resp->getStatusCode(), 'body' => is_array($body) ? $body : null, 'headers' => $resp->getHeaders()];
+}
+
+function normalize(mixed $content): mixed
+{
+    if ($content === null || is_array($content)) {
+        return $content;
+    }
+    if (is_object($content)) {
+        return json_decode((string) json_encode($content), true);
+    }
+
+    return $content;
+}
+
+/**
+ * 调 v2 登录接口换 bearer token。
+ * `POST /v2/sign-in`(username 可以是用户名或邮箱),成功返回 {ok,data:JWT},
+ * 失败也是 JSON({ok:false,message})且 HTTP 可能 200 或 400,所以只看 ok 字段。
+ */
+function login(string $username, string $password): string
+{
+    $res = raw('POST', '/v2/sign-in', null, ['username' => $username, 'password' => $password]);
+    if (($res['body']['ok'] ?? false) === true && is_string($res['body']['data'] ?? null) && $res['body']['data'] !== '') {
+        return $res['body']['data'];
+    }
+    fail(sprintf('登录失败(HTTP %d):%s', $res['status'], $res['body']['message'] ?? '未知错误'));
+}
+
+/** 取当前 token;没有就 FAIL(需登录用例不跳过,缺 token 就是失败)。 */
+function required_token(): string
+{
+    $token = config()['token'];
+    if (! $token) {
+        fail('缺少 token:请用 --token= 或 --username/--password(自动调 /v2/sign-in)');
+    }
+
+    return $token;
+}
+
+/* ---- 断言 ---- */
+
+function assert_status(int $expected, array $res, string $what): void
+{
+    if ($res['status'] !== $expected) {
+        fail(sprintf(
+            '%s:期望 HTTP %d,实际 %d,body=%s',
+            $what,
+            $expected,
+            $res['status'],
+            substr((string) json_encode($res['body'], JSON_UNESCAPED_UNICODE), 0, 400)
+        ));
+    }
+}
+
+function assert_has_key(string $key, mixed $value, string $what): void
+{
+    if (! is_array($value) || ! array_key_exists($key, $value)) {
+        fail(sprintf('%s:响应缺少字段 `%s`,body=%s', $what, $key, substr((string) json_encode($value, JSON_UNESCAPED_UNICODE), 0, 400)));
+    }
+}
+
+function assert_eq(mixed $expected, mixed $actual, string $what): void
+{
+    if ($expected !== $actual) {
+        fail(sprintf('%s:期望 %s,实际 %s', $what, var_export($expected, true), var_export($actual, true)));
+    }
+}
+
+/** 断言失败响应是 RFC 9457 Problem Details,且 status 与 HTTP 码一致。 */
+function assert_problem(array $res, string $what): void
+{
+    $b = $res['body'];
+    if (! is_array($b) || ! isset($b['type'], $b['title'], $b['status'])) {
+        fail(sprintf('%s:不是 Problem Details(缺 type/title/status),body=%s', $what, substr((string) json_encode($b, JSON_UNESCAPED_UNICODE), 0, 400)));
+    }
+    assert_eq($res['status'], (int) $b['status'], $what . ':Problem.status 与 HTTP 状态码不一致');
+}

+ 169 - 0
api-test/tools/filter-v3.php

@@ -0,0 +1,169 @@
+<?php
+
+/**
+ * 从打包后的全量 OpenAPI spec 里切出仅 /v3/* 的部分,供 openapi-generator
+ * 生成精简的 PHP 客户端(目前只测 /api/v3 路由)。
+ *
+ * 用法:
+ *   php tools/filter-v3.php spec/openapi.json spec/openapi-v3.json
+ *
+ * 逻辑:保留 /v3/* 路径,并递归收集它们引用的 components(schemas / responses /
+ * parameters / requestBodies / examples / headers / securitySchemes / links /
+ * callbacks),其余组件与路径一律丢弃。
+ */
+
+if (PHP_SAPI !== 'cli') {
+    fwrite(STDERR, "只允许 CLI 运行\n");
+    exit(2);
+}
+
+[$script, $in, $out] = array_pad($argv, 3, null);
+if (!$in || !$out || !is_file($in)) {
+    fwrite(STDERR, "用法: php {$script} <in.json> <out.json>\n");
+    exit(2);
+}
+
+$spec = json_decode(file_get_contents($in), true);
+if (!is_array($spec) || !isset($spec['paths'])) {
+    fwrite(STDERR, "无法解析 {$in}\n");
+    exit(2);
+}
+
+/** 递归收集节点里所有指向 #/components/ 的 $ref */
+function collectComponentRefs(mixed $node, array &$refs): void
+{
+    if (!is_array($node)) {
+        return;
+    }
+    if (isset($node['$ref']) && is_string($node['$ref']) && str_starts_with($node['$ref'], '#/components/')) {
+        $refs[$node['$ref']] = true;
+        // $ref 节点本身不再展开(其内容在 components 里,会被下一轮收集)
+        return;
+    }
+    foreach ($node as $value) {
+        collectComponentRefs($value, $refs);
+    }
+}
+
+/** 按 "#/components/{category}/{name}" 取节点 */
+function resolveComponent(array $spec, string $ref): mixed
+{
+    $parts = explode('/', ltrim($ref, '#/'));
+    // ['components', category, name]
+    return $spec[$parts[0]][$parts[1]][$parts[2]] ?? null;
+}
+
+// 1) 选出 v3 路径
+$v3Paths = [];
+foreach ($spec['paths'] as $path => $item) {
+    if (str_starts_with((string) $path, '/v3/')) {
+        $v3Paths[$path] = $item;
+    }
+}
+if ($v3Paths === []) {
+    fwrite(STDERR, "spec 里没有 /v3/* 路径\n");
+    exit(2);
+}
+
+// 2) 修正鉴权语义:源 spec 用全局 security 把全部路径标成 bearerAuth,
+//    但 v3 实际只有 /v3/me/* 挂 auth.v3。这里逐 operation 写准,并清掉全局 security。
+foreach ($v3Paths as $path => &$item) {
+    foreach ($item as $method => &$op) {
+        if (!is_array($op) || strtolower($method) === 'parameters') {
+            continue;
+        }
+        $op['security'] = str_starts_with((string) $path, '/v3/me/')
+            ? [['bearerAuth' => []]]
+            : [];
+    }
+    unset($op);
+}
+unset($item);
+
+// 3) 递归收集引用的组件(含 security scheme)
+$refs = ['#/components/securitySchemes/bearerAuth' => true];
+collectComponentRefs($v3Paths, $refs);
+do {
+    $before = count($refs);
+    foreach (array_keys($refs) as $ref) {
+        $node = resolveComponent($spec, $ref);
+        if (is_array($node)) {
+            collectComponentRefs($node, $refs);
+        }
+    }
+} while (count($refs) !== $before);
+
+// 4) 只保留被引用的组件(保留原始分类顺序)
+$categories = ['schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks'];
+$components = $spec['components'] ?? [];
+$keptComponents = [];
+foreach ($categories as $cat) {
+    if (!isset($components[$cat])) {
+        continue;
+    }
+    foreach ($components[$cat] as $name => $node) {
+        $ref = "#/components/{$cat}/{$name}";
+        if (isset($refs[$ref])) {
+            $keptComponents[$cat][$name] = $node;
+        }
+    }
+}
+
+$result = [];
+foreach (['openapi', 'info', 'servers', 'tags', 'externalDocs'] as $key) {
+    if (isset($spec[$key])) {
+        $result[$key] = $spec[$key];
+    }
+}
+$result['components'] = $keptComponents;
+$result['paths'] = $v3Paths;
+
+// 5) 修掉源 spec 的数组缺 items 问题:generate.php 给 query/body 参数只写了
+//    `type: array` 没写 `items`(严格校验会拒绝)。v3 里的这类参数都是字符串数组
+//    (page_refs / related_id),补成 string items。
+normalizeArraySchemas($result);
+
+// 6) 修掉「spec 与实现不一致」的 3 个端点(搜索/建议/升级):实现仍返回
+//    {success, data:对象} 或 {data:对象},而 spec 写成了 {data:数组, meta}。
+//    按现实修正 200 响应 schema,否则生成的客户端反序列化会抛
+//    "Invalid array 'object[]'"。根因在控制器还没迁到 v3 Resource 信封,
+//    正确的长期修法在 api-v13 侧,这里只是让测试客户端能用。
+applyDriftPatches($result);
+
+file_put_contents($out, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+fwrite(STDOUT, 'v3 paths: ' . count($v3Paths) . ', components refs: ' . count($refs) . " -> {$out}\n");
+
+/** 递归给缺 items 的 `type: array` schema 补 string items */
+function normalizeArraySchemas(mixed &$node): void
+{
+    if (! is_array($node)) {
+        return;
+    }
+    if (($node['type'] ?? null) === 'array' && ! isset($node['items'])) {
+        $node['items'] = ['type' => 'string'];
+    }
+    foreach ($node as &$value) {
+        normalizeArraySchemas($value);
+    }
+    unset($value);
+}
+
+/** 修正 search / search-suggest / upgrade 的 200 响应 schema,使其与实现一致 */
+function applyDriftPatches(array &$spec): void
+{
+    $object = ['type' => 'object'];
+    $dataObject = ['type' => 'object', 'properties' => ['data' => $object]];
+    $successData = ['type' => 'object', 'properties' => ['success' => ['type' => 'boolean'], 'data' => $object]];
+
+    foreach (['get', 'post'] as $method) {
+        if (isset($spec['paths']['/v3/search'][$method]['responses']['200']['content']['application/json']['schema'])) {
+            $spec['paths']['/v3/search'][$method]['responses']['200']['content']['application/json']['schema'] = $successData;
+        }
+    }
+    if (isset($spec['paths']['/v3/search-suggest']['get']['responses']['200']['content']['application/json']['schema'])) {
+        $spec['paths']['/v3/search-suggest']['get']['responses']['200']['content']['application/json']['schema'] = $successData;
+    }
+    if (isset($spec['paths']['/v3/upgrade']['get']['responses']['200']['content']['application/json']['schema'])) {
+        $spec['paths']['/v3/upgrade']['get']['responses']['200']['content']['application/json']['schema'] = $dataObject;
+    }
+}

+ 54 - 0
api-test/tools/regen.sh

@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+#
+# 一键重新生成测试客户端:
+#   全量 spec bundle → 切出 /v3 + 打补丁 → openapi-generator 生成 PHP(Guzzle) 客户端 → composer install
+#
+# 依赖:
+#   - Java 17+(openapi-generator 是 jar)
+#   - npx(openapi/ 里已装 @redocly/cli)
+#   - php / composer
+#
+# openapi-generator-cli.jar 通过环境变量 OPENAPI_GENERATOR_JAR 指定,
+# 默认 /tmp/og/openapi-generator-cli.jar。下载方式:
+#   curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.25.0/openapi-generator-cli-7.25.0.jar \
+#     -o /tmp/og/openapi-generator-cli.jar
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+OG_JAR="${OPENAPI_GENERATOR_JAR:-/tmp/og/openapi-generator-cli.jar}"
+COMPOSER_CACHE="${COMPOSER_CACHE_DIR:-/tmp/composer-cache}"
+
+if [ ! -f "$OG_JAR" ]; then
+  echo "找不到 openapi-generator-cli.jar:$OG_JAR" >&2
+  echo "下载:curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.25.0/openapi-generator-cli-7.25.0.jar -o /tmp/og/openapi-generator-cli.jar" >&2
+  exit 1
+fi
+
+echo "1/4  bundle 全量 spec → spec/openapi.json"
+(cd "$ROOT/openapi" && npx redocly bundle public/assets/protocol/main.yaml -o "$ROOT/api-test/spec/openapi.json" --ext json)
+
+echo "2/4  切出 /v3 + 打补丁 → spec/openapi-v3.json"
+php "$ROOT/api-test/tools/filter-v3.php" "$ROOT/api-test/spec/openapi.json" "$ROOT/api-test/spec/openapi-v3.json"
+
+echo "3/4  openapi-generator 生成 PHP 客户端 → client/"
+rm -rf "$ROOT/api-test/client"
+java -jar "$OG_JAR" generate -g php \
+  -i "$ROOT/api-test/spec/openapi-v3.json" \
+  -o "$ROOT/api-test/client" \
+  --additional-properties=invokerPackage=WikipaliApi,packageName=wikipali/api-client,artifactVersion=1.0.0
+
+# 清掉 openapi-generator 的样板文件(.openapi-generator-ignore 只能防止覆盖已编辑文件,
+# 不能阻止生成,所以这里生成后直接删,只留 lib/ + composer.json)
+rm -rf \
+  "$ROOT/api-test/client/docs" \
+  "$ROOT/api-test/client/test" \
+  "$ROOT/api-test/client/README.md" \
+  "$ROOT/api-test/client/.travis.yml" \
+  "$ROOT/api-test/client/phpunit.xml.dist" \
+  "$ROOT/api-test/client/.php-cs-fixer.dist.php" \
+  "$ROOT/api-test/client/git_push.sh"
+
+echo "4/4  composer install(Guzzle)"
+(cd "$ROOT/api-test/client" && COMPOSER_CACHE_DIR="$COMPOSER_CACHE" composer install --no-dev --no-interaction --no-progress)
+
+echo "完成。运行:php api-test/run.php --server=local"

+ 2 - 1
api-v13/app/Console/Commands/ExportPaliSynonyms.php

@@ -56,7 +56,8 @@ class ExportPaliSynonyms extends Command
         $dictId[] = DictApi::getSysDict('robot_compound');
 */
         $filename =  $this->option('output');
-        $fp = fopen(Str::finish(config('mint.opensearch.config.synonyms_path'), '/') . $filename, 'w') or exit('Unable to open file!');
+        $BASE_PATH = '/mnt/nfs/pali/';
+        $fp = fopen($BASE_PATH . $filename, 'w') or exit('Unable to open file!');
 
         $parents = UserDict::select('parent')
             ->whereNotNull('parent')

+ 2 - 2
api-v13/app/Console/Commands/MqExport.php

@@ -43,7 +43,7 @@ class MqExport extends Command
         $exchange = 'router';
         $queue = 'export';
         $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
-        Log::debug('mq:progress start.');
+        Log::debug('mq:export start.');
         Mq::worker($exchange, $queue, function ($message) {
             $data = [
                 'book' => $message->book,
@@ -60,7 +60,7 @@ class MqExport extends Command
             }
             $ok = $this->call('export:chapter', $data);
             if ($ok !== 0) {
-                Log::error('mq:progress upgrade:progress fail', $data);
+                Log::error('mq:export export:chapter fail', $data);
             } else {
                 $this->info('Received book='.$message->book.' result='.$ok);
                 Log::debug('mq:export: done ', $data);

+ 1 - 1
api-v13/app/Console/Commands/MqExportPaliChapter.php

@@ -63,7 +63,7 @@ class MqExportPaliChapter extends Command
             }
             $ok = $this->call('export:chapter', $data);
             if ($ok !== 0) {
-                Log::error('mq:export.pali.chapter upgrade:progress fail', $data);
+                Log::error('mq:export.pali.chapter export:chapter fail', $data);
             } else {
                 $this->info('Received book='.$message->book.' result='.$ok);
                 Log::debug('mq:export.pali.chapter done ', $data);

+ 2 - 2
api-v13/app/Console/Commands/MqProgress.php

@@ -54,9 +54,9 @@ class MqProgress extends Command
                 '--para' => $message->para,
                 '--channel' => $message->channel,
             ];
-            $ok1 = $this->call('upgrade:progress', $data);
+            $ok1 = $this->call('upgrade:progress.para', $data);
             if ($ok1 !== 0) {
-                Log::error('mq:progress upgrade:progress fail', $data);
+                Log::error('mq:progress upgrade:progress.para fail', $data);
             }
             $ok2 = $this->call('upgrade:progress.chapter', $data);
             if ($ok2 !== 0) {

+ 567 - 0
api-v13/app/Console/Commands/TestMqProgress.php

@@ -0,0 +1,567 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Channel;
+use App\Models\PaliSentence;
+use App\Models\Progress;
+use App\Models\ProgressChapter;
+use App\Services\AuthService;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Console\Attributes\Description;
+use Illuminate\Console\Attributes\Signature;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+use Symfony\Component\Process\Process;
+
+#[Signature('test:mq-progress {--book=93} {--channel=} {--limit=} {--wait=1} {--base-url=} {--mq-log=} {--no-start} {--keep} {--show-every=10} {--drain-timeout=14400}')]
+#[Description('Loop-test mq:progress by writing one sentence translation per API call and monitoring MQ output, logs and DB tables')]
+class TestMqProgress extends Command
+{
+    /** worker 输出 / 日志里需要高亮为异常的关键词 */
+    private const ERROR_MARKERS = [
+        'ambiguous',
+        'Command "',
+        'is not defined',
+        'exception',
+        'Exception',
+        'SQLSTATE',
+        'fail',
+        'task error',
+    ];
+
+    /** laravel.log 里值得展示的关键词(其余 debug/info 噪音折叠为计数) */
+    private const LARAVEL_RELEVANT = [
+        'mq:progress',
+        'upgrade:progress',
+        'mq worker',
+        'received message',
+        'mq done',
+        'ERROR',
+        'exception',
+        'ambiguous',
+        'Command "',
+        'SQLSTATE',
+        'fail',
+    ];
+
+    private string $mqLogPath = '';
+
+    private int $mqLogOffset = 0;
+
+    private int $laravelLogOffset = 0;
+
+    private int $laravelSuppressed = 0;
+
+    public function handle(): int
+    {
+        $book = (int) ($this->option('book') ?: 93);
+        $channelUid = $this->option('channel');
+        $limit = $this->option('limit') !== null && $this->option('limit') !== '' ? (int) $this->option('limit') : null;
+        $wait = max(0, (int) ($this->option('wait') ?: 1));
+        $baseUrl = rtrim((string) ($this->option('base-url') ?: config('app.url')), '/');
+        $this->mqLogPath = (string) ($this->option('mq-log') ?: storage_path('logs/mq-progress-test.log'));
+        $showEvery = max(1, (int) ($this->option('show-every') ?: 10));
+        $drainTimeout = max(0, (int) ($this->option('drain-timeout') ?: 14400));
+        $noStart = (bool) $this->option('no-start');
+        $keep = (bool) $this->option('keep');
+
+        $token = trim((string) env('TESTING_TOKEN', ''));
+        $account = (string) env('TESTING_ACCOUNT', '');
+
+        if ($token === '') {
+            $this->error('TESTING_TOKEN is empty in .env');
+
+            return 1;
+        }
+
+        $accountUid = $this->decodeUid($token);
+        if (! $accountUid) {
+            $this->error('TESTING_TOKEN is invalid or expired');
+
+            return 1;
+        }
+
+        if (! $channelUid) {
+            $channelUid = $this->resolveChannel($accountUid);
+            if (! $channelUid) {
+                $this->error('No translation channel found for the account. Pass --channel=<uid>.');
+
+                return 1;
+            }
+            $this->warn("Auto-selected channel: {$channelUid}");
+        }
+
+        $this->newLine();
+        $this->info('== test:mq-progress ==');
+        $this->line("  account     : {$account} ({$accountUid})");
+        $this->line("  book        : {$book}");
+        $this->line("  channel     : {$channelUid}");
+        $this->line("  base url    : {$baseUrl}");
+        $this->line('  mq log      : '.$this->mqLogPath);
+        $this->line('  laravel log : '.storage_path('logs/laravel.log'));
+        $this->newLine();
+
+        // 先确认 token 有效,避免每一句都 401
+        $authResp = Http::timeout(15)->withToken($token)->get("{$baseUrl}/api/v2/auth/current");
+        if ($authResp->failed() || $authResp->json('ok') !== true) {
+            $this->error('auth check failed: HTTP '.$authResp->status().' '.mb_substr($authResp->body(), 0, 200));
+
+            return 1;
+        }
+        $this->info('auth check ok.');
+
+        $paliRows = PaliSentence::where('book', $book)
+            ->orderBy('paragraph')
+            ->orderBy('word_begin')
+            ->get();
+        $total = $paliRows->count();
+        if ($limit !== null) {
+            $paliRows = $paliRows->take($limit);
+        }
+        $this->info("pali_sentences book={$book}: {$total}".($limit !== null ? " (writing first {$paliRows->count()})" : ''));
+
+        if ($paliRows->isEmpty()) {
+            $this->error('no pali sentences found');
+
+            return 1;
+        }
+
+        $before = $this->snapshot($book, $channelUid);
+        $this->info('DB snapshot (before):');
+        $this->table(['metric', 'value'], $this->snapshotRows($before));
+        $this->newLine();
+
+        // 重置 mq 输出文件并记录 laravel.log 当前位置
+        $this->resetFile($this->mqLogPath);
+        $this->mqLogOffset = 0;
+        $this->laravelLogOffset = $this->fileSize(storage_path('logs/laravel.log'));
+        $this->laravelSuppressed = 0;
+
+        $process = null;
+        if (! $noStart) {
+            $process = $this->startWorker();
+            $this->info('mq:progress started (pid='.$process->getPid().'), waiting for queue...');
+            sleep(2);
+            $this->flushMqLog($process);
+        } else {
+            $this->warn('--no-start: assuming mq:progress is already running');
+        }
+
+        $url = "{$baseUrl}/api/v2/sentence";
+        $okCount = 0;
+        $failCount = 0;
+        $lastFingerprint = $before['fingerprint'];
+        $mqErrors = 0;
+
+        $this->newLine();
+        $this->info("Start writing {$paliRows->count()} sentences (one POST each)...");
+        $this->newLine();
+
+        foreach ($paliRows as $i => $pali) {
+            $idx = $i + 1;
+            $content = mb_substr((string) $pali->text, 0, 20, 'UTF-8');
+            if ($content === '') {
+                $this->warn("[{$idx}] skip empty text para={$pali->paragraph} word_begin={$pali->word_begin}");
+
+                continue;
+            }
+
+            $payload = [
+                'channel' => $channelUid,
+                'sentences' => [[
+                    'book_id' => $book,
+                    'paragraph' => (int) $pali->paragraph,
+                    'word_start' => (int) $pali->word_begin,
+                    'word_end' => (int) $pali->word_end,
+                    'content' => $content,
+                ]],
+            ];
+
+            $status = 0;
+            $body = null;
+
+            try {
+                $resp = Http::timeout(30)->withToken($token)->post($url, $payload);
+                $status = $resp->status();
+                $body = $resp->json();
+                $written = $body['data']['count'] ?? null;
+                $ok = $resp->successful() && ($body['ok'] ?? false) === true;
+            } catch (\Throwable $e) {
+                $ok = false;
+                $written = null;
+                $this->error("[{$idx}] HTTP exception: {$e->getMessage()}");
+            }
+
+            if ($ok) {
+                $okCount++;
+                $this->line("[{$idx}/{$paliRows->count()}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} written={$written}");
+            } else {
+                $failCount++;
+                $this->error("[{$idx}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} FAILED ".mb_substr((string) json_encode($body, JSON_UNESCAPED_UNICODE), 0, 220));
+            }
+
+            if ($wait > 0) {
+                sleep($wait);
+            }
+
+            // 拉取 worker 控制台输出和 laravel.log 新内容
+            $mqErrors += $this->flushMqLog($process);
+            $mqErrors += $this->flushLaravelLog();
+
+            // 每 N 句报告一次 DB 指纹变化
+            if ($idx % $showEvery === 0 || $idx === $paliRows->count()) {
+                $snap = $this->snapshot($book, $channelUid);
+                if ($snap['fingerprint'] !== $lastFingerprint) {
+                    $lastFingerprint = $snap['fingerprint'];
+                    $this->line('  [db] progress_rows='.$snap['progress_count'].' all_strlen='.$snap['progress_sum']
+                        .' chapter_rows='.$snap['chapter_count'].' titles='.$snap['chapter_titles_count']);
+                }
+            }
+        }
+
+        // 等 worker 把队列全部消费完,保证最终 DB 快照准确
+        $received = $okCount;
+        if ($process !== null && $okCount > 0 && $drainTimeout > 0) {
+            [$received, $drainErrors] = $this->waitForWorkerToDrain($process, $okCount, $drainTimeout);
+            $mqErrors += $drainErrors;
+        }
+
+        sleep(1);
+        $mqErrors += $this->flushMqLog($process);
+        $mqErrors += $this->flushLaravelLog();
+
+        $this->newLine();
+        $this->info('== results ==');
+        $after = $this->snapshot($book, $channelUid);
+
+        $this->line('-- DB changes --');
+        $this->table(['metric', 'before', 'after', 'delta'], [
+            ['progress rows', (string) $before['progress_count'], (string) $after['progress_count'], (string) ($after['progress_count'] - $before['progress_count'])],
+            ['progress all_strlen sum', (string) $before['progress_sum'], (string) $after['progress_sum'], (string) ($after['progress_sum'] - $before['progress_sum'])],
+            ['progress_chapters rows', (string) $before['chapter_count'], (string) $after['chapter_count'], (string) ($after['chapter_count'] - $before['chapter_count'])],
+            ['progress_chapters titles', (string) $before['chapter_titles_count'], (string) $after['chapter_titles_count'], (string) ($after['chapter_titles_count'] - $before['chapter_titles_count'])],
+        ]);
+
+        $this->line('-- progress_chapters sample (title 应为写入内容的前 20 字符) --');
+        $samples = ProgressChapter::where('book', $book)
+            ->where('channel_id', $channelUid)
+            ->orderBy('para')
+            ->limit(10)
+            ->get(['para', 'title', 'progress', 'updated_at'])
+            ->toArray();
+        if ($samples) {
+            $this->table(
+                ['para', 'title', 'progress', 'updated_at'],
+                array_map(fn ($r) => [
+                    (string) $r['para'],
+                    mb_substr((string) $r['title'], 0, 30, 'UTF-8'),
+                    (string) $r['progress'],
+                    (string) $r['updated_at'],
+                ], $samples)
+            );
+        } else {
+            $this->line('  (no rows)');
+        }
+
+        $this->newLine();
+        $this->line('-- summary --');
+        $this->line("  api writes ok    : {$okCount}");
+        $this->line("  api writes failed: {$failCount}");
+        $this->line("  worker processed : {$received}/{$okCount}");
+        $this->line("  error lines (mq console + laravel.log): {$mqErrors}");
+        $this->line('  laravel.log suppressed noise: '.$this->laravelSuppressed);
+        $dbChanged = $after['fingerprint'] !== $before['fingerprint'];
+        $this->line('  db changed       : '.($dbChanged ? 'yes' : 'no'));
+
+        if ($okCount > 0 && ! $dbChanged) {
+            $this->error('⚠ 有写入成功但 progress / progress_chapters 完全没有变化 —— 说明 MqProgress 没有真正更新 DB(疑似 bug)。');
+        }
+
+        if ($process && ! $keep) {
+            $this->info('stopping mq:progress worker...');
+            $this->stopWorker($process);
+        } elseif ($keep) {
+            $this->warn('--keep: worker left running');
+        }
+
+        return $failCount === 0 && $mqErrors === 0 ? 0 : 1;
+    }
+
+    /** 从 token 解析账号 uid */
+    private function decodeUid(string $token): ?string
+    {
+        try {
+            $jwt = JWT::decode($token, new Key(AuthService::getJwtKey(), 'HS512'));
+
+            return $jwt->uid ?? null;
+        } catch (\Throwable $e) {
+            return null;
+        }
+    }
+
+    /** 默认选账号名下第一个 translation channel */
+    private function resolveChannel(string $accountUid): ?string
+    {
+        $candidates = Channel::where('owner_uid', $accountUid)
+            ->where('type', 'translation')
+            ->where('status', 30)
+            ->orderBy('name')
+            ->get(['uid', 'name']);
+
+        if ($candidates->isEmpty()) {
+            return null;
+        }
+        $this->line('candidate translation channels:');
+        foreach ($candidates as $c) {
+            $this->line("  - {$c->uid} ({$c->name})");
+        }
+
+        return $candidates->first()->uid;
+    }
+
+    private function snapshot(int $book, string $channelUid): array
+    {
+        $progressQuery = Progress::where('book', $book)->where('channel_id', $channelUid);
+        $chapterQuery = ProgressChapter::where('book', $book)->where('channel_id', $channelUid);
+
+        $snap = [
+            'progress_count' => (clone $progressQuery)->count(),
+            'progress_sum' => (int) (clone $progressQuery)->sum('all_strlen'),
+            'progress_max_updated_at' => (string) (clone $progressQuery)->max('updated_at'),
+            'chapter_count' => (clone $chapterQuery)->count(),
+            'chapter_titles_count' => (clone $chapterQuery)->whereNotNull('title')->where('title', '!=', '')->count(),
+            'chapter_max_updated_at' => (string) (clone $chapterQuery)->max('updated_at'),
+        ];
+        $snap['fingerprint'] = implode('|', [
+            $snap['progress_count'],
+            $snap['progress_sum'],
+            $snap['progress_max_updated_at'],
+            $snap['chapter_count'],
+            $snap['chapter_titles_count'],
+            $snap['chapter_max_updated_at'],
+        ]);
+
+        return $snap;
+    }
+
+    /** @return array<int, array{0: string, 1: string}> */
+    private function snapshotRows(array $snap): array
+    {
+        return [
+            ['progress rows', (string) $snap['progress_count']],
+            ['progress all_strlen sum', (string) $snap['progress_sum']],
+            ['progress max updated_at', $snap['progress_max_updated_at']],
+            ['progress_chapters rows', (string) $snap['chapter_count']],
+            ['progress_chapters titles', (string) $snap['chapter_titles_count']],
+            ['progress_chapters max updated_at', $snap['chapter_max_updated_at']],
+        ];
+    }
+
+    private function startWorker(): Process
+    {
+        $this->resetFile($this->mqLogPath);
+
+        // 数组形式会让 Symfony 自动加 `exec`,stop() 的信号能直接打到 php 进程,不会留下孤儿 worker。
+        $process = new Process(['php', 'artisan', 'mq:progress'], base_path(), null, null, null);
+        $process->setTimeout(null);
+        $process->start();
+
+        return $process;
+    }
+
+    /** 把 worker 管道里的增量输出转存到 mq 日志文件(stdout 行缓冲,管道可实时读到) */
+    private function drainWorker(Process $process): void
+    {
+        $out = $process->getIncrementalOutput();
+        $err = $process->getIncrementalErrorOutput();
+        if ($out !== '') {
+            file_put_contents($this->mqLogPath, $out, FILE_APPEND);
+        }
+        if ($err !== '') {
+            file_put_contents($this->mqLogPath, $err, FILE_APPEND);
+        }
+    }
+
+    /**
+     * 等待 worker 处理完 expected 条消息(以控制台里的 "Received book=" 行计数)。
+     *
+     * @return array{0: int, 1: int} [已处理条数, 期间命中的错误行数]
+     */
+    private function waitForWorkerToDrain(Process $process, int $expected, int $timeoutSeconds): array
+    {
+        $deadline = time() + $timeoutSeconds;
+        $lastLog = 0;
+        $received = 0;
+        $errors = 0;
+
+        while (true) {
+            $before = $this->fileSize($this->mqLogPath);
+            $this->drainWorker($process);
+            $after = $this->fileSize($this->mqLogPath);
+
+            if ($after > $before) {
+                $errors += $this->scanFileErrors($this->mqLogPath, $before, $after);
+                // 静默推进 offset,避免结束时把几千行 drain 内容刷屏
+                $this->mqLogOffset = $after;
+            }
+
+            $received = substr_count((string) @file_get_contents($this->mqLogPath), 'Received book=');
+            $now = time();
+
+            if ($received >= $expected) {
+                $this->info("worker drained all messages ({$received}/{$expected})");
+
+                break;
+            }
+            if (! $process->isRunning()) {
+                $this->warn("worker exited before draining ({$received}/{$expected})");
+
+                break;
+            }
+            if ($now >= $deadline) {
+                $this->warn("drain timeout after {$timeoutSeconds}s ({$received}/{$expected})");
+
+                break;
+            }
+            if ($now - $lastLog >= 30) {
+                $this->line("  [drain] worker processed {$received}/{$expected} messages, waiting...");
+                $lastLog = $now;
+            }
+
+            sleep(2);
+        }
+
+        return [$received, $errors];
+    }
+
+    /** 统计文件 [start,end) 字节区间内命中错误关键词的行数 */
+    private function scanFileErrors(string $path, int $start, int $end): int
+    {
+        $fh = @fopen($path, 'r');
+        if (! $fh) {
+            return 0;
+        }
+        fseek($fh, $start);
+        $content = (string) fread($fh, $end - $start);
+        fclose($fh);
+
+        $errors = 0;
+        foreach (preg_split('/\r\n|\r|\n/', $content) ?: [] as $line) {
+            if ($line !== '' && $this->hasErrorMarker($line)) {
+                $errors++;
+            }
+        }
+
+        return $errors;
+    }
+
+    private function stopWorker(Process $process): void
+    {
+        if (! $process->isRunning()) {
+            return;
+        }
+        try {
+            $process->stop(5, 15);
+        } catch (\Throwable $e) {
+            // 忽略超时,下面强制杀
+        }
+        if ($process->isRunning()) {
+            try {
+                $process->stop(0, 9);
+            } catch (\Throwable $e) {
+                // ignore
+            }
+        }
+    }
+
+    /** 输出 worker 控制台文件新内容,返回命中的错误行数 */
+    private function flushMqLog(?Process $process = null): int
+    {
+        if ($process !== null) {
+            $this->drainWorker($process);
+        }
+
+        return $this->flushFile($this->mqLogPath, $this->mqLogOffset, 'mq', true);
+    }
+
+    private function flushLaravelLog(): int
+    {
+        return $this->flushFile(storage_path('logs/laravel.log'), $this->laravelLogOffset, 'laravel', false);
+    }
+
+    /** @param  bool  $showAll  true=逐行全打印;false=只打印相关/错误行,其余折叠计数 */
+    private function flushFile(string $path, int &$offset, string $label, bool $showAll): int
+    {
+        $size = $this->fileSize($path);
+        if ($size <= $offset) {
+            return 0;
+        }
+        $fh = @fopen($path, 'r');
+        if (! $fh) {
+            return 0;
+        }
+        fseek($fh, $offset);
+        $content = (string) fread($fh, $size - $offset);
+        $offset = $size;
+        fclose($fh);
+
+        $errors = 0;
+        $lines = preg_split('/\r\n|\r|\n/', $content) ?: [];
+        foreach ($lines as $line) {
+            if ($line === '') {
+                continue;
+            }
+            $hasError = $this->hasErrorMarker($line);
+            if ($hasError) {
+                $errors++;
+            }
+            if ($showAll || $hasError || $this->hasRelevantMarker($line)) {
+                if ($hasError) {
+                    $this->error("  [{$label}] {$line}");
+                } else {
+                    $this->line("  <comment>[{$label}]</comment> {$line}");
+                }
+            } elseif ($label === 'laravel') {
+                $this->laravelSuppressed++;
+            }
+        }
+
+        return $errors;
+    }
+
+    private function hasErrorMarker(string $line): bool
+    {
+        foreach (self::ERROR_MARKERS as $marker) {
+            if (str_contains($line, $marker)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function hasRelevantMarker(string $line): bool
+    {
+        foreach (self::LARAVEL_RELEVANT as $marker) {
+            if (str_contains($line, $marker)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function resetFile(string $path): void
+    {
+        @file_put_contents($path, '');
+    }
+
+    private function fileSize(string $path): int
+    {
+        $size = @filesize($path);
+
+        return $size === false ? 0 : $size;
+    }
+}

+ 1 - 1
api-v13/app/Console/Commands/UpdateCorpus.php

@@ -78,7 +78,7 @@ class UpdateCorpus extends Command
                 $this->info("Directory processed: {$stats['processed']} records saved, {$stats['errors']} errors");
                 if ($this->option('es') && isset($stats['channels'])) {
                     foreach ($stats['channels'] as $key => $channelId) {
-                        $this->call('upgrade:progress', ['--channel' => $channelId]);
+                        $this->call('upgrade:progress.para', ['--channel' => $channelId]);
                         $this->call('upgrade:progress.chapter', ['--channel' => $channelId]);
                         $this->call('opensearch:index-tipitaka', [
                             'book' => 0,

+ 1 - 1
api-v13/app/Console/Commands/UpgradeWeekly.php

@@ -43,7 +43,7 @@ class UpgradeWeekly extends Command
         }
         $currTime = time();
         // 译文进度
-        $this->call('upgrade:progress');
+        $this->call('upgrade:progress.para');
         $time = time() - $currTime;
         $message = "progress time:{$time}; ";
         $this->info($message);

+ 57 - 0
api-v13/app/Exceptions/BusinessException.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Exceptions;
+
+use Exception;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * 需要自己的 type / title / 扩展字段的业务错误。
+ *
+ * 普通的 404、403、422 不要用它——直接 abort() 或让 FormRequest 抛
+ * ValidationException,bootstrap/app.php 会统一渲染。只有当这个错误
+ * 是一种**客户端需要分别处理的业务状态**时才定义 slug,例如:
+ *
+ *     throw new BusinessException(
+ *         __('sentence.locked'), 409, 'sentence-locked',
+ *         ['locked_by' => $editor->nickname],
+ *     );
+ *
+ * Laravel 的 Handler 会先看异常有没有 render(),所以这里的实现优先级
+ * 最高,不受 bootstrap/app.php 里那些处理器影响。
+ */
+class BusinessException extends Exception
+{
+    /**
+     * @param  string  $detail  面向人的文案,调用方传已经过 __() 的字符串
+     * @param  string  $slug  进 type 字段的问题类型标识
+     * @param  array<string, mixed>  $extra  扩展成员
+     */
+    public function __construct(
+        string $detail,
+        protected int $status = 409,
+        protected string $slug = 'business-error',
+        protected array $extra = [],
+        protected ?string $problemTitle = null,
+    ) {
+        parent::__construct($detail, $status);
+    }
+
+    public function render(Request $request): ?JsonResponse
+    {
+        // 只接管 v3;v2 的错误形状在 dashboard-v4 下线前不能变
+        if (! $request->is('api/v3/*')) {
+            return null;
+        }
+
+        return Problem::response(
+            $request,
+            $this->status,
+            $this->slug,
+            $this->problemTitle,
+            $this->getMessage(),
+            $this->extra,
+        );
+    }
+}

+ 73 - 0
api-v13/app/Exceptions/Problem.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Exceptions;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * RFC 9457 Problem Details 响应的唯一构造点。
+ *
+ * v3 端点的所有错误都从这里出去:bootstrap/app.php 的异常处理器、
+ * 以及自带 render() 的 BusinessException。控制器不要直接调用它——
+ * 控制器只负责抛异常。
+ */
+class Problem
+{
+    /**
+     * 状态码 → 稳定的英文 title。
+     *
+     * 按 RFC 9457,title 应在同类问题的所有场合保持一致、不随语言变化,
+     * 便于机器识别;面向人的文案放 detail,由调用方传入已 __() 的字符串。
+     */
+    private const TITLES = [
+        400 => 'Bad request.',
+        401 => 'Authentication required.',
+        403 => 'Forbidden.',
+        404 => 'Resource not found.',
+        405 => 'Method not allowed.',
+        409 => 'Conflict.',
+        419 => 'Page expired.',
+        422 => 'Your request is not valid.',
+        429 => 'Too many requests.',
+        500 => 'Server error.',
+        503 => 'Service unavailable.',
+    ];
+
+    public static function titleFor(int $status): string
+    {
+        return self::TITLES[$status] ?? (
+            $status >= 500 ? self::TITLES[500] : self::TITLES[400]
+        );
+    }
+
+    /**
+     * @param  string  $slug  问题类型标识,进 type 字段
+     * @param  array<string, mixed>  $extra  扩展成员,如 ['errors' => [...]]
+     */
+    public static function response(
+        Request $request,
+        int $status,
+        string $slug,
+        ?string $title = null,
+        ?string $detail = null,
+        array $extra = []
+    ): JsonResponse {
+        $body = array_filter([
+            // 刻意用 URN 而不是 url():绝对地址由 APP_URL 拼出,
+            // 反向代理下容易拼错,而且目前没有文档页可供解引用。
+            'type' => "urn:problem:{$slug}",
+            'title' => $title ?? self::titleFor($status),
+            'status' => $status,
+            'detail' => $detail,
+            'instance' => $request->getRequestUri(),
+        ], fn ($v) => $v !== null && $v !== '') + $extra;
+
+        return response()->json(
+            $body,
+            $status,
+            ['Content-Type' => 'application/problem+json'],
+            JSON_UNESCAPED_UNICODE
+        );
+    }
+}

+ 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);
     }
 }

+ 35 - 4
api-v13/app/Http/Api/MdRender.php

@@ -287,12 +287,25 @@ class MdRender
                         }
                     }
                     if (empty($paramName)) {
+                        /**
+                         * 位置参数。多个文本节点要拼接,不能互相覆盖。
+                         * 如果参数里含有嵌套模版(元素节点),纯文本是残缺的,
+                         * 此时不设置该参数,交给渲染结果的子节点(children)显示。
+                         * TODO: html / markdown 格式下嵌套模版仍会丢失外层模版结构,
+                         * 因为 dfn 是按文档顺序遍历的,外层先于内层渲染。
+                         */
+                        $paramText = '';
+                        $hasNestedTpl = false;
                         foreach ($child->childNodes as $param_child) {
-                            // code...
                             if ($param_child->nodeType === 3) {
-                                $props["{$param_id}"] = $param_child->nodeValue;
+                                $paramText .= $param_child->nodeValue;
+                            } elseif ($param_child->nodeType === 1) {
+                                $hasNestedTpl = true;
                             }
                         }
+                        if (! $hasNestedTpl) {
+                            $props["{$param_id}"] = $paramText;
+                        }
                     }
                 }
                 $child = $child->nextSibling;
@@ -389,8 +402,26 @@ class MdRender
                 $nissayaWord = [];
                 if (is_array($json)) {
                     foreach ($json as $word) {
-                        if (count($word->sn) === 1) {
-                            // 只输出第一层级
+                        if (isset($word->original)) {
+                            // 对齐后的 nissaya json 格式:
+                            // {original, translation, note, confidence}
+                            // translation 用 ">" 分隔多层意思,转成 wiki 模版使用的 "=" 分隔符
+                            $str = '{{nissaya|';
+                            $str .= $word->original;
+                            $str .= '|';
+                            $str .= str_replace(
+                                '>',
+                                '=',
+                                isset($word->translation) ? $word->translation : ''
+                            );
+                            $str .= '}}';
+                            $nissayaWord[] = $str;
+
+                            if (! empty($word->note)) {
+                                $nissayaWord[] = '{{note|'.$word->note.'|[nt]}}';
+                            }
+                        } elseif (isset($word->sn) && count($word->sn) === 1) {
+                            // 逐词解析 wbw json 格式,只输出第一层级
                             $str = '{{nissaya|';
                             if (isset($word->word->value)) {
                                 $str .= $word->word->value;

+ 35 - 8
api-v13/app/Http/Api/TemplateRender.php

@@ -548,12 +548,27 @@ class TemplateRender
     {
         $note = $this->get_param($this->param, 'text', 1);
         $trigger = $this->get_param($this->param, 'trigger', 2, '');
+        // 可选:cite 参数用于在 .sidenote 里追加跳转锚点;citelink 是跳转坐标
+        // (book-para-start-end),同时挂到角标 <label> 与 <cite> 上(前者跨栏高亮,后者跳页)。
+        $cite = $this->get_param($this->param, 'cite', 3, '');
+        $citelink = $this->get_param($this->param, 'citelink', 4, '');
         $props = ['note' => $note];
         $innerString = '';
         if (! empty($trigger)) {
             $props['trigger'] = $trigger;
             $innerString = $props['trigger'];
         }
+        $target = '';
+        if ($citelink !== '') {
+            $parts = explode('-', $citelink);
+            if (count($parts) === 4) {
+                $target = ' data-book="'.$parts[0].'" data-para="'.$parts[1].'" data-start="'.$parts[2].'" data-end="'.$parts[3].'"';
+            }
+        }
+        $citeHtml = '';
+        if ($cite !== '') {
+            $citeHtml = '<cite class="anno-jump"'.$target.'>'.e($cite).'</cite>';
+        }
         if ($this->format === 'unity') {
             $props['note'] = MdRender::render(
                 $props['note'],
@@ -610,10 +625,10 @@ class TemplateRender
                     $output = $link.$trigger.'</a>';
                 }
                 $output = "<label for=\"sn-{$GLOBALS['note_sn']}\"
-                class=\"margin-toggle sidenote-number\" >{$trigger}</label>
+                class=\"margin-toggle sidenote-number\"{$target}>{$trigger}</label>
                 <input type=\"checkbox\" id=\"sn-{$GLOBALS['note_sn']}\"
                 class=\"margin-toggle\"/>
-                <span class=\"sidenote\">{$noteContent}</span>";
+                <span class=\"sidenote\">{$noteContent}{$citeHtml}</span>";
                 break;
             case 'text':
                 $output = $trigger;
@@ -681,6 +696,14 @@ class TemplateRender
                     'tpl' => 'nissaya',
                 ];
                 break;
+            case 'html':
+                if ($this->lang === 'my') {
+                    $output = "<span lang='pi-Mymr'>{$pali}</span>၊ ";
+                } else {
+                    $output = "<span lang='pi-Latn'>{$pali}</span> ";
+                }
+                $output .= "<span lang='{$this->lang}'>{$meaning}</span>";
+                break;
             case 'prompt':
                 $output = Tools::MyToRm($pali).':'.end($props['meaning']);
                 break;
@@ -1130,14 +1153,18 @@ class TemplateRender
                 break;
             case 'text':
                 $output = '';
-                if (isset($props['origin']) && is_array($props['origin'])) {
-                    foreach ($props['origin'] as $key => $value) {
-                        $output .= $value['html'];
+                if ($show === 'both' || $show === 'origin') {
+                    if (isset($props['origin']) && is_array($props['origin'])) {
+                        foreach ($props['origin'] as $key => $value) {
+                            $output .= $value['html'];
+                        }
                     }
                 }
-                if (isset($props['translation']) && is_array($props['translation'])) {
-                    foreach ($props['translation'] as $key => $value) {
-                        $output .= $value['html'];
+                if ($show === 'both' || $show === 'translation') {
+                    if (isset($props['translation']) && is_array($props['translation'])) {
+                        foreach ($props['translation'] as $key => $value) {
+                            $output .= $value['html'];
+                        }
                     }
                 }
                 break;

+ 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);
     }
 }

+ 90 - 21
api-v13/app/Http/Controllers/ArticleController.php

@@ -16,7 +16,6 @@ use App\Models\Sentence;
 use App\Services\AuthService;
 use App\Tools\OpsLog;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
@@ -138,13 +137,28 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display a listing of the resource.
+     * 列出 article(文章)
      *
-     * @return Response
+     * 按 view 指定的口径返回文章列表,支持关键字搜索、分页、排序。
+     * 默认只返回列表字段(uid、title、subtitle、summary、owner、lang、status、editor_id、时间),
+     * content=true 时才附带正文与正文类型。
+     * view=studio 需要登录,且当前登录用户必须就是 name 指定的 studio 的拥有者,否则返回 401/403。
+     *
+     * @queryParam view string required 查询口径。template=按 studio_name 取该 studio 的全部文章(模版库);studio=取某 studio 内的文章,需要登录;public=只取已公开(status=30)的文章。Enum: template,studio,public
+     * @queryParam content string 是否返回文章正文。传 true 时结果附带 content 与 content_type。Example: true
+     * @queryParam studio_name string view=template 时使用,指定 studio 的名称
+     * @queryParam name string view=studio 时使用,指定 studio 的名称,必须与当前登录用户一致
+     * @queryParam view2 string view=studio 时的二级口径:my=我创建的(owner 为该 studio),其余值=协作的(别人共享给我的,res_type=3)。Default: my
+     * @queryParam anthology string view=studio 时按文集过滤。all=不过滤;none=不属于我任何文集的文章;其余值按文集 uid 过滤。Enum: all,none
+     * @queryParam search string 按标题模糊搜索(title like %search%)
+     * @queryParam subtitle string 按副标题过滤(subtitle like,需自行带通配符)
+     * @queryParam order string 排序字段。Default: updated_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 分页起始位置。Default: 0
+     * @queryParam limit integer 每页条数。Default: 1000
      */
     public function index(Request $request)
     {
-        //
         $field = [
             'uid',
             'title',
@@ -217,6 +231,8 @@ class ArticleController extends Controller
                 $table = $table->where('status', 30);
                 break;
             default:
+                // FIXME: 这里只调用 error() 没有 return,view 非法时会继续往下执行并返回全表数据(最多 limit 条);
+                // 建议改成 return $this->error('view error', [], 400)。
                 $this->error('view error');
                 break;
         }
@@ -244,9 +260,12 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display a listing of the resource.
+     * 统计我的文章数量
+     *
+     * 返回指定 studio 下「我的」和「协作的」两类文章条数。
+     * 需要登录,且当前登录用户必须就是 studio 的拥有者,否则返回鉴权失败。
      *
-     * @return Response
+     * @queryParam studio string required studio 名称,必须与当前登录用户一致
      */
     public function showMyNumber(Request $request)
     {
@@ -273,9 +292,22 @@ class ArticleController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建 article(文章)
+     *
+     * 需要登录。权限:当前用户必须是 studio 的拥有者;若不是,则必须对 anthologyId 指定的文集
+     * 拥有大于只读的权限(power>10),否则返回 403。
+     * 副作用:整个新建过程在事务中执行——写入 articles 表,若给了 anthologyId 则同时把文章挂进文集;
+     * 若再给了 parentNode,会按目录树重建该文集的 article_collections 记录(删除后整表重插)并刷新文集缓存。
+     * 标题超过 128 字会被截断。
      *
-     * @return Response
+     * @bodyParam studio string required studio 名称,文章归属的 studio
+     * @bodyParam title string required 文章标题,最多 128 字,超出截断
+     * @bodyParam lang string required 文章语言代码
+     * @bodyParam status integer 公开状态,30 表示公开。不传则用数据库默认值
+     * @bodyParam parentId string 父文章 uid,写入 articles.parent
+     * @bodyParam anthologyId string 文集 uid,传入则把新文章加入该文集
+     * @bodyParam parentNode string 文集目录中的挂接点文章 uid,新文章作为它的子节点插入;
+     *                             不传则作为一级节点(level=1)追加到文集末尾
      */
     public function store(Request $request)
     {
@@ -370,6 +402,8 @@ class ArticleController extends Controller
                             $newMap[] = $newNode;
                         }
                     } else {
+                        // FIXME: parentNode 找不到挂接点时只写日志,不把文章加入文集,结果文章建成了却不在目录里、前端看不到;
+                        // 建议改为回退成一级节点追加,或直接抛异常让事务回滚并向前端报错。
                         Log::warning('没找到挂接点'.$parentNode);
                     }
 
@@ -410,13 +444,19 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单篇 article(文章)
      *
-     * @return Response
+     * 前端阅读页最常用的接口。允许匿名访问:status=30(公开)的文章任何人可读;
+     * 非公开文章需要登录,且满足以下之一——是文章 owner、对该文章有只读以上共享权限(power>=10)、
+     * 或对该文章所在的(同一 owner 的)文集有只读以上共享权限,否则返回 403。
+     *
+     * @urlParam article string required 文章 uid(路由模型绑定到 Article,主键即 uid)
+     *
+     * @queryParam anthology string 所在文集 uid,用于在返回结果里渲染文集路径(path)
+     * @queryParam channel string 渲染标题所用的 channel uid,多个用下划线分隔;不传则回退到文集的默认 channel
      */
     public function show(Request $request, Article $article)
     {
-        //
         if (! $article) {
             return $this->error('no recorder');
         }
@@ -437,14 +477,22 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 预览文章正文
+     *
+     * PUT /api/v2/article-preview/{id}。用请求体里的 content 覆盖文章正文后走一遍渲染再返回,
+     * 不写数据库,用于编辑器里的即时预览。
+     * 权限与查看文章一致:公开文章匿名可预览,否则需要读权限,无权限返回 401。
+     * 未传 content 时返回 error('no content'),但 HTTP 状态码仍是 200。
      *
-     * @param  string  $article
-     * @return Response
+     * @urlParam id string required 文章 uid
+     *
+     * @bodyParam content string required 待预览的正文内容(markdown)
+     *
+     * @queryParam anthology string 所在文集 uid,用于渲染文集路径
+     * @queryParam channel string 渲染所用 channel uid,多个用下划线分隔
      */
     public function preview(Request $request, string $articleId)
     {
-        //
         $article = Article::find($articleId);
         if (! $article) {
             return $this->error('no recorder');
@@ -471,13 +519,28 @@ class ArticleController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新 article(文章)
+     *
+     * 需要登录。权限:必须是文章 owner,或对文章有编辑以上共享权限(power>=20),
+     * 或对文章所在的同 owner 文集有编辑以上权限,否则返回 401。
+     * 字段会整体覆盖:title/subtitle 截断到 128 字,summary 截断到 1024 字,
+     * status 未传时会被重置为 10(非公开),即部分更新也必须带全字段。
+     * 副作用:to_tpl=true 时把正文转成模版——按行切句,正文中的句子被替换成 {{book-para-start-end}} 占位,
+     * 同时把原文逐句写入 sentences 表(必要时为文集新建 CustomBook 与 channel),该过程需要对应 channel 的写权限。
      *
-     * @return Response
+     * @urlParam article string required 文章 uid(路由模型绑定)
+     *
+     * @bodyParam title string required 标题,最多 128 字
+     * @bodyParam subtitle string 副标题,最多 128 字
+     * @bodyParam summary string 摘要,最多 1024 字
+     * @bodyParam content string required 正文(markdown)
+     * @bodyParam lang string required 语言代码
+     * @bodyParam status integer 公开状态,30 表示公开。Default: 10
+     * @bodyParam to_tpl boolean 是否把正文转换为模版并把原文切句入库,必须是布尔 true 才生效
+     * @bodyParam anthology_id string to_tpl=true 时必填,文集 uid,用于定位/创建书号与 channel
      */
     public function update(Request $request, Article $article)
     {
-        //
         if (! $article) {
             return $this->error('no recorder');
         }
@@ -521,6 +584,8 @@ class ArticleController extends Controller
         $article->summary = mb_substr($request->input('summary'), 0, 1024, 'UTF-8');
         $article->content = $content;
         $article->lang = $request->input('lang');
+        // FIXME: status 未传时会被静默重置为 10(非公开),已公开的文章做部分更新会被降级;
+        // 建议改为只有请求体明确带 status 时才赋值,否则保留原值。
         $article->status = $request->input('status', 10);
         $article->editor_id = $user['user_id'];
         $article->modify_time = time() * 1000;
@@ -532,13 +597,15 @@ class ArticleController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除 article(文章)
+     *
+     * 需要登录,且只有文章 owner 本人可以删除(协作者不行),否则返回鉴权失败。
+     * 副作用:在事务中删除文章记录,并同步把该文章从所有文集目录(article_collections)中摘除。
      *
-     * @return Response
+     * @urlParam article string required 文章 uid(路由模型绑定)
      */
     public function destroy(Request $request, Article $article)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'));
@@ -548,6 +615,8 @@ class ArticleController extends Controller
             return $this->error(__('auth.failed'));
         }
         $delete = 0;
+        // FIXME: $delete 按值捕获,闭包内的赋值传不出来,返回值恒为 0,前端拿不到真实删除结果;
+        // 建议改为 use (&$delete) 引用捕获,或直接用 DB::transaction 的返回值。
         DB::transaction(function () use ($article, $delete) {
             // TODO 删除文集中的文章
             $delete = $article->delete();

+ 74 - 18
api-v13/app/Http/Controllers/ChannelController.php

@@ -22,14 +22,33 @@ use Illuminate\Support\Facades\DB;
 class ChannelController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出 channel(译文集)
      *
-     * @return Response
+     * 按 view 指定的口径返回 channel 列表,支持关键字搜索、分页、排序。
+     * 需要登录的口径:studio、studio-all、user-edit、user-in-chapter。
+     *
+     * @queryParam view string required 查询口径。
+     *             Enum: public,studio,studio-all,user-edit,user-in-chapter,system,paragraphs,id
+     * @queryParam name string studio 名称,view=studio / studio-all 时必填
+     * @queryParam view2 string view=studio 时的二级口径:my=我的,其余值=协作的。Default: my
+     * @queryParam collaborator string view=studio 且 view2 为协作时,按协作者 uid 过滤。Default: all
+     * @queryParam id string view=id 时的 channel uid 列表,逗号分隔
+     * @queryParam book integer view=user-in-chapter 时的典籍 id;传入后会联查该章节的翻译进度
+     * @queryParam para string view=user-in-chapter 时的段落号
+     * @queryParam book_id integer view=paragraphs 时的典籍 id
+     * @queryParam paragraph string 联查 progress_chapters 时的段落号,与 book 配合使用
+     * @queryParam progress string 传入则在每行附带 final 逐句完成情况,依赖 book 与 para
+     * @queryParam type integer 按 channel 类型过滤
+     * @queryParam updated_at string 只返回该时间之后更新的记录,用于离线包增量同步。
+     *             Example: 2023-09-18T05:39:51.000000Z
+     * @queryParam created_at string 只返回该时间之后新建的记录
      */
     public function index(Request $request)
     {
         //
         $result = false;
+        // $chapter 只有 view=user-in-chapter 会赋值,下面按进度取数时要先判断
+        $chapter = null;
         $indexCol = [
             'channels.uid',
             'name',
@@ -45,6 +64,8 @@ class ChannelController extends Controller
         if ($request->has('book')) {
             $indexCol[] = 'progress_chapters.progress';
         }
+        // FIXME: switch 没有 default 分支,view 缺失或不在枚举内时 $table 始终未定义,
+        // 下面的 $table->count() 会直接 500。应补 default 返回参数错误。
         switch ($request->input('view')) {
             case 'public':
                 $table = Channel::select($indexCol)
@@ -181,6 +202,8 @@ class ChannelController extends Controller
                     $table = Channel::select($indexCol)
                         ->whereIn('uid', $channelIds);
                 } else {
+                    // FIXME: whereIsNull 不是 Eloquent 方法,会被动态 where 解析成 where('is_null', 'uid'),
+                    // 查不存在的列直接报 SQL 错误。本意应是返回空结果集,改用 ->whereRaw('1 = 0') 或 whereNull('uid')。
                     $table = Channel::select($indexCol)->whereIsNull('uid');
                 }
                 break;
@@ -229,7 +252,7 @@ class ChannelController extends Controller
         $result = $table->get();
         // TODO 将下面代码转移到resource
         if ($result) {
-            if ($request->has('progress')) {
+            if ($request->has('progress') && $chapter) {
                 // 获取进度
                 // 获取单句长度
                 $sentLen = PaliSentence::where('book', $request->input('book'))
@@ -239,7 +262,7 @@ class ChannelController extends Controller
                     ->get();
             }
             foreach ($result as $key => $value) {
-                if ($request->has('progress')) {
+                if ($request->has('progress') && $chapter) {
                     // 获取进度
                     $finalTable = Sentence::where('book_id', $request->input('book'))
                         ->whereBetween('paragraph', $chapter)
@@ -531,19 +554,29 @@ class ChannelController extends Controller
             }
         }
 
+        // FIXME: 第二个元素漏写了键名,实际返回的是 {rows: [...], 0: n} 而不是 {rows, count},
+        // 与本控制器其他列表接口的返回结构不一致。应为 'count' => count($result)。
         return $this->ok(['rows' => $result, count($result)]);
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建 channel
      *
-     * @return Response
+     * 在指定 studio 下新建译文集。同一 studio 内不允许重名。
+     * 调用者必须对该 studio 有管理权限。
+     *
+     * @bodyParam studio string required 所属 studio 名称
+     * @bodyParam name string required channel 名称,在同一 studio 内唯一
+     * @bodyParam type integer required channel 类型
+     * @bodyParam lang string required 译文语言代码。Example: zh-Hans
      */
     public function store(Request $request)
     {
         //
         $user = AuthService::current($request);
         if (! $user) {
+            // FIXME: error() 的签名是 error(string $message, mixed $data, int $status),
+            // 这里把 401 当成了 data 传进去,响应体的 data 会变成数字 401。本文件多处同样写法。
             return $this->error(__('auth.failed'), 401, 401);
         }
         // 判断当前用户是否有指定的studio的权限
@@ -580,10 +613,11 @@ class ChannelController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 获取单个 channel
      *
-     * @param  int  $id
-     * @return Response
+     * 返回 channel 详情,附带所属 studio 与作者信息。
+     *
+     * @urlParam channel string required channel uid
      */
     public function show($id)
     {
@@ -600,9 +634,9 @@ class ChannelController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 按名称获取 channel
      *
-     * @return Response
+     * @urlParam name string required channel 名称
      */
     public function showByName(string $name)
     {
@@ -617,9 +651,20 @@ class ChannelController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 整体更新 channel
      *
-     * @return Response
+     * 覆盖式更新,未传的字段会被写成 null;只改部分字段请用 PATCH /channel。
+     * 系统 channel 不可修改;非 owner 需要 30 以上的协作权限。
+     *
+     * @urlParam channel string required channel uid
+     *
+     * @bodyParam name string required channel 名称
+     * @bodyParam type integer required channel 类型
+     * @bodyParam summary string required 简介
+     * @bodyParam lang string required 译文语言代码
+     * @bodyParam status integer required 状态。30 为全网公开
+     * @bodyParam source_type string 来源类型,传入才会更新
+     * @bodyParam source_id string 来源 id,传入才会更新
      */
     public function update(Request $request, Channel $channel)
     {
@@ -655,9 +700,16 @@ class ChannelController extends Controller
     }
 
     /**
-     * patch the specified resource in storage.
+     * 局部更新 channel
      *
-     * @return Response
+     * 只更新请求体中出现的字段。系统 channel 不可修改;非 owner 需要 30 以上的协作权限。
+     *
+     * @bodyParam name string channel 名称
+     * @bodyParam type integer channel 类型
+     * @bodyParam summary string 简介
+     * @bodyParam lang string 译文语言代码
+     * @bodyParam status integer 状态。30 为全网公开
+     * @bodyParam config string channel 配置
      */
     public function patch(Request $request, Channel $channel)
     {
@@ -692,7 +744,7 @@ class ChannelController extends Controller
             $channel->status = $request->input('status');
         }
         if ($request->has('config')) {
-            $channel->status = $request->input('config');
+            $channel->config = $request->input('config');
         }
         $channel->save();
 
@@ -700,9 +752,11 @@ class ChannelController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除 channel
      *
-     * @return Response
+     * 只有 owner 可以删除。channel 下已有译文、术语或逐词解析数据时拒绝删除。
+     *
+     * @urlParam channel string required channel uid
      */
     public function destroy(Request $request, Channel $channel)
     {
@@ -725,6 +779,8 @@ class ChannelController extends Controller
         if (WbwBlock::where('channel_uid', $channel->uid)->exists()) {
             return $this->error('逐词解析有数据无法删除');
         }
+        // FIXME: $delete 按值捕获,闭包内的赋值传不出来,接口永远返回 0。
+        // 应改为 use (&$delete) 或让闭包 return 删除结果。
         $delete = 0;
         DB::transaction(function () use ($channel, $delete) {
             // TODO 删除相关资源

+ 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;
+    }
+}

+ 19 - 0
api-v13/app/Http/Controllers/Controller.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Routing\Controller as BaseController;
 
 class Controller extends BaseController
@@ -37,4 +38,22 @@ class Controller extends BaseController
             'message' => $message,
         ], $status, [], JSON_UNESCAPED_UNICODE);
     }
+
+    /*
+    |--------------------------------------------------------------------------
+    | v3 不需要任何响应 helper
+    |--------------------------------------------------------------------------
+    |
+    | 成功:直接 return Resource。JsonResource 默认就包 data,paginator 交给
+    | 框架算分页,形状是 {data: {...}} 与 {data: [...], meta: {...}}。
+    |
+    |     return ChannelV3Resource::make($channel);
+    |     return ChannelV3Resource::collection($query->paginate($n));
+    |
+    | 失败:抛异常。abort()/ValidationException/BusinessException,
+    | 由 bootstrap/app.php 统一渲染成 RFC 9457 Problem Details。
+    |
+    | 上面 ok()/error() 只给 v2 用,v3 控制器里不准出现。
+    |
+    */
 }

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

@@ -155,9 +155,15 @@ class CorpusController extends Controller
 
         // 获取wbw channel
         // 目前默认的 wbw channel 是第一个translation channel
+        // TODO: channel 不存在时目前只是静默跳过(句子会渲染为空)。
+        // 以后改为抛出异常,由外层(render_sent / 控制器)捕获,
+        // 把出错信息 message 传给前端提示用户 channel 无效。
         foreach ($channels as $channel) {
             // code...
-            if ($channelIndex[$channel]->type === 'translation') {
+            if (
+                isset($channelIndex[$channel]) &&
+                $channelIndex[$channel]->type === 'translation'
+            ) {
                 $this->wbwChannels[] = $channel;
                 break;
             }

+ 83 - 13
api-v13/app/Http/Controllers/CourseMemberController.php

@@ -9,21 +9,37 @@ use App\Models\CourseMember;
 use App\Models\UserInfo;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
 class CourseMemberController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出课程成员
      *
-     * @return Response
+     * 按 view 指定的口径返回课程成员列表,支持角色/状态过滤、按昵称搜索、分页与排序。
+     * 必须登录,且当前用户必须已经是该 course 的成员(在 course_member 中有 role),否则返回 403。
+     * 返回 rows(成员列表)、role(当前用户在该 course 中的当前角色)、count(过滤后的总数)。
+     *
+     * @queryParam view string required 查询口径。course=课程内全部当前成员;timeline=某用户的成员变更时间线。Enum: course,timeline
+     * @queryParam id string required course uid。view=timeline 时也用于权限校验
+     * @queryParam course string 权限校验与 timeline 口径使用的 course uid,缺省时回退到 id
+     * @queryParam userId string view=timeline 时必填,要查看时间线的用户 uid
+     * @queryParam timeline string view=timeline 时的范围:current=只看 course 参数指定的课程,其余值=该用户全部课程。Default: current
+     * @queryParam role string 按角色过滤,all 表示不过滤。Example: student
+     * @queryParam status string 按状态过滤,逗号分隔可多选。Example: joined,applied
+     * @queryParam search string 按成员昵称模糊搜索
+     * @queryParam order string 排序字段。Default: created_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: asc
+     * @queryParam offset integer 跳过的记录数。Default: 0
+     * @queryParam limit integer 返回条数上限。Default: 1000
      */
     public function index(Request $request)
     {
         //
         $user = AuthService::current($request);
+        // FIXME: [403] 和 403 被当成了 __() 的替换参数与 locale,没有传给 error(),实际返回的仍是默认状态码。
+        // 应改为 $this->error(__('auth.failed'), [403], 403);本文件多处同样写法。
         if (! $user) {
             return $this->error(__('auth.failed', [403], 403));
         }
@@ -76,6 +92,8 @@ class CourseMemberController extends Controller
             $usersId = UserInfo::where('nickname', 'like', '%'.$request->input('search').'%')
                 ->select('userid')
                 ->get();
+            // FIXME: whereIn 传入的是 UserInfo 模型集合而非 id 数组,取到的是主键而不是 userid 列,search 过滤很可能不生效。
+            // 建议改为 ->pluck('userid')->all() 后再传入。
             $table = $table->whereIn('user_id', $usersId);
         }
 
@@ -92,6 +110,8 @@ class CourseMemberController extends Controller
         $result = $table->get();
 
         // 获取当前用户角色
+        // FIXME: 这里固定读 input('id'),view=timeline 且只传了 course 时取不到 role,返回的 role 会是空。
+        // 建议与上面的权限校验一致,改用 input('id', input('course'))。
         $role = CourseMember::where('course_id', $request->input('id'))
             ->where('user_id', $user['user_uid'])
             ->where('is_current', true)
@@ -101,9 +121,19 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新增课程成员记录(加入/申请/邀请)
+     *
+     * 必须登录。status=invited 表示由当前用户邀请他人,成员归属 user_id 指定的用户;
+     * 其余情况(自己加入或申请)一律忽略 user_id,成员归属当前登录用户。
+     * 非 invited 时若该 user 已在该 course 中存在任何记录,返回 member exists(HTTP 200)。
+     * 写入前会把同一 course + user 的旧记录 is_current 置为 false,新记录成为当前记录。
+     * 状态必须与课程加入方式匹配:course.join=open 只接受 joined/invited,manual 只接受 applied/invited,
+     * 不匹配返回 invalid course(HTTP 200)。course 不存在返回 invalid course。
      *
-     * @return Response
+     * @bodyParam user_id string required 成员用户 uid,仅在 status=invited 时生效
+     * @bodyParam course_id string required course uid
+     * @bodyParam role string required 成员角色。Example: student
+     * @bodyParam status string required 成员状态。Enum: joined,applied,invited
      */
     public function store(Request $request)
     {
@@ -178,9 +208,14 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询某用户在指定课程中的当前成员记录
+     *
+     * 必须登录。默认查当前登录用户,传 user_uid 可查他人(无额外权限校验)。
+     * 只返回 is_current=true 的那条记录;查不到返回 no result(HTTP 200)。
+     *
+     * @urlParam courseId string required course uid
      *
-     * @return Response
+     * @queryParam user_uid string 要查询的用户 uid,缺省为当前登录用户
      */
     public function show(Request $request, string $courseId)
     {
@@ -205,9 +240,16 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新课程成员(以新增记录的方式保留历史)
      *
-     * @return Response
+     * 必须登录。不会就地修改原记录:先复制一条新记录,把原记录 is_current 置为 false,
+     * 再把请求中出现的字段写到新记录上,原记录作为历史保留。
+     * 修改 channel_id 只允许成员本人操作,否则返回 auth.failed。
+     *
+     * @urlParam course_member string required 要更新的 course_member 记录 id
+     *
+     * @bodyParam channel_id string 该成员在课程中使用的 channel uid,仅本人可改
+     * @bodyParam status string 新的成员状态。Example: joined
      */
     public function update(Request $request, CourseMember $courseMember)
     {
@@ -246,6 +288,15 @@ class CourseMemberController extends Controller
         return $this->ok(new CourseMemberResource($newMember));
     }
 
+    /**
+     * 设置当前用户在某课程中使用的 channel
+     *
+     * 必须登录。就地修改当前用户在该 course 的 is_current 记录(不产生历史记录)。
+     * 未传 channel_id、或当前用户不是该课程的当前成员,都返回 auth.failed。
+     *
+     * @bodyParam course_id string required course uid
+     * @bodyParam channel_id string required 作业所用的 channel uid
+     */
     public function set_channel(Request $request)
     {
         //
@@ -273,9 +324,12 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除课程成员记录
      *
-     * @return Response
+     * 必须登录。课程所有者(course.studio_id 等于当前用户)可以直接删除;
+     * 否则按当前用户在该课程中的角色判断权限,普通 student 无删除权限,返回 auth.failed。
+     *
+     * @urlParam course_member string required 要删除的 course_member 记录 id
      */
     public function destroy(Request $request, CourseMember $courseMember)
     {
@@ -293,6 +347,8 @@ class CourseMemberController extends Controller
                 ->select('role')->first();
             // open 课程 可以删除自己
 
+            // FIXME: 条件写反了。$courseUser 为 null 时才进入分支并立刻对 null 取属性,会 fatal error;
+            // 而真正有记录的 student 反而绕过了权限检查,等于对所有非 owner 的已有成员放行。应改为 if ($courseUser) {。
             if (! $courseUser) {
                 // 被删除的不是自己
                 if ($courseUser->role === 'student') {
@@ -308,9 +364,12 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * 获取当前用户权限
+     * 获取当前用户在指定课程中的角色与 channel
+     *
+     * 必须登录。只读取 is_current=true 的记录,返回 role 与 channel_id 两个字段。
+     * 当前用户不是该课程成员时返回 not member。
      *
-     * @return Response
+     * @queryParam course_id string required course uid
      */
     public function curr(Request $request)
     {
@@ -329,8 +388,19 @@ class CourseMemberController extends Controller
         }
     }
 
+    /**
+     * 导出课程成员名单为 Excel
+     *
+     * 导出该课程全部 is_current 成员,表头为 nickname、username、role、status、created_at,
+     * 昵称与用户名通过 UserApi 按 user_id 逐条查询。
+     * 直接以 xlsx 附件(course_member.xlsx)写入输出流,不返回 JSON,且不做登录与权限校验。
+     *
+     * @queryParam course_id string required course uid
+     */
     public function export(Request $request)
     {
+        // FIXME: 本方法没有任何登录与权限校验,任何人都能导出任意课程的成员名单(含昵称与用户名),属于信息泄露。
+        // 应先 AuthService::current() 并校验当前用户是该 course 的管理者或 studio 拥有者。
 
         $courseUser = CourseMember::where('course_id', $request->input('course_id'))
             ->where('is_current', true)

+ 114 - 21
api-v13/app/Http/Controllers/DhammaTermController.php

@@ -14,7 +14,6 @@ use App\Services\AuthService;
 use App\Services\TermIndexService;
 use App\Tools\Tools;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
@@ -29,9 +28,35 @@ class DhammaTermController extends Controller
     ) {}
 
     /**
-     * Display a listing of the resource.
+     * 列出术语(dhamma term)
      *
-     * @return Response
+     * 按 view 指定的口径返回术语列表。studio、channel、user 三个口径需要登录;
+     * studio 要求 name 指向的 studio 属于当前用户,否则 403;channel 要求当前用户是
+     * channel owner 或协作者(ShareApi 权限值非 0),否则 403。
+     * create-by-channel、show、hot-meaning 三个口径直接返回各自的数据结构,
+     * 不走后面的搜索/排序/分页;其余口径统一返回 {rows, count}。
+     * hot-meaning 结果会按 config('mint.cache.expire') 缓存在 term/hot_meaning 键下。
+     * 未指定 view(或给了无法识别的值)时没有任何基础查询,会直接报错,调用方必须传 view。
+     *
+     * @queryParam view string required 查询口径。Enum: create-by-channel,studio,channel,show,user,word,tag,hot-meaning
+     * @queryParam channel string view=create-by-channel 时必填,目标 channel 的 uid,
+     *                            用于确定语言、同 studio 的 channel 列表与 studio 信息
+     * @queryParam word string view=create-by-channel 时为要新建的术语词形;
+     *                         view=word 时为逗号分隔的词或义项列表,按 word 或 meaning 命中
+     * @queryParam name string view=studio 时必填,studio 名称,须与当前用户的 studio 一致
+     * @queryParam id string view=channel 时为 channel id(uid);view=show 时为术语主键 id
+     * @queryParam tag string view=tag 时必填,逗号分隔的 tag 列表
+     * @queryParam language string view=hot-meaning 时必填,统计该语言下每个词出现次数最多的义项
+     * @queryParam search string 关键字,按 word、word_en 前缀匹配或 meaning 模糊匹配
+     * @queryParam order string 排序字段。Default: updated_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 分页偏移量。Default: 0
+     * @queryParam limit integer 每页条数。Default: 1000
+     * @queryParam mode string 渲染 note/summary 的模式,透传给 Markdown 渲染器。Default: read
+     * @queryParam format string note 的渲染输出格式。Enum: react,html,text Default: react
+     * @queryParam community_summary boolean 传入该参数时,自身没有 note 的术语会回落到社区
+     *                                       channel 的解释作为 summary
+     * @queryParam exp boolean 传入该参数时附带编辑者截至本条更新时间的累计经验值(秒)
      */
     public function index(Request $request)
     {
@@ -53,6 +78,8 @@ class DhammaTermController extends Controller
         ];
 
         switch ($request->input('view')) {
+            // FIXME: create-by-channel 口径完全不做登录/权限校验,任何人都能枚举任意
+            // channel 所属 studio 的全部 channel 列表;应补上与 channel 口径一致的鉴权。
             case 'create-by-channel':
                 // 新建术语时。根据术语所在channel 给出新建术语所需数据。如语言,备选意思等。
                 // 获取channel信息
@@ -122,6 +149,8 @@ class DhammaTermController extends Controller
                     return $this->error(__('auth.failed'));
                 }
                 // 判断当前用户是否有指定的 channel 的权限
+                // FIXME: find() 按主键查(其余地方 channel 都按 uid 查),且结果未判空就访问
+                // ->owner_uid,id 不存在时直接 500;应改为 where('uid', ...)->first() 并判空返回 404。
                 $channel = Channel::find($request->input('id'));
                 if ($user['user_uid'] !== $channel->owner_uid) {
                     // 看是否为协作
@@ -134,6 +163,8 @@ class DhammaTermController extends Controller
                     ->where('channal', $request->input('id'));
                 break;
             case 'show':
+                // FIXME: 不做登录校验且直接返回原始模型(未过 TermResource),会把 owner、
+                // editor_id 等非公开字段暴露出去;应改用 TermResource 并补权限判定。
                 return $this->ok(DhammaTerm::find($request->input('id')));
                 break;
             case 'user':
@@ -143,11 +174,14 @@ class DhammaTermController extends Controller
                     return $this->error(__('auth.failed'));
                 }
                 $userUid = $user['user_uid'];
+                // FIXME: 这里的 $search 是死代码,会被方法末尾同名变量重新赋值覆盖;应直接删除。
                 $search = $request->input('search');
                 $table = DhammaTerm::select($indexCol)
                     ->where('owner', $userUid);
                 break;
             case 'word':
+                // FIXME: whereIn / orWhereIn 未用闭包分组,后面追加的 search 条件会与这个 or
+                // 错误结合导致越界结果;应包成 ->where(fn ($q) => $q->whereIn(...)->orWhereIn(...))。
                 $table = DhammaTerm::select($indexCol)
                     ->whereIn('word', explode(',', $request->input('word')))
                     ->orWhereIn('meaning', explode(',', $request->input('word')));
@@ -165,6 +199,8 @@ class DhammaTermController extends Controller
                         ->groupby('word')
                         ->get();
 
+                    // FIXME: 典型 N+1,每个 word 单独发一次聚合查询;应改成一次
+                    // group by word, meaning 的查询再在 PHP 里取每个词的 top 1。
                     foreach ($words as $key => $word) {
                         // code...
                         $result = DhammaTerm::select(DB::raw('count(*) as word_count, meaning'))
@@ -182,6 +218,8 @@ class DhammaTermController extends Controller
                             ];
                         }
                     }
+                    // FIXME: 外层 Cache::remember 已负责写缓存,这里重复写入且 TTL(3600)
+                    // 与 config('mint.cache.expire') 不一致;应删除这一行。
                     Cache::put($key, $hotMeaning, 3600);
 
                     return $hotMeaning;
@@ -189,6 +227,8 @@ class DhammaTermController extends Controller
 
                 return $this->ok(['rows' => $value, 'count' => count($value)]);
                 break;
+                // FIXME: 未传 view 或传了无法识别的 view 时 $table 未定义,会一路走到下面的
+                // $table->count() 直接抛错;应在 default 里返回明确的参数错误。
             default:
                 // code...
                 break;
@@ -197,9 +237,9 @@ class DhammaTermController extends Controller
         $search = $request->input('search');
         if (! empty($search)) {
             $table = $table->where(function ($query) use ($search) {
-                $query->where('word', 'like', $search . '%')
-                    ->orWhere('word_en', 'like', $search . '%')
-                    ->orWhere('meaning', 'like', '%' . $search . '%');
+                $query->where('word', 'like', $search.'%')
+                    ->orWhere('word_en', 'like', $search.'%')
+                    ->orWhere('meaning', 'like', '%'.$search.'%');
             });
         }
         $count = $table->count();
@@ -212,9 +252,28 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建术语
+     *
+     * 需要登录。传了 channel 则建 channel 级术语,owner 取该 channel 所属 studio、
+     * language 取该 channel 的语言(请求里的 language 被覆盖),并要求当前身份对该
+     * channel 有编辑权(owner / 协作者 / channel access token),否则 403。
+     * 不传 channel 则建 studio 级术语,只允许 studio 本人创建(studioId 必须等于当前
+     * 用户 uid,否则 403);AI 模型身份一律禁止(403),因为 access token 只能代持
+     * channel 级权限。
+     * 唯一性:channel 级按 channel+word+tag 查重,studio 级按 owner+word+tag+language
+     * 且 channal 为空查重;重复时返回 'word existed',HTTP 状态仍是 200。
+     * 写入成功后清除该词的 term 缓存并重建 OpenSearch 索引(索引失败只记日志)。
      *
-     * @return Response
+     * @bodyParam word string required 术语词形,同时用于生成 word_en
+     * @bodyParam meaning string required 主要义项
+     * @bodyParam other_meaning string 其他义项,逗号分隔
+     * @bodyParam note string 术语解释,Markdown
+     * @bodyParam tag string 标签,参与查重
+     * @bodyParam channel string channel uid。给出则建 channel 级术语,否则建 studio 级术语
+     * @bodyParam language string 语言。仅 studio 级术语生效,channel 级会被 channel 语言覆盖
+     * @bodyParam studioId string studio 的 uuid,不传 channel 时必填(或改用 studioName)
+     * @bodyParam studioName string studio 名称,未给 studioId 时用它换取 studioId
+     * @bodyParam access_token string channel access token,供 AI 模型等非 owner 身份验证 channel 编辑权
      */
     public function store(Request $request)
     {
@@ -343,6 +402,8 @@ class DhammaTermController extends Controller
             // 通用 查询studio所有channel
             $channels = Channel::where('owner_uid', $term->owner)->select('uid')->get();
             foreach ($channels as $channel) {
+                // FIXME: $channel 是 Eloquent 模型对象,插值后得到的是 JSON 而非 uid,
+                // 缓存键与写入时的键对不上,studio 级术语的缓存实际没被清掉;应用 $channel->uid。
                 Cache::forget("/term/{$channel}/{$term->word}");
             }
         } else {
@@ -369,14 +430,21 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询单条术语
+     *
+     * 按 guid(不是自增 id)查找,公开接口,不需要登录。查不到返回错误 '没有查询到数据'。
      *
-     * @param  string  $id
-     * @return Response
+     * @urlParam id string required 术语的 guid
+     *
+     * @queryParam channel string 渲染 note 时使用的 channel 列表,多个用下划线分隔;
+     *                            不传则按术语自身的 channel 或对应语言的社区 channel 渲染
+     * @queryParam mode string 渲染模式。Default: read
+     * @queryParam format string note 的渲染输出格式。Enum: react,html,text Default: react
+     * @queryParam community_summary boolean 传入该参数时,没有 note 的术语回落到社区解释作为 summary
+     * @queryParam exp boolean 传入该参数时附带编辑者累计经验值(秒)
      */
     public function show(Request $request, $id)
     {
-        //
         $result = DhammaTerm::where('guid', $id)->first();
         if ($result) {
             return $this->ok(new TermResource($result));
@@ -386,14 +454,27 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改术语
+     *
+     * 需要登录(未登录 401),按主键 id 查找,不存在返回 '404'。
+     * 权限:studio 级术语(channal 为空)只有 owner 本人能改,AI 模型一律 403;
+     * channel 级术语要求对该 channel 有编辑权(owner / 协作者 / access token),否则 403。
+     * 增量更新——只有请求里出现的字段才会被写入,未提交的字段保持原值;
+     * 提交 word 时会同步重算 word_en。create_time 不变,只刷新 modify_time。
+     * 保存后清除该词的 term 缓存并重建 OpenSearch 索引(索引失败只记日志)。
      *
-     * @param  DhammaTerm  $dhammaTerm
-     * @return Response
+     * @urlParam id string required 术语主键 id
+     *
+     * @bodyParam word string 术语词形,提交时同步重算 word_en
+     * @bodyParam meaning string 主要义项
+     * @bodyParam other_meaning string 其他义项,逗号分隔
+     * @bodyParam note string 术语解释,Markdown
+     * @bodyParam tag string 标签
+     * @bodyParam language string 语言
+     * @bodyParam access_token string channel access token,供非 owner 身份验证 channel 编辑权
      */
     public function update(Request $request, string $id)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), [], 401);
@@ -449,22 +530,32 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 批量删除术语
+     *
+     * 需要登录。一次可以删除多条,返回实际删除的条数;无权限的条目被跳过而不报错。
+     * 传了 uuid 参数时,id 按数组处理:owner 本人可删,非 owner 且术语属于某个 channel 时
+     * 需要该 channel 的协作权限达到 20(编辑)才可删,studio 级术语非 owner 一律跳过。
+     * 未传 uuid 时,id 按 JSON 字符串数组处理,且只能删除 owner 是当前用户的术语。
+     * 每删一条都会清除该词对应的 term 缓存。
+     *
+     * @urlParam dhammaTerm string required 路由模型绑定占位的术语 id,实际删除对象由 id 参数决定
      *
-     * @return Response
+     * @queryParam uuid string 存在该参数即表示 id 以数组形式提交,并启用 channel 协作者删除权限判定
+     * @queryParam id array required 要删除的术语 id 列表。带 uuid 时为数组,否则为 JSON 数组字符串
      */
     public function destroy(DhammaTerm $dhammaTerm, Request $request)
     {
-        /**
-         * 一次删除多个单词
-         */
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'));
         }
         $count = 0;
+        // FIXME: 判断用的是 uuid 参数、实际读的却是 id 参数,uuid 纯当开关用,语义混乱
+        // 极易误用;应改为按 id 的实际类型分支,或统一成一个明确的参数格式。
         if ($request->has('uuid')) {
             // 查看是否有删除权限
+            // FIXME: 未校验 input('id') 是数组,客户端传字符串会导致 foreach 直接 fatal;
+            // 应先 validate(['id' => 'required|array'])。
             foreach ($request->input('id') as $key => $uuid) {
                 $term = DhammaTerm::find($uuid);
                 if (! $term) {
@@ -486,6 +577,8 @@ class DhammaTermController extends Controller
                 $this->deleteCache($term);
             }
         } else {
+            // FIXME: json_decode 结果未判空,非法 JSON 时得到 null 会让下面的 foreach 报错;
+            // 应判断解析结果是数组,否则返回参数错误。
             $arrId = json_decode($request->input('id'), true);
             foreach ($arrId as $key => $id) {
                 // code...

+ 119 - 18
api-v13/app/Http/Controllers/DiscussionController.php

@@ -16,14 +16,29 @@ use App\Models\Wbw;
 use App\Models\WbwBlock;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 
 class DiscussionController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出讨论 / 批注
      *
-     * @return Response
+     * 按 view 指定的口径返回 discussion 列表,支持状态过滤、分页与排序。
+     * 权限:未登录用户请求 type=discussion 时直接返回空结果;basic 角色用户在自己无编辑权限的
+     * channel 上只能看到自己发表的 discussion;view=topic-by-user 必须登录,否则返回 403。
+     * 返回 rows、count(当前口径总数)、active/close(顶级节点的活跃与关闭数)、
+     * can_create/can_reply(当前用户能否发起与回复,按 type 与 res_type 计算)。
+     *
+     * @queryParam view string required 查询口径。question=按资源查顶级讨论;question-by-topic=按某条讨论所属资源查同资源讨论;answer=查某讨论的回复;res_id=某资源的顶级节点及其直接子节点;topic-by-user=当前用户发表的全部顶级讨论;all=全部顶级讨论。Enum: question,question-by-topic,answer,res_id,topic-by-user,all
+     * @queryParam id string required 资源 uid(question/res_id)、讨论 id(question-by-topic/answer)
+     * @queryParam type string 讨论类型。Enum: discussion,qa,help,note,commentary Default: discussion
+     * @queryParam res_type string 资源类型,影响权限判断与学员提问聚合。Enum: sentence,wbw,article
+     * @queryParam status string 状态过滤;res_id 与 topic-by-user 口径支持逗号分隔多选。Enum: active,close Default: active
+     * @queryParam course string 课程 uid,配合 show_student 聚合学员提问
+     * @queryParam show_student string 是否把学员 channel 上的同位置提问一并返回。Enum: true,false
+     * @queryParam order string 排序字段。Default: created_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 跳过的记录数。Default: 0
+     * @queryParam limit integer 返回条数上限。Default: 100
      */
     public function index(Request $request)
     {
@@ -34,6 +49,9 @@ class DiscussionController extends Controller
         }
         switch ($request->input('view')) {
             case 'question-by-topic':
+                // FIXME: $topic 拿到的是 Builder 而不是模型,->first() 的结果被丢弃,
+                // 因此下面的 if (! $topic) 永远为 false,$topic->res_id 走的是 Builder 魔术属性。
+                // 应写成 $topic = Discussion::where('id', ...)->where('status', ...)->select('res_id')->first();
                 $topic = Discussion::where('id', $request->input('id'));
                 $topic->where('status', $request->input('status', 'active'))
                     ->select('res_id')->first();
@@ -197,9 +215,13 @@ class DiscussionController extends Controller
                     ->where('status', 'close')->count();
                 break;
         }
+        // FIXME: $search 从未定义,这段标题搜索是永不执行的死代码。
+        // 应改为从 $request->input('search') 取值,或直接删除。
         if (! empty($search)) {
             $table = $table->where('title', 'like', $search.'%');
         }
+        // FIXME: view 不在枚举内(含缺省)时 $table 从未被赋值,这里会 fatal error。
+        // 建议 switch 补 default 分支直接返回参数错误。
         $count = $table->count();
 
         $table = $table->orderBy($request->input('order', 'created_at'), $request->input('dir', 'desc'));
@@ -253,6 +275,20 @@ class DiscussionController extends Controller
         ]);
     }
 
+    /**
+     * 按句子批量查询讨论概览
+     *
+     * 对传入的每个句子定位(book/paragraph/word_start/word_end/channel_id)先查到 sentence,
+     * 再取该句上全部顶级讨论(title、children_count、editor_uid,按创建时间倒序)。
+     * 没有讨论的句子会被跳过,不做登录与权限校验。返回 rows 与 count。
+     *
+     * @bodyParam data array required 句子定位数组
+     * @bodyParam data[].book integer required 卷号
+     * @bodyParam data[].paragraph integer required 段号
+     * @bodyParam data[].word_start integer required 句子起始词序号
+     * @bodyParam data[].word_end integer required 句子结束词序号
+     * @bodyParam data[].channel_id string required 译文集 channel uid
+     */
     public function discussion_tree(Request $request)
     {
         $output = [];
@@ -291,9 +327,28 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 发表讨论 / 批注或回复
+     *
+     * 必须登录,未登录返回 401。传了 parent 即为回复:res_id 与 res_type 继承自父节点,
+     * 无需再传,父节点不存在返回 no record,保存后父节点 children_count 自增;
+     * 不传 parent 为顶级节点,此时 res_id、res_type、title 均为必填。
+     * 锚点字段(pos_start/pos_end/quote_exact/quote_prefix/quote_suffix)两种情况都可选。
+     * 默认会向消息队列 discussion 推送通知,可用 notification=false 关闭。
      *
-     * @return Response
+     * @bodyParam parent string 父讨论 id,传入表示这是一条回复
+     * @bodyParam res_id string required 关联资源 uid(有 parent 时忽略并继承父节点)
+     * @bodyParam res_type string required 关联资源类型(有 parent 时忽略并继承父节点)。Enum: sentence,wbw,article
+     * @bodyParam title string 标题(可选)
+     * @bodyParam content string 正文内容
+     * @bodyParam content_type string 正文格式。Default: markdown
+     * @bodyParam type string 讨论类型。Enum: discussion,qa,help,note,commentary Default: discussion
+     * @bodyParam tpl_id string 模板 id
+     * @bodyParam pos_start integer 锚点在资源文本中的起始偏移,非负整数
+     * @bodyParam pos_end integer 锚点在资源文本中的结束偏移,非负整数
+     * @bodyParam quote_exact string 锚定的原文片段
+     * @bodyParam quote_prefix string 锚定片段前的上下文,用于重新定位
+     * @bodyParam quote_suffix string 锚定片段后的上下文,用于重新定位
+     * @bodyParam notification boolean 是否推送消息队列通知。Default: true
      */
     public function store(Request $request)
     {
@@ -305,18 +360,24 @@ class DiscussionController extends Controller
         // validate
         // read more on validation at http://laravel.com/docs/validation
 
+        $annotationRules = [
+            'pos_start' => 'nullable|integer|min:0',
+            'pos_end' => 'nullable|integer|min:0',
+            'quote_exact' => 'nullable|string',
+            'quote_prefix' => 'nullable|string',
+            'quote_suffix' => 'nullable|string',
+        ];
         if ($request->has('parent')) {
-            $rules = [];
+            $rules = $annotationRules;
             $parentInfo = Discussion::find($request->input('parent'));
             if (! $parentInfo) {
                 return $this->error('no record');
             }
         } else {
-            $rules = [
+            $rules = array_merge([
                 'res_id' => 'required',
                 'res_type' => 'required',
-                'title' => 'required',
-            ];
+            ], $annotationRules);
         }
 
         $validated = $request->validate($rules);
@@ -336,6 +397,11 @@ class DiscussionController extends Controller
         $discussion->content_type = $request->input('content_type', 'markdown');
         $discussion->parent = $request->input('parent', null);
         $discussion->editor_uid = $user['user_uid'];
+        $discussion->pos_start = $request->input('pos_start');
+        $discussion->pos_end = $request->input('pos_end');
+        $discussion->quote_exact = $request->input('quote_exact');
+        $discussion->quote_prefix = $request->input('quote_prefix');
+        $discussion->quote_suffix = $request->input('quote_suffix');
         $discussion->save();
         // 更新parent children_count
         if ($request->has('parent')) {
@@ -350,9 +416,11 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询单条讨论
+     *
+     * 按路径参数做路由模型绑定并直接返回,不做登录与权限校验。
      *
-     * @return Response
+     * @urlParam discussion string required 讨论 id
      */
     public function show(Discussion $discussion)
     {
@@ -361,10 +429,13 @@ class DiscussionController extends Controller
     }
 
     /**
-     * 获取discussion 锚点的数据。以句子为最小单位,逐词解析也要显示单词所在的句子
+     * 获取 discussion 锚点的数据
      *
-     * @param  string  $id
-     * @return Response
+     * 以句子为最小单位返回锚点上下文:res_type=wbw 时,由单词定位到所属 wbw block 与巴利句子,
+     * 再用该 block 的 channel 渲染出句子内容的 markdown。其他 res_type 暂返回空字符串。
+     * 不做登录与权限校验;wbw、wbw block 或句子任一查不到时分别返回 no wbw data / no wbwBlock data / no sent data。
+     *
+     * @urlParam id string required 讨论 id
      */
     public function anchor($id)
     {
@@ -404,9 +475,25 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改讨论 / 批注
+     *
+     * 必须登录,未登录返回 403。作者本人可改;非作者则要求是资源所属 channel 的可编辑者
+     * (res_type=sentence 取 sentence.channel_uid,res_type=wbw 经 wbw block 取 channel_uid),
+     * 两者都不满足返回 403。
+     * title、content、status 按请求覆盖,未提交时分别回落为 null、null、active;
+     * type 与五个锚点字段为增量更新,只改请求里出现的字段,未提交的原样保留。
+     *
+     * @urlParam discussion string required 讨论 id
      *
-     * @return Response
+     * @bodyParam title string 标题,未提交会被清空
+     * @bodyParam content string 正文内容,未提交会被清空
+     * @bodyParam status string 状态,未提交会重置为 active。Enum: active,close Default: active
+     * @bodyParam type string 讨论类型,仅在提交时更新。Enum: discussion,qa,help,note,commentary
+     * @bodyParam pos_start integer 锚点起始偏移,仅在提交时更新
+     * @bodyParam pos_end integer 锚点结束偏移,仅在提交时更新
+     * @bodyParam quote_exact string 锚定的原文片段,仅在提交时更新
+     * @bodyParam quote_prefix string 锚定片段前的上下文,仅在提交时更新
+     * @bodyParam quote_suffix string 锚定片段后的上下文,仅在提交时更新
      */
     public function update(Request $request, Discussion $discussion)
     {
@@ -447,12 +534,21 @@ class DiscussionController extends Controller
             return $this->error(__('auth.failed'), [403], 403);
         }
 
+        // FIXME: 未提交 title/content/status 时会被覆盖成 null/active,与下方锚点字段的增量更新策略不一致,
+        // 前端只想改单个字段时容易误清内容。建议统一改为 $request->has() 判断后再赋值。
         $discussion->title = $request->input('title', null);
         $discussion->content = $request->input('content', null);
         $discussion->status = $request->input('status', 'active');
         if ($request->has('type')) {
             $discussion->type = $request->input('type');
         }
+        // 注释锚点字段:增量更新,只改请求里出现的字段,好让前端能显式清空;
+        // 未提交的字段必须原样保留。
+        foreach (['pos_start', 'pos_end', 'quote_exact', 'quote_prefix', 'quote_suffix'] as $field) {
+            if ($request->has($field)) {
+                $discussion->{$field} = $request->input($field);
+            }
+        }
         // $discussion->editor_uid = $user['user_uid'];
         $discussion->save();
 
@@ -460,9 +556,12 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除讨论 / 批注
+     *
+     * 必须登录,未登录返回 401。目前只允许作者本人删除,其他人返回 403。
+     * 不会级联删除子回复,也不会回退父节点的 children_count。
      *
-     * @return Response
+     * @urlParam discussion string required 讨论 id
      */
     public function destroy(Request $request, Discussion $discussion)
     {
@@ -475,6 +574,8 @@ class DiscussionController extends Controller
         if ($discussion->editor_uid !== $user['user_uid']) {
             return $this->error(__('auth.failed'), [403], 403);
         }
+        // FIXME: 删除时既不级联删除子回复(产生孤儿节点),也不回退父节点的 children_count,计数会漂移。
+        // 建议在事务里一并删除子节点并 decrement 父节点计数。
         $delete = $discussion->delete();
 
         return $this->ok($delete);

+ 6 - 1
api-v13/app/Http/Controllers/Library/BookController.php

@@ -103,6 +103,10 @@ class BookController extends Controller
     {
 
         $channelId = $request->input('channel');
+        $isDefaultChannel = empty($channelId);
+        if ($isDefaultChannel) {
+            $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        }
 
         [$bookId, $paraId] = explode('-', $id);
         $bookId = (int) $bookId;
@@ -132,6 +136,7 @@ class BookController extends Controller
         $book['categories'] = $chapter['category'];
         $book['title'] = $chapter['title'];
         $book['author'] = $channel['name'];
+        $book['is_original'] = in_array($channel['type'], ['original', 'wbw'], true);
         $book['studio'] = $studio;
         $book['tags'] = [];
         $book['book_title'] = $this->getBookTitle($bookId, $paraId, $channelId);
@@ -154,7 +159,7 @@ class BookController extends Controller
         $editor_link = config('mint.server.dashboard_base_path')
             ."/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
 
-        $view = view('library.book.read', compact('book', 'channels', 'editor_link', 'commentaryChannels'));
+        $view = view('library.book.read', compact('book', 'channels', 'editor_link', 'commentaryChannels', 'isDefaultChannel'));
 
         return $view;
     }

+ 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,
-        ]);
-    }
 }

+ 11 - 3
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;
@@ -467,9 +474,10 @@ class ProgressChapterController extends Controller
      *
      * @return Response
      */
-    public function show(ProgressChapter $progressChapter)
+    public function show(ProgressChapter $progress)
     {
         //
+        return $this->ok($progress);
     }
 
     /**

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

@@ -1,104 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Models\ProgressChapter;
-use Illuminate\Http\Request;
-use Illuminate\Http\Response;
-
-class ProgressController extends Controller
-{
-    /**
-     * Display a listing of the resource.
-     *
-     * @return Response
-     */
-    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:
-                return $this->error('invalid view', 400, 400);
-                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'));
-        }
-        $count = $table->count();
-
-        $table = $table->orderBy(
-            'progress_chapters.'.$request->input('order', 'completed_at'),
-            $request->input('dir', 'desc')
-        );
-
-        $table = $table->skip($request->input('offset', 0))
-            ->take($request->input('limit', 10));
-
-        $result = $table->get();
-
-        return $this->ok(
-            [
-                'rows' => $result->toArray(),
-                'total' => $count,
-            ]
-        );
-    }
-
-    /**
-     * 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)
-    {
-        //
-    }
-}

+ 64 - 14
api-v13/app/Http/Controllers/ProjectController.php

@@ -8,15 +8,27 @@ use App\Http\Resources\ProjectResource;
 use App\Models\Project;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Str;
 
 class ProjectController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出工程(project)
      *
-     * @return Response
+     * 按 view 指定的口径返回工程列表,支持关键字搜索、状态过滤、排序和分页。
+     * 必须登录,否则返回 401;view 缺失或不在枚举内时返回错误提示 view。
+     * 未传 studio 时,studio 默认取当前用户自己。
+     *
+     * @queryParam view string required 查询口径:studio=某 studio 下的顶层工程,project-tree=某工程及其所有子工程,shared=分享给该 studio 的工程,community=其他 studio 的公开顶层工程。Enum: studio,project-tree,shared,community
+     * @queryParam studio string studio 名称;不传则使用当前登录用户
+     * @queryParam type string 工程类型,用于 studio/shared/community 口径。Enum: instance,article Default: instance
+     * @queryParam project_id string view=project-tree 时必填,工程 uid;同时匹配该工程本身及 path 中包含它的子工程
+     * @queryParam keyword string 标题模糊搜索关键字
+     * @queryParam status string 工程状态,逗号分隔可多选
+     * @queryParam order string 排序字段。Default: id
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: asc
+     * @queryParam offset integer 分页偏移量。Default: 0
+     * @queryParam limit integer 每页条数。Default: 10000
      */
     public function index(Request $request)
     {
@@ -56,6 +68,8 @@ class ProjectController extends Controller
                     ->where('type', $request->input('type', 'instance'));
                 break;
             default:
+                // FIXME: view 非法时用 200 状态码表达错误,与项目其他接口的 4xx 约定不一致,
+                // 建议改为 422 或 400。
                 return $this->error('view', 200, 200);
                 break;
         }
@@ -64,6 +78,8 @@ class ProjectController extends Controller
             $table = $table->where('title', 'like', '%'.$request->input('keyword').'%');
         }
         if ($request->has('status')) {
+            // FIXME: projects.status 是 jsonb 列,这里却按字符串 whereIn 比较,PostgreSQL 下匹配不到或报类型错误,
+            // 建议改用 whereJsonContains 或把 status 规整为字符串列。
             $table = $table->whereIn('status', explode(',', $request->input('status')));
         }
         $count = $table->count();
@@ -83,19 +99,36 @@ class ProjectController extends Controller
         );
     }
 
+    /**
+     * 判断用户是否有权编辑该 studio 下的工程
+     *
+     * 目前仅允许 studio 本人,即用户 uid 与 studio uid 相同。
+     *
+     * @param  string  $user_uid  用户 uuid
+     * @param  string  $studio_uid  studio uuid
+     */
     public static function canEdit($user_uid, $studio_uid)
     {
         return $user_uid == $studio_uid;
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建工程
+     *
+     * 在指定 studio 下创建工程。必须登录(否则 401),且当前用户必须是该 studio 本人
+     * (studio_name 解析出的 id 等于当前用户 uid),否则返回 403。
+     * 传入 parent_id 时会读取父工程的 path 并追加父工程 id,写入本工程的 path,
+     * 以便按树查询子工程。
      *
-     * @return Response
+     * @bodyParam studio_name string required studio 名称,用于解析归属 studio 并做权限校验
+     * @bodyParam id string 工程 uid;为合法 uuid 时用它(已存在则更新该工程),否则服务端生成新 uuid
+     * @bodyParam title string required 工程标题
+     * @bodyParam description string 工程描述
+     * @bodyParam parent_id string 父工程 uid,填写后作为子工程并自动计算 path
+     * @bodyParam type string 工程类型。Enum: instance,article Default: instance
      */
     public function store(Request $request)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -132,24 +165,37 @@ class ProjectController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单个工程
      *
-     * @return Response
+     * 通过路由模型绑定按工程 uid 取出工程并返回详情,工程不存在时返回 404。
+     * 该接口不做登录与权限校验,私有工程同样可被读取。
+     *
+     * @urlParam project string required 工程 uid
      */
     public function show(Project $project)
     {
-        //
+        // FIXME: 该接口完全不鉴权,privacy=private 的工程也能被任意匿名用户按 uid 读取,
+        // 建议补上登录校验并对私有工程限制为拥有者或被分享者可见。
         return $this->ok(new ProjectResource($project));
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改工程
+     *
+     * 整体覆盖式更新:title、description、parent_id、privacy 会无条件按请求体写入,
+     * 未传的字段会被置空,调用方需回传完整数据。必须登录(否则 401),
+     * 且只有工程拥有者本人可以修改,否则返回 403。
+     * 传入合法的 parent_id 时会按父工程重新计算 path。
      *
-     * @return Response
+     * @urlParam project string required 工程 uid
+     *
+     * @bodyParam title string required 工程标题
+     * @bodyParam description string 工程描述
+     * @bodyParam parent_id string 父工程 uid;为合法 uuid 时重算 path
+     * @bodyParam privacy string 隐私性:public 表示社区可见。Enum: private,public Default: private
      */
     public function update(Request $request, Project $project)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -158,6 +204,8 @@ class ProjectController extends Controller
             return $this->error(__('auth.failed'), 403, 403);
         }
 
+        // FIXME: 以下字段无条件整体覆盖,前端只想改单个字段时会把其余字段清空,且 privacy 未做白名单校验,
+        // 建议改为 $request->has() 逐字段更新并校验 privacy 只能为 private|public。
         $project->title = $request->input('title');
         $project->description = $request->input('description');
         $project->parent_id = $request->input('parent_id');
@@ -179,9 +227,11 @@ class ProjectController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除工程
+     *
+     * 尚未实现:方法体为空,不会删除任何数据,也不返回任何内容(响应为 200 空 body)。
      *
-     * @return Response
+     * @urlParam project string required 工程 uid
      */
     public function destroy(Project $project)
     {

+ 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
      */

+ 7 - 6
api-v13/app/Http/Controllers/SentenceController.php

@@ -8,15 +8,12 @@ use App\Http\Api\PaliTextApi;
 use App\Http\Api\ShareApi;
 use App\Http\Controllers\Concerns\ChecksChannelEditPower;
 use App\Http\Resources\SentResource;
-use App\Models\AccessToken;
 use App\Models\Channel;
 use App\Models\Sentence;
 use App\Models\WbwAnalysis;
 use App\Services\AuthService;
 use App\Services\SentenceService;
 use App\Tools\OpsLog;
-use Firebase\JWT\JWT;
-use Firebase\JWT\Key;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Cache;
@@ -80,8 +77,12 @@ 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)) {
@@ -89,7 +90,7 @@ class SentenceController extends Controller
                 }
                 $table = Sentence::select($indexCol)
                     ->where('content', 'like', '%'.$key.'%')
-                    ->where('editor_uid', $userUid);
+                    ->where('editor_uid', $user['user_uid']);
 
                 break;
             case 'channel':

+ 86 - 23
api-v13/app/Http/Controllers/TaskController.php

@@ -10,19 +10,37 @@ use App\Models\Task;
 use App\Models\TaskAssignee;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Str;
 
 class TaskController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出任务
      *
-     * @return Response
+     * 按 view 指定的口径返回任务列表,支持按执行人、责任人(指派)、参与者、
+     * 关键字、状态过滤,并支持排序与分页。必须登录,未登录返回 401。
+     * 注意:view 取值不在枚举内时不会构造查询,会导致请求失败。
+     *
+     * @queryParam view string required 查询口径:all=全部有归属的任务,instance=实例任务(type=instance),studio=当前用户名下(owner_id=当前用户)的任务,project=某工程及其子工程下的任务。Enum: all,instance,studio,project
+     * @queryParam project_id string view=project 时必填,工程 uid;同时匹配该工程本身及 path 中包含它的子工程
+     * @queryParam executor_id_includes string 实际执行人 uid,逗号分隔,只返回执行人在其中的任务
+     * @queryParam executor_id_not-includes string 实际执行人 uid,逗号分隔,排除执行人在其中的任务
+     * @queryParam assignees_id_includes string 责任人(指派人)uid,逗号分隔,只返回指派给其中任一人的任务
+     * @queryParam assignees_id_not-includes string 责任人(指派人)uid,逗号分隔,排除指派给其中任一人的任务
+     * @queryParam assignees_id_null string 传该参数(任意值)表示只返回没有任何责任人的任务
+     * @queryParam assignees_id_not-null string 传该参数(任意值)表示只返回至少有一个责任人的任务
+     * @queryParam sign_up_equals string 值为 true 时只返回可领取的任务,即责任人和实际执行人都为空。Enum: true,false
+     * @queryParam participants_id_includes string 参与者 uid,逗号分隔,实际执行人或责任人命中其一即返回
+     * @queryParam participants_id_not-includes string 参与者 uid,逗号分隔,排除这些人参与的任务
+     * @queryParam keyword string 标题模糊搜索关键字
+     * @queryParam status string 任务状态,逗号分隔可多选;传 all 表示不过滤。数据库默认值为 pending
+     * @queryParam order string 排序字段。Default: created_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: asc
+     * @queryParam offset integer 分页偏移量。Default: 0
+     * @queryParam limit integer 每页条数。Default: 1000
      */
     public function index(Request $request)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -46,6 +64,8 @@ class TaskController extends Controller
                 $table = Task::whereIn('project_id', $projects);
                 break;
             default:
+                // FIXME: view 缺失或不在枚举内时 $table 未被赋值,后续 $table->count() 会致命错误,
+                // 建议参照 ProjectController::index 在此直接返回参数错误响应。
                 // code...
                 break;
         }
@@ -68,7 +88,7 @@ class TaskController extends Controller
             $table = $table->whereIn('id', $assigneesTasks);
         }
         if ($request->has('assignees_id_not-includes')) {
-            $assigneesId = explode(',', $request->input('assignees_id_includes'));
+            $assigneesId = explode(',', $request->input('assignees_id_not-includes'));
             $assigneesTasks = TaskAssignee::whereIn('assignee_id', $assigneesId)
                 ->select('task_id')->get();
             $table = $table->whereNotIn('id', $assigneesTasks);
@@ -81,6 +101,8 @@ class TaskController extends Controller
             $table = $table->has('task_assignees');
         }
 
+        // FIXME: 责任人现已存放在 task_assignees 表,tasks.assignees_id 列不再写入,
+        // 这里的 whereNull('assignees_id') 形同虚设,建议改用 doesntHave('task_assignees')。
         if ($request->input('sign_up_equals') === 'true') {
             $table = $table->whereNull('assignees_id')
                 ->whereNull('executor_id');
@@ -96,7 +118,7 @@ class TaskController extends Controller
         }
 
         if ($request->has('participants_id_not-includes')) {
-            $id = explode(',', $request->input('participants_id_includes'));
+            $id = explode(',', $request->input('participants_id_not-includes'));
             $tasks_id = TaskAssignee::whereIn('assignee_id', $id)->select('task_id')->get();
             $table = $table->where(function ($query) use ($id, $tasks_id) {
                 $query->whereNotIn('executor_id', $id)
@@ -131,13 +153,22 @@ class TaskController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建任务
      *
-     * @return Response
+     * 在指定 studio 下创建任务。必须登录(否则 401),且当前用户必须是该 studio 本人
+     * (studio_name 解析出的 id 等于当前用户 uid),否则返回 403。
+     * 新任务的 order 自动取同一 parent(无 parent 时取同一 project)下最大 order + 1,
+     * 没有兄弟节点时为 1;owner_id 为该 studio,creator_id 与 editor_id 为当前用户。
+     *
+     * @bodyParam studio_name string required studio 名称,用于解析归属 studio 并做权限校验
+     * @bodyParam id string 任务 uuid;为合法 uuid 时用它,否则服务端生成新 uuid
+     * @bodyParam title string required 任务标题
+     * @bodyParam project_id string 所属工程 uid
+     * @bodyParam parent_id string 父任务 uuid,填写后为子任务
+     * @bodyParam type string 任务类型。Default: project
      */
     public function store(Request $request)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -147,6 +178,8 @@ class TaskController extends Controller
         if (! self::canEdit($user['user_uid'], $studioId)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
+        // FIXME: 传入已存在的 id 时会命中旧任务并覆盖其 title/type/parent_id 并重算 order,
+        // 而权限只校验了 studio_name 而非该任务自身归属,建议按 id 存在与否区分新建/更新并校验任务 owner。
         $new = Task::firstOrNew(
             [
                 'id' => $request->input('id'),
@@ -186,24 +219,47 @@ class TaskController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单个任务
+     *
+     * 通过路由模型绑定按任务 uuid 取出任务并返回详情,任务不存在时返回 404。
+     * 该接口不做登录与权限校验,任何人都能读取。
      *
-     * @return Response
+     * @urlParam task string required 任务 uuid
      */
     public function show(Task $task)
     {
-        //
+        // FIXME: 该接口完全不鉴权,任何匿名用户按 uuid 即可读取任意任务详情,
+        // 建议补上 AuthService::current 校验并按任务归属/可见性过滤。
         return $this->ok(new TaskResource($task));
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改任务
      *
-     * @return Response
+     * 局部更新:只更新请求体里出现的字段,未出现的字段保持不变。必须登录(否则 401);
+     * 只有任务拥有者、实际执行人或责任人之一可以修改,否则返回 403。
+     * 副作用:传 assignees_id 会先删除该任务全部责任人再整体重建;
+     * 传 pre_task_id / next_task_id 会整体重写任务的前置 / 后续关联关系。
+     * 每次保存都会把 editor_id 更新为当前用户。
+     *
+     * @urlParam task string required 任务 uuid
+     *
+     * @bodyParam title string 任务标题
+     * @bodyParam description string 任务描述(markdown)
+     * @bodyParam category string 类别,如 翻译、审稿、百科
+     * @bodyParam progress integer 进度,0-100
+     * @bodyParam assignees_id array 责任人 uid 数组,整体覆盖原有责任人;传空数组等于清空
+     * @bodyParam roles array 领取该任务所需的角色要求,存为 json
+     * @bodyParam executor_id string 实际执行人 uid
+     * @bodyParam executor_relation_task_id string 执行人关联任务 uuid,表示执行人与该任务保持一致
+     * @bodyParam project_id string 所属工程 uid
+     * @bodyParam pre_task_id string 前置任务 uuid,逗号分隔,整体覆盖原有前置关系
+     * @bodyParam next_task_id string 后续任务 uuid,逗号分隔,整体覆盖原有后续关系
+     * @bodyParam is_milestone boolean 是否为里程碑
+     * @bodyParam order integer 拖拽排序顺序
      */
     public function update(Request $request, Task $task)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -278,21 +334,27 @@ class TaskController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除任务
      *
-     * @return Response
+     * 必须登录(否则 401),且当前用户必须是任务拥有者,否则返回 403。
+     * 删除成功返回 ok,失败返回 500。
+     *
+     * @urlParam task string required 任务 uuid
      */
     public function destroy(Request $request, Task $task)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
+        // FIXME: tasks 表只有 owner_id 列,Task 模型也没有 owner 关系,$task->owner 恒为 null,
+        // 导致删除接口对所有人恒返回 403;应改为 $task->owner_id,但需与下方 trashed() 一并修复。
         if (! self::canEdit($user['user_uid'], $task->owner)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
         $task->delete();
+        // FIXME: Task 模型未 use SoftDeletes(tasks 表也无 deleted_at 列),trashed() 会抛
+        // BadMethodCallException;上面 owner 那条修好后就会走到这里,两处必须一起改(加软删除或改判 delete() 返回值)。
         if ($task->trashed()) {
             return $this->ok('ok');
         } else {
@@ -306,13 +368,14 @@ class TaskController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 判断用户是否有权修改该任务
+     *
+     * 任务拥有者、实际执行人,或该任务的责任人之一,均可修改。
      *
-     * @param  string  $user_uid
-     * @param  Task  $task
-     * @return bool
+     * @param  string  $user_uid  用户 uuid
+     * @param  Task  $task  任务
      */
-    public static function canUpdate($user_uid, $task)
+    public static function canUpdate($user_uid, $task): bool
     {
         if ($user_uid === $task->owner_id) {
             return true;

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

@@ -1,183 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Models\PaliText;
-use App\Services\PaliContentService;
-use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-
-class TipitakaReadChapterController extends Controller
-{
-    /**
-     * 阅读模式章节内容。输入章节起始 book/para,按 pagesize 分批返回段落。
-     *
-     * pagesize 两种写法:
-     *   - `20000b` 按字节,累加 pali_texts.lenght 直到超过上限(每页至少一段)
-     *   - `10p`    按段落数
-     */
-    public function index(Request $request, PaliContentService $paliService): JsonResponse
-    {
-        $data = $request->validate([
-            'book' => 'required|integer',
-            'para' => 'required|integer',
-            'channel' => 'required|uuid',
-            'format' => 'string|in:html,markdown,react,text',
-            'view' => 'string|in:display,sentences,all',
-            'pagesize' => ['string', 'regex:/^\d+[bp]$/'],
-            'page' => 'integer|min:1',
-        ]);
-
-        return $this->chapter(
-            (int) $data['book'],
-            (int) $data['para'],
-            $data['channel'],
-            $data,
-            $paliService
-        );
-    }
-
-    /**
-     * 同 index,id 格式 {book}-{para}
-     */
-    public function show(Request $request, string $id, PaliContentService $paliService): JsonResponse
-    {
-        $arrId = explode('-', $id);
-        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            return $this->error('invalid id');
-        }
-        $channel = $request->input('channel');
-        if (! Str::isUuid($channel)) {
-            return $this->error('invalid channel');
-        }
-
-        return $this->chapter(
-            (int) $arrId[0],
-            (int) $arrId[1],
-            $channel,
-            $request->only(['format', 'view', 'pagesize', 'page']),
-            $paliService
-        );
-    }
-
-    /**
-     * @param  array{format?: string, view?: string, pagesize?: string, page?: int|string}  $param
-     */
-    protected function chapter(
-        int $book,
-        int $para,
-        string $channel,
-        array $param,
-        PaliContentService $paliService
-    ): JsonResponse {
-        $format = $param['format'] ?? 'html';
-        $view = $param['view'] ?? 'display';
-        $pageSize = $param['pagesize'] ?? '10p';
-        $page = max(1, (int) ($param['page'] ?? 1));
-
-        $chapter = PaliText::where('book', $book)->where('paragraph', $para)->first();
-        if (! $chapter) {
-            return $this->error('chapter not found');
-        }
-        $to = $para + max(1, (int) $chapter->chapter_len) - 1;
-
-        /** @var array<int, array{paragraph: int, lenght: int}> $paragraphs */
-        $paragraphs = PaliText::where('book', $book)
-            ->whereBetween('paragraph', [$para, $to])
-            ->orderBy('paragraph')
-            ->get(['paragraph', 'lenght'])
-            ->all();
-        $total = count($paragraphs);
-        if ($total === 0) {
-            return $this->error('chapter is empty');
-        }
-
-        $slice = $this->slice($paragraphs, $pageSize, $page);
-        if ($slice === null) {
-            return $this->error('page out of range');
-        }
-
-        $items = [];
-        foreach ($slice as $row) {
-            $paragraph = $paliService->readParagraph($book, (int) $row->paragraph, $channel, $format);
-            if (empty($paragraph['display'])) {
-                continue;
-            }
-            $items[] = $this->filterView($paragraph, $view);
-        }
-
-        $first = $slice[0]->paragraph;
-        $last = $slice[count($slice) - 1]->paragraph;
-
-        return $this->ok([
-            'items' => $items,
-            'pagination' => [
-                'page' => $page,
-                'pageSize' => $pageSize,
-                'total' => $total,
-                'book' => $book,
-                'from' => (int) $first,
-                'to' => (int) $last,
-                'hasMore' => $last < $paragraphs[$total - 1]->paragraph,
-            ],
-        ]);
-    }
-
-    /**
-     * 取第 $page 批段落。按段落数时直接切片,按字节时从头累加 lenght 逐页推进。
-     *
-     * @param  array<int, PaliText>  $paragraphs
-     * @return array<int, PaliText>|null 页码越界时返回 null
-     */
-    protected function slice(array $paragraphs, string $pageSize, int $page): ?array
-    {
-        $limit = (int) substr($pageSize, 0, -1);
-        $unit = substr($pageSize, -1);
-        if ($limit < 1) {
-            return null;
-        }
-
-        if ($unit === 'p') {
-            $offset = ($page - 1) * $limit;
-            $slice = array_slice($paragraphs, $offset, $limit);
-
-            return $slice === [] ? null : $slice;
-        }
-
-        // 字节模式:每页累加 lenght,超过上限即断页,每页至少一段
-        $offset = 0;
-        $count = count($paragraphs);
-        for ($current = 1; $offset < $count; $current++) {
-            $bytes = 0;
-            $take = 0;
-            while ($offset + $take < $count) {
-                $bytes += (int) $paragraphs[$offset + $take]->lenght;
-                $take++;
-                if ($bytes >= $limit) {
-                    break;
-                }
-            }
-            if ($current === $page) {
-                return array_slice($paragraphs, $offset, $take);
-            }
-            $offset += $take;
-        }
-
-        return null;
-    }
-
-    /**
-     * 按 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 - 100
api-v13/app/Http/Controllers/TipitakaReadParaController.php

@@ -1,100 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Services\PaliContentService;
-use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-
-class TipitakaReadParaController extends Controller
-{
-    /**
-     * 阅读模式段落内容列表。指定 book 段落区间和 channel
-     */
-    public function index(Request $request, PaliContentService $paliService): JsonResponse
-    {
-        $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) {
-            return $this->error('invalid paragraph range');
-        }
-        $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 $this->ok([
-            'items' => $items,
-            'pagination' => [
-                'page' => 1,
-                'pageSize' => $to - $from + 1,
-                'total' => count($items),
-            ],
-        ]);
-    }
-
-    /**
-     * 单个段落内容。id 格式 {book}-{para}
-     */
-    public function show(Request $request, string $id, PaliContentService $paliService): JsonResponse
-    {
-        $arrId = explode('-', $id);
-        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            return $this->error('invalid id');
-        }
-        $channel = $request->input('channel');
-        if (! Str::isUuid($channel)) {
-            return $this->error('invalid channel');
-        }
-        $book = (int) $arrId[0];
-        $para = (int) $arrId[1];
-
-        $paragraph = $paliService->readParagraph(
-            $book,
-            $para,
-            $channel,
-            $request->input('format', 'html')
-        );
-        if (empty($paragraph['display'])) {
-            return $this->error('no data');
-        }
-
-        return $this->ok($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 - 51
api-v13/app/Http/Controllers/UpgradeController.php

@@ -1,51 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Http\Requests\StoreUpgradeRequest;
-use App\Http\Requests\UpdateUpgradeRequest;
-use App\Models\Upgrade;
-
-class UpgradeController extends Controller
-{
-    /**
-     * Display a listing of the resource.
-     */
-    public function index()
-    {
-        //
-        return $this->ok(['data' => '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)
-    {
-        //
-    }
-}

+ 45 - 17
api-v13/app/Http/Controllers/UserController.php

@@ -5,20 +5,32 @@ namespace App\Http\Controllers;
 use App\Http\Resources\UserResource;
 use App\Models\UserInfo;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 
 class UserController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出用户
      *
-     * @return Response
+     * 按 view 指定的口径返回用户列表,支持按昵称搜索、按角色过滤、分页与排序。
+     * 注意:view 只识别 key 和 all 两个值,传其他值或不传会因为查询对象未初始化而报错。
+     *
+     * @queryParam view string required 查询口径。key=按关键字匹配用户名或昵称;all=全部用户。Enum: key,all
+     * @queryParam key string view=key 时的关键字,同时模糊匹配 username 与 nickname
+     * @queryParam search string 按昵称再做一次模糊过滤(nickname like %search%)
+     * @queryParam role string 按角色过滤,匹配 role 这个 JSON 数组中是否包含该值。Example: admin
+     * @queryParam order string 排序字段。Default: username
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 分页起始位置。Default: 0
+     * @queryParam limit integer 每页条数。Default: 20
      */
     public function index(Request $request)
     {
-        //
+        // FIXME: switch 没有 default 分支,view 不是 key/all(含不传)时 $table 从未初始化,下面直接致命错误 500;
+        // 建议补 default 返回 400 错误,或先给 $table 一个基础查询再按 view 叠加条件。
         switch ($request->input('view')) {
             case 'key':
+                // FIXME: where(...)->orWhere(...) 没有分组括号,后面追加的 search / role 过滤会因 AND/OR 优先级
+                // 对 orWhere 那半边失效;建议把这两个条件包进 where(function ($q) { ... }) 里。
                 $table = UserInfo::where('username', 'like', '%'.$request->input('key').'%')
                     ->orWhere('nickname', 'like', '%'.$request->input('key').'%');
 
@@ -47,9 +59,10 @@ class UserController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建用户(未实现)
      *
-     * @return Response
+     * 路由已注册 POST /api/v2/user,但方法体为空,不做任何事也不返回内容。
+     * 用户注册走认证相关接口,不走这里。
      */
     public function store(Request $request)
     {
@@ -57,28 +70,40 @@ class UserController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单个用户
      *
-     * @param  int  $id
-     * @return Response
+     * 按 userid 查询用户资料并返回(id、用户名、昵称、邮箱、角色、头像等)。
+     * 方法内没有任何鉴权,任何人都可以读取;查不到用户时会以空资源返回。
+     *
+     * @urlParam user integer required 用户的 userid(不是表自增主键 id)
      */
     public function show($id)
     {
-        //
+        // FIXME: 没有任何鉴权,任何人可读取任意用户资料(含邮箱);且 userid 不存在时会以空资源返回。
+        // 建议按产品侧确认的可见性策略补鉴权,并对查不到的情况返回 404。
         $user = UserInfo::where('userid', $id)->first();
 
         return $this->ok(new UserResource($user));
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新用户资料或角色
      *
-     * @param  string  $id
-     * @return Response
+     * 两种互斥的用法:请求体里带 roles 时只更新角色(整体覆盖,以 JSON 存入 role 字段);
+     * 不带 roles 时更新昵称、头像、邮箱三个字段(未传的会被置空)。
+     * 方法内没有任何鉴权,也没有校验,调用方需自行保证权限;userid 不存在时会报错。
+     *
+     * @urlParam user integer required 用户的 userid(不是表自增主键 id)
+     *
+     * @bodyParam roles array 角色列表,传了就只更新角色,整体覆盖原有角色
+     * @bodyParam nickName string 昵称,未传 roles 时生效
+     * @bodyParam avatar string 头像文件名,未传 roles 时生效
+     * @bodyParam email string 邮箱,未传 roles 时生效
      */
     public function update(Request $request, $id)
     {
-        //
+        // FIXME: 安全问题——本方法没有任何鉴权和参数校验,任意调用者都能改他人的 roles(提权)、邮箱、昵称、头像;
+        // 需先由产品侧确认权限策略(本人仅可改资料、roles 仅管理员可改)后再补鉴权与 validate,同时处理 userid 不存在报错。
         $user = UserInfo::where('userid', $id)->first();
         if ($request->has('roles')) {
             $user->role = json_encode($request->input('roles'));
@@ -93,13 +118,16 @@ class UserController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除用户(未实现)
+     *
+     * 路由已注册 DELETE /api/v2/user/{user},但方法体为空,不会删除任何数据也不返回内容。
      *
-     * @param  int  $id
-     * @return Response
+     * @urlParam user integer required 用户的 userid
      */
     public function destroy($id)
     {
+        // FIXME: 方法体为空但路由已暴露 DELETE,调用方会以为删除成功;且同样没有鉴权。
+        // 建议要么实现并加上鉴权,要么下掉该路由。
         //
     }
 }

+ 98 - 17
api-v13/app/Http/Controllers/UserDictController.php

@@ -9,16 +9,30 @@ use App\Models\DictInfo;
 use App\Models\UserDict;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Redis;
 use Illuminate\Support\Str;
 
 class UserDictController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出用户词典条目
      *
-     * @return Response
+     * 按 view 指定的口径返回 user_dict 记录,支持按单词、词根、词典过滤,以及分页与排序。
+     * 需要登录的口径:all、studio;studio 口径还要求 name 指定的 studio 属于当前用户,否则返回 auth.failed。
+     * community 口径只返回 status>5 且来源为 _USER_WBW_/_USER_DICT_ 的社区公开数据。
+     * 返回 rows 与 count(过滤后总数)。
+     *
+     * @queryParam view string required 查询口径。all=全部;studio=某 studio 创建者的用户词条;user=cookie 中用户的词条(排除系统汇总);word=按单词查全部;community=社区公开词条;compound=复合词机器人词典;dict=指定词典。Enum: all,studio,user,word,community,compound,dict
+     * @queryParam name string studio 口径为 studio 名;dict 口径为系统词典短名。Example: robot_compound
+     * @queryParam id string dict 口径下直接指定的词典 uid(与 name 二选一)
+     * @queryParam word string 要查询的单词,word/community/compound 口径必填;其他口径传入时也会追加过滤
+     * @queryParam parent string 按词根(parent)过滤
+     * @queryParam dict string 按词典短名(dict_info.shortname)过滤,解析为 uuid 后才生效
+     * @queryParam search string 按单词前缀模糊搜索
+     * @queryParam order string 排序字段。Default: updated_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 跳过的记录数。Default: 0
+     * @queryParam limit integer 返回条数上限。Default: 200
      */
     public function index(Request $request)
     {
@@ -64,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':
@@ -84,6 +104,7 @@ class UserDictController extends Controller
                 break;
             case 'compound':
                 $dict_id = DictApi::getSysDict('robot_compound');
+                // FIXME: $this->error(...) 没有 return,取不到词典时不会中断,会继续用 false 去查询。应改为 return $this->error(...)。
                 if ($dict_id === false) {
                     $this->error('no robot_compound');
                 }
@@ -97,15 +118,19 @@ class UserDictController extends Controller
                     $dict_id = $request->input('id');
                 }
 
+                // FIXME: $this->error(...) 没有 return,词典不存在时不会中断,会继续用 false 去查询。应改为 return $this->error(...)。
                 if ($dict_id === false) {
                     $this->error('no dict', [], 404);
                 }
                 $table = UserDict::select($indexCol)
                     ->where('dict_id', $dict_id);
+                // FIXME: 这里缺 break,会贯穿到 default(当前恰好无副作用,但属于隐患)。应补上 break;。
             default:
                 // code...
                 break;
         }
+        // FIXME: view 不在枚举内(含缺省)时 $table 从未被赋值,下面的 $table->count() 会 fatal error。
+        // 建议 default 分支直接 return $this->error('无法识别的参数view', 400, 400)。
         if ($request->has('search')) {
             $table->where('word', 'like', $request->input('search').'%');
         }
@@ -137,14 +162,23 @@ class UserDictController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 批量新增/更新用户词典条目
+     *
+     * 需要登录。data 是 JSON 字符串,解码后逐条处理:以 creator_id + word 以及请求中给出的
+     * type/grammar/parent/mean/factors 组合判重,不存在则插入(自动生成雪花 id、按 view 写入 source、
+     * 记录 create_time 与 creator_id),已存在则只更新 note 与 confidence。
+     * 新增且 status>5 时会同步刷新系统汇总词典 _SYS_USER_WBW_;每次新增都会刷新该单词的 Redis 缓存 dict/user。
+     * 返回 [新增条数, 汇总表更新结果]。
      *
-     * @return Response
+     * @bodyParam view string required 数据来源口径,决定写入的 source:dict=_USER_DICT_,wbw=_USER_WBW_。Enum: dict,wbw
+     * @bodyParam data string required 词条数组的 JSON 字符串,每项含 word(必填)及可选 type、grammar、parent、mean、factors、factormean、note、confidence、status、language
      */
     public function store(Request $request)
     {
         //
         $user = AuthService::current($request);
+        // FIXME: $this->error('not login') 没有 return,未登录时会继续往下执行并在 $user['user_id'] 处 fatal error。
+        // 应改为 return $this->error(__('auth.failed'), [], 401)。
         if (! $user) {
             $this->error('not login');
         }
@@ -216,10 +250,11 @@ class UserDictController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询单条用户词典记录
+     *
+     * 按主键查找,不做登录与权限校验;查不到返回「没有查询到数据」。
      *
-     * @param  int  $id
-     * @return Response
+     * @urlParam userdict string required 词条 id
      */
     public function show($id)
     {
@@ -233,14 +268,31 @@ class UserDictController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新单条用户词典记录
+     *
+     * 把请求体中的全部字段直接写入该记录(未做字段白名单与权限校验)。
+     * 更新成功后同步刷新系统汇总词典 _SYS_USER_WBW_ 与该单词的 Redis 缓存 dict/user。
+     * 没有记录被更新时返回「没有查询到数据」。
+     *
+     * @urlParam userdict string required 词条 id
      *
-     * @param  int  $id
-     * @return Response
+     * @bodyParam word string 单词
+     * @bodyParam type string 词性
+     * @bodyParam grammar string 语法属性
+     * @bodyParam parent string 词根
+     * @bodyParam mean string 词义
+     * @bodyParam factors string 复合词拆分
+     * @bodyParam factormean string 复合词各部分词义
+     * @bodyParam note string 备注
+     * @bodyParam confidence integer 置信度
+     * @bodyParam status integer 状态,大于 5 表示公开到社区
+     * @bodyParam language string 语言代码
      */
     public function update(Request $request, $id)
     {
         //
+        // FIXME: 安全问题。直接把 $request->all() 整体落库,既无字段白名单(可改 creator_id、source、dict_id 等)
+        // 也无登录与创建者校验,任何人都能改任意词条。应先鉴权并只允许更新白名单字段。
         $newData = $request->all();
         $result = UserDict::where('id', $id)
             ->update($newData);
@@ -255,10 +307,15 @@ class UserDictController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除用户词典记录(支持批量)
      *
-     * @param  int  $id
-     * @return Response
+     * 需要登录,未登录返回 403。传了查询参数 id(JSON 数组)时走批量分支,逐条只删除 creator_id
+     * 等于当前用户的记录,返回 [实际删除条数, 汇总表更新结果];否则删除路径参数指定的单条记录,
+     * 非本人创建返回 auth.failed。两种分支都会同步刷新 _SYS_USER_WBW_ 汇总与 Redis 缓存 dict/user。
+     *
+     * @urlParam userdict string required 要删除的词条 id(未传查询参数 id 时使用)
+     *
+     * @queryParam id string 要批量删除的词条 id 数组的 JSON 字符串。Example: ["123","456"]
      */
     public function destroy(Request $request, $id)
     {
@@ -275,6 +332,7 @@ class UserDictController extends Controller
             $updateOk = false;
             foreach ($arrId as $key => $id) {
                 // 找到对应数据
+                // FIXME: find() 可能返回 null,下一行对 null 取属性会 fatal error。应先判空再处理。
                 $data = UserDict::find($id);
                 // 查看是否有权限删除
                 if ($data->creator_id == $user_id) {
@@ -291,6 +349,8 @@ class UserDictController extends Controller
             // 删除单个单词
             $userDict = UserDict::find($id);
             // 判断当前用户是否有指定的studio的权限
+            // FIXME: $userDict 可能为 null(fatal error);且用 (int) 与 creator_id 做 !== 严格比较,
+            // creator_id 若是字符串列则永远不相等,会误判为无权限。建议先判空并改用 == 或统一类型。
             if ((int) $user_id !== $userDict->creator_id) {
                 return $this->error(__('auth.failed'));
             }
@@ -300,8 +360,29 @@ class UserDictController extends Controller
         }
     }
 
+    /**
+     * 批量删除用户词典记录(旧接口)
+     *
+     * 不走 AuthService,凭据取自 cookie 中的 user_id,只删除该用户创建的记录。
+     * 每删一条都会同步刷新 _SYS_USER_WBW_ 汇总与该单词的 Redis 缓存 dict/user。
+     * 返回 deleted(实际删除条数)。
+     *
+     * @queryParam id string required 要删除的词条 id 数组的 JSON 字符串。Example: ["123","456"]
+     */
     public function delete(Request $request)
     {
+        // 原先凭据取自 $_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;
@@ -311,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;

+ 44 - 0
api-v13/app/Http/Controllers/V3/HeartbeatController.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Exceptions\BusinessException;
+use App\Http\Controllers\Controller;
+use App\Http\Resources\V3\HeartbeatResource;
+
+class HeartbeatController extends Controller
+{
+    /**
+     * 心跳
+     *
+     * 取代 `GET /v2/heartbeat`(`HeartbeatController@index`)。v2 那份在
+     * dashboard-v4 下线前原样保留,不要改动。
+     *
+     * **这不是健康检查。** 它不查数据库、不查 OpenSearch / RabbitMQ / Cache,
+     * 只回答一件事:进程还在服务吗、是不是被运维停机了。供前端与负载均衡做
+     * 存活探测(liveness)用,开销接近零,可以高频调用。
+     *
+     * 真正的健康检查是 `GET /v2/health-check`(会逐项探测外部依赖并返回 checks
+     * 明细),将来单独迁为 `/v3/health-check`,与本端点是两件事,不要混用。
+     *
+     * 仓库根目录存在 `.stop` 文件时返回 503(运维停机开关),此时响应体是
+     * RFC 9457 Problem Details 而非正常结构。
+     *
+     * @unauthenticated
+     *
+     * @responseStatus 503 服务已进入停机维护状态
+     */
+    public function show(): HeartbeatResource
+    {
+        if (file_exists(base_path('.stop'))) {
+            // 用 BusinessException 而不是 abort(503):兜底处理器会屏蔽 5xx 的 detail
+            // (防止泄露内部错误),而停机是预期状态,维护文案必须原样送到客户端
+            throw new BusinessException(__('site.maintenance'), 503, 'maintenance');
+        }
+
+        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())
+        );
+    }
+}

Неке датотеке нису приказане због велике количине промена