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

feat(api-test): 新增 /api/v3 契约测试(openapi-generator Guzzle 客户端 + 纯 PHP 用例)

visuddhinanda 1 неделя назад
Родитель
Сommit
cc78d869ec
60 измененных файлов с 23961 добавлено и 0 удалено
  1. 5 0
      api-test/.gitignore
  2. 153 0
      api-test/README.md
  3. 20 0
      api-test/cases/01_system.php
  4. 44 0
      api-test/cases/02_search.php
  5. 115 0
      api-test/cases/03_reactions.php
  6. 22 0
      api-test/cases/04_progress.php
  7. 67 0
      api-test/cases/05_tipitaka.php
  8. 15 0
      api-test/client/.gitignore
  9. 23 0
      api-test/client/.openapi-generator-ignore
  10. 122 0
      api-test/client/.openapi-generator/FILES
  11. 1 0
      api-test/client/.openapi-generator/VERSION
  12. 39 0
      api-test/client/composer.json
  13. 463 0
      api-test/client/lib/Api/HeartbeatApi.php
  14. 1118 0
      api-test/client/lib/Api/MeApi.php
  15. 589 0
      api-test/client/lib/Api/ProgressApi.php
  16. 819 0
      api-test/client/lib/Api/ReactionsApi.php
  17. 1260 0
      api-test/client/lib/Api/SearchApi.php
  18. 529 0
      api-test/client/lib/Api/SearchSuggestApi.php
  19. 632 0
      api-test/client/lib/Api/TipitakaReadingApi.php
  20. 1216 0
      api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php
  21. 449 0
      api-test/client/lib/Api/UpgradeApi.php
  22. 119 0
      api-test/client/lib/ApiException.php
  23. 613 0
      api-test/client/lib/Configuration.php
  24. 246 0
      api-test/client/lib/FormDataProcessor.php
  25. 273 0
      api-test/client/lib/HeaderSelector.php
  26. 409 0
      api-test/client/lib/Model/GetApiV3Heartbeat200Response.php
  27. 443 0
      api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php
  28. 443 0
      api-test/client/lib/Model/GetApiV3MeReactions200Response.php
  29. 661 0
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php
  30. 613 0
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
  31. 443 0
      api-test/client/lib/Model/GetApiV3Progress200Response.php
  32. 702 0
      api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php
  33. 443 0
      api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php
  34. 518 0
      api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php
  35. 443 0
      api-test/client/lib/Model/GetApiV3Search200Response.php
  36. 409 0
      api-test/client/lib/Model/GetApiV3SearchSearch200Response.php
  37. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php
  38. 477 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php
  39. 545 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php
  40. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php
  41. 627 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php
  42. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php
  43. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php
  44. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php
  45. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php
  46. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
  47. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
  48. 111 0
      api-test/client/lib/Model/ModelInterface.php
  49. 600 0
      api-test/client/lib/Model/PaginationMeta.php
  50. 409 0
      api-test/client/lib/Model/PostApiV3MeReactions200Response.php
  51. 602 0
      api-test/client/lib/Model/PostApiV3MeReactionsRequest.php
  52. 858 0
      api-test/client/lib/Model/PostApiV3SearchRequest.php
  53. 589 0
      api-test/client/lib/Model/ProblemDetails.php
  54. 603 0
      api-test/client/lib/ObjectSerializer.php
  55. 47 0
      api-test/config.php
  56. 26 0
      api-test/fixtures.php
  57. 124 0
      api-test/run.php
  58. 199 0
      api-test/src/bootstrap.php
  59. 169 0
      api-test/tools/filter-v3.php
  60. 54 0
      api-test/tools/regen.sh

+ 5 - 0
api-test/.gitignore

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

+ 153 - 0
api-test/README.md

@@ -0,0 +1,153 @@
+# api-test —— api-v13 的 `/api/v3` 契约测试
+
+用 openapi-generator 从 `openapi/public/assets/protocol/main.yaml` 生成 PHP(Guzzle)客户端,
+测试脚本是**纯 PHP**,用它来对 `/api/v3/*` 做契约冒烟 + 负向测试。**目前只测 v3 路由。**
+
+## 运行
+
+```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"