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

Merge pull request #2481 from visuddhinanda/development

Development
visuddhinanda 2 недель назад
Родитель
Сommit
b37ccb3257
100 измененных файлов с 12944 добавлено и 109 удалено
  1. 15 7
      .claude/skills/v3-resource/SKILL.md
  2. 19 3
      CLAUDE.md
  3. 18 6
      api-test/README.md
  4. 262 0
      api-test/cases/06_auth.php
  5. 63 0
      api-test/client/.openapi-generator/FILES
  6. 446 0
      api-test/client/lib/Api/EmailCertificationsApi.php
  7. 1 1
      api-test/client/lib/Api/HeartbeatApi.php
  8. 817 0
      api-test/client/lib/Api/InvitesApi.php
  9. 274 1
      api-test/client/lib/Api/MeApi.php
  10. 1013 0
      api-test/client/lib/Api/PasswordResetsApi.php
  11. 1 1
      api-test/client/lib/Api/ProgressApi.php
  12. 1 1
      api-test/client/lib/Api/ReactionsApi.php
  13. 1 1
      api-test/client/lib/Api/SearchApi.php
  14. 1 1
      api-test/client/lib/Api/SearchSuggestApi.php
  15. 514 0
      api-test/client/lib/Api/SessionsApi.php
  16. 1 1
      api-test/client/lib/Api/TipitakaReadingApi.php
  17. 1 1
      api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php
  18. 1 1
      api-test/client/lib/Api/UpgradeApi.php
  19. 514 0
      api-test/client/lib/Api/UsersApi.php
  20. 1 1
      api-test/client/lib/ApiException.php
  21. 8 8
      api-test/client/lib/Configuration.php
  22. 1 1
      api-test/client/lib/FormDataProcessor.php
  23. 1 1
      api-test/client/lib/HeaderSelector.php
  24. 1 1
      api-test/client/lib/Model/GetApiV3Heartbeat200Response.php
  25. 1 1
      api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php
  26. 409 0
      api-test/client/lib/Model/GetApiV3Me200Response.php
  27. 654 0
      api-test/client/lib/Model/GetApiV3Me200ResponseData.php
  28. 1 1
      api-test/client/lib/Model/GetApiV3MeReactions200Response.php
  29. 1 1
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php
  30. 1 1
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
  31. 409 0
      api-test/client/lib/Model/GetApiV3PasswordResetsToken200Response.php
  32. 443 0
      api-test/client/lib/Model/GetApiV3PasswordResetsToken200ResponseData.php
  33. 1 1
      api-test/client/lib/Model/GetApiV3Progress200Response.php
  34. 1 1
      api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php
  35. 1 1
      api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php
  36. 1 1
      api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php
  37. 1 1
      api-test/client/lib/Model/GetApiV3Search200Response.php
  38. 1 1
      api-test/client/lib/Model/GetApiV3SearchSearch200Response.php
  39. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php
  40. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php
  41. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php
  42. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php
  43. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php
  44. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php
  45. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php
  46. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php
  47. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php
  48. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
  49. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
  50. 1 1
      api-test/client/lib/Model/ModelInterface.php
  51. 1 1
      api-test/client/lib/Model/PaginationMeta.php
  52. 449 0
      api-test/client/lib/Model/PatchApiV3PasswordResetsTokenRequest.php
  53. 484 0
      api-test/client/lib/Model/PostApiV3EmailCertificationsRequest.php
  54. 409 0
      api-test/client/lib/Model/PostApiV3Invites200Response.php
  55. 518 0
      api-test/client/lib/Model/PostApiV3Invites200ResponseData.php
  56. 449 0
      api-test/client/lib/Model/PostApiV3InvitesRequest.php
  57. 1 1
      api-test/client/lib/Model/PostApiV3MeReactions200Response.php
  58. 1 1
      api-test/client/lib/Model/PostApiV3MeReactionsRequest.php
  59. 484 0
      api-test/client/lib/Model/PostApiV3PasswordResetsRequest.php
  60. 1 1
      api-test/client/lib/Model/PostApiV3SearchRequest.php
  61. 409 0
      api-test/client/lib/Model/PostApiV3Sessions200Response.php
  62. 443 0
      api-test/client/lib/Model/PostApiV3Sessions200ResponseData.php
  63. 449 0
      api-test/client/lib/Model/PostApiV3SessionsRequest.php
  64. 409 0
      api-test/client/lib/Model/PostApiV3Users200Response.php
  65. 552 0
      api-test/client/lib/Model/PostApiV3Users200ResponseData.php
  66. 594 0
      api-test/client/lib/Model/PostApiV3UsersRequest.php
  67. 1 1
      api-test/client/lib/Model/ProblemDetails.php
  68. 1 1
      api-test/client/lib/ObjectSerializer.php
  69. 4 0
      api-test/fixtures.php
  70. 3 3
      api-test/run.php
  71. 21 10
      api-test/src/bootstrap.php
  72. 3 2
      api-test/tools/filter-v3.php
  73. 45 25
      api-v13/app/Http/Controllers/Library/CourseController.php
  74. 42 0
      api-v13/app/Http/Controllers/V3/EmailCertificationController.php
  75. 61 0
      api-v13/app/Http/Controllers/V3/InviteController.php
  76. 33 0
      api-v13/app/Http/Controllers/V3/MeController.php
  77. 87 0
      api-v13/app/Http/Controllers/V3/PasswordResetController.php
  78. 42 0
      api-v13/app/Http/Controllers/V3/SessionController.php
  79. 44 0
      api-v13/app/Http/Controllers/V3/UserController.php
  80. 48 0
      api-v13/app/Http/Middleware/V3/NegotiateLocale.php
  81. 38 0
      api-v13/app/Http/Requests/V3/StoreEmailCertificationRequest.php
  82. 27 0
      api-v13/app/Http/Requests/V3/StoreInviteRequest.php
  83. 34 0
      api-v13/app/Http/Requests/V3/StorePasswordResetRequest.php
  84. 28 0
      api-v13/app/Http/Requests/V3/StoreSessionRequest.php
  85. 36 0
      api-v13/app/Http/Requests/V3/StoreUserRequest.php
  86. 29 0
      api-v13/app/Http/Requests/V3/UpdatePasswordResetRequest.php
  87. 36 0
      api-v13/app/Http/Resources/V3/InviteResource.php
  88. 46 0
      api-v13/app/Http/Resources/V3/MeResource.php
  89. 30 0
      api-v13/app/Http/Resources/V3/PasswordResetResource.php
  90. 29 0
      api-v13/app/Http/Resources/V3/SessionResource.php
  91. 38 0
      api-v13/app/Http/Resources/V3/UserResource.php
  92. 45 0
      api-v13/app/Mail/V3/EmailCertificationMail.php
  93. 45 0
      api-v13/app/Mail/V3/PasswordResetMail.php
  94. 36 0
      api-v13/app/Providers/AppServiceProvider.php
  95. 28 0
      api-v13/app/Services/V3/PasswordHasher.php
  96. 120 0
      api-v13/app/Services/V3/PasswordResetService.php
  97. 91 0
      api-v13/app/Services/V3/SessionService.php
  98. 201 0
      api-v13/app/Services/V3/SignUpService.php
  99. 6 2
      api-v13/config/mint.php
  100. 1 2
      api-v13/resources/css/modules/library-course.css

+ 15 - 7
.claude/skills/v3-resource/SKILL.md

@@ -645,10 +645,11 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    `lang/`,本项目把 langPath 指到了 resources 下)。支持 8 个语言:
    `en`、`zh-Hans`、`zh-Hant`、`my`、`th`、`si`、`vi`、`lo`。
 
-   **不要新建语言文件,合并进已有的那 11 个**:`site`、`labels`、`buttons`、
-   `home`、`library`、`auth`、`validation`、`passwords`、`pagination`、
-   `grammar`、`language`。(注意没有 `messages.php`。)
-   API 的服务状态类文案放 **`site.php`**,界面标签放 `labels.php`,
+   **不要新建语言文件,合并进已有的那 12 个**:`site`、`messages`、`labels`、
+   `buttons`、`home`、`library`、`auth`、`validation`、`passwords`、`pagination`、
+   `grammar`、`language`。
+   API 的服务状态类文案(停机维护、404 等)放 **`site.php`**,操作结果提示
+   (如「邮件发送失败」)放 **`messages.php`**,界面标签放 `labels.php`,
    按钮文字放 `buttons.php`,各就各位。
 
    新增文案至少补 `en` 与 `zh-Hans` 两份,其余语言缺失会自动回退到 `en`,
@@ -668,9 +669,16 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    expect($detail)->not->toBe('site.maintenance');
    ```
 
-   > ⚠️ 已知问题:`SetLocale` 中间件只挂在 `web` 组,**api 组没有语言协商**,
-   > 所以目前 API 响应永远是 `en`,`Accept-Language` 与 `?lang=` 均无效。
-   > 文案仍然必须外置到翻译文件(否则将来想修也修不了),但别指望它现在会变中文。
+   **语言协商**:v3 路由组挂了 `App\Http\Middleware\V3\NegotiateLocale`,按请求头
+   `Accept-Language` 选语言(q 值排序;`zh-CN`→`zh-Hans`、`zh-TW`→`zh-Hant`;匹配不上用
+   `mint.default_language`),响应带 `Content-Language` 与 `Vary: Accept-Language`。
+   **只认请求头**:不读 `?lang=`(不少端点的 `lang` 是业务参数,如译文语言)、不读写
+   cookie / session。前端:dashboard-v6 的 `src/api/client.ts` 按界面语言设置这个头。
+   v2 不挂(错误文案在 v4 下线前保持原样)。
+
+   测试里要验证某种语言,直接带头:
+   `$this->postJson($url, $data, ['Accept-Language' => 'zh-Hans'])`,范例见
+   `tests/Feature/LocaleV3Test.php`。
 
 ## 路由怎么设计
 

+ 19 - 3
CLAUDE.md

@@ -49,9 +49,10 @@ wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。
   测试连独立的 `mint_test` 库(不要让 `RefreshDatabase` 打到开发库)。
 - `storage/resources` 是 git submodule(clove)。
 - 面向用户的文案一律走 `__()`。翻译文件在 **`resources/lang/{locale}/`**(不是
-  `lang/`),共 8 个语言。**不要新建语言文件,合并进已有的 11 个**
-  (`site`/`labels`/`buttons`/`home`/`library`/`auth`/… 没有 `messages.php`);
-  服务状态类文案放 `site.php`。至少补 `en` 与 `zh-Hans`,其余自动回退 en。
+  `lang/`),共 8 个语言。**不要新建语言文件,合并进已有的 12 个**
+  (`site`/`messages`/`labels`/`buttons`/`home`/`library`/`auth`/…);
+  服务状态类文案(停机维护、404 等)放 `site.php`,操作结果提示(如「邮件发送失败」)
+  放 `messages.php`。至少补 `en` 与 `zh-Hans`,其余自动回退 en。
 
 ## 前端 dashboard-v6
 
@@ -241,6 +242,18 @@ v4 有两处引用 v3,**均不构成生产依赖**:
 
 v6 全量稳定 → 下线 v4 → 删掉 C 类与 B 类的 v2 残留 → v2 前缀整体下线。
 
+### 认证迁移待办(2026-10 定)
+
+v2 认证端点**一律不动**(v4 部署已冻结,修了也上不去),漏洞随 v4 下线消失。
+找回密码、注册、登录在 v3 新建端点(见「v2 → v3 对照表」),dashboard-v6 与
+wikipali-mobile 都已切到 v3,**v2 认证端点只剩 dashboard-v4 在用**。还剩一件:
+
+- **TODO v4 下线后:md5 → bcrypt,删 v2 auth 全家**。v2 登录在 SQL 里比对
+  `md5(password)`,v4 在线时密码只能存 md5;v3 写密码统一走
+  `App\Services\V3\PasswordHasher`,届时只改那一处(登录时校验 md5 并就地
+  rehash,或加新列——加列由用户决定)。随后删 `sign-in`、`sign-up`、
+  `auth/*`、`email-certification`、`invite` 读取等 v2 路由与控制器。
+
 ## v3 重构
 
 正在把资源从 `/api/v2` 逐个迁到 `/api/v3`。原则是 `声明 → 生成 → 契约测试`:
@@ -314,6 +327,9 @@ Resource / FormRequest / 测试同理;URL 只换版本前缀
 | `like` / `LikeController` | `reactions` / `V3\ReactionController` 等 | `likes` 表早已不只存点赞:`type` 的实际取值有 `like`、`dislike`、`favorite`、`watch`、`bookmark`、`download` 六种 |
 | (无对应 v2 端点) | `tipitaka-reading/{channel}` / `V3\TipitakaReadingController` | 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 `tipitaka-read-para` / `tipitaka-read-chapter`(两条),也短暂叫过 `channels/{c}/books/{b}/paragraphs\|chapters`,2026-09-24 并成一条 |
 | `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
+| `auth/forgot-password` + `auth/reset-password` / `ForgotPasswordController` + `ResetPasswordController` | `password-resets` (+`/{token}`) / `V3\PasswordResetController` | 三条并成一个资源:POST 发信、GET 看账号、PATCH 设密码(204)。库里只存 token 的 sha256、60 分钟过期、邮箱未注册也 204、不收 `dashboard` 参数(链接用 `mint.server.dashboard_v6_base_path` 拼到 v6 的 `/anonymous/reset-password/{token}`)、限流 |
+| `email-certification` + `invite/{id}`(读)+ `sign-up` / `EmailCertificationController` + `InviteController@show` + `SignUpController@store` | `email-certifications` + `invites` (+`/{invite}`) + `users` / `V3\EmailCertificationController` + `V3\InviteController` + `V3\UserController` | 注册。验证码改在服务端比对:v2 的 `GET email-certification/{id}`(把码交给前端)在 v3 没有对应端点,换成 `POST invites {email, code}` 签发 invite;自助与邀请注册都凭 invite 走 `POST users`。验证码 6 位、30 分钟、试错 5 次作废;验证通过前不写 invites 表。studio 发邀请(`POST /v2/invite`)不在此列,将来迁成 `studios/{studio}/invites` |
+| `sign-in` + `auth/current` / `AuthController@signIn` + `@getUserInfoByToken` | `sessions` + `me` / `V3\SessionController` + `V3\MeController` | 登录一次返回 `{token, user}`,不再二次查询;`login` 字段收用户名或邮箱(邮箱不区分大小写),失败统一 422 `errors.login`;按账号 + IP 限流。`me` 的字段沿用 v2 的驼峰用户摘要(`UserService::profile()`),`roles` 恒为数组,另加 `email`,不再回显 token。token 与 v2 完全相同、两边互认 |
 
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;

+ 18 - 6
api-test/README.md

@@ -32,7 +32,7 @@ cd ../..
 
 ```bash
 cd api-test
-php run.php --username=<username> --password=<password>            # 调 /v2/sign-in 换 token,跑全量
+php run.php --username=<username> --password=<password>            # 调 /v3/sessions 换 token,跑全量
 php run.php --username=<username> --password=<password>  --server=staging
 php run.php --token=<bearer>                                # 或直接给 token
 ```
@@ -52,7 +52,7 @@ php run.php --list                   # 列出可用服务器
 
 # 跑 /v3/me/*(需登录)用例 —— 两种给 token 的方式:
 php run.php --token=<bearer>                                   # 直接给 token
-php run.php --username=alice --password=secret                 # 调 /v2/sign-in 现换 token
+php run.php --username=alice --password=secret                 # 调 /v3/sessions 现换 token
 ```
 
 ### 可用 `--server` 一览
@@ -70,16 +70,25 @@ php run.php --username=alice --password=secret                 # 调 /v2/sign-in
 
 - 服务器切换优先级:`--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。
+  (或 `API_TEST_USERNAME`/`API_TEST_PASSWORD`)调 `POST /v3/sessions` 登录换 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。
+- 登录接口 `POST /v3/sessions {login, password}`:`login` 可以是用户名或邮箱;成功 201
+  `{data:{token, user}}`,账号或密码不对 422。**目标服务器还没部署 v3 登录时**,
+  改用 `--token=` 直接给 token(v2 / v3 签的 token 一样,可以从 v4 登录后拿)。
+- **登录有限流:同一账号 + IP 每分钟 5 次。** 一次全量登录 2 次(run.php 换 token 一次、
+  sessions 用例一次)。刚跑过 dashboard-test(它也用 test161 登录多次)或连跑几遍时
+  会撞 429,表现为「登录失败(HTTP 429)」+ 带 token 的用例全挂,等一分钟再跑。
+- **账号类端点(`cases/06_auth.php`)例外:只走无副作用的路径**——不给真实邮箱发信、
+  不建账号。找回密码只用未注册的 `.invalid` 邮箱(服务端什么都不发);注册验证码、
+  建号只测校验失败和不存在的 invite。完整成功路径由 api-v13 的 Pest 测试覆盖
+  (`SignUpV3Test`、`PasswordResetV3Test`,Mail 是 fake 的)。
+- **账号类端点有限流**:`password-resets` / `email-certifications` 同 IP 每分钟 10 次。
+  一次全量只打几次,但**一分钟内连跑两三遍全量会撞 429**,等一分钟再跑即可。
 - 退出码:`0` 全绿;`1` 有失败/错误;`2` 参数/加载错误。
 
 ## 目录结构
@@ -181,6 +190,9 @@ Problem Details、成败只看状态码)。正确的长期修法在 api-v13 
 - **staging 的 OpenSearch**:`/v3/search`、`/v3/search-suggest` 在 staging 会 500
   (索引 mapping 与代码不一致:`resource_type` 缺 fielddata、`content.suggest.pali` 没映射)。
   这是 staging 环境问题,本地跑这几条是绿的。
+- **`registered_email` 种子**:`email-certifications` 的「已注册邮箱 → 422」用例需要一个
+  真实存在的账号邮箱,从环境变量 `API_TEST_REGISTERED_EMAIL` 读,没给就 SKIP。它只走
+  校验、不发信。
 - **种子数据按环境不同**:`fixtures.php` 里的 channel/target_id/doc_id 取自本地开发库,
   换环境后可能需要更新。跨环境跑时,数据不存在通常只是返回空集(仍 200),不会假红,
   但 `search/{id}` 的 200 用例依赖真实 doc id。

+ 262 - 0
api-test/cases/06_auth.php

@@ -0,0 +1,262 @@
+<?php
+
+use \WikipaliApi\Api\EmailCertificationsApi;
+use \WikipaliApi\Api\InvitesApi;
+use \WikipaliApi\Api\MeApi;
+use \WikipaliApi\Api\PasswordResetsApi;
+use \WikipaliApi\Api\SessionsApi;
+use \WikipaliApi\Api\UsersApi;
+use \WikipaliApi\Model\PatchApiV3PasswordResetsTokenRequest;
+use \WikipaliApi\Model\PostApiV3InvitesRequest;
+use \WikipaliApi\Model\PostApiV3PasswordResetsRequest;
+use \WikipaliApi\Model\PostApiV3SessionsRequest;
+use \WikipaliApi\Model\PostApiV3UsersRequest;
+
+/*
+ * 账号:登录、我是谁、找回密码、注册。除 /v3/me 外全部匿名端点。
+ *
+ * ⚠️ 这些用例会在所有服务器(含 prod)上真实执行,所以**只走不产生副作用的路径**:
+ *   - 不给任何真实邮箱发信:找回密码只用未注册的 `.invalid` 邮箱(服务端什么都不发);
+ *     注册验证码只测参数校验失败(发码的成功路径会真的发邮件,不测)。
+ *   - 不建账号:POST /v3/users 只用不存在的 invite,在建号之前就被拒。
+ * 完整的成功路径(收码 → 换 invite → 建号 → 重置密码)由 api-v13 的 Pest 测试覆盖
+ * (tests/Feature/SignUpV3Test.php、PasswordResetV3Test.php),那里 Mail 是 fake 的。
+ *
+ * 发信类端点有限流(同 IP 每分钟 10 次、同邮箱每小时 5 次),本文件每次跑只打几次,
+ * 但短时间内反复跑全量可能撞到 429。
+ */
+
+/** 每次运行不同的未注册邮箱:`.invalid` 是 RFC 2606 保留域,不会有人收到信 */
+function auth_unregistered_email(): string
+{
+    static $email = null;
+
+    return $email ??= 'api-test-' . bin2hex(random_bytes(6)) . '@example.invalid';
+}
+
+/**
+ * 格式不合法的邮箱,每次运行不同。
+ *
+ * 发信类端点还按邮箱每小时限 5 次,而且限流先于参数校验执行——写死一个
+ * `not-an-email` 的话,一小时内跑到第 6 遍就从 422 变成 429。
+ */
+function auth_malformed_email(): string
+{
+    return 'not-an-email-' . bin2hex(random_bytes(4));
+}
+
+/** 格式合法但不可能存在的 reset token(64 位字母数字) */
+const AUTH_UNKNOWN_RESET_TOKEN = 'apitest000000000000000000000000000000000000000000000000000000000';
+
+/* ---- /v3/password-resets ---- */
+
+test('POST /v3/password-resets(未注册邮箱)→ 204,不泄露账号是否存在', function () {
+    $body = new PostApiV3PasswordResetsRequest();
+    $body->setEmail(auth_unregistered_email());
+    $body->setLang('zh-Hans');
+    $res = request(fn () => api(PasswordResetsApi::class)->postApiV3PasswordResetsWithHttpInfo($body));
+    assert_status(204, $res, 'password-resets 未注册邮箱');
+});
+
+test('POST /v3/password-resets(邮箱格式错)→ 422 errors.email', function () {
+    $res = raw('POST', '/v3/password-resets', null, ['email' => auth_malformed_email()]);
+    assert_field_error('email', $res, 'password-resets 邮箱格式错');
+});
+
+test('POST /v3/password-resets(缺 email)→ 422 errors.email', function () {
+    $res = raw('POST', '/v3/password-resets', null, []);
+    assert_field_error('email', $res, 'password-resets 缺 email');
+});
+
+test('GET /v3/password-resets/{token}(不存在)→ 404', function () {
+    $res = request(fn () => api(PasswordResetsApi::class)->getApiV3PasswordResetsTokenWithHttpInfo(AUTH_UNKNOWN_RESET_TOKEN));
+    assert_status(404, $res, 'password-resets show 不存在');
+    assert_problem($res, 'password-resets show 不存在');
+    // 业务 404(token 无效)而不是路由 404(路径没匹配上)——后者说明测试自己的 token 格式写错了
+    if (str_contains((string) ($res['body']['detail'] ?? ''), 'could not be found')) {
+        fail('password-resets show 不存在:拿到的是路由 404,检查 AUTH_UNKNOWN_RESET_TOKEN 是否正好 64 位字母数字');
+    }
+});
+
+test('GET /v3/password-resets/{token}(格式不对)→ 404', function () {
+    // 路由约束是 64 位字母数字,匹配不上任何路由 → 404(不是 422)
+    $res = raw('GET', '/v3/password-resets/short-token');
+    assert_status(404, $res, 'password-resets show 格式不对');
+    assert_problem($res, 'password-resets show 格式不对');
+});
+
+test('PATCH /v3/password-resets/{token}(不存在)→ 404', function () {
+    $body = new PatchApiV3PasswordResetsTokenRequest();
+    $body->setPassword('api-test-pw');
+    $body->setPasswordConfirmation('api-test-pw');
+    $res = request(fn () => api(PasswordResetsApi::class)->patchApiV3PasswordResetsTokenWithHttpInfo(AUTH_UNKNOWN_RESET_TOKEN, $body));
+    assert_status(404, $res, 'password-resets update 不存在');
+    assert_problem($res, 'password-resets update 不存在');
+});
+
+test('PATCH /v3/password-resets/{token}(两次密码不一致)→ 422 errors.password', function () {
+    // 入参校验先于 token 查找,所以 token 不存在也是 422
+    $res = raw('PATCH', '/v3/password-resets/' . AUTH_UNKNOWN_RESET_TOKEN, null, [
+        'password' => 'api-test-pw',
+        'password_confirmation' => 'api-test-PW',
+    ]);
+    assert_field_error('password', $res, 'password-resets update 确认不一致');
+});
+
+test('PUT /v3/password-resets/{token} → 405(只注册了 PATCH)', function () {
+    $res = raw('PUT', '/v3/password-resets/' . AUTH_UNKNOWN_RESET_TOKEN, null, []);
+    assert_status(405, $res, 'password-resets PUT');
+});
+
+/* ---- /v3/email-certifications(只测校验失败:成功路径会真的发邮件)---- */
+
+test('POST /v3/email-certifications(邮箱格式错)→ 422 errors.email', function () {
+    $res = raw('POST', '/v3/email-certifications', null, ['email' => auth_malformed_email()]);
+    assert_field_error('email', $res, 'email-certifications 邮箱格式错');
+});
+
+test('POST /v3/email-certifications(已注册邮箱)→ 422 errors.email', function () {
+    $email = fixtures()['registered_email'] ?? null;
+    if (! $email) {
+        skip('fixtures.php 未配置 registered_email');
+    }
+    $res = raw('POST', '/v3/email-certifications', null, ['email' => $email]);
+    assert_field_error('email', $res, 'email-certifications 已注册邮箱');
+});
+
+/* ---- /v3/invites ---- */
+
+test('POST /v3/invites(没发过码的邮箱)→ 422 errors.code', function () {
+    $body = new PostApiV3InvitesRequest();
+    $body->setEmail(auth_unregistered_email());
+    $body->setCode('000000');
+    $res = request(fn () => api(InvitesApi::class)->postApiV3InvitesWithHttpInfo($body));
+    assert_field_error('code', $res, 'invites 无效验证码');
+});
+
+test('POST /v3/invites(验证码不是 6 位数字)→ 422 errors.code', function () {
+    $res = raw('POST', '/v3/invites', null, ['email' => auth_unregistered_email(), 'code' => '12ab']);
+    assert_field_error('code', $res, 'invites 验证码格式错');
+});
+
+test('GET /v3/invites/{invite}(不存在)→ 404', function () {
+    $res = request(fn () => api(InvitesApi::class)->getApiV3InvitesInviteWithHttpInfo('00000000-0000-4000-8000-000000000000'));
+    assert_status(404, $res, 'invites show 不存在');
+    assert_problem($res, 'invites show 不存在');
+});
+
+test('GET /v3/invites/{invite}(不是 uuid)→ 404', function () {
+    $res = raw('GET', '/v3/invites/not-a-uuid');
+    assert_status(404, $res, 'invites show 非 uuid');
+    assert_problem($res, 'invites show 非 uuid');
+});
+
+/* ---- /v3/users(只用不存在的 invite:不会建出账号)---- */
+
+test('POST /v3/users(invite 不存在)→ 422 errors.invite', function () {
+    $body = new PostApiV3UsersRequest();
+    $body->setInvite('00000000-0000-4000-8000-000000000000');
+    $body->setUsername('apitest_' . bin2hex(random_bytes(4)));
+    $body->setPassword('api-test-pw');
+    $body->setPasswordConfirmation('api-test-pw');
+    $body->setLang('zh-Hans');
+    $res = request(fn () => api(UsersApi::class)->postApiV3UsersWithHttpInfo($body));
+    assert_field_error('invite', $res, 'users invite 不存在');
+});
+
+test('POST /v3/users(字段不合法)→ 422,逐字段报错', function () {
+    $res = raw('POST', '/v3/users', null, [
+        'invite' => 'not-a-uuid',
+        'username' => 'abc中文',
+        'password' => '123',
+        'password_confirmation' => '456',
+    ]);
+    foreach (['invite', 'username', 'password', 'lang'] as $field) {
+        assert_field_error($field, $res, "users 字段不合法({$field})");
+    }
+});
+
+/* ---- /v3/sessions、/v3/me ---- */
+// 登录按「账号 + IP」每分钟限 5 次:带账号跑时 run.php 已经登录过一次,这里再登一次,
+// 错误密码只用不存在的账号(不占测试账号的额度)。
+
+test('POST /v3/sessions(正确账号)→ 201 {token, user}', function () {
+    $username = config()['username'] ?? null;
+    $password = config()['password'] ?? null;
+    if (! $username || ! $password) {
+        skip('未给 --username/--password');
+    }
+    $body = new PostApiV3SessionsRequest();
+    $body->setLogin($username);
+    $body->setPassword($password);
+    $res = request(fn () => api(SessionsApi::class)->postApiV3SessionsWithHttpInfo($body));
+    assert_status(201, $res, 'sessions 登录');
+    $data = $res['body']['data'] ?? null;
+    assert_has_key('token', $data, 'sessions.data');
+    assert_has_key('user', $data, 'sessions.data');
+    foreach (['id', 'nickName', 'userName', 'roles', 'email'] as $key) {
+        assert_has_key($key, $data['user'], 'sessions.data.user');
+    }
+    if (array_key_exists('password', $data['user'])) {
+        fail('sessions.data.user 不应包含 password');
+    }
+});
+
+test('POST /v3/sessions(账号不存在)→ 422 errors.login', function () {
+    $res = raw('POST', '/v3/sessions', null, [
+        'login' => 'apitest_nobody_' . bin2hex(random_bytes(4)),
+        'password' => 'whatever',
+    ]);
+    assert_field_error('login', $res, 'sessions 账号不存在');
+});
+
+test('POST /v3/sessions(缺字段)→ 422 errors.login + errors.password', function () {
+    $res = raw('POST', '/v3/sessions', null, []);
+    assert_field_error('login', $res, 'sessions 缺字段');
+    assert_field_error('password', $res, 'sessions 缺字段');
+});
+
+test('GET /v3/me(无 token)→ 401', function () {
+    $res = request(fn () => api(MeApi::class)->getApiV3MeWithHttpInfo());
+    assert_status(401, $res, 'me 无 token');
+    assert_problem($res, 'me 无 token');
+});
+
+test('GET /v3/me(带 token)→ 200,不回显 token', function () {
+    $res = request(fn () => api(MeApi::class, required_token())->getApiV3MeWithHttpInfo());
+    assert_status(200, $res, 'me');
+    $data = $res['body']['data'] ?? null;
+    foreach (['id', 'nickName', 'userName', 'realName', 'avatar', 'roles', 'email'] as $key) {
+        assert_has_key($key, $data, 'me.data');
+    }
+    if (array_key_exists('token', $data)) {
+        fail('me.data 不应回显 token(v2 auth/current 会)');
+    }
+});
+
+/* ---- 语言协商(v3 按 Accept-Language 本地化 detail / errors,title 保持英文)---- */
+// 探针用「错验证码换 invite」:不写库、不发信,走 sign-up 限流(同 IP 每分钟 20 次),
+// 不占登录的「账号 + IP」额度;返回的是 messages.php 里的文案。
+
+/** 用指定 Accept-Language 发一次错验证码请求 */
+function auth_wrong_code_in(string $acceptLanguage): array
+{
+    return raw('POST', '/v3/invites', null, [
+        'email' => auth_unregistered_email(),
+        'code' => '000000',
+    ], null, ['Accept-Language' => $acceptLanguage]);
+}
+
+test('Accept-Language: zh-Hans → 中文错误文案 + Content-Language', function () {
+    $res = auth_wrong_code_in('zh-Hans');
+    assert_field_error('code', $res, 'invites 错验证码(zh-Hans)');
+    assert_eq('验证码不正确或已过期。', $res['body']['errors']['code'][0] ?? null, 'errors.code');
+    assert_eq('zh-Hans', $res['headers']['Content-Language'][0] ?? null, 'Content-Language');
+});
+
+test('Accept-Language: zh-TW → 繁体;不支持的语言 → en', function () {
+    $hant = auth_wrong_code_in('zh-TW');
+    assert_eq('驗證碼不正確或已過期。', $hant['body']['errors']['code'][0] ?? null, 'zh-TW 的 errors.code');
+    $fr = auth_wrong_code_in('fr-FR');
+    assert_eq('en', $fr['headers']['Content-Language'][0] ?? null, 'fr-FR 的 Content-Language');
+});

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

@@ -4,20 +4,29 @@
 .travis.yml
 README.md
 composer.json
+docs/Api/EmailCertificationsApi.md
 docs/Api/HeartbeatApi.md
+docs/Api/InvitesApi.md
 docs/Api/MeApi.md
+docs/Api/PasswordResetsApi.md
 docs/Api/ProgressApi.md
 docs/Api/ReactionsApi.md
 docs/Api/SearchApi.md
 docs/Api/SearchSuggestApi.md
+docs/Api/SessionsApi.md
 docs/Api/TipitakaReadingApi.md
 docs/Api/TipitakaRelatedParagraphsApi.md
 docs/Api/UpgradeApi.md
+docs/Api/UsersApi.md
 docs/Model/GetApiV3Heartbeat200Response.md
 docs/Model/GetApiV3Heartbeat200ResponseData.md
+docs/Model/GetApiV3Me200Response.md
+docs/Model/GetApiV3Me200ResponseData.md
 docs/Model/GetApiV3MeReactions200Response.md
 docs/Model/GetApiV3MeReactions200ResponseDataInner.md
 docs/Model/GetApiV3MeReactions200ResponseDataInnerUser.md
+docs/Model/GetApiV3PasswordResetsToken200Response.md
+docs/Model/GetApiV3PasswordResetsToken200ResponseData.md
 docs/Model/GetApiV3Progress200Response.md
 docs/Model/GetApiV3Progress200ResponseDataInner.md
 docs/Model/GetApiV3ReactionsTally200Response.md
@@ -36,29 +45,50 @@ docs/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.md
 docs/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.md
 docs/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.md
 docs/Model/PaginationMeta.md
+docs/Model/PatchApiV3PasswordResetsTokenRequest.md
+docs/Model/PostApiV3EmailCertificationsRequest.md
+docs/Model/PostApiV3Invites200Response.md
+docs/Model/PostApiV3Invites200ResponseData.md
+docs/Model/PostApiV3InvitesRequest.md
 docs/Model/PostApiV3MeReactions200Response.md
 docs/Model/PostApiV3MeReactionsRequest.md
+docs/Model/PostApiV3PasswordResetsRequest.md
 docs/Model/PostApiV3SearchRequest.md
+docs/Model/PostApiV3Sessions200Response.md
+docs/Model/PostApiV3Sessions200ResponseData.md
+docs/Model/PostApiV3SessionsRequest.md
+docs/Model/PostApiV3Users200Response.md
+docs/Model/PostApiV3Users200ResponseData.md
+docs/Model/PostApiV3UsersRequest.md
 docs/Model/ProblemDetails.md
 git_push.sh
+lib/Api/EmailCertificationsApi.php
 lib/Api/HeartbeatApi.php
+lib/Api/InvitesApi.php
 lib/Api/MeApi.php
+lib/Api/PasswordResetsApi.php
 lib/Api/ProgressApi.php
 lib/Api/ReactionsApi.php
 lib/Api/SearchApi.php
 lib/Api/SearchSuggestApi.php
+lib/Api/SessionsApi.php
 lib/Api/TipitakaReadingApi.php
 lib/Api/TipitakaRelatedParagraphsApi.php
 lib/Api/UpgradeApi.php
+lib/Api/UsersApi.php
 lib/ApiException.php
 lib/Configuration.php
 lib/FormDataProcessor.php
 lib/HeaderSelector.php
 lib/Model/GetApiV3Heartbeat200Response.php
 lib/Model/GetApiV3Heartbeat200ResponseData.php
+lib/Model/GetApiV3Me200Response.php
+lib/Model/GetApiV3Me200ResponseData.php
 lib/Model/GetApiV3MeReactions200Response.php
 lib/Model/GetApiV3MeReactions200ResponseDataInner.php
 lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
+lib/Model/GetApiV3PasswordResetsToken200Response.php
+lib/Model/GetApiV3PasswordResetsToken200ResponseData.php
 lib/Model/GetApiV3Progress200Response.php
 lib/Model/GetApiV3Progress200ResponseDataInner.php
 lib/Model/GetApiV3ReactionsTally200Response.php
@@ -78,26 +108,47 @@ lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
 lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
 lib/Model/ModelInterface.php
 lib/Model/PaginationMeta.php
+lib/Model/PatchApiV3PasswordResetsTokenRequest.php
+lib/Model/PostApiV3EmailCertificationsRequest.php
+lib/Model/PostApiV3Invites200Response.php
+lib/Model/PostApiV3Invites200ResponseData.php
+lib/Model/PostApiV3InvitesRequest.php
 lib/Model/PostApiV3MeReactions200Response.php
 lib/Model/PostApiV3MeReactionsRequest.php
+lib/Model/PostApiV3PasswordResetsRequest.php
 lib/Model/PostApiV3SearchRequest.php
+lib/Model/PostApiV3Sessions200Response.php
+lib/Model/PostApiV3Sessions200ResponseData.php
+lib/Model/PostApiV3SessionsRequest.php
+lib/Model/PostApiV3Users200Response.php
+lib/Model/PostApiV3Users200ResponseData.php
+lib/Model/PostApiV3UsersRequest.php
 lib/Model/ProblemDetails.php
 lib/ObjectSerializer.php
 phpunit.xml.dist
+test/Api/EmailCertificationsApiTest.php
 test/Api/HeartbeatApiTest.php
+test/Api/InvitesApiTest.php
 test/Api/MeApiTest.php
+test/Api/PasswordResetsApiTest.php
 test/Api/ProgressApiTest.php
 test/Api/ReactionsApiTest.php
 test/Api/SearchApiTest.php
 test/Api/SearchSuggestApiTest.php
+test/Api/SessionsApiTest.php
 test/Api/TipitakaReadingApiTest.php
 test/Api/TipitakaRelatedParagraphsApiTest.php
 test/Api/UpgradeApiTest.php
+test/Api/UsersApiTest.php
 test/Model/GetApiV3Heartbeat200ResponseDataTest.php
 test/Model/GetApiV3Heartbeat200ResponseTest.php
+test/Model/GetApiV3Me200ResponseDataTest.php
+test/Model/GetApiV3Me200ResponseTest.php
 test/Model/GetApiV3MeReactions200ResponseDataInnerTest.php
 test/Model/GetApiV3MeReactions200ResponseDataInnerUserTest.php
 test/Model/GetApiV3MeReactions200ResponseTest.php
+test/Model/GetApiV3PasswordResetsToken200ResponseDataTest.php
+test/Model/GetApiV3PasswordResetsToken200ResponseTest.php
 test/Model/GetApiV3Progress200ResponseDataInnerTest.php
 test/Model/GetApiV3Progress200ResponseTest.php
 test/Model/GetApiV3ReactionsTally200ResponseDataInnerTest.php
@@ -116,7 +167,19 @@ test/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseTest.php
 test/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInnerTest.php
 test/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseTest.php
 test/Model/PaginationMetaTest.php
+test/Model/PatchApiV3PasswordResetsTokenRequestTest.php
+test/Model/PostApiV3EmailCertificationsRequestTest.php
+test/Model/PostApiV3Invites200ResponseDataTest.php
+test/Model/PostApiV3Invites200ResponseTest.php
+test/Model/PostApiV3InvitesRequestTest.php
 test/Model/PostApiV3MeReactions200ResponseTest.php
 test/Model/PostApiV3MeReactionsRequestTest.php
+test/Model/PostApiV3PasswordResetsRequestTest.php
 test/Model/PostApiV3SearchRequestTest.php
+test/Model/PostApiV3Sessions200ResponseDataTest.php
+test/Model/PostApiV3Sessions200ResponseTest.php
+test/Model/PostApiV3SessionsRequestTest.php
+test/Model/PostApiV3Users200ResponseDataTest.php
+test/Model/PostApiV3Users200ResponseTest.php
+test/Model/PostApiV3UsersRequestTest.php
 test/Model/ProblemDetailsTest.php

+ 446 - 0
api-test/client/lib/Api/EmailCertificationsApi.php

@@ -0,0 +1,446 @@
+<?php
+/**
+ * EmailCertificationsApi
+ * 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.10.8
+ * 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;
+
+/**
+ * EmailCertificationsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class EmailCertificationsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'postApiV3EmailCertifications' => [
+            '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 postApiV3EmailCertifications
+     *
+     * 发送注册验证码
+     *
+     * @param  \WikipaliApi\Model\PostApiV3EmailCertificationsRequest $post_api_v3_email_certifications_request post_api_v3_email_certifications_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3EmailCertifications'] 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 postApiV3EmailCertifications($post_api_v3_email_certifications_request, string $contentType = self::contentTypes['postApiV3EmailCertifications'][0])
+    {
+        $this->postApiV3EmailCertificationsWithHttpInfo($post_api_v3_email_certifications_request, $contentType);
+    }
+
+    /**
+     * Operation postApiV3EmailCertificationsWithHttpInfo
+     *
+     * 发送注册验证码
+     *
+     * @param  \WikipaliApi\Model\PostApiV3EmailCertificationsRequest $post_api_v3_email_certifications_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3EmailCertifications'] 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 postApiV3EmailCertificationsWithHttpInfo($post_api_v3_email_certifications_request, string $contentType = self::contentTypes['postApiV3EmailCertifications'][0])
+    {
+        $request = $this->postApiV3EmailCertificationsRequest($post_api_v3_email_certifications_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();
+
+
+            return [null, $statusCode, $response->getHeaders()];
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 429:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $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 postApiV3EmailCertificationsAsync
+     *
+     * 发送注册验证码
+     *
+     * @param  \WikipaliApi\Model\PostApiV3EmailCertificationsRequest $post_api_v3_email_certifications_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3EmailCertifications'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3EmailCertificationsAsync($post_api_v3_email_certifications_request, string $contentType = self::contentTypes['postApiV3EmailCertifications'][0])
+    {
+        return $this->postApiV3EmailCertificationsAsyncWithHttpInfo($post_api_v3_email_certifications_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3EmailCertificationsAsyncWithHttpInfo
+     *
+     * 发送注册验证码
+     *
+     * @param  \WikipaliApi\Model\PostApiV3EmailCertificationsRequest $post_api_v3_email_certifications_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3EmailCertifications'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3EmailCertificationsAsyncWithHttpInfo($post_api_v3_email_certifications_request, string $contentType = self::contentTypes['postApiV3EmailCertifications'][0])
+    {
+        $returnType = '';
+        $request = $this->postApiV3EmailCertificationsRequest($post_api_v3_email_certifications_request, $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 'postApiV3EmailCertifications'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3EmailCertificationsRequest $post_api_v3_email_certifications_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3EmailCertifications'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3EmailCertificationsRequest($post_api_v3_email_certifications_request, string $contentType = self::contentTypes['postApiV3EmailCertifications'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_email_certifications_request' is set
+        if ($post_api_v3_email_certifications_request === null || (is_array($post_api_v3_email_certifications_request) && count($post_api_v3_email_certifications_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_email_certifications_request when calling postApiV3EmailCertifications'
+            );
+        }
+
+
+        $resourcePath = '/v3/email-certifications';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (isset($post_api_v3_email_certifications_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_email_certifications_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_email_certifications_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;
+    }
+}

+ 1 - 1
api-test/client/lib/Api/HeartbeatApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 817 - 0
api-test/client/lib/Api/InvitesApi.php

@@ -0,0 +1,817 @@
+<?php
+/**
+ * InvitesApi
+ * 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.10.8
+ * 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;
+
+/**
+ * InvitesApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class InvitesApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3InvitesInvite' => [
+            'application/json',
+        ],
+        'postApiV3Invites' => [
+            '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 getApiV3InvitesInvite
+     *
+     * 查看一条可用的注册邀请
+     *
+     * @param  string $invite 邀请邮件链接里的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3InvitesInvite'] 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\PostApiV3Invites200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3InvitesInvite($invite, string $contentType = self::contentTypes['getApiV3InvitesInvite'][0])
+    {
+        list($response) = $this->getApiV3InvitesInviteWithHttpInfo($invite, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3InvitesInviteWithHttpInfo
+     *
+     * 查看一条可用的注册邀请
+     *
+     * @param  string $invite 邀请邮件链接里的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3InvitesInvite'] 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\PostApiV3Invites200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3InvitesInviteWithHttpInfo($invite, string $contentType = self::contentTypes['getApiV3InvitesInvite'][0])
+    {
+        $request = $this->getApiV3InvitesInviteRequest($invite, $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\PostApiV3Invites200Response',
+                        $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\PostApiV3Invites200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3Invites200Response',
+                        $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 getApiV3InvitesInviteAsync
+     *
+     * 查看一条可用的注册邀请
+     *
+     * @param  string $invite 邀请邮件链接里的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3InvitesInvite'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3InvitesInviteAsync($invite, string $contentType = self::contentTypes['getApiV3InvitesInvite'][0])
+    {
+        return $this->getApiV3InvitesInviteAsyncWithHttpInfo($invite, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3InvitesInviteAsyncWithHttpInfo
+     *
+     * 查看一条可用的注册邀请
+     *
+     * @param  string $invite 邀请邮件链接里的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3InvitesInvite'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3InvitesInviteAsyncWithHttpInfo($invite, string $contentType = self::contentTypes['getApiV3InvitesInvite'][0])
+    {
+        $returnType = '\WikipaliApi\Model\PostApiV3Invites200Response';
+        $request = $this->getApiV3InvitesInviteRequest($invite, $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 'getApiV3InvitesInvite'
+     *
+     * @param  string $invite 邀请邮件链接里的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3InvitesInvite'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3InvitesInviteRequest($invite, string $contentType = self::contentTypes['getApiV3InvitesInvite'][0])
+    {
+
+        // verify the required parameter 'invite' is set
+        if ($invite === null || (is_array($invite) && count($invite) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $invite when calling getApiV3InvitesInvite'
+            );
+        }
+
+
+        $resourcePath = '/v3/invites/{invite}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+        // path params
+        if ($invite !== null) {
+            $resourcePath = str_replace(
+                '{invite}',
+                ObjectSerializer::toPathValue($invite),
+                $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 postApiV3Invites
+     *
+     * 用验证码换注册邀请
+     *
+     * @param  \WikipaliApi\Model\PostApiV3InvitesRequest $post_api_v3_invites_request post_api_v3_invites_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Invites'] 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\PostApiV3Invites200Response|\WikipaliApi\Model\PostApiV3Invites200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function postApiV3Invites($post_api_v3_invites_request, string $contentType = self::contentTypes['postApiV3Invites'][0])
+    {
+        list($response) = $this->postApiV3InvitesWithHttpInfo($post_api_v3_invites_request, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation postApiV3InvitesWithHttpInfo
+     *
+     * 用验证码换注册邀请
+     *
+     * @param  \WikipaliApi\Model\PostApiV3InvitesRequest $post_api_v3_invites_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Invites'] 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\PostApiV3Invites200Response|\WikipaliApi\Model\PostApiV3Invites200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function postApiV3InvitesWithHttpInfo($post_api_v3_invites_request, string $contentType = self::contentTypes['postApiV3Invites'][0])
+    {
+        $request = $this->postApiV3InvitesRequest($post_api_v3_invites_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\PostApiV3Invites200Response',
+                        $request,
+                        $response,
+                    );
+                case 201:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\PostApiV3Invites200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 429:
+                    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\PostApiV3Invites200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3Invites200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 201:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3Invites200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 429:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation postApiV3InvitesAsync
+     *
+     * 用验证码换注册邀请
+     *
+     * @param  \WikipaliApi\Model\PostApiV3InvitesRequest $post_api_v3_invites_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Invites'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3InvitesAsync($post_api_v3_invites_request, string $contentType = self::contentTypes['postApiV3Invites'][0])
+    {
+        return $this->postApiV3InvitesAsyncWithHttpInfo($post_api_v3_invites_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3InvitesAsyncWithHttpInfo
+     *
+     * 用验证码换注册邀请
+     *
+     * @param  \WikipaliApi\Model\PostApiV3InvitesRequest $post_api_v3_invites_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Invites'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3InvitesAsyncWithHttpInfo($post_api_v3_invites_request, string $contentType = self::contentTypes['postApiV3Invites'][0])
+    {
+        $returnType = '\WikipaliApi\Model\PostApiV3Invites200Response';
+        $request = $this->postApiV3InvitesRequest($post_api_v3_invites_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 'postApiV3Invites'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3InvitesRequest $post_api_v3_invites_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Invites'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3InvitesRequest($post_api_v3_invites_request, string $contentType = self::contentTypes['postApiV3Invites'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_invites_request' is set
+        if ($post_api_v3_invites_request === null || (is_array($post_api_v3_invites_request) && count($post_api_v3_invites_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_invites_request when calling postApiV3Invites'
+            );
+        }
+
+
+        $resourcePath = '/v3/invites';
+        $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_invites_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_invites_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_invites_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;
+    }
+}

+ 274 - 1
api-test/client/lib/Api/MeApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */
@@ -77,6 +77,9 @@ class MeApi
         'deleteApiV3MeReactionsReaction' => [
             'application/json',
         ],
+        'getApiV3Me' => [
+            'application/json',
+        ],
         'getApiV3MeReactions' => [
             'application/json',
         ],
@@ -381,6 +384,276 @@ class MeApi
         );
     }
 
+    /**
+     * Operation getApiV3Me
+     *
+     * 我的资料
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Me'] 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\GetApiV3Me200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Me(string $contentType = self::contentTypes['getApiV3Me'][0])
+    {
+        list($response) = $this->getApiV3MeWithHttpInfo($contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3MeWithHttpInfo
+     *
+     * 我的资料
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Me'] 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\GetApiV3Me200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3MeWithHttpInfo(string $contentType = self::contentTypes['getApiV3Me'][0])
+    {
+        $request = $this->getApiV3MeRequest($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\GetApiV3Me200Response',
+                        $request,
+                        $response,
+                    );
+                case 401:
+                    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\GetApiV3Me200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Me200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 401:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3MeAsync
+     *
+     * 我的资料
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Me'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3MeAsync(string $contentType = self::contentTypes['getApiV3Me'][0])
+    {
+        return $this->getApiV3MeAsyncWithHttpInfo($contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3MeAsyncWithHttpInfo
+     *
+     * 我的资料
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Me'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3MeAsyncWithHttpInfo(string $contentType = self::contentTypes['getApiV3Me'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Me200Response';
+        $request = $this->getApiV3MeRequest($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 'getApiV3Me'
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Me'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3MeRequest(string $contentType = self::contentTypes['getApiV3Me'][0])
+    {
+
+
+        $resourcePath = '/v3/me';
+        $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);
+            }
+        }
+
+        // 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 getApiV3MeReactions
      *

+ 1013 - 0
api-test/client/lib/Api/PasswordResetsApi.php

@@ -0,0 +1,1013 @@
+<?php
+/**
+ * PasswordResetsApi
+ * 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.10.8
+ * 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;
+
+/**
+ * PasswordResetsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class PasswordResetsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3PasswordResetsToken' => [
+            'application/json',
+        ],
+        'patchApiV3PasswordResetsToken' => [
+            'application/json',
+        ],
+        'postApiV3PasswordResets' => [
+            '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 getApiV3PasswordResetsToken
+     *
+     * 查看一次待完成的重置
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3PasswordResetsToken'] 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\GetApiV3PasswordResetsToken200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3PasswordResetsToken($token, string $contentType = self::contentTypes['getApiV3PasswordResetsToken'][0])
+    {
+        list($response) = $this->getApiV3PasswordResetsTokenWithHttpInfo($token, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3PasswordResetsTokenWithHttpInfo
+     *
+     * 查看一次待完成的重置
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3PasswordResetsToken'] 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\GetApiV3PasswordResetsToken200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3PasswordResetsTokenWithHttpInfo($token, string $contentType = self::contentTypes['getApiV3PasswordResetsToken'][0])
+    {
+        $request = $this->getApiV3PasswordResetsTokenRequest($token, $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\GetApiV3PasswordResetsToken200Response',
+                        $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\GetApiV3PasswordResetsToken200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3PasswordResetsToken200Response',
+                        $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 getApiV3PasswordResetsTokenAsync
+     *
+     * 查看一次待完成的重置
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3PasswordResetsToken'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3PasswordResetsTokenAsync($token, string $contentType = self::contentTypes['getApiV3PasswordResetsToken'][0])
+    {
+        return $this->getApiV3PasswordResetsTokenAsyncWithHttpInfo($token, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3PasswordResetsTokenAsyncWithHttpInfo
+     *
+     * 查看一次待完成的重置
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3PasswordResetsToken'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3PasswordResetsTokenAsyncWithHttpInfo($token, string $contentType = self::contentTypes['getApiV3PasswordResetsToken'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3PasswordResetsToken200Response';
+        $request = $this->getApiV3PasswordResetsTokenRequest($token, $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 'getApiV3PasswordResetsToken'
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3PasswordResetsToken'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3PasswordResetsTokenRequest($token, string $contentType = self::contentTypes['getApiV3PasswordResetsToken'][0])
+    {
+
+        // verify the required parameter 'token' is set
+        if ($token === null || (is_array($token) && count($token) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $token when calling getApiV3PasswordResetsToken'
+            );
+        }
+
+
+        $resourcePath = '/v3/password-resets/{token}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+        // path params
+        if ($token !== null) {
+            $resourcePath = str_replace(
+                '{token}',
+                ObjectSerializer::toPathValue($token),
+                $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 patchApiV3PasswordResetsToken
+     *
+     * 设置新密码
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  \WikipaliApi\Model\PatchApiV3PasswordResetsTokenRequest $patch_api_v3_password_resets_token_request patch_api_v3_password_resets_token_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['patchApiV3PasswordResetsToken'] 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 patchApiV3PasswordResetsToken($token, $patch_api_v3_password_resets_token_request, string $contentType = self::contentTypes['patchApiV3PasswordResetsToken'][0])
+    {
+        $this->patchApiV3PasswordResetsTokenWithHttpInfo($token, $patch_api_v3_password_resets_token_request, $contentType);
+    }
+
+    /**
+     * Operation patchApiV3PasswordResetsTokenWithHttpInfo
+     *
+     * 设置新密码
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  \WikipaliApi\Model\PatchApiV3PasswordResetsTokenRequest $patch_api_v3_password_resets_token_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['patchApiV3PasswordResetsToken'] 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 patchApiV3PasswordResetsTokenWithHttpInfo($token, $patch_api_v3_password_resets_token_request, string $contentType = self::contentTypes['patchApiV3PasswordResetsToken'][0])
+    {
+        $request = $this->patchApiV3PasswordResetsTokenRequest($token, $patch_api_v3_password_resets_token_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();
+
+
+            return [null, $statusCode, $response->getHeaders()];
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                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 patchApiV3PasswordResetsTokenAsync
+     *
+     * 设置新密码
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  \WikipaliApi\Model\PatchApiV3PasswordResetsTokenRequest $patch_api_v3_password_resets_token_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['patchApiV3PasswordResetsToken'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function patchApiV3PasswordResetsTokenAsync($token, $patch_api_v3_password_resets_token_request, string $contentType = self::contentTypes['patchApiV3PasswordResetsToken'][0])
+    {
+        return $this->patchApiV3PasswordResetsTokenAsyncWithHttpInfo($token, $patch_api_v3_password_resets_token_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation patchApiV3PasswordResetsTokenAsyncWithHttpInfo
+     *
+     * 设置新密码
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  \WikipaliApi\Model\PatchApiV3PasswordResetsTokenRequest $patch_api_v3_password_resets_token_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['patchApiV3PasswordResetsToken'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function patchApiV3PasswordResetsTokenAsyncWithHttpInfo($token, $patch_api_v3_password_resets_token_request, string $contentType = self::contentTypes['patchApiV3PasswordResetsToken'][0])
+    {
+        $returnType = '';
+        $request = $this->patchApiV3PasswordResetsTokenRequest($token, $patch_api_v3_password_resets_token_request, $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 'patchApiV3PasswordResetsToken'
+     *
+     * @param  string $token 邮件链接里的 token (required)
+     * @param  \WikipaliApi\Model\PatchApiV3PasswordResetsTokenRequest $patch_api_v3_password_resets_token_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['patchApiV3PasswordResetsToken'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function patchApiV3PasswordResetsTokenRequest($token, $patch_api_v3_password_resets_token_request, string $contentType = self::contentTypes['patchApiV3PasswordResetsToken'][0])
+    {
+
+        // verify the required parameter 'token' is set
+        if ($token === null || (is_array($token) && count($token) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $token when calling patchApiV3PasswordResetsToken'
+            );
+        }
+
+        // verify the required parameter 'patch_api_v3_password_resets_token_request' is set
+        if ($patch_api_v3_password_resets_token_request === null || (is_array($patch_api_v3_password_resets_token_request) && count($patch_api_v3_password_resets_token_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $patch_api_v3_password_resets_token_request when calling patchApiV3PasswordResetsToken'
+            );
+        }
+
+
+        $resourcePath = '/v3/password-resets/{token}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+        // path params
+        if ($token !== null) {
+            $resourcePath = str_replace(
+                '{token}',
+                ObjectSerializer::toPathValue($token),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (isset($patch_api_v3_password_resets_token_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($patch_api_v3_password_resets_token_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $patch_api_v3_password_resets_token_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(
+            'PATCH',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation postApiV3PasswordResets
+     *
+     * 申请重置密码(发邮件)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3PasswordResetsRequest $post_api_v3_password_resets_request post_api_v3_password_resets_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3PasswordResets'] 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 postApiV3PasswordResets($post_api_v3_password_resets_request, string $contentType = self::contentTypes['postApiV3PasswordResets'][0])
+    {
+        $this->postApiV3PasswordResetsWithHttpInfo($post_api_v3_password_resets_request, $contentType);
+    }
+
+    /**
+     * Operation postApiV3PasswordResetsWithHttpInfo
+     *
+     * 申请重置密码(发邮件)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3PasswordResetsRequest $post_api_v3_password_resets_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3PasswordResets'] 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 postApiV3PasswordResetsWithHttpInfo($post_api_v3_password_resets_request, string $contentType = self::contentTypes['postApiV3PasswordResets'][0])
+    {
+        $request = $this->postApiV3PasswordResetsRequest($post_api_v3_password_resets_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();
+
+
+            return [null, $statusCode, $response->getHeaders()];
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 429:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $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 postApiV3PasswordResetsAsync
+     *
+     * 申请重置密码(发邮件)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3PasswordResetsRequest $post_api_v3_password_resets_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3PasswordResets'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3PasswordResetsAsync($post_api_v3_password_resets_request, string $contentType = self::contentTypes['postApiV3PasswordResets'][0])
+    {
+        return $this->postApiV3PasswordResetsAsyncWithHttpInfo($post_api_v3_password_resets_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3PasswordResetsAsyncWithHttpInfo
+     *
+     * 申请重置密码(发邮件)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3PasswordResetsRequest $post_api_v3_password_resets_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3PasswordResets'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3PasswordResetsAsyncWithHttpInfo($post_api_v3_password_resets_request, string $contentType = self::contentTypes['postApiV3PasswordResets'][0])
+    {
+        $returnType = '';
+        $request = $this->postApiV3PasswordResetsRequest($post_api_v3_password_resets_request, $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 'postApiV3PasswordResets'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3PasswordResetsRequest $post_api_v3_password_resets_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3PasswordResets'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3PasswordResetsRequest($post_api_v3_password_resets_request, string $contentType = self::contentTypes['postApiV3PasswordResets'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_password_resets_request' is set
+        if ($post_api_v3_password_resets_request === null || (is_array($post_api_v3_password_resets_request) && count($post_api_v3_password_resets_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_password_resets_request when calling postApiV3PasswordResets'
+            );
+        }
+
+
+        $resourcePath = '/v3/password-resets';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (isset($post_api_v3_password_resets_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_password_resets_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_password_resets_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;
+    }
+}

+ 1 - 1
api-test/client/lib/Api/ProgressApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Api/ReactionsApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Api/SearchApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Api/SearchSuggestApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 514 - 0
api-test/client/lib/Api/SessionsApi.php

@@ -0,0 +1,514 @@
+<?php
+/**
+ * SessionsApi
+ * 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.10.8
+ * 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;
+
+/**
+ * SessionsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class SessionsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'postApiV3Sessions' => [
+            '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 postApiV3Sessions
+     *
+     * 登录
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SessionsRequest $post_api_v3_sessions_request post_api_v3_sessions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Sessions'] 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\PostApiV3Sessions200Response|\WikipaliApi\Model\PostApiV3Sessions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function postApiV3Sessions($post_api_v3_sessions_request, string $contentType = self::contentTypes['postApiV3Sessions'][0])
+    {
+        list($response) = $this->postApiV3SessionsWithHttpInfo($post_api_v3_sessions_request, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation postApiV3SessionsWithHttpInfo
+     *
+     * 登录
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SessionsRequest $post_api_v3_sessions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Sessions'] 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\PostApiV3Sessions200Response|\WikipaliApi\Model\PostApiV3Sessions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function postApiV3SessionsWithHttpInfo($post_api_v3_sessions_request, string $contentType = self::contentTypes['postApiV3Sessions'][0])
+    {
+        $request = $this->postApiV3SessionsRequest($post_api_v3_sessions_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\PostApiV3Sessions200Response',
+                        $request,
+                        $response,
+                    );
+                case 201:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\PostApiV3Sessions200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 429:
+                    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\PostApiV3Sessions200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3Sessions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 201:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3Sessions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 429:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation postApiV3SessionsAsync
+     *
+     * 登录
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SessionsRequest $post_api_v3_sessions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Sessions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3SessionsAsync($post_api_v3_sessions_request, string $contentType = self::contentTypes['postApiV3Sessions'][0])
+    {
+        return $this->postApiV3SessionsAsyncWithHttpInfo($post_api_v3_sessions_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3SessionsAsyncWithHttpInfo
+     *
+     * 登录
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SessionsRequest $post_api_v3_sessions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Sessions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3SessionsAsyncWithHttpInfo($post_api_v3_sessions_request, string $contentType = self::contentTypes['postApiV3Sessions'][0])
+    {
+        $returnType = '\WikipaliApi\Model\PostApiV3Sessions200Response';
+        $request = $this->postApiV3SessionsRequest($post_api_v3_sessions_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 'postApiV3Sessions'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SessionsRequest $post_api_v3_sessions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Sessions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3SessionsRequest($post_api_v3_sessions_request, string $contentType = self::contentTypes['postApiV3Sessions'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_sessions_request' is set
+        if ($post_api_v3_sessions_request === null || (is_array($post_api_v3_sessions_request) && count($post_api_v3_sessions_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_sessions_request when calling postApiV3Sessions'
+            );
+        }
+
+
+        $resourcePath = '/v3/sessions';
+        $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_sessions_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_sessions_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_sessions_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;
+    }
+}

+ 1 - 1
api-test/client/lib/Api/TipitakaReadingApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Api/UpgradeApi.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 514 - 0
api-test/client/lib/Api/UsersApi.php

@@ -0,0 +1,514 @@
+<?php
+/**
+ * UsersApi
+ * 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.10.8
+ * 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;
+
+/**
+ * UsersApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class UsersApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'postApiV3Users' => [
+            '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 postApiV3Users
+     *
+     * 注册账号
+     *
+     * @param  \WikipaliApi\Model\PostApiV3UsersRequest $post_api_v3_users_request post_api_v3_users_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Users'] 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\PostApiV3Users200Response|\WikipaliApi\Model\PostApiV3Users200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function postApiV3Users($post_api_v3_users_request, string $contentType = self::contentTypes['postApiV3Users'][0])
+    {
+        list($response) = $this->postApiV3UsersWithHttpInfo($post_api_v3_users_request, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation postApiV3UsersWithHttpInfo
+     *
+     * 注册账号
+     *
+     * @param  \WikipaliApi\Model\PostApiV3UsersRequest $post_api_v3_users_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Users'] 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\PostApiV3Users200Response|\WikipaliApi\Model\PostApiV3Users200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function postApiV3UsersWithHttpInfo($post_api_v3_users_request, string $contentType = self::contentTypes['postApiV3Users'][0])
+    {
+        $request = $this->postApiV3UsersRequest($post_api_v3_users_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\PostApiV3Users200Response',
+                        $request,
+                        $response,
+                    );
+                case 201:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\PostApiV3Users200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 429:
+                    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\PostApiV3Users200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3Users200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 201:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3Users200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 429:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation postApiV3UsersAsync
+     *
+     * 注册账号
+     *
+     * @param  \WikipaliApi\Model\PostApiV3UsersRequest $post_api_v3_users_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Users'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3UsersAsync($post_api_v3_users_request, string $contentType = self::contentTypes['postApiV3Users'][0])
+    {
+        return $this->postApiV3UsersAsyncWithHttpInfo($post_api_v3_users_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3UsersAsyncWithHttpInfo
+     *
+     * 注册账号
+     *
+     * @param  \WikipaliApi\Model\PostApiV3UsersRequest $post_api_v3_users_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Users'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3UsersAsyncWithHttpInfo($post_api_v3_users_request, string $contentType = self::contentTypes['postApiV3Users'][0])
+    {
+        $returnType = '\WikipaliApi\Model\PostApiV3Users200Response';
+        $request = $this->postApiV3UsersRequest($post_api_v3_users_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 'postApiV3Users'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3UsersRequest $post_api_v3_users_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Users'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3UsersRequest($post_api_v3_users_request, string $contentType = self::contentTypes['postApiV3Users'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_users_request' is set
+        if ($post_api_v3_users_request === null || (is_array($post_api_v3_users_request) && count($post_api_v3_users_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_users_request when calling postApiV3Users'
+            );
+        }
+
+
+        $resourcePath = '/v3/users';
+        $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_users_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_users_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_users_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;
+    }
+}

+ 1 - 1
api-test/client/lib/ApiException.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 8 - 8
api-test/client/lib/Configuration.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */
@@ -489,7 +489,7 @@ class Configuration
         $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 .= '    The version of the OpenAPI document: 2026.10.8' . PHP_EOL;
         $report .= '    SDK Package Version: 1.0.0' . PHP_EOL;
         $report .= '    Temp Folder Path: ' . self::getDefaultConfiguration()->getTempFolderPath() . PHP_EOL;
 
@@ -541,21 +541,21 @@ class Configuration
                 "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://www.wikipali.org/api",
+                "description" => "生产",
             ],
             [
                 "url" => "https://next.wikipali.cc/api",
                 "description" => "生产预览(中国)",
+            ],
+            [
+                "url" => "https://www.wikipali.cc/api",
+                "description" => "生产(中国)",
             ]
         ];
     }

+ 1 - 1
api-test/client/lib/FormDataProcessor.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/HeaderSelector.php

@@ -14,7 +14,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3Heartbeat200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

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

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3Me200Response
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * GetApiV3Me200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Me200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3Me200ResponseData'
+    ];
+
+    /**
+     * 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\GetApiV3Me200ResponseData|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3Me200ResponseData|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));
+    }
+}
+
+

+ 654 - 0
api-test/client/lib/Model/GetApiV3Me200ResponseData.php

@@ -0,0 +1,654 @@
+<?php
+/**
+ * GetApiV3Me200ResponseData
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * GetApiV3Me200ResponseData Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Me200ResponseData implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_200_response_data';
+
+    /**
+     * 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[]',
+        'email' => '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,
+        'email' => 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' => true,
+        'roles' => false,
+        'email' => 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',
+        'email' => 'email'
+    ];
+
+    /**
+     * 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',
+        'email' => 'setEmail'
+    ];
+
+    /**
+     * 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',
+        'email' => 'getEmail'
+    ];
+
+    /**
+     * 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);
+        $this->setIfExists('email', $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)) {
+            array_push($this->openAPINullablesSetToNull, 'avatar');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('avatar', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $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;
+    }
+
+    /**
+     * Gets email
+     *
+     * @return string|null
+     */
+    public function getEmail()
+    {
+        return $this->container['email'];
+    }
+
+    /**
+     * Sets email
+     *
+     * @param string|null $email email
+     *
+     * @return self
+     */
+    public function setEmail($email)
+    {
+        if (is_null($email)) {
+            throw new \InvalidArgumentException('non-nullable email cannot be null');
+        }
+        $this->container['email'] = $email;
+
+        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));
+    }
+}
+
+

+ 1 - 1
api-test/client/lib/Model/GetApiV3MeReactions200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

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

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3PasswordResetsToken200Response
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * GetApiV3PasswordResetsToken200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3PasswordResetsToken200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_password_resets_token__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3PasswordResetsToken200ResponseData'
+    ];
+
+    /**
+     * 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\GetApiV3PasswordResetsToken200ResponseData|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3PasswordResetsToken200ResponseData|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/GetApiV3PasswordResetsToken200ResponseData.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3PasswordResetsToken200ResponseData
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * GetApiV3PasswordResetsToken200ResponseData Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3PasswordResetsToken200ResponseData implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_password_resets_token__200_response_data';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'username' => 'string',
+        'expires_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 = [
+        'username' => null,
+        'expires_at' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'username' => false,
+        'expires_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 = [
+        'username' => 'username',
+        'expires_at' => 'expires_at'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'username' => 'setUsername',
+        'expires_at' => 'setExpiresAt'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'username' => 'getUsername',
+        'expires_at' => 'getExpiresAt'
+    ];
+
+    /**
+     * 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('username', $data ?? [], null);
+        $this->setIfExists('expires_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 username
+     *
+     * @return string|null
+     */
+    public function getUsername()
+    {
+        return $this->container['username'];
+    }
+
+    /**
+     * Sets username
+     *
+     * @param string|null $username username
+     *
+     * @return self
+     */
+    public function setUsername($username)
+    {
+        if (is_null($username)) {
+            throw new \InvalidArgumentException('non-nullable username cannot be null');
+        }
+        $this->container['username'] = $username;
+
+        return $this;
+    }
+
+    /**
+     * Gets expires_at
+     *
+     * @return string|null
+     */
+    public function getExpiresAt()
+    {
+        return $this->container['expires_at'];
+    }
+
+    /**
+     * Sets expires_at
+     *
+     * @param string|null $expires_at expires_at
+     *
+     * @return self
+     */
+    public function setExpiresAt($expires_at)
+    {
+        if (is_null($expires_at)) {
+            throw new \InvalidArgumentException('non-nullable expires_at cannot be null');
+        }
+        $this->container['expires_at'] = $expires_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));
+    }
+}
+
+

+ 1 - 1
api-test/client/lib/Model/GetApiV3Progress200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3Search200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3SearchSearch200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/ModelInterface.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/PaginationMeta.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 449 - 0
api-test/client/lib/Model/PatchApiV3PasswordResetsTokenRequest.php

@@ -0,0 +1,449 @@
+<?php
+/**
+ * PatchApiV3PasswordResetsTokenRequest
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PatchApiV3PasswordResetsTokenRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PatchApiV3PasswordResetsTokenRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'patch_api_v3_password_resets_token__request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'password' => 'string',
+        'password_confirmation' => '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 = [
+        'password' => null,
+        'password_confirmation' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'password' => false,
+        'password_confirmation' => 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 = [
+        'password' => 'password',
+        'password_confirmation' => 'password_confirmation'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'password' => 'setPassword',
+        'password_confirmation' => 'setPasswordConfirmation'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'password' => 'getPassword',
+        'password_confirmation' => 'getPasswordConfirmation'
+    ];
+
+    /**
+     * 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('password', $data ?? [], null);
+        $this->setIfExists('password_confirmation', $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['password'] === null) {
+            $invalidProperties[] = "'password' can't be null";
+        }
+        if ($this->container['password_confirmation'] === null) {
+            $invalidProperties[] = "'password_confirmation' 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 password
+     *
+     * @return string
+     */
+    public function getPassword()
+    {
+        return $this->container['password'];
+    }
+
+    /**
+     * Sets password
+     *
+     * @param string $password 新密码,6–32 位
+     *
+     * @return self
+     */
+    public function setPassword($password)
+    {
+        if (is_null($password)) {
+            throw new \InvalidArgumentException('non-nullable password cannot be null');
+        }
+        $this->container['password'] = $password;
+
+        return $this;
+    }
+
+    /**
+     * Gets password_confirmation
+     *
+     * @return string
+     */
+    public function getPasswordConfirmation()
+    {
+        return $this->container['password_confirmation'];
+    }
+
+    /**
+     * Sets password_confirmation
+     *
+     * @param string $password_confirmation 再输一次新密码
+     *
+     * @return self
+     */
+    public function setPasswordConfirmation($password_confirmation)
+    {
+        if (is_null($password_confirmation)) {
+            throw new \InvalidArgumentException('non-nullable password_confirmation cannot be null');
+        }
+        $this->container['password_confirmation'] = $password_confirmation;
+
+        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));
+    }
+}
+
+

+ 484 - 0
api-test/client/lib/Model/PostApiV3EmailCertificationsRequest.php

@@ -0,0 +1,484 @@
+<?php
+/**
+ * PostApiV3EmailCertificationsRequest
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3EmailCertificationsRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3EmailCertificationsRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_email_certifications_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'email' => 'string',
+        'lang' => '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 = [
+        'email' => null,
+        'lang' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'email' => false,
+        'lang' => 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 = [
+        'email' => 'email',
+        'lang' => 'lang'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'email' => 'setEmail',
+        'lang' => 'setLang'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'email' => 'getEmail',
+        'lang' => 'getLang'
+    ];
+
+    /**
+     * 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 LANG_EN = 'en';
+    public const LANG_EN_US = 'en-US';
+    public const LANG_ZH_HANS = 'zh-Hans';
+    public const LANG_ZH_HANT = 'zh-Hant';
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getLangAllowableValues()
+    {
+        return [
+            self::LANG_EN,
+            self::LANG_EN_US,
+            self::LANG_ZH_HANS,
+            self::LANG_ZH_HANT,
+        ];
+    }
+
+    /**
+     * 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('email', $data ?? [], null);
+        $this->setIfExists('lang', $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['email'] === null) {
+            $invalidProperties[] = "'email' can't be null";
+        }
+        $allowedValues = $this->getLangAllowableValues();
+        if (!is_null($this->container['lang']) && !in_array($this->container['lang'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'lang', must be one of '%s'",
+                $this->container['lang'],
+                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 email
+     *
+     * @return string
+     */
+    public function getEmail()
+    {
+        return $this->container['email'];
+    }
+
+    /**
+     * Sets email
+     *
+     * @param string $email 待注册的邮箱
+     *
+     * @return self
+     */
+    public function setEmail($email)
+    {
+        if (is_null($email)) {
+            throw new \InvalidArgumentException('non-nullable email cannot be null');
+        }
+        $this->container['email'] = $email;
+
+        return $this;
+    }
+
+    /**
+     * Gets lang
+     *
+     * @return string|null
+     */
+    public function getLang()
+    {
+        return $this->container['lang'];
+    }
+
+    /**
+     * Sets lang
+     *
+     * @param string|null $lang 邮件语言,没有对应模板的回落 en
+     *
+     * @return self
+     */
+    public function setLang($lang)
+    {
+        if (is_null($lang)) {
+            throw new \InvalidArgumentException('non-nullable lang cannot be null');
+        }
+        $allowedValues = $this->getLangAllowableValues();
+        if (!in_array($lang, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'lang', must be one of '%s'",
+                    $lang,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['lang'] = $lang;
+
+        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/PostApiV3Invites200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * PostApiV3Invites200Response
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3Invites200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3Invites200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_invites_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\PostApiV3Invites200ResponseData'
+    ];
+
+    /**
+     * 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\PostApiV3Invites200ResponseData|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\PostApiV3Invites200ResponseData|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));
+    }
+}
+
+

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

@@ -0,0 +1,518 @@
+<?php
+/**
+ * PostApiV3Invites200ResponseData
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3Invites200ResponseData Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3Invites200ResponseData implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_invites_200_response_data';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'email' => 'string',
+        'status' => 'string',
+        'created_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 = [
+        'id' => null,
+        'email' => null,
+        'status' => null,
+        'created_at' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'email' => false,
+        'status' => false,
+        'created_at' => 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',
+        'email' => 'email',
+        'status' => 'status',
+        'created_at' => 'created_at'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'email' => 'setEmail',
+        'status' => 'setStatus',
+        'created_at' => 'setCreatedAt'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'email' => 'getEmail',
+        'status' => 'getStatus',
+        'created_at' => 'getCreatedAt'
+    ];
+
+    /**
+     * 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('email', $data ?? [], null);
+        $this->setIfExists('status', $data ?? [], null);
+        $this->setIfExists('created_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 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 email
+     *
+     * @return string|null
+     */
+    public function getEmail()
+    {
+        return $this->container['email'];
+    }
+
+    /**
+     * Sets email
+     *
+     * @param string|null $email email
+     *
+     * @return self
+     */
+    public function setEmail($email)
+    {
+        if (is_null($email)) {
+            throw new \InvalidArgumentException('non-nullable email cannot be null');
+        }
+        $this->container['email'] = $email;
+
+        return $this;
+    }
+
+    /**
+     * 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 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)) {
+            array_push($this->openAPINullablesSetToNull, 'created_at');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('created_at', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['created_at'] = $created_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));
+    }
+}
+
+

+ 449 - 0
api-test/client/lib/Model/PostApiV3InvitesRequest.php

@@ -0,0 +1,449 @@
+<?php
+/**
+ * PostApiV3InvitesRequest
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3InvitesRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3InvitesRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_invites_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'email' => 'string',
+        'code' => '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 = [
+        'email' => null,
+        'code' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'email' => false,
+        'code' => 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 = [
+        'email' => 'email',
+        'code' => 'code'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'email' => 'setEmail',
+        'code' => 'setCode'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'email' => 'getEmail',
+        'code' => 'getCode'
+    ];
+
+    /**
+     * 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('email', $data ?? [], null);
+        $this->setIfExists('code', $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['email'] === null) {
+            $invalidProperties[] = "'email' can't be null";
+        }
+        if ($this->container['code'] === null) {
+            $invalidProperties[] = "'code' 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 email
+     *
+     * @return string
+     */
+    public function getEmail()
+    {
+        return $this->container['email'];
+    }
+
+    /**
+     * Sets email
+     *
+     * @param string $email 收到验证码的邮箱
+     *
+     * @return self
+     */
+    public function setEmail($email)
+    {
+        if (is_null($email)) {
+            throw new \InvalidArgumentException('non-nullable email cannot be null');
+        }
+        $this->container['email'] = $email;
+
+        return $this;
+    }
+
+    /**
+     * Gets code
+     *
+     * @return string
+     */
+    public function getCode()
+    {
+        return $this->container['code'];
+    }
+
+    /**
+     * Sets code
+     *
+     * @param string $code 邮件里的 6 位验证码
+     *
+     * @return self
+     */
+    public function setCode($code)
+    {
+        if (is_null($code)) {
+            throw new \InvalidArgumentException('non-nullable code cannot be null');
+        }
+        $this->container['code'] = $code;
+
+        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));
+    }
+}
+
+

+ 1 - 1
api-test/client/lib/Model/PostApiV3MeReactions200Response.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/Model/PostApiV3MeReactionsRequest.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 484 - 0
api-test/client/lib/Model/PostApiV3PasswordResetsRequest.php

@@ -0,0 +1,484 @@
+<?php
+/**
+ * PostApiV3PasswordResetsRequest
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3PasswordResetsRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3PasswordResetsRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_password_resets_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'email' => 'string',
+        'lang' => '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 = [
+        'email' => null,
+        'lang' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'email' => false,
+        'lang' => 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 = [
+        'email' => 'email',
+        'lang' => 'lang'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'email' => 'setEmail',
+        'lang' => 'setLang'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'email' => 'getEmail',
+        'lang' => 'getLang'
+    ];
+
+    /**
+     * 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 LANG_EN = 'en';
+    public const LANG_EN_US = 'en-US';
+    public const LANG_ZH_HANS = 'zh-Hans';
+    public const LANG_ZH_HANT = 'zh-Hant';
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getLangAllowableValues()
+    {
+        return [
+            self::LANG_EN,
+            self::LANG_EN_US,
+            self::LANG_ZH_HANS,
+            self::LANG_ZH_HANT,
+        ];
+    }
+
+    /**
+     * 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('email', $data ?? [], null);
+        $this->setIfExists('lang', $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['email'] === null) {
+            $invalidProperties[] = "'email' can't be null";
+        }
+        $allowedValues = $this->getLangAllowableValues();
+        if (!is_null($this->container['lang']) && !in_array($this->container['lang'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'lang', must be one of '%s'",
+                $this->container['lang'],
+                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 email
+     *
+     * @return string
+     */
+    public function getEmail()
+    {
+        return $this->container['email'];
+    }
+
+    /**
+     * Sets email
+     *
+     * @param string $email 账号邮箱
+     *
+     * @return self
+     */
+    public function setEmail($email)
+    {
+        if (is_null($email)) {
+            throw new \InvalidArgumentException('non-nullable email cannot be null');
+        }
+        $this->container['email'] = $email;
+
+        return $this;
+    }
+
+    /**
+     * Gets lang
+     *
+     * @return string|null
+     */
+    public function getLang()
+    {
+        return $this->container['lang'];
+    }
+
+    /**
+     * Sets lang
+     *
+     * @param string|null $lang 邮件语言,没有对应模板的回落 en
+     *
+     * @return self
+     */
+    public function setLang($lang)
+    {
+        if (is_null($lang)) {
+            throw new \InvalidArgumentException('non-nullable lang cannot be null');
+        }
+        $allowedValues = $this->getLangAllowableValues();
+        if (!in_array($lang, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'lang', must be one of '%s'",
+                    $lang,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['lang'] = $lang;
+
+        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));
+    }
+}
+
+

+ 1 - 1
api-test/client/lib/Model/PostApiV3SearchRequest.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

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

@@ -0,0 +1,409 @@
+<?php
+/**
+ * PostApiV3Sessions200Response
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3Sessions200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3Sessions200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_sessions_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\PostApiV3Sessions200ResponseData'
+    ];
+
+    /**
+     * 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\PostApiV3Sessions200ResponseData|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\PostApiV3Sessions200ResponseData|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/PostApiV3Sessions200ResponseData.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * PostApiV3Sessions200ResponseData
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3Sessions200ResponseData Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3Sessions200ResponseData implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_sessions_200_response_data';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'token' => 'string',
+        'user' => '\WikipaliApi\Model\GetApiV3Me200ResponseData'
+    ];
+
+    /**
+     * 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 = [
+        'token' => null,
+        'user' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'token' => false,
+        'user' => 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 = [
+        'token' => 'token',
+        'user' => 'user'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'token' => 'setToken',
+        'user' => 'setUser'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'token' => 'getToken',
+        '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('token', $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 token
+     *
+     * @return string|null
+     */
+    public function getToken()
+    {
+        return $this->container['token'];
+    }
+
+    /**
+     * Sets token
+     *
+     * @param string|null $token token
+     *
+     * @return self
+     */
+    public function setToken($token)
+    {
+        if (is_null($token)) {
+            throw new \InvalidArgumentException('non-nullable token cannot be null');
+        }
+        $this->container['token'] = $token;
+
+        return $this;
+    }
+
+    /**
+     * Gets user
+     *
+     * @return \WikipaliApi\Model\GetApiV3Me200ResponseData|null
+     */
+    public function getUser()
+    {
+        return $this->container['user'];
+    }
+
+    /**
+     * Sets user
+     *
+     * @param \WikipaliApi\Model\GetApiV3Me200ResponseData|null $user user
+     *
+     * @return self
+     */
+    public function setUser($user)
+    {
+        if (is_null($user)) {
+            throw new \InvalidArgumentException('non-nullable user cannot be null');
+        }
+        $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));
+    }
+}
+
+

+ 449 - 0
api-test/client/lib/Model/PostApiV3SessionsRequest.php

@@ -0,0 +1,449 @@
+<?php
+/**
+ * PostApiV3SessionsRequest
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3SessionsRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3SessionsRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_sessions_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'login' => 'string',
+        'password' => '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 = [
+        'login' => null,
+        'password' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'login' => false,
+        'password' => 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 = [
+        'login' => 'login',
+        'password' => 'password'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'login' => 'setLogin',
+        'password' => 'setPassword'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'login' => 'getLogin',
+        'password' => 'getPassword'
+    ];
+
+    /**
+     * 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('login', $data ?? [], null);
+        $this->setIfExists('password', $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['login'] === null) {
+            $invalidProperties[] = "'login' can't be null";
+        }
+        if ($this->container['password'] === null) {
+            $invalidProperties[] = "'password' 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 login
+     *
+     * @return string
+     */
+    public function getLogin()
+    {
+        return $this->container['login'];
+    }
+
+    /**
+     * Sets login
+     *
+     * @param string $login 用户名或邮箱(邮箱不区分大小写)
+     *
+     * @return self
+     */
+    public function setLogin($login)
+    {
+        if (is_null($login)) {
+            throw new \InvalidArgumentException('non-nullable login cannot be null');
+        }
+        $this->container['login'] = $login;
+
+        return $this;
+    }
+
+    /**
+     * Gets password
+     *
+     * @return string
+     */
+    public function getPassword()
+    {
+        return $this->container['password'];
+    }
+
+    /**
+     * Sets password
+     *
+     * @param string $password 密码
+     *
+     * @return self
+     */
+    public function setPassword($password)
+    {
+        if (is_null($password)) {
+            throw new \InvalidArgumentException('non-nullable password cannot be null');
+        }
+        $this->container['password'] = $password;
+
+        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/PostApiV3Users200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * PostApiV3Users200Response
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3Users200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3Users200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_users_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\PostApiV3Users200ResponseData'
+    ];
+
+    /**
+     * 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\PostApiV3Users200ResponseData|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\PostApiV3Users200ResponseData|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));
+    }
+}
+
+

+ 552 - 0
api-test/client/lib/Model/PostApiV3Users200ResponseData.php

@@ -0,0 +1,552 @@
+<?php
+/**
+ * PostApiV3Users200ResponseData
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3Users200ResponseData Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3Users200ResponseData implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_users_200_response_data';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'username' => 'string',
+        'nickname' => 'string',
+        'email' => 'string',
+        'created_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 = [
+        'id' => null,
+        'username' => null,
+        'nickname' => null,
+        'email' => null,
+        'created_at' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'username' => false,
+        'nickname' => false,
+        'email' => false,
+        'created_at' => 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',
+        'username' => 'username',
+        'nickname' => 'nickname',
+        'email' => 'email',
+        'created_at' => 'created_at'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'username' => 'setUsername',
+        'nickname' => 'setNickname',
+        'email' => 'setEmail',
+        'created_at' => 'setCreatedAt'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'username' => 'getUsername',
+        'nickname' => 'getNickname',
+        'email' => 'getEmail',
+        'created_at' => 'getCreatedAt'
+    ];
+
+    /**
+     * 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('username', $data ?? [], null);
+        $this->setIfExists('nickname', $data ?? [], null);
+        $this->setIfExists('email', $data ?? [], null);
+        $this->setIfExists('created_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 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 username
+     *
+     * @return string|null
+     */
+    public function getUsername()
+    {
+        return $this->container['username'];
+    }
+
+    /**
+     * Sets username
+     *
+     * @param string|null $username username
+     *
+     * @return self
+     */
+    public function setUsername($username)
+    {
+        if (is_null($username)) {
+            throw new \InvalidArgumentException('non-nullable username cannot be null');
+        }
+        $this->container['username'] = $username;
+
+        return $this;
+    }
+
+    /**
+     * Gets nickname
+     *
+     * @return string|null
+     */
+    public function getNickname()
+    {
+        return $this->container['nickname'];
+    }
+
+    /**
+     * Sets nickname
+     *
+     * @param string|null $nickname nickname
+     *
+     * @return self
+     */
+    public function setNickname($nickname)
+    {
+        if (is_null($nickname)) {
+            throw new \InvalidArgumentException('non-nullable nickname cannot be null');
+        }
+        $this->container['nickname'] = $nickname;
+
+        return $this;
+    }
+
+    /**
+     * Gets email
+     *
+     * @return string|null
+     */
+    public function getEmail()
+    {
+        return $this->container['email'];
+    }
+
+    /**
+     * Sets email
+     *
+     * @param string|null $email email
+     *
+     * @return self
+     */
+    public function setEmail($email)
+    {
+        if (is_null($email)) {
+            throw new \InvalidArgumentException('non-nullable email cannot be null');
+        }
+        $this->container['email'] = $email;
+
+        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)) {
+            array_push($this->openAPINullablesSetToNull, 'created_at');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('created_at', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['created_at'] = $created_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));
+    }
+}
+
+

+ 594 - 0
api-test/client/lib/Model/PostApiV3UsersRequest.php

@@ -0,0 +1,594 @@
+<?php
+/**
+ * PostApiV3UsersRequest
+ *
+ * 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.10.8
+ * 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;
+
+/**
+ * PostApiV3UsersRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3UsersRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_users_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'invite' => 'string',
+        'username' => 'string',
+        'nickname' => 'string',
+        'password' => 'string',
+        'password_confirmation' => 'string',
+        'lang' => '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 = [
+        'invite' => null,
+        'username' => null,
+        'nickname' => null,
+        'password' => null,
+        'password_confirmation' => null,
+        'lang' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'invite' => false,
+        'username' => false,
+        'nickname' => false,
+        'password' => false,
+        'password_confirmation' => false,
+        'lang' => 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 = [
+        'invite' => 'invite',
+        'username' => 'username',
+        'nickname' => 'nickname',
+        'password' => 'password',
+        'password_confirmation' => 'password_confirmation',
+        'lang' => 'lang'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'invite' => 'setInvite',
+        'username' => 'setUsername',
+        'nickname' => 'setNickname',
+        'password' => 'setPassword',
+        'password_confirmation' => 'setPasswordConfirmation',
+        'lang' => 'setLang'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'invite' => 'getInvite',
+        'username' => 'getUsername',
+        'nickname' => 'getNickname',
+        'password' => 'getPassword',
+        'password_confirmation' => 'getPasswordConfirmation',
+        'lang' => 'getLang'
+    ];
+
+    /**
+     * 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('invite', $data ?? [], null);
+        $this->setIfExists('username', $data ?? [], null);
+        $this->setIfExists('nickname', $data ?? [], null);
+        $this->setIfExists('password', $data ?? [], null);
+        $this->setIfExists('password_confirmation', $data ?? [], null);
+        $this->setIfExists('lang', $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['invite'] === null) {
+            $invalidProperties[] = "'invite' can't be null";
+        }
+        if ($this->container['username'] === null) {
+            $invalidProperties[] = "'username' can't be null";
+        }
+        if ($this->container['password'] === null) {
+            $invalidProperties[] = "'password' can't be null";
+        }
+        if ($this->container['password_confirmation'] === null) {
+            $invalidProperties[] = "'password_confirmation' can't be null";
+        }
+        if ($this->container['lang'] === null) {
+            $invalidProperties[] = "'lang' 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 invite
+     *
+     * @return string
+     */
+    public function getInvite()
+    {
+        return $this->container['invite'];
+    }
+
+    /**
+     * Sets invite
+     *
+     * @param string $invite 注册邀请的 uuid
+     *
+     * @return self
+     */
+    public function setInvite($invite)
+    {
+        if (is_null($invite)) {
+            throw new \InvalidArgumentException('non-nullable invite cannot be null');
+        }
+        $this->container['invite'] = $invite;
+
+        return $this;
+    }
+
+    /**
+     * Gets username
+     *
+     * @return string
+     */
+    public function getUsername()
+    {
+        return $this->container['username'];
+    }
+
+    /**
+     * Sets username
+     *
+     * @param string $username 用户名,6–32 位字母、数字、下划线
+     *
+     * @return self
+     */
+    public function setUsername($username)
+    {
+        if (is_null($username)) {
+            throw new \InvalidArgumentException('non-nullable username cannot be null');
+        }
+        $this->container['username'] = $username;
+
+        return $this;
+    }
+
+    /**
+     * Gets nickname
+     *
+     * @return string|null
+     */
+    public function getNickname()
+    {
+        return $this->container['nickname'];
+    }
+
+    /**
+     * Sets nickname
+     *
+     * @param string|null $nickname 昵称,不传或空白时用 username
+     *
+     * @return self
+     */
+    public function setNickname($nickname)
+    {
+        if (is_null($nickname)) {
+            throw new \InvalidArgumentException('non-nullable nickname cannot be null');
+        }
+        $this->container['nickname'] = $nickname;
+
+        return $this;
+    }
+
+    /**
+     * Gets password
+     *
+     * @return string
+     */
+    public function getPassword()
+    {
+        return $this->container['password'];
+    }
+
+    /**
+     * Sets password
+     *
+     * @param string $password 密码,6–32 位
+     *
+     * @return self
+     */
+    public function setPassword($password)
+    {
+        if (is_null($password)) {
+            throw new \InvalidArgumentException('non-nullable password cannot be null');
+        }
+        $this->container['password'] = $password;
+
+        return $this;
+    }
+
+    /**
+     * Gets password_confirmation
+     *
+     * @return string
+     */
+    public function getPasswordConfirmation()
+    {
+        return $this->container['password_confirmation'];
+    }
+
+    /**
+     * Sets password_confirmation
+     *
+     * @param string $password_confirmation 再输一次密码
+     *
+     * @return self
+     */
+    public function setPasswordConfirmation($password_confirmation)
+    {
+        if (is_null($password_confirmation)) {
+            throw new \InvalidArgumentException('non-nullable password_confirmation cannot be null');
+        }
+        $this->container['password_confirmation'] = $password_confirmation;
+
+        return $this;
+    }
+
+    /**
+     * Gets lang
+     *
+     * @return string
+     */
+    public function getLang()
+    {
+        return $this->container['lang'];
+    }
+
+    /**
+     * Sets lang
+     *
+     * @param string $lang 常用译文语言,用作 draft channel 的语言
+     *
+     * @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;
+    }
+    /**
+     * 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));
+    }
+}
+
+

+ 1 - 1
api-test/client/lib/Model/ProblemDetails.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 1 - 1
api-test/client/lib/ObjectSerializer.php

@@ -15,7 +15,7 @@
  *
  * 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
+ * The version of the OpenAPI document: 2026.10.8
  * Generated by: https://openapi-generator.tech
  * Generator version: 7.25.0
  */

+ 4 - 0
api-test/fixtures.php

@@ -23,4 +23,8 @@ return [
     // 搜索关键词 + 一条真实 OpenSearch 文档 id(search/{id} 的 200 用例)
     'search_q'       => 'dukkha',
     'search_doc_id'  => 'term_f8afdcaa-ad68-4795-b3d8-d5f12bdb3a5f',
+
+    // 一个已注册账号的邮箱(email-certifications 的「已注册」用例;只校验、不发信)。
+    // 留空则该用例 SKIP。
+    'registered_email' => getenv('API_TEST_REGISTERED_EMAIL') ?: null,
 ];

+ 3 - 3
api-test/run.php

@@ -12,7 +12,7 @@
  *   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
+ *   php run.php --username=alice --password=secret   # 调 /v3/sessions 换 token
  *
  * 退出码:0 全绿;1 有失败/错误;2 参数或加载错误。
  */
@@ -71,12 +71,12 @@ $GLOBALS['fixtures'] = require __DIR__ . '/fixtures.php';
 
 require __DIR__ . '/src/bootstrap.php';
 
-// 没给 token 但给了账号密码 → 调 /v2/sign-in 现换一个
+// 没给 token 但给了账号密码 → 调 /v3/sessions 现换一个
 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";
+        echo "已用 {$config['username']} 登录(/v3/sessions)换取 token\n\n";
     } catch (Fail $e) {
         echo "登录失败:{$e->getMessage()}(需登录的用例将 FAIL)\n\n";
     }

+ 21 - 10
api-test/src/bootstrap.php

@@ -97,10 +97,10 @@ function request(callable $fn): array
  * 原始 HTTP 请求:绕过生成客户端的必填参数校验,用于「缺参/非法组合」这类负向用例。
  * $path 以 / 开头(如 /v3/reactions),base URL 已含 /api 前缀。
  */
-function raw(string $method, string $path, ?array $query = null, ?array $json = null, ?string $token = null): array
+function raw(string $method, string $path, ?array $query = null, ?array $json = null, ?string $token = null, array $headers = []): array
 {
     $http = new Client(['timeout' => config()['timeout'], 'http_errors' => false]);
-    $opts = ['headers' => []];
+    $opts = ['headers' => $headers];
     if ($query !== null) {
         $opts['query'] = $query;
     }
@@ -135,17 +135,18 @@ function normalize(mixed $content): mixed
 }
 
 /**
- * 调 v2 登录接口换 bearer token。
- * `POST /v2/sign-in`(username 可以是用户名或邮箱),成功返回 {ok,data:JWT},
- * 失败也是 JSON({ok:false,message})且 HTTP 可能 200 或 400,所以只看 ok 字段。
+ * 调 v3 登录接口换 bearer token。
+ * `POST /v3/sessions {login, password}`(login 可以是用户名或邮箱),成功 201
+ * `{data:{token, user}}`;账号或密码不对 422,尝试过频 429(同账号 + IP 每分钟 5 次)。
  */
 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'];
+    $res = raw('POST', '/v3/sessions', null, ['login' => $username, 'password' => $password]);
+    $token = $res['body']['data']['token'] ?? null;
+    if ($res['status'] === 201 && is_string($token) && $token !== '') {
+        return $token;
     }
-    fail(sprintf('登录失败(HTTP %d):%s', $res['status'], $res['body']['message'] ?? '未知错误'));
+    fail(sprintf('登录失败(HTTP %d):%s', $res['status'], $res['body']['detail'] ?? $res['body']['title'] ?? '未知错误'));
 }
 
 /** 取当前 token;没有就 FAIL(需登录用例不跳过,缺 token 就是失败)。 */
@@ -153,7 +154,7 @@ function required_token(): string
 {
     $token = config()['token'];
     if (! $token) {
-        fail('缺少 token:请用 --token= 或 --username/--password(自动调 /v2/sign-in)');
+        fail('缺少 token:请用 --token= 或 --username/--password(自动调 /v3/sessions)');
     }
 
     return $token;
@@ -197,3 +198,13 @@ function assert_problem(array $res, string $what): void
     }
     assert_eq($res['status'], (int) $b['status'], $what . ':Problem.status 与 HTTP 状态码不一致');
 }
+
+/** 断言 422 Problem Details 的 `errors` 里有指定字段(FormRequest / ValidationException 的字段级错误)。 */
+function assert_field_error(string $field, array $res, string $what): void
+{
+    assert_status(422, $res, $what);
+    assert_problem($res, $what);
+    if (! isset($res['body']['errors'][$field])) {
+        fail(sprintf('%s:errors 里缺少字段 `%s`,body=%s', $what, $field, substr((string) json_encode($res['body'], JSON_UNESCAPED_UNICODE), 0, 400)));
+    }
+}

+ 3 - 2
api-test/tools/filter-v3.php

@@ -66,13 +66,14 @@ if ($v3Paths === []) {
 }
 
 // 2) 修正鉴权语义:源 spec 用全局 security 把全部路径标成 bearerAuth,
-//    但 v3 实际只有 /v3/me/* 挂 auth.v3。这里逐 operation 写准,并清掉全局 security。
+//    但 v3 实际只有 /v3/me 与 /v3/me/* 挂 auth.v3。这里逐 operation 写准,并清掉全局 security。
+//    注意 /v3/me 本身(不带斜杠)也要算进去,否则生成的客户端不带 token。
 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/')
+        $op['security'] = ($path === '/v3/me' || str_starts_with((string) $path, '/v3/me/'))
             ? [['bearerAuth' => []]]
             : [];
     }

+ 45 - 25
api-v13/app/Http/Controllers/Library/CourseController.php

@@ -6,7 +6,6 @@ use App\Http\Api\UserApi;
 use App\Http\Controllers\Controller;
 use App\Models\Attachment;
 use App\Models\Course;
-use App\Models\CourseMember;
 use Carbon\Carbon;
 use Illuminate\Contracts\View\View;
 use Illuminate\Http\Request;
@@ -44,17 +43,44 @@ class CourseController extends Controller
      */
     public function index(): View
     {
-        $today = Carbon::today()->toDateString();
+        $now = Carbon::now();
         $base = Course::where('publicity', 30);
 
-        $latest = (clone $base)->orderByDesc('created_at')->take(4)->get();
-        $open = (clone $base)->whereDate('start_at', '>', $today)->orderBy('start_at')->get();
-        $history = (clone $base)->whereDate('start_at', '<=', $today)->orderByDesc('start_at')->take(5)->get();
+        // 可报名:当前处于报名窗口内(报名开始 <= 现在 <= 报名结束)
+        $signUpOpen = fn ($query) => $query
+            ->where('sign_up_start_at', '<=', $now)
+            ->where('sign_up_end_at', '>=', $now);
+
+        // 进行中:当前处于课程时间窗口内(课程开始 <= 现在 <= 课程结束)
+        $inProgress = fn ($query) => $query
+            ->where('start_at', '<=', $now)
+            ->where('end_at', '>=', $now);
+
+        // 最新课程:可报名 且 进行中(已结束的课程无论多新都不出现在这里)
+        $latest = (clone $base)
+            ->where($signUpOpen)
+            ->where($inProgress)
+            ->orderByDesc('created_at')
+            ->take(4)
+            ->get();
+
+        // 开放报名:可报名
+        $open = (clone $base)
+            ->where($signUpOpen)
+            ->orderBy('start_at')
+            ->get();
+
+        // 历史课程:已结束(课程时间已过)
+        $history = (clone $base)
+            ->where('end_at', '<', $now)
+            ->orderByDesc('end_at')
+            ->take(5)
+            ->get();
 
         $stats = [
             'total' => (clone $base)->count(),
-            'open' => (clone $base)->whereDate('start_at', '>', $today)->count(),
-            'closed' => (clone $base)->whereDate('start_at', '<=', $today)->count(),
+            'open' => (clone $base)->where($signUpOpen)->count(),
+            'closed' => (clone $base)->where('end_at', '<', $now)->count(),
         ];
 
         return view('library.course.index', [
@@ -66,16 +92,15 @@ class CourseController extends Controller
     }
 
     /**
-     * 历史课程列表页:分页展示已开课/已结束的公开课程。
+     * 历史课程列表页:分页展示已结束的公开课程。
      */
     public function history(Request $request): View
     {
-        $today = Carbon::today()->toDateString();
         $perPage = 10;
 
         $paginator = Course::where('publicity', 30)
-            ->whereDate('start_at', '<=', $today)
-            ->orderByDesc('start_at')
+            ->where('end_at', '<', Carbon::now())
+            ->orderByDesc('end_at')
             ->paginate($perPage);
 
         $paginator->setCollection($this->present($paginator->getCollection()));
@@ -87,7 +112,7 @@ class CourseController extends Controller
     }
 
     // -------------------------------------------------------------------------
-    // 将 Course 集合加工为视图所需数组:批量解析讲师与报名人数,避免 N+1
+    // 将 Course 集合加工为视图所需数组:批量解析讲师,避免 N+1
     // -------------------------------------------------------------------------
     private function present(Collection $courses): Collection
     {
@@ -95,13 +120,6 @@ class CourseController extends Controller
             return collect();
         }
 
-        // 报名人数:按 course_id 一次聚合
-        $memberCounts = CourseMember::whereIn('course_id', $courses->pluck('id'))
-            ->where('is_current', true)
-            ->selectRaw('course_id, count(*) as cnt')
-            ->groupBy('course_id')
-            ->pluck('cnt', 'course_id');
-
         // 讲师:按 teacher uuid 一次批量解析
         $teacherUids = $courses->pluck('teacher')->filter()->unique()->values();
         $teachers = $teacherUids->isEmpty()
@@ -110,7 +128,7 @@ class CourseController extends Controller
 
         $baseUrl = rtrim(config('mint.server.workspace_base_path'), '/');
 
-        return $courses->values()->map(function (Course $course, int $index) use ($memberCounts, $teachers, $baseUrl) {
+        return $courses->values()->map(function (Course $course, int $index) use ($teachers, $baseUrl) {
             $teacher = $teachers->get($course->teacher);
 
             return [
@@ -128,20 +146,22 @@ class CourseController extends Controller
                 'cover_url' => $this->coverUrl($course),
                 'cover_gradient' => $this->coverGradients[$this->colorIndex($course->id) % count($this->coverGradients)],
                 'teacher' => $this->formatTeacher($teacher, $index),
-                'member_count' => (int) ($memberCounts[$course->id] ?? 0),
-                'status' => $this->statusOf($course->start_at),
+                'status' => $this->statusOf($course),
                 'detail_url' => $baseUrl.'/course/'.$course->id,
             ];
         });
     }
 
-    private function statusOf(mixed $startAt): string
+    private function statusOf(Course $course): string
     {
-        if (! $startAt) {
+        if (! $course->sign_up_start_at || ! $course->sign_up_end_at) {
             return 'closed';
         }
 
-        return Carbon::parse($startAt)->startOfDay()->isAfter(Carbon::today()) ? 'open' : 'closed';
+        return Carbon::now()->between(
+            Carbon::parse($course->sign_up_start_at),
+            Carbon::parse($course->sign_up_end_at)
+        ) ? 'open' : 'closed';
     }
 
     private function formatTeacher(?array $teacher, int $index): array

+ 42 - 0
api-v13/app/Http/Controllers/V3/EmailCertificationController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\StoreEmailCertificationRequest;
+use App\Services\V3\SignUpService;
+
+/**
+ * 注册邮箱验证码。
+ *
+ * 取代 `POST /v2/email-certification`(`EmailCertificationController@store`)。
+ * v2 那份在 dashboard-v4 下线前原样保留,不要改动。v2 的
+ * `GET /v2/email-certification/{id}` 会把验证码交给前端比对,v3 没有对应端点——
+ * 比对改在服务端做,见 `POST /v3/invites`。
+ */
+class EmailCertificationController extends Controller
+{
+    public function __construct(private readonly SignUpService $signUp) {}
+
+    /**
+     * 发送注册验证码
+     *
+     * 向邮箱发一个 6 位验证码,30 分钟内有效;重复申请会作废上一个。
+     * 邮箱已注册时返回 422。按 IP 与邮箱限流,超出返回 429。
+     *
+     * @unauthenticated
+     *
+     * @bodyParam email string required 待注册的邮箱。Example: someone@example.com
+     * @bodyParam lang string 邮件语言,没有对应模板的回落 en。Enum: en,en-US,zh-Hans,zh-Hant
+     *
+     * @responseStatus 204 已发送,无响应体
+     * @responseStatus 429 请求过于频繁
+     * @responseStatus 503 邮件发送失败
+     */
+    public function store(StoreEmailCertificationRequest $request)
+    {
+        $this->signUp->sendCode($request->validated('email'), $request->validated('lang'));
+
+        return response()->noContent();
+    }
+}

+ 61 - 0
api-v13/app/Http/Controllers/V3/InviteController.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\StoreInviteRequest;
+use App\Http\Resources\V3\InviteResource;
+use App\Services\V3\SignUpService;
+
+/**
+ * 注册邀请。invite id 是 `POST /v3/users` 的注册凭据。
+ *
+ * - show 取代 `GET /v2/invite/{invite}`(`InviteController@show`),邀请注册页用。
+ * - store 取代 v2「`GET /v2/email-certification/{id}` 取回验证码在前端比对」那一步:
+ *   改成服务端比对验证码,通过后签发 invite。
+ *
+ * studio 发邀请(`POST /v2/invite`,要登录、要 studio 权限)是另一个契约,
+ * 将来迁成嵌套资源 `studios/{studio}/invites`,不放在这里。
+ * v2 那几条在 dashboard-v4 下线前原样保留,不要改动。
+ */
+class InviteController extends Controller
+{
+    public function __construct(private readonly SignUpService $signUp) {}
+
+    /**
+     * 用验证码换注册邀请
+     *
+     * 验证码正确则返回该邮箱的 invite(已有则复用,状态重置为未使用),验证码随即作废。
+     * 验证码错误、过期或试错 5 次后返回 422(errors.code)。
+     *
+     * @unauthenticated
+     *
+     * @bodyParam email string required 收到验证码的邮箱
+     * @bodyParam code string required 邮件里的 6 位验证码。Example: 123456
+     *
+     * @responseStatus 201 首次为该邮箱签发 invite。响应体与 200 同形;复用已有 invite 时是 200
+     * @responseStatus 429 请求过于频繁
+     */
+    public function store(StoreInviteRequest $request): InviteResource
+    {
+        return InviteResource::make(
+            $this->signUp->verifyCode($request->validated('email'), $request->validated('code'))
+        );
+    }
+
+    /**
+     * 查看一条可用的注册邀请
+     *
+     * 邀请注册页用它显示邮箱。不存在或已被使用都返回 404。
+     *
+     * @unauthenticated
+     *
+     * @urlParam invite string required 邀请邮件链接里的 uuid
+     *
+     * @responseStatus 404 邀请不存在或已被使用
+     */
+    public function show(string $invite): InviteResource
+    {
+        return InviteResource::make($this->signUp->findPendingInvite($invite));
+    }
+}

+ 33 - 0
api-v13/app/Http/Controllers/V3/MeController.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Resources\V3\MeResource;
+use App\Services\V3\SessionService;
+use Illuminate\Http\Request;
+
+/**
+ * 当前登录用户本人。
+ *
+ * 取代 `GET /v2/auth/current`(`AuthController@getUserInfoByToken`)。v2 那份在
+ * dashboard-v4 下线前原样保留,不要改动;wikipali-mobile 目前也还在用它。
+ */
+class MeController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly SessionService $sessions) {}
+
+    /**
+     * 我的资料
+     *
+     * 前端启动时用已存的 token 调它恢复登录态;401 表示 token 无效或过期,应清掉 token。
+     * 不回传 token(v2 的 auth/current 会把请求里的 token 原样回显)。
+     */
+    public function show(Request $request): MeResource
+    {
+        return MeResource::make($this->sessions->me($this->currentUser($request)['user_uid']));
+    }
+}

+ 87 - 0
api-v13/app/Http/Controllers/V3/PasswordResetController.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\StorePasswordResetRequest;
+use App\Http\Requests\V3\UpdatePasswordResetRequest;
+use App\Http\Resources\V3\PasswordResetResource;
+use App\Services\V3\PasswordResetService;
+
+/**
+ * 找回密码。
+ *
+ * 取代 `POST /v2/auth/forgot-password`、`GET /v2/auth/reset-password/{token}`、
+ * `POST /v2/auth/reset-password`(`ForgotPasswordController` / `ResetPasswordController`)。
+ * v2 那几条在 dashboard-v4 下线前原样保留,不要改动。
+ *
+ * 一次「密码重置」是一个以 token 为标识的资源:store 发起(发邮件),
+ * show 查看(给哪个账号、何时过期),update 完成(设新密码,token 作废)。
+ */
+class PasswordResetController extends Controller
+{
+    public function __construct(private readonly PasswordResetService $resets) {}
+
+    /**
+     * 申请重置密码(发邮件)
+     *
+     * 无论邮箱是否注册都返回 204,不泄露账号是否存在。邮件是同步发出的,
+     * 返回时已经发完,所以是 204 而不是 202。邮件里的链接指向
+     * dashboard-v6 的 `/anonymous/reset-password/{token}`,60 分钟内有效。
+     *
+     * 按 IP 与邮箱限流,超出返回 429。
+     *
+     * @unauthenticated
+     *
+     * @bodyParam email string required 账号邮箱。Example: someone@example.com
+     * @bodyParam lang string 邮件语言,没有对应模板的回落 en。Enum: en,en-US,zh-Hans,zh-Hant
+     *
+     * @responseStatus 204 已发送(或邮箱未注册),无响应体
+     * @responseStatus 429 请求过于频繁
+     * @responseStatus 503 邮件发送失败
+     */
+    public function store(StorePasswordResetRequest $request)
+    {
+        $this->resets->request($request->validated('email'), $request->validated('lang'));
+
+        return response()->noContent();
+    }
+
+    /**
+     * 查看一次待完成的重置
+     *
+     * 重置页用它显示「正在为哪个账号设置新密码」。token 不存在或已过期一律 404。
+     *
+     * @unauthenticated
+     *
+     * @urlParam token string required 邮件链接里的 token
+     *
+     * @responseStatus 404 token 无效或已过期
+     */
+    public function show(string $token): PasswordResetResource
+    {
+        return PasswordResetResource::make($this->resets->find($token));
+    }
+
+    /**
+     * 设置新密码
+     *
+     * 成功后 token 作废,返回 204 空体;之后用新密码走登录。
+     *
+     * @unauthenticated
+     *
+     * @urlParam token string required 邮件链接里的 token
+     *
+     * @bodyParam password string required 新密码,6–32 位
+     * @bodyParam password_confirmation string required 再输一次新密码
+     *
+     * @responseStatus 204 已重置,无响应体
+     * @responseStatus 404 token 无效或已过期
+     */
+    public function update(UpdatePasswordResetRequest $request, string $token)
+    {
+        $this->resets->reset($token, $request->validated('password'));
+
+        return response()->noContent();
+    }
+}

+ 42 - 0
api-v13/app/Http/Controllers/V3/SessionController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\StoreSessionRequest;
+use App\Http\Resources\V3\SessionResource;
+use App\Services\V3\SessionService;
+
+/**
+ * 登录。
+ *
+ * 取代 `POST /v2/sign-in`(`AuthController@signIn`)+ 紧随其后的
+ * `GET /v2/auth/current`。v2 那两条在 dashboard-v4 下线前原样保留,不要改动;
+ * wikipali-mobile 目前也还在用它们。
+ */
+class SessionController extends Controller
+{
+    public function __construct(private readonly SessionService $sessions) {}
+
+    /**
+     * 登录
+     *
+     * 用用户名或邮箱 + 密码换取 bearer token,同时返回当前用户资料,前端不必再查一次。
+     * 账号或密码不对返回 422(errors.login),不区分是哪一个错。
+     * 按 IP 与账号限流,超出返回 429。
+     *
+     * @unauthenticated
+     *
+     * @bodyParam login string required 用户名或邮箱(邮箱不区分大小写)
+     * @bodyParam password string required 密码
+     *
+     * @responseStatus 201 登录成功
+     * @responseStatus 429 尝试过于频繁
+     */
+    public function store(StoreSessionRequest $request)
+    {
+        return SessionResource::make(
+            $this->sessions->signIn($request->validated('login'), $request->validated('password'))
+        )->response()->setStatusCode(201);
+    }
+}

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

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\StoreUserRequest;
+use App\Http\Resources\V3\UserResource;
+use App\Services\V3\SignUpService;
+
+/**
+ * 账号。
+ *
+ * store 取代 `POST /v2/sign-up`(`SignUpController@store`)。v2 那份在
+ * dashboard-v4 下线前原样保留,不要改动。
+ */
+class UserController extends Controller
+{
+    public function __construct(private readonly SignUpService $signUp) {}
+
+    /**
+     * 注册账号
+     *
+     * 凭 invite(邀请邮件里的,或 `POST /v3/invites` 换来的)建账号,同时建一个
+     * 私有的 draft 译文 channel,invite 标为已用。邮箱取自 invite。
+     *
+     * 注册不等于登录:返回 201 与新账号,之后用用户名/邮箱 + 密码走登录。
+     *
+     * @unauthenticated
+     *
+     * @bodyParam invite string required 注册邀请的 uuid
+     * @bodyParam username string required 用户名,6–32 位字母、数字、下划线
+     * @bodyParam nickname string 昵称,不传或空白时用 username
+     * @bodyParam password string required 密码,6–32 位
+     * @bodyParam password_confirmation string required 再输一次密码
+     * @bodyParam lang string required 常用译文语言,用作 draft channel 的语言。Example: zh-Hans
+     *
+     * @responseStatus 201 已创建
+     * @responseStatus 429 请求过于频繁
+     */
+    public function store(StoreUserRequest $request): UserResource
+    {
+        return UserResource::make($this->signUp->register($request->validated()));
+    }
+}

+ 48 - 0
api-v13/app/Http/Middleware/V3/NegotiateLocale.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Http\Middleware\V3;
+
+use App\Http\Middleware\SetLocale;
+use Closure;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\App;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * v3 的语言协商:按 `Accept-Language` 决定 `__()` 用哪种语言。
+ *
+ * 只挂在 v3 路由组上(routes/api.php)。之前 api 组没有任何语言协商,`__()` 永远取
+ * en,中文界面里显示英文错误(dashboard-test BUG-5)。
+ *
+ * 继承 web 组的 {@see SetLocale},复用它的标签解析(q 值排序、大小写不敏感、
+ * zh-CN/zh-TW 归简繁、th-TH → th),但**只读请求头**:
+ *
+ * - 不读 `?lang=` / cookie:API 的调用方(v6、mobile)明确知道界面语言,直接放请求头里;
+ *   `?lang=` 在不少 v3 端点上已有业务含义(如译文语言),不能拿来当界面语言。
+ * - 不读写 cookie / session:API 无状态,api 组也没有 session。尤其不能读 web 组写的
+ *   `language` cookie——Laravel 页面与 dashboard-v6 同域,这个 cookie 会跟着 v6 的请求
+ *   一起发来,若它优先,用户在 Laravel 页面选过的语言就会盖过 v6 明确声明的界面语言。
+ *   Laravel 页面将来要调 v3 并显示错误文案,也应像 v6 一样自己带 Accept-Language
+ *   (取 `<html lang>`,即 `app()->getLocale()`)。
+ *
+ * 匹配不上就用 `config('mint.default_language')`。Problem Details 的 `title` 按 RFC 9457
+ * 保持稳定英文,不受这里影响;本地化的是 `detail` 与 `errors`。
+ */
+class NegotiateLocale extends SetLocale
+{
+    public function handle(Request $request, Closure $next): Response
+    {
+        $supported = array_keys(config('mint.languages'));
+
+        // 兜底用 mint.default_language 而不是 app.locale:App::setLocale() 会顺带改写
+        // config('app.locale'),常驻进程(Octane、队列)里上一个请求的语言会串到下一个
+        App::setLocale($this->getBrowserLocale($request, $supported) ?? config('mint.default_language', 'en'));
+
+        $response = $next($request);
+        // 响应随 Accept-Language 而变,告诉缓存别混用
+        $response->headers->set('Vary', trim($response->headers->get('Vary').', Accept-Language', ', '));
+        $response->headers->set('Content-Language', App::getLocale());
+
+        return $response;
+    }
+}

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

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Services\V3\SignUpService;
+use Closure;
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `POST /v3/email-certifications` 的入参:给待注册的邮箱发验证码。
+ */
+class StoreEmailCertificationRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            // user_infos.email 是 varchar(256)
+            'email' => [
+                'required', 'email', 'max:256',
+                function (string $attribute, mixed $value, Closure $fail): void {
+                    if (app(SignUpService::class)->emailRegistered((string) $value)) {
+                        $fail(__('messages.email_registered'));
+                    }
+                },
+            ],
+            // 邮件语言,没有对应模板的回落 en,所以这里不限定取值
+            'lang' => ['sometimes', 'string', 'max:16'],
+        ];
+    }
+}

+ 27 - 0
api-v13/app/Http/Requests/V3/StoreInviteRequest.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `POST /v3/invites` 的入参:用邮箱验证码换一条注册邀请。
+ */
+class StoreInviteRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'email' => ['required', 'email', 'max:256'],
+            'code' => ['required', 'digits:6'],
+        ];
+    }
+}

+ 34 - 0
api-v13/app/Http/Requests/V3/StorePasswordResetRequest.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `POST /v3/password-resets` 的入参:申请发送重置邮件。
+ *
+ * 不收 v2 那个 `dashboard` 参数:重置链接一律用服务端配置拼。
+ */
+class StorePasswordResetRequest extends FormRequest
+{
+    /**
+     * 匿名端点,没有授权可言。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            // user_infos.email 是 varchar(256)
+            'email' => ['required', 'email', 'max:256'],
+            // 邮件语言,没有对应模板的回落 en,所以这里不限定取值
+            'lang' => ['sometimes', 'string', 'max:16'],
+        ];
+    }
+}

+ 28 - 0
api-v13/app/Http/Requests/V3/StoreSessionRequest.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `POST /v3/sessions` 的入参:用户名或邮箱 + 密码。
+ */
+class StoreSessionRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            // 用户名(varchar 64)或邮箱(varchar 256)
+            'login' => ['required', 'string', 'max:256'],
+            'password' => ['required', 'string', 'max:64'],
+        ];
+    }
+}

+ 36 - 0
api-v13/app/Http/Requests/V3/StoreUserRequest.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `POST /v3/users` 的入参:凭邀请注册账号。
+ *
+ * 邮箱不在这里收:它来自 invite,客户端改不了。
+ * invite 是否可用在 Service 里查(要连带检查邮箱是否已被注册,规则写不干净)。
+ */
+class StoreUserRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'invite' => ['required', 'uuid'],
+            // v2 前端的规则是 6–32 位字母数字下划线,但正则漏了 $,后端也不校验;这里补齐
+            'username' => ['required', 'string', 'min:6', 'max:32', 'regex:/^[A-Za-z0-9_]+$/', 'unique:user_infos,username'],
+            // 不传或空白时用 username
+            'nickname' => ['sometimes', 'nullable', 'string', 'max:32'],
+            'password' => ['required', 'string', 'min:6', 'max:32', 'confirmed'],
+            // draft channel 的语言,channels.lang 是 varchar(16)
+            'lang' => ['required', 'string', 'max:16'],
+        ];
+    }
+}

+ 29 - 0
api-v13/app/Http/Requests/V3/UpdatePasswordResetRequest.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `PATCH /v3/password-resets/{token}` 的入参:设新密码。
+ *
+ * 长度沿用注册时的 6–32。不用 `Password::defaults()`:它在生产环境要求 12 位
+ * 混合字符并联网查泄露库,与注册规则不一致,要收紧得注册、重置一起改。
+ */
+class UpdatePasswordResetRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'password' => ['required', 'string', 'min:6', 'max:32', 'confirmed'],
+        ];
+    }
+}

+ 36 - 0
api-v13/app/Http/Resources/V3/InviteResource.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Models\Invite;
+use Illuminate\Http\Request;
+
+/**
+ * 一条注册邀请(`invites` 表)。
+ *
+ * id 就是注册凭据:只有邮箱主人能拿到(邀请邮件里,或比对验证码之后)。
+ * 不返回邀请人 user_uid——注册页用不到。
+ *
+ * @mixin Invite
+ */
+class InviteResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     email: string,
+     *     status: string,
+     *     created_at: string|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => $this->id,
+            'email' => $this->email,
+            'status' => $this->status,
+            'created_at' => $this->created_at?->toIso8601ZuluString(),
+        ];
+    }
+}

+ 46 - 0
api-v13/app/Http/Resources/V3/MeResource.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\UserService;
+use App\Services\V3\SessionService;
+use Illuminate\Http\Request;
+
+/**
+ * 当前登录用户本人的资料。
+ *
+ * 前几个字段与 v3 其他资源里的 user 摘要同形({@see UserService::profile()},
+ * 驼峰是 v2 留下的既有口径,前端的 IUser 就是这个形状);roles 恒为数组,
+ * 另加只有本人能看的 email。
+ *
+ * 载荷是 {@see SessionService} 拼好的数组。
+ */
+class MeResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     nickName: string,
+     *     userName: string,
+     *     realName: string,
+     *     sn: int,
+     *     avatar: string|null,
+     *     roles: string[],
+     *     email: string
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => $this->resource['id'],
+            'nickName' => $this->resource['nickName'],
+            'userName' => $this->resource['userName'],
+            'realName' => $this->resource['realName'],
+            'sn' => $this->resource['sn'],
+            'avatar' => $this->resource['avatar'],
+            'roles' => $this->resource['roles'],
+            'email' => $this->resource['email'],
+        ];
+    }
+}

+ 30 - 0
api-v13/app/Http/Resources/V3/PasswordResetResource.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\V3\PasswordResetService;
+use Illuminate\Http\Request;
+
+/**
+ * 一次待完成的密码重置:只告诉持 token 的人「你在给哪个账号改密码」、「链接何时失效」。
+ *
+ * 载荷是 {@see PasswordResetService::find()} 返回的数组,不是模型——
+ * 别在这里暴露 user_infos 的其他列(v2 的 reset-password 曾把整行连同密码哈希吐出去)。
+ */
+class PasswordResetResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     username: string,
+     *     expires_at: string
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'username' => $this->resource['username'],
+            'expires_at' => $this->resource['expires_at']->toIso8601ZuluString(),
+        ];
+    }
+}

+ 29 - 0
api-v13/app/Http/Resources/V3/SessionResource.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 一次登录的结果:bearer token + 当前用户资料。
+ *
+ * token 是 JWT,有效期一年,与 v2 `POST /v2/sign-in` 签出的完全相同、两边互认。
+ * 服务端不保存会话,退出登录就是客户端丢掉 token。
+ */
+class SessionResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     token: string,
+     *     user: array{id: string, nickName: string, userName: string, realName: string, sn: int, avatar: string|null, roles: string[], email: string}
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'token' => $this->resource['token'],
+            'user' => MeResource::make($this->resource['user'])->toArray($request),
+        ];
+    }
+}

+ 38 - 0
api-v13/app/Http/Resources/V3/UserResource.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Models\UserInfo;
+use Illuminate\Http\Request;
+
+/**
+ * 账号本人能看到的账号信息(`user_infos` 表)。
+ *
+ * id 是 `userid`(uuid),不是自增主键——与 v2 的用户对象口径一致。
+ * 只列白名单字段:密码哈希、各种 token 不出这个类。
+ *
+ * @mixin UserInfo
+ */
+class UserResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     username: string,
+     *     nickname: string,
+     *     email: string,
+     *     created_at: string|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => $this->userid,
+            'username' => $this->username,
+            'nickname' => $this->nickname,
+            'email' => $this->email,
+            'created_at' => $this->created_at?->toIso8601ZuluString(),
+        ];
+    }
+}

+ 45 - 0
api-v13/app/Mail/V3/EmailCertificationMail.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Mail\V3;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Mail\Mailables\Content;
+use Illuminate\Mail\Mailables\Envelope;
+use Illuminate\Queue\SerializesModels;
+
+/**
+ * v3 的注册验证码邮件。
+ *
+ * 与 v2 的 `App\Mail\EmailCertif` 的区别:验证码由调用方生成并保管,邮件只负责送达。
+ * v2 在 `build()` 里生成验证码并写 Cache,渲染一次邮件就换一个码。
+ * 邮件模板沿用 `emails.certification.*`。
+ */
+class EmailCertificationMail extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    /**
+     * @param  string  $code  明文验证码
+     * @param  string  $lang  模板语言,调用方保证是 `emails.certification.*` 里存在的那几个
+     */
+    public function __construct(
+        public string $code,
+        public string $lang,
+    ) {}
+
+    public function envelope(): Envelope
+    {
+        return new Envelope(
+            subject: __('auth.email_certification_subject', [], $this->lang),
+        );
+    }
+
+    public function content(): Content
+    {
+        return new Content(
+            view: 'emails.certification.'.$this->lang,
+            with: ['code' => $this->code],
+        );
+    }
+}

+ 45 - 0
api-v13/app/Mail/V3/PasswordResetMail.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Mail\V3;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Mail\Mailables\Content;
+use Illuminate\Mail\Mailables\Envelope;
+use Illuminate\Queue\SerializesModels;
+
+/**
+ * v3 的密码重置邮件。
+ *
+ * 与 v2 的 `App\Mail\ForgotPassword` 的区别:链接由调用方用服务端配置拼好传进来,
+ * 不接受客户端提供的站点地址(v2 收 `dashboard` 参数原样拼进链接,攻击者可借此
+ * 把 reset token 引到自己的域名)。邮件模板沿用 `emails.reset_password.*`。
+ */
+class PasswordResetMail extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    /**
+     * @param  string  $url  完整的重置链接(含 token)
+     * @param  string  $lang  模板语言,调用方保证是 `emails.reset_password.*` 里存在的那几个
+     */
+    public function __construct(
+        public string $url,
+        public string $lang,
+    ) {}
+
+    public function envelope(): Envelope
+    {
+        return new Envelope(
+            subject: __('passwords.subject', [], $this->lang),
+        );
+    }
+
+    public function content(): Content
+    {
+        return new Content(
+            view: 'emails.reset_password.'.$this->lang,
+            with: ['url' => $this->url],
+        );
+    }
+}

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

@@ -10,9 +10,12 @@ use App\View\Composers\BlogViewComposer;
 use Carbon\CarbonImmutable;
 use Godruoyi\Snowflake\LaravelSequenceResolver;
 use Godruoyi\Snowflake\Snowflake;
+use Illuminate\Cache\RateLimiting\Limit;
 use Illuminate\Database\Query\Builder as QueryBuilder;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Date;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\RateLimiter;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Validation\Rules\Password;
@@ -99,6 +102,39 @@ class AppServiceProvider extends ServiceProvider
         */
         View::composer('blog.*', BlogViewComposer::class);
         View::composer('layouts.blog', BlogViewComposer::class);
+
+        /*
+        |--------------------------------------------------------------------------
+        | Rate Limiters
+        |--------------------------------------------------------------------------
+        |
+        | 找回密码会往外发邮件:按 IP 限住撞库式的批量请求,按邮箱限住对同一个人的
+        | 邮件轰炸。设新密码的请求不带 email,只按 IP 计。
+        |
+        */
+        RateLimiter::for('password-resets', fn (Request $request): array => array_filter([
+            Limit::perMinute(10)->by('ip:'.$request->ip()),
+            $request->filled('email')
+                ? Limit::perHour(5)->by('email:'.mb_strtolower((string) $request->input('email')))
+                : null,
+        ]));
+
+        // 注册验证码同样往外发邮件,口径与找回密码一致
+        RateLimiter::for('email-certifications', fn (Request $request): array => array_filter([
+            Limit::perMinute(10)->by('ip:'.$request->ip()),
+            $request->filled('email')
+                ? Limit::perHour(5)->by('email:'.mb_strtolower((string) $request->input('email')))
+                : null,
+        ]));
+
+        // 比对验证码、建账号:按 IP 限,挡住对 6 位验证码的穷举(单个码另有 5 次试错上限)
+        RateLimiter::for('sign-up', fn (Request $request): Limit => Limit::perMinute(20)->by('ip:'.$request->ip()));
+
+        // 登录:按「账号 + IP」挡住对单个账号的猜密码,按 IP 挡住换着账号撞库
+        RateLimiter::for('sessions', fn (Request $request): array => [
+            Limit::perMinute(5)->by('login:'.mb_strtolower((string) $request->input('login')).'|'.$request->ip()),
+            Limit::perMinute(30)->by('ip:'.$request->ip()),
+        ]);
     }
 
     /**

+ 28 - 0
api-v13/app/Services/V3/PasswordHasher.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Services\V3;
+
+/**
+ * v3 写入 `user_infos.password` 的唯一出口。
+ *
+ * 现在仍是 md5:v2 的 `POST /v2/sign-in` 在 SQL 里比对 `md5(password)`,
+ * dashboard-v4 与 wikipali-mobile 都还在用它登录,存成别的格式它们就登不上了。
+ *
+ * TODO: v4 下线后换 bcrypt(`Hash::make()`),只改这一处。登录端点届时要能识别
+ * 旧的 md5 值并就地 rehash,见根目录 CLAUDE.md「认证迁移待办」。
+ */
+class PasswordHasher
+{
+    public function hash(string $password): string
+    {
+        return md5($password);
+    }
+
+    /**
+     * 明文是否与库里存的哈希相符。用 hash_equals 防计时攻击。
+     */
+    public function check(string $password, string $stored): bool
+    {
+        return hash_equals($stored, $this->hash($password));
+    }
+}

+ 120 - 0
api-v13/app/Services/V3/PasswordResetService.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Exceptions\BusinessException;
+use App\Mail\V3\PasswordResetMail;
+use App\Models\UserInfo;
+use Carbon\CarbonInterface;
+use Illuminate\Support\Facades\Date;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+use Throwable;
+
+/**
+ * 找回密码:发重置邮件、凭 token 查用户、凭 token 设新密码。
+ *
+ * 与 v2 的 `ForgotPasswordController` / `ResetPasswordController` 共用
+ * `user_infos.reset_password_token` / `reset_password_sent_at` 两列,但不共用代码
+ * (CLAUDE.md 铁律 2)。两边的差别:
+ *
+ * - 库里只存 token 的 sha256,明文只出现在邮件里;v2 存明文 uuid。
+ *   两边写同一列,后写的覆盖先写的,这正是「重新申请一次」应有的语义。
+ * - token 60 分钟过期(v2 永不过期)。
+ * - 邮箱不存在时同样「成功」,不泄露某个邮箱是否注册过(v2 回 404)。
+ * - 重置链接用服务端配置 `mint.server.dashboard_v6_base_path` 拼,不收客户端传的站点地址。
+ */
+class PasswordResetService
+{
+    /** token 有效期(分钟) */
+    public const TTL_MINUTES = 60;
+
+    /** 存在对应模板的语言(`resources/views/emails/reset_password/*`),其余回落 en */
+    private const MAIL_LOCALES = ['en', 'en-US', 'zh-Hans', 'zh-Hant'];
+
+    public function __construct(private readonly PasswordHasher $hasher) {}
+
+    /**
+     * 给该邮箱的用户发重置邮件;邮箱未注册时什么都不做。
+     *
+     * @throws BusinessException 邮件发送失败(503)
+     */
+    public function request(string $email, ?string $lang): void
+    {
+        $user = UserInfo::where('email', $email)->first();
+        if (! $user) {
+            return;
+        }
+
+        $token = Str::random(64);
+        $user->reset_password_token = $this->digest($token);
+        $user->reset_password_sent_at = now();
+        $user->save();
+
+        $locale = in_array($lang, self::MAIL_LOCALES, true) ? $lang : 'en';
+        $url = rtrim(config('mint.server.dashboard_v6_base_path'), '/').'/anonymous/reset-password/'.$token;
+
+        try {
+            Mail::to($user->email)->send(new PasswordResetMail($url, $locale));
+        } catch (Throwable $e) {
+            Log::error('v3 password reset mail failed', ['message' => $e->getMessage()]);
+
+            // 用 BusinessException 而不是让它冒成 500:兜底处理器会抹掉 5xx 的 detail,
+            // 而这条文案得让用户看到(稍后重试,而不是干等一封不会来的邮件)
+            throw new BusinessException(__('messages.mail_send_failed'), 503, 'mail-send-failed');
+        }
+    }
+
+    /**
+     * 凭 token 找到待重置的用户,token 不存在或已过期时 404。
+     *
+     * @return array{username: string, expires_at: CarbonInterface}
+     */
+    public function find(string $token): array
+    {
+        $user = $this->userByToken($token);
+
+        return [
+            'username' => $user->username,
+            'expires_at' => $this->expiresAt($user),
+        ];
+    }
+
+    /**
+     * 设新密码并作废 token(一次性)。
+     */
+    public function reset(string $token, string $password): void
+    {
+        $user = $this->userByToken($token);
+        $user->password = $this->hasher->hash($password);
+        $user->reset_password_token = null;
+        $user->reset_password_sent_at = null;
+        $user->save();
+    }
+
+    private function userByToken(string $token): UserInfo
+    {
+        $user = UserInfo::where('reset_password_token', $this->digest($token))
+            ->whereNotNull('reset_password_sent_at')
+            ->first();
+
+        // 不存在与已过期同样回 404:不给调用方区分两者的信号
+        if (! $user || $this->expiresAt($user)->isPast()) {
+            abort(404, __('passwords.token'));
+        }
+
+        return $user;
+    }
+
+    private function expiresAt(UserInfo $user): CarbonInterface
+    {
+        // UserInfo 是 v2/v3 共享模型,没给这列配 cast,这里自己解析,不去改共享模型
+        return Date::parse($user->reset_password_sent_at)->addMinutes(self::TTL_MINUTES);
+    }
+
+    private function digest(string $token): string
+    {
+        return hash('sha256', $token);
+    }
+}

+ 91 - 0
api-v13/app/Services/V3/SessionService.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\UserInfo;
+use App\Services\AuthService;
+use App\Services\UserService;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * 登录与「我是谁」。
+ *
+ * 与 v2 的 `AuthController@signIn` / `@getUserInfoByToken` 不共用代码(CLAUDE.md 铁律 2)。
+ * 签 token 用共享的 `AuthService::getUserToken()`,v2 / v3 签出的 token 完全一样,
+ * 两边互认——v4 登录拿到的 token 在 v3 也能用,反之亦然。
+ *
+ * 与 v2 的区别:
+ * - 登录一次就返回 token 与用户信息,前端不必再调一次 `auth/current`。
+ * - 邮箱登录不区分大小写;用户名仍区分(与 user_infos 的唯一索引一致)。
+ * - 密码比对走 {@see PasswordHasher::check()}(hash_equals);v2 在 SQL 里比 md5。
+ * - 登录失败统一 422 `errors.login`,不区分「没这个人」与「密码错」。
+ */
+class SessionService
+{
+    public function __construct(
+        private readonly PasswordHasher $hasher,
+        private readonly UserService $users,
+    ) {}
+
+    /**
+     * 用用户名或邮箱 + 密码登录。
+     *
+     * @return array{token: string, user: array<string, mixed>}
+     *
+     * @throws ValidationException 账号或密码不对
+     */
+    public function signIn(string $login, string $password): array
+    {
+        $user = UserInfo::where('username', $login)
+            ->orWhereRaw('lower(email) = ?', [mb_strtolower($login)])
+            ->first();
+
+        if (! $user || ! $this->hasher->check($password, (string) $user->password)) {
+            throw ValidationException::withMessages(['login' => __('auth.failed')]);
+        }
+
+        $token = AuthService::getUserToken($user->userid);
+        if (! $token) {
+            // getUserToken 只在查无此人时返回 null,而上面刚查到——走到这里是数据不一致
+            abort(500);
+        }
+
+        return [
+            'token' => $token,
+            'user' => $this->profile($user),
+        ];
+    }
+
+    /**
+     * 当前登录用户的资料。token 有效但账号已不存在(被删)时 401。
+     *
+     * @return array<string, mixed>
+     */
+    public function me(string $userUid): array
+    {
+        $user = UserInfo::where('userid', $userUid)->first();
+        if (! $user) {
+            abort(401);
+        }
+
+        return $this->profile($user);
+    }
+
+    /**
+     * 公开摘要(与 v3 其他资源里的 user 同形)+ 本人才需要的补充:
+     * roles 恒为数组(没有就是 []),root 管理员固定是 ['root']——与 v2 auth/current 一致。
+     *
+     * @return array<string, mixed>
+     */
+    private function profile(UserInfo $user): array
+    {
+        $profile = $this->users->profile($user);
+        $profile['roles'] = $user->userid === config('mint.admin.root_uuid')
+            ? ['root']
+            : (is_array($profile['roles'] ?? null) ? $profile['roles'] : []);
+        $profile['avatar'] ??= null;
+        $profile['email'] = $user->email;
+
+        return $profile;
+    }
+}

+ 201 - 0
api-v13/app/Services/V3/SignUpService.php

@@ -0,0 +1,201 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Exceptions\BusinessException;
+use App\Mail\V3\EmailCertificationMail;
+use App\Models\Channel;
+use App\Models\Invite;
+use App\Models\UserInfo;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+/**
+ * 注册:发邮箱验证码 → 用验证码换邀请 → 凭邀请建账号。
+ *
+ * 自助注册与邀请注册在「邀请」这一步汇合:
+ *
+ * - 邀请注册:studio 发出的邀请邮件里就带着 invite id。
+ * - 自助注册:验证码比对通过后,服务端为该邮箱签发(或复用)一条 invite。
+ *
+ * 之后两条路都是 `POST /v3/users {invite, …}`。invite id 是只有邮箱主人才拿得到的
+ * uuid,持有它就等于证明了邮箱归属。
+ *
+ * 与 v2 的区别(v2 的 `EmailCertificationController` / `SignUpController` 原样保留,
+ * 不共用代码,见 CLAUDE.md 铁律 2):
+ *
+ * - 验证码只在服务端比对,绝不回传;v2 用 `GET /v2/email-certification/{id}` 把码交给前端比。
+ * - 验证码 6 位、30 分钟有效、最多试 5 次;Cache 里只存它的 sha256。
+ * - 验证通过之前不写 invites 表;v2 一发码就建 invite 行。
+ * - 建账号时 invite 必须存在且未被使用;v2 漏了 return,invite 校验形同虚设。
+ */
+class SignUpService
+{
+    /** 验证码有效期(分钟),与邮件模板里写的「三十分钟」一致 */
+    public const CODE_TTL_MINUTES = 30;
+
+    /** 同一个验证码最多试几次,超过作废 */
+    public const CODE_MAX_ATTEMPTS = 5;
+
+    /** 存在对应模板的语言(`resources/views/emails/certification/*`),其余回落 en */
+    private const MAIL_LOCALES = ['en', 'en-US', 'zh-Hans', 'zh-Hant'];
+
+    /** invites.status:已发出、未使用 */
+    private const INVITE_PENDING = 'invited';
+
+    /** invites.status:已用于注册(与 v2 的取值一致) */
+    private const INVITE_USED = 'sign-up';
+
+    public function __construct(private readonly PasswordHasher $hasher) {}
+
+    /**
+     * 给邮箱发验证码。重复申请会作废上一个码。
+     *
+     * 邮箱是否已注册由 FormRequest 先挡掉。
+     *
+     * @throws BusinessException 邮件发送失败(503)
+     */
+    public function sendCode(string $email, ?string $lang): void
+    {
+        $code = (string) random_int(100000, 999999);
+        Cache::put($this->codeKey($email), [
+            'digest' => hash('sha256', $code),
+            'attempts' => 0,
+        ], now()->addMinutes(self::CODE_TTL_MINUTES));
+
+        $locale = in_array($lang, self::MAIL_LOCALES, true) ? $lang : 'en';
+
+        try {
+            Mail::to($email)->send(new EmailCertificationMail($code, $locale));
+        } catch (Throwable $e) {
+            Log::error('v3 email certification mail failed', ['message' => $e->getMessage()]);
+
+            throw new BusinessException(__('messages.mail_send_failed'), 503, 'mail-send-failed');
+        }
+    }
+
+    /**
+     * 比对验证码,通过则为该邮箱签发一条可用的 invite。
+     *
+     * 该邮箱已有 invite(例如 studio 早先邀请过)时复用那一行,保留原邀请人。
+     *
+     * @throws ValidationException 验证码不对、过期或试错次数用完
+     */
+    public function verifyCode(string $email, string $code): Invite
+    {
+        $key = $this->codeKey($email);
+        $entry = Cache::get($key);
+
+        if (! is_array($entry) || ! hash_equals($entry['digest'], hash('sha256', $code))) {
+            if (is_array($entry)) {
+                $attempts = $entry['attempts'] + 1;
+                $attempts >= self::CODE_MAX_ATTEMPTS
+                    ? Cache::forget($key)
+                    : Cache::put($key, [...$entry, 'attempts' => $attempts], now()->addMinutes(self::CODE_TTL_MINUTES));
+            }
+
+            throw ValidationException::withMessages(['code' => __('messages.sign_up_code_invalid')]);
+        }
+
+        Cache::forget($key);
+
+        $invite = Invite::whereRaw('lower(email) = ?', [mb_strtolower($email)])->first()
+            ?? (new Invite)->forceFill([
+                'id' => (string) Str::uuid(),
+                'email' => $email,
+                'user_uid' => config('mint.admin.root_uuid'),
+            ]);
+        $invite->status = self::INVITE_PENDING;
+        $invite->save();
+
+        return $invite;
+    }
+
+    /**
+     * 查一条可用的 invite(邀请注册页用它显示邮箱)。已用过或不存在都 404。
+     */
+    public function findPendingInvite(string $id): Invite
+    {
+        $invite = Invite::where('id', $id)->where('status', self::INVITE_PENDING)->first();
+        if (! $invite) {
+            abort(404, __('messages.invite_invalid'));
+        }
+
+        return $invite;
+    }
+
+    /**
+     * 凭 invite 建账号:user_infos 一行 + 一个私有的 draft 译文 channel,invite 标为已用。
+     *
+     * 与 v2 `SignUpController@store` 建出的数据形状一致(role=basic、draft channel
+     * status=5),v4 读这些行不会有差别。
+     *
+     * @param  array{invite: string, username: string, nickname?: string|null, password: string, lang: string}  $input
+     *
+     * @throws ValidationException invite 不可用,或其邮箱已被注册
+     */
+    public function register(array $input): UserInfo
+    {
+        $invite = Invite::where('id', $input['invite'])->where('status', self::INVITE_PENDING)->first();
+        if (! $invite) {
+            throw ValidationException::withMessages(['invite' => __('messages.invite_invalid')]);
+        }
+        if ($this->emailRegistered($invite->email)) {
+            throw ValidationException::withMessages(['invite' => __('messages.email_registered')]);
+        }
+
+        return DB::transaction(function () use ($invite, $input): UserInfo {
+            $nowMs = now()->getTimestampMs();
+            $nickname = trim((string) ($input['nickname'] ?? ''));
+
+            $user = (new UserInfo)->forceFill([
+                'userid' => (string) Str::uuid(),
+                'username' => $input['username'],
+                'nickname' => $nickname !== '' ? $nickname : $input['username'],
+                'email' => $invite->email,
+                'password' => $this->hasher->hash($input['password']),
+                'role' => json_encode(['basic']),
+                'create_time' => $nowMs,
+                'modify_time' => $nowMs,
+            ]);
+            $user->save();
+
+            (new Channel)->forceFill([
+                'id' => app('snowflake')->id(),
+                'uid' => (string) Str::uuid(),
+                'name' => 'draft',
+                'owner_uid' => $user->userid,
+                'type' => 'translation',
+                'lang' => $input['lang'],
+                'status' => 5,
+                'editor_id' => $user->id,
+                'create_time' => $nowMs,
+                'modify_time' => $nowMs,
+            ])->save();
+
+            $invite->status = self::INVITE_USED;
+            $invite->save();
+
+            return $user;
+        });
+    }
+
+    /**
+     * 邮箱是否已注册。user_infos.email 的唯一索引区分大小写,这里不区分:
+     * `Foo@x.org` 与 `foo@x.org` 是同一个邮箱。
+     */
+    public function emailRegistered(string $email): bool
+    {
+        return UserInfo::whereRaw('lower(email) = ?', [mb_strtolower($email)])->exists();
+    }
+
+    private function codeKey(string $email): string
+    {
+        return 'v3:email-certification:'.hash('sha256', mb_strtolower($email));
+    }
+}

+ 6 - 2
api-v13/config/mint.php

@@ -59,13 +59,17 @@ return [
         ],
         'api' => [
             'default' => env('APP_API', 'http://localhost:8000/api'),
-            'bamboo' => env('BAMBOO_API_HOST', env('APP_URL') . '/api'),
+            'bamboo' => env('BAMBOO_API_HOST', env('APP_URL').'/api'),
         ],
         'assets' => env('ASSETS_SERVER', 'localhost:9999'),
 
         'dashboard_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:4000/pcd-v2026'),
+        // dashboard-v6 的站点根。DASHBOARD_BASE_PATH 现在是 v4 的地址,v6 部署在它加 -v2026 的路径下,
+        // 与 workspace_base_path 同一约定。v3 发出的邮件链接(找回密码等)都指向 v6,用这一项。
         // TODO v4下线后需要修改
-        'workspace_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:4000/pcd') . '-v2026/workspace',
+        'dashboard_v6_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:4000/pcd').'-v2026',
+        // TODO v4下线后需要修改
+        'workspace_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:4000/pcd').'-v2026/workspace',
 
         'cdn_urls' => explode(',', env('CDN_URLS', 'https://www.wikipali.cc/downloads')),
 

+ 1 - 2
api-v13/resources/css/modules/library-course.css

@@ -340,8 +340,7 @@
     font-variant-numeric: tabular-nums;
 }
 
-.course-row__period,
-.course-row__members,
+.course-row__quota,
 .course-row__date {
     white-space: nowrap;
 }

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