Parcourir la source

test: api-test 与 dashboard-test 覆盖 v3 登录 / 注册 / 找回密码

api-test:
- 重新生成客户端(新增 Sessions / PasswordResets / EmailCertifications / Invites / Users)
- cases/06_auth.php:只走无副作用的路径(不给真实邮箱发信、不建账号),可对任何服务器跑
- login() 改调 POST /v3/sessions
- 修 filter-v3.php:/v3/me 本身也要带 bearer(原先只认 /v3/me/ 前缀,客户端不带 token)
- 「邮箱格式错」用例每次随机,避免撞上按邮箱每小时 5 次的限流

dashboard-test:
- src/tests/users.spec.ts:登录、自助注册、邀请注册、找回密码、重置密码;
  会发信 / 建账号的请求用 page.route 拦截,并断言前端发出的请求体
- BUG-5(v3 错误文案未本地化,中文界面显示英文)写成红灯断言
- 测试日志 documents/testlog/users_20261008_072502.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda il y a 1 semaine
Parent
commit
c891a7d0de
88 fichiers modifiés avec 12104 ajouts et 71 suppressions
  1. 18 6
      api-test/README.md
  2. 235 0
      api-test/cases/06_auth.php
  3. 63 0
      api-test/client/.openapi-generator/FILES
  4. 446 0
      api-test/client/lib/Api/EmailCertificationsApi.php
  5. 1 1
      api-test/client/lib/Api/HeartbeatApi.php
  6. 817 0
      api-test/client/lib/Api/InvitesApi.php
  7. 274 1
      api-test/client/lib/Api/MeApi.php
  8. 1013 0
      api-test/client/lib/Api/PasswordResetsApi.php
  9. 1 1
      api-test/client/lib/Api/ProgressApi.php
  10. 1 1
      api-test/client/lib/Api/ReactionsApi.php
  11. 1 1
      api-test/client/lib/Api/SearchApi.php
  12. 1 1
      api-test/client/lib/Api/SearchSuggestApi.php
  13. 514 0
      api-test/client/lib/Api/SessionsApi.php
  14. 1 1
      api-test/client/lib/Api/TipitakaReadingApi.php
  15. 1 1
      api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php
  16. 1 1
      api-test/client/lib/Api/UpgradeApi.php
  17. 514 0
      api-test/client/lib/Api/UsersApi.php
  18. 1 1
      api-test/client/lib/ApiException.php
  19. 8 8
      api-test/client/lib/Configuration.php
  20. 1 1
      api-test/client/lib/FormDataProcessor.php
  21. 1 1
      api-test/client/lib/HeaderSelector.php
  22. 1 1
      api-test/client/lib/Model/GetApiV3Heartbeat200Response.php
  23. 1 1
      api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php
  24. 409 0
      api-test/client/lib/Model/GetApiV3Me200Response.php
  25. 654 0
      api-test/client/lib/Model/GetApiV3Me200ResponseData.php
  26. 1 1
      api-test/client/lib/Model/GetApiV3MeReactions200Response.php
  27. 1 1
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php
  28. 1 1
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
  29. 409 0
      api-test/client/lib/Model/GetApiV3PasswordResetsToken200Response.php
  30. 443 0
      api-test/client/lib/Model/GetApiV3PasswordResetsToken200ResponseData.php
  31. 1 1
      api-test/client/lib/Model/GetApiV3Progress200Response.php
  32. 1 1
      api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php
  33. 1 1
      api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php
  34. 1 1
      api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php
  35. 1 1
      api-test/client/lib/Model/GetApiV3Search200Response.php
  36. 1 1
      api-test/client/lib/Model/GetApiV3SearchSearch200Response.php
  37. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php
  38. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php
  39. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php
  40. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php
  41. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php
  42. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php
  43. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php
  44. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php
  45. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php
  46. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
  47. 1 1
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
  48. 1 1
      api-test/client/lib/Model/ModelInterface.php
  49. 1 1
      api-test/client/lib/Model/PaginationMeta.php
  50. 449 0
      api-test/client/lib/Model/PatchApiV3PasswordResetsTokenRequest.php
  51. 484 0
      api-test/client/lib/Model/PostApiV3EmailCertificationsRequest.php
  52. 409 0
      api-test/client/lib/Model/PostApiV3Invites200Response.php
  53. 518 0
      api-test/client/lib/Model/PostApiV3Invites200ResponseData.php
  54. 449 0
      api-test/client/lib/Model/PostApiV3InvitesRequest.php
  55. 1 1
      api-test/client/lib/Model/PostApiV3MeReactions200Response.php
  56. 1 1
      api-test/client/lib/Model/PostApiV3MeReactionsRequest.php
  57. 484 0
      api-test/client/lib/Model/PostApiV3PasswordResetsRequest.php
  58. 1 1
      api-test/client/lib/Model/PostApiV3SearchRequest.php
  59. 409 0
      api-test/client/lib/Model/PostApiV3Sessions200Response.php
  60. 443 0
      api-test/client/lib/Model/PostApiV3Sessions200ResponseData.php
  61. 449 0
      api-test/client/lib/Model/PostApiV3SessionsRequest.php
  62. 409 0
      api-test/client/lib/Model/PostApiV3Users200Response.php
  63. 552 0
      api-test/client/lib/Model/PostApiV3Users200ResponseData.php
  64. 594 0
      api-test/client/lib/Model/PostApiV3UsersRequest.php
  65. 1 1
      api-test/client/lib/Model/ProblemDetails.php
  66. 1 1
      api-test/client/lib/ObjectSerializer.php
  67. 4 0
      api-test/fixtures.php
  68. 3 3
      api-test/run.php
  69. 19 8
      api-test/src/bootstrap.php
  70. 3 2
      api-test/tools/filter-v3.php
  71. 24 3
      dashboard-test/README.md
  72. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/01-signup-welcome.png
  73. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/02-signup-code-sent.png
  74. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/03-signup-wrong-code.png
  75. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/04-signup-account.png
  76. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/05-signup-client-validation.png
  77. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/06-signup-server-422.png
  78. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/07-signup-done.png
  79. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/08-invite-invalid.png
  80. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/10-invite-done.png
  81. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/12-forgot-sent.png
  82. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/13-reset-invalid.png
  83. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/15-reset-done.png
  84. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/16-signin-wrong.png
  85. BIN
      dashboard-test/documents/testlog/assets/users_20261008_072502/18-signin-restored.png
  86. 105 0
      dashboard-test/documents/testlog/users_20261008_072502.md
  87. 1 0
      dashboard-test/package.json
  88. 439 0
      dashboard-test/src/tests/users.spec.ts

+ 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。

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

@@ -0,0 +1,235 @@
+<?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 会)');
+    }
+});

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

+ 19 - 8
api-test/src/bootstrap.php

@@ -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' => []]]
             : [];
     }

+ 24 - 3
dashboard-test/README.md

@@ -39,6 +39,7 @@ cd ../dashboard-v6 && npm run dev            # http://127.0.0.1:4000/pcd-v2026/
 ```bash
 npm test                    # 跑全部
 npm run test:channel        # 只跑 channel 路由
+npm run test:users          # 只跑匿名账号页面(登录 / 注册 / 找回密码 / 重置密码)
 npm run test:ui             # UI Mode,可视化单步调试
 npm run test:headed         # 显示浏览器窗口
 npm run report              # 打开上次的 HTML 报告
@@ -73,7 +74,8 @@ dashboard-test/
 ├── src/
 │   ├── tests/
 │   │   ├── auth.setup.ts      登录一次,存 storageState
-│   │   └── channel.spec.ts    /workspace/channel 及子路由
+│   │   ├── channel.spec.ts    /workspace/channel 及子路由
+│   │   └── users.spec.ts      /anonymous 下的登录、注册、邀请注册、找回密码、重置密码
 │   └── lib/
 │       └── collect.ts         console / pageerror / HTTP 错误采集与噪音过滤
 ├── documents/
@@ -114,7 +116,17 @@ dashboard-test/
 - BUG-1 Webhooks 页签不应跳到已废弃的 `/studio/...`
 - BUG-4 不存在的频道不应出现 `Unexpected Application Error`
 
-所以 `npm test` 现在是 **4 通过 / 2 失败**,两个失败即上述待修项。
+`src/tests/users.spec.ts` 有一条:
+
+- BUG-5 中文界面下 v3 接口的错误文案应为中文(目前 api 组没有语言协商,总是英文)
+
+所以 `npm test` 现在是 **10 通过 / 3 失败**(channel 4/2、users 6/1),三个失败即上述待修项。
+
+### 限流
+
+登录(同账号 + IP 每分钟 5 次)、找回密码 / 注册验证码(同 IP 每分钟 10 次)有限流。
+setup 与 `users.spec.ts` 都用 `E2E_USER` 登录,api-test 也用同一个账号;**一分钟内连跑
+两三遍就会撞 429**(页面上出现「Too Many Attempts.」),隔一分钟再跑。
 
 ### 破坏性操作
 
@@ -125,6 +137,12 @@ dashboard-test/
 - 涉及第三方的操作(转让、分享授权)只验证弹窗渲染,不提交
 - `E2E_DESTRUCTIVE=0` 可整体跳过
 
+**会发邮件、或建出删不掉的数据的请求,不打真实后端,用 `page.route` 拦截。**
+开发机的邮件是真实 SMTP;注册建出的账号没有删除接口。拦截后验证前端流程、错误展示
+和**前端实际发出的请求体**(`route.request().postDataJSON()`),后端行为交给 api-v13
+的 Pest 测试。没有副作用的请求(无效邀请、错误验证码、未注册邮箱申请重置)照常走真实后端。
+`users.spec.ts` 是范例。
+
 ### 测试日志
 
 每次正式测试在 `documents/testlog/` 下写一个
@@ -134,4 +152,7 @@ dashboard-test/
 (每条一个 `- [ ]` 代办,含日志片段、截图、定位到文件行号的原因、修复方向)→
 三、未覆盖 / 待确认。
 
-已有记录:[`documents/testlog/channel_20260907_034904.md`](./documents/testlog/channel_20260907_034904.md)
+已有记录:
+
+- [`documents/testlog/channel_20260907_034904.md`](./documents/testlog/channel_20260907_034904.md)
+- [`documents/testlog/users_20261008_072502.md`](./documents/testlog/users_20261008_072502.md)

BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/01-signup-welcome.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/02-signup-code-sent.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/03-signup-wrong-code.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/04-signup-account.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/05-signup-client-validation.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/06-signup-server-422.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/07-signup-done.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/08-invite-invalid.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/10-invite-done.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/12-forgot-sent.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/13-reset-invalid.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/15-reset-done.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/16-signin-wrong.png


BIN
dashboard-test/documents/testlog/assets/users_20261008_072502/18-signin-restored.png


+ 105 - 0
dashboard-test/documents/testlog/users_20261008_072502.md

@@ -0,0 +1,105 @@
+# 测试日志 · /anonymous 账号页面
+
+| 项目     | 内容                                                                                  |
+| -------- | ------------------------------------------------------------------------------------- |
+| 路由     | `/anonymous/sign-in`、`/anonymous/sign-up`、`/anonymous/sign-up/:token`、`/anonymous/forgot-password`、`/anonymous/reset-password/:token` |
+| 时间     | 2026-10-08 07:25                                                                      |
+| 账号     | 匿名(不复用登录态)                                                                  |
+| 环境     | dashboard-v6 `:4000` · api-v13 `:8000` · 本地容器                                     |
+| 脚本     | [`src/tests/users.spec.ts`](../../src/tests/users.spec.ts)                            |
+| 截图     | `assets/users_20261008_072502/`                                                       |
+| 复跑     | `E2E_SHOT=documents/testlog/assets/users_20261008_072502 npm run test:users`          |
+
+---
+
+## 结论
+
+登录、注册、找回密码迁到 v3(`sessions` / `me` / `email-certifications` / `invites` / `users` /
+`password-resets`)后的首次 e2e。21 个步骤 + 1 条已知缺陷断言:**6 个用例通过,1 个按设计保持红灯(BUG-5)**。
+
+会发邮件或建账号的请求(发验证码、换 invite、建号、设新密码)用 `page.route` 拦截,
+验证的是前端流程、错误展示与**前端发出的请求体**;没有副作用的请求走真实后端。
+本次无数据残留:真实后端只收到了查询、校验失败与测试账号 `test161` 的登录请求,
+未建任何账号、未发任何邮件。
+
+---
+
+## 一、通过的功能
+
+| #  | 功能                                        | 后端   | 结果 |
+| -- | ------------------------------------------- | ------ | ---- |
+| 1  | 注册第一步:未勾选「已了解」时下一步禁用    | —      | 正常;功能清单图标不依赖 emoji 字体 |
+| 2  | 获取验证码                                  | 拦截   | 请求体 `{email, lang:"zh-Hans"}`;提示「验证码已发送」,按钮进入倒计时 |
+| 3  | 错误验证码                                  | 真实 422 | 错误挂在验证码输入框下(文案为英文,见 BUG-5) |
+| 4  | 正确验证码 → 账号信息,邮箱只读展示         | 拦截   | 请求体 `{email, code}` |
+| 5  | 前端校验:用户名非法字符、两次密码不一致    | —      | 两条错误各自挂在对应字段下 |
+| 6  | 后端 422 字段错误                           | 拦截   | `errors.username` 挂到用户名输入框下 |
+| 7  | 建号成功 → 完成页 → 去登录                  | 拦截   | 请求体含 `invite`、**不含 `email`**;跳到 `/anonymous/sign-in` |
+| 8  | 无效邀请                                    | 真实 404 | 「邀请无效或已被使用」 |
+| 9  | v2 邀请邮件旧路径 `/anonymous/users/sign-up/:token` | 真实 404 | 重定向到 `/anonymous/sign-up/:token` |
+| 10 | 有效邀请 → 显示邮箱 → 建号成功              | 拦截   | 请求体含 `invite`、`nickname` |
+| 11 | 找回密码:邮箱格式错                        | —      | 前端拦下,未发请求 |
+| 12 | 找回密码:未注册邮箱                        | 真实 204 | 「如果该邮箱已注册……60 分钟内」——不泄露是否注册 |
+| 13 | 无效重置链接 → 「忘记密码」入口             | 真实 404 | 跳回 `/anonymous/forgot-password` |
+| 14 | 重置页:两次密码不一致                      | 拦截 GET | 显示账号名;错误挂在确认密码下 |
+| 15 | 设新密码 → 成功页 → 去登录                  | 拦截 PATCH 204 | 请求体 `{password, password_confirmation}` |
+| 16 | 登录:密码错误                              | 真实 422 | 表单上方「用户名或密码错误」 |
+| 17 | 登录:只发一次请求                          | 真实 201 | 只有 `POST /api/v3/sessions`,不再调 `/v2/sign-in`、`/v2/auth/current` |
+| 18 | 刷新页面恢复登录态                          | 真实 200 | `GET /api/v3/me`,停留在 `/workspace` |
+| 19 | `?url=` 同源地址                            | 回放真实登录结果 | 登录后跳回该地址 |
+| 20 | `?url=` 外站地址                            | 回放真实登录结果 | 不跳,进 `/workspace`(原先是开放重定向) |
+| 21 | `?url=` `javascript:`                       | 回放真实登录结果 | 不执行,进 `/workspace`(原先是 XSS) |
+
+![注册第一步](assets/users_20261008_072502/01-signup-welcome.png)
+![验证码已发送](assets/users_20261008_072502/02-signup-code-sent.png)
+![后端 422 挂到字段](assets/users_20261008_072502/06-signup-server-422.png)
+![注册完成](assets/users_20261008_072502/07-signup-done.png)
+![无效邀请](assets/users_20261008_072502/08-invite-invalid.png)
+![找回密码已提交](assets/users_20261008_072502/12-forgot-sent.png)
+![无效重置链接](assets/users_20261008_072502/13-reset-invalid.png)
+![重置成功](assets/users_20261008_072502/15-reset-done.png)
+![登录失败](assets/users_20261008_072502/16-signin-wrong.png)
+![刷新后仍登录](assets/users_20261008_072502/18-signin-restored.png)
+
+---
+
+## 二、待修复
+
+- [ ] **BUG-5 v3 接口的错误文案没有本地化,中文界面里显示英文**
+
+  ```text
+  Expected: "验证码不正确或已过期。"
+  Received: "The verification code is incorrect or has expired."
+  ```
+
+  ![英文错误文案](assets/users_20261008_072502/06-signup-server-422.png)
+
+  原因:`api-v13/bootstrap/app.php` 只把 `SetLocale` 挂在 web 组,api 组没有语言协商,
+  `__()` 永远取 `en`(v3-resource skill 第 11 条已记录);`dashboard-v6/src/api/client.ts`
+  也没有把界面语言告诉后端。翻译文件本身是齐的(`resources/lang/zh-Hans/messages.php`)。
+
+  修复方向:v3 路由组挂一个只认 `Accept-Language` 的语言协商中间件(不要动 v2 的 api 组);
+  `client.ts` 的中间件按 `locales.get()` 设置 `Accept-Language`。两边同一个 commit。
+
+---
+
+## 三、未覆盖 / 待确认
+
+- **真实收信的端到端流程未覆盖**:发验证码、设新密码的成功路径会真的发邮件(开发机是真实 SMTP),
+  建号没有删除接口。这部分由 api-v13 的 Pest 测试覆盖(`SignUpV3Test` 22 条、
+  `PasswordResetV3Test` 14 条,Mail 是 fake 的)。要做真实端到端,需要一个能收信的测试邮箱
+  或把开发机的 `MAIL_MAILER` 换成 `log`。
+- **同一条错误显示两次**:字段级 422 时,`unwrap()` 先弹一条全局提示,表单再把同一条错误挂到字段下
+  (见截图 06 顶部)。是 `src/api/client.ts` 的既定行为;表单场景要不要只挂字段、不弹提示,待定。
+- **204 响应被记成 `[REQFAIL] net::ERR_ABORTED`**:`password-resets`、`email-certifications`
+  返回 204 时 Chromium 报请求中止,但页面行为正确(测试通过)。疑为 openapi-fetch 对空响应体的处理,
+  不影响功能;暂不加进 `collect.ts` 的噪音过滤,以免掩盖真正的中止。
+- **开发态重复请求**:邀请页、重置页的 GET 各发了两次,是 React StrictMode 在开发态双调 effect,生产构建不会。
+- **限流会让测试互相干扰**:登录按「账号 + IP」每分钟 5 次,`password-resets` /
+  `email-certifications` 按 IP 每分钟 10 次。本 spec 一次用 test161 登录 4 次
+  (setup 1 + 16、17 + 用例二开头 1,19–21 回放这份结果、不再真登),api-test 一次 2 次。
+  两套测试连着跑要隔一分钟,否则表现为「Too Many Attempts.」。
+- **mobile 仍走 v2 登录**:wikipali-mobile 的 `src/api/auth.ts` 还在调 `/v2/sign-in`、
+  `/v2/auth/current`,不在本测试范围。
+- **`AnonymousLayout` 仍是占位**:页面顶部是 `anonymous header`、底部是 `anonymous layout footer`,
+  没有 v4 的语言切换和样式。

+ 1 - 0
dashboard-test/package.json

@@ -9,6 +9,7 @@
     "test:ui": "playwright test --ui",
     "test:headed": "playwright test --headed",
     "test:channel": "playwright test channel",
+    "test:users": "playwright test users",
     "report": "playwright show-report",
     "codegen": "playwright codegen http://127.0.0.1:4000/pcd-v2026"
   },

+ 439 - 0
dashboard-test/src/tests/users.spec.ts

@@ -0,0 +1,439 @@
+import { test, expect, type Page, type Request } from '@playwright/test';
+import { collect } from '../lib/collect.js';
+
+/**
+ * 匿名账号页面:/anonymous/sign-in、/anonymous/sign-up、/anonymous/sign-up/:token、
+ * /anonymous/forgot-password、/anonymous/reset-password/:token
+ *
+ * 后端是 v3:sessions / me / email-certifications / invites / users / password-resets。
+ * 登录用 E2E_USER / E2E_PASS 走真实后端(只读)。
+ *
+ * 真实后端 vs 拦截:
+ * - 只读、或不产生副作用的请求走真实后端:无效邀请、无效重置链接、错误验证码、
+ *   用未注册的 `.invalid` 邮箱申请重置(服务端不发信)。
+ * - **会发信或建账号的请求一律用 page.route 拦截**:开发机的 MAIL_MAILER 是真实 SMTP,
+ *   收不到验证码;建出来的账号也没有删除接口,清不干净。这些步骤验证的是前端流程、
+ *   错误展示和**前端发出的请求体**,后端行为由 api-v13 的 Pest 测试覆盖
+ *   (SignUpV3Test、PasswordResetV3Test)。
+ *
+ * 截图输出目录由 E2E_SHOT 指定,默认 documents/testlog/assets/users_latest。
+ */
+const SHOT = process.env.E2E_SHOT ?? 'documents/testlog/assets/users_latest';
+
+const shot = (page: Page, name: string) =>
+  page.screenshot({ path: `${SHOT}/${name}.png`, fullPage: true });
+
+/** 一个格式合法、但不可能存在的 v4 uuid */
+const UNKNOWN_UUID = '00000000-0000-4000-8000-000000000000';
+/** 格式合法(64 位字母数字)、但不可能存在的重置 token */
+const UNKNOWN_RESET_TOKEN = 'e2e'.padEnd(64, '0');
+/** RFC 2606 保留域:服务端查无此人,不会发信 */
+const unregisteredEmail = () => `e2e-${Date.now()}@example.invalid`;
+
+/** 拦截一个 v3 端点,记录请求体,按给定状态码与 body 回应 */
+async function stub(page: Page, path: string, status: number, body?: unknown) {
+  const seen: Request[] = [];
+  await page.route(`**/api${path}`, (route) => {
+    seen.push(route.request());
+    return route.fulfill({
+      status,
+      contentType: status >= 400 ? 'application/problem+json' : 'application/json',
+      body: body === undefined ? '' : JSON.stringify(body),
+    });
+  });
+  return seen;
+}
+
+/** 422 Problem Details,与后端 bootstrap/app.php 的渲染形状一致 */
+const problem422 = (errors: Record<string, string[]>) => ({
+  type: 'urn:problem:validation-error',
+  title: 'Unprocessable Content',
+  status: 422,
+  detail: Object.values(errors)[0][0],
+  instance: '/api/v3/…',
+  errors,
+});
+
+/**
+ * 断言某个表单项下面出现了指定的校验错误。
+ *
+ * 按文字过滤而不是取「唯一一条」:antd 换错误时旧的那条有淡出动画,
+ * 同一时刻 DOM 里会并存新旧两条。
+ */
+const expectFieldError = (page: Page, label: string, text: string) =>
+  expect(
+    page
+      .locator('.ant-form-item')
+      .filter({ has: page.locator('label').getByText(label, { exact: true }) })
+      .locator('.ant-form-item-explain-error', { hasText: text })
+  ).toBeVisible();
+
+/* 全部是匿名页面:不复用 setup 存下的登录态 */
+test.use({ storageState: { cookies: [], origins: [] } });
+
+test.describe('自助注册 /anonymous/sign-up', () => {
+  test('版本说明 → 邮箱验证 → 账号信息 → 完成', async ({ page }) => {
+    const log = collect(page);
+    const email = unregisteredEmail();
+
+    await test.step('1. 版本说明:勾选「已了解」前不能下一步', async () => {
+      await page.goto('anonymous/sign-up', { waitUntil: 'networkidle' });
+      const next = page.getByRole('button', { name: '下一步' });
+      await expect(next).toBeDisabled();
+      // 图标替代了 emoji,不依赖系统 emoji 字体
+      await expect(page.locator('.anticon-check').first()).toBeVisible();
+      await shot(page, '01-signup-welcome');
+
+      await page.getByText('我已经了解基础版的功能限制').click();
+      await expect(next).toBeEnabled();
+      await next.click();
+      await expect(page.getByPlaceholder('电子邮箱')).toBeVisible();
+      log.dump('1. 版本说明');
+    });
+
+    await test.step('2. 获取验证码(拦截:真发会寄出邮件)', async () => {
+      const sent = await stub(page, '/v3/email-certifications', 204);
+
+      await page.getByPlaceholder('电子邮箱').fill(email);
+      await page.getByRole('button', { name: '获取验证码' }).click();
+      await expect(page.locator('.ant-message')).toContainText('验证码已发送');
+      await expect(page.getByRole('button', { name: /秒后重新获取/ })).toBeVisible();
+
+      expect(sent).toHaveLength(1);
+      expect(sent[0].postDataJSON()).toEqual({ email, lang: 'zh-Hans' });
+      await shot(page, '02-signup-code-sent');
+      log.dump('2. 获取验证码');
+    });
+
+    await test.step('3. 错误验证码:真实后端 422,错误挂在验证码输入框下', async () => {
+      // 没有真的发过码,服务端 Cache 里查不到 → 必然 422 errors.code,不写库
+      await page.getByPlaceholder('请输入邮件里的 6 位验证码').fill('000000');
+      await page.getByRole('button', { name: '下一步' }).click();
+      // 文案语言不在这里断言:v3 的错误文案目前总是英文,见下面 BUG-5
+      await expect(
+        page.locator('.ant-form-item-explain-error', {
+          hasText: /验证码不正确或已过期|verification code is incorrect/,
+        })
+      ).toBeVisible();
+      await shot(page, '03-signup-wrong-code');
+      log.dump('3. 错误验证码(预期 HTTP 422)');
+    });
+
+    await test.step('4. 正确验证码(拦截)→ 进入账号信息,邮箱只读展示', async () => {
+      const exchanged = await stub(page, '/v3/invites', 201, {
+        data: { id: UNKNOWN_UUID, email, status: 'invited', created_at: null },
+      });
+      await page.getByPlaceholder('请输入邮件里的 6 位验证码').fill('123456');
+      await page.getByRole('button', { name: '下一步' }).click();
+
+      await expect(page.getByText(email, { exact: true })).toBeVisible();
+      expect(exchanged[0].postDataJSON()).toEqual({ email, code: '123456' });
+      await shot(page, '04-signup-account');
+      log.dump('4. 换 invite');
+    });
+
+    await test.step('5. 账号信息:前端校验(用户名字符、两次密码)', async () => {
+      await page.getByLabel('用户名(登录名)').fill('abc中文def');
+      await page.getByLabel('密码', { exact: true }).fill('secret-pw');
+      await page.getByLabel('确认密码').fill('secret-PW');
+      await page.getByRole('button', { name: '下一步' }).click();
+
+      await expectFieldError(page, '用户名(登录名)', '只允许字母、数字、下划线');
+      await expectFieldError(page, '确认密码', '两次密码不一致');
+      await shot(page, '05-signup-client-validation');
+      log.dump('5. 前端校验');
+    });
+
+    await test.step('6. 后端 422(拦截)→ 字段错误挂到用户名下', async () => {
+      await stub(page, '/v3/users', 422, problem422({ username: ['用户名已经存在。'] }));
+      await page.getByLabel('用户名(登录名)').fill('e2e_taken_name');
+      await page.getByLabel('确认密码').fill('secret-pw');
+      await page.getByRole('button', { name: '下一步' }).click();
+
+      await expectFieldError(page, '用户名(登录名)', '用户名已经存在');
+      await shot(page, '06-signup-server-422');
+      await page.unroute('**/api/v3/users');
+      log.dump('6. 后端 422(预期 HTTP 422)');
+    });
+
+    await test.step('7. 建号成功(拦截)→ 完成页 → 去登录', async () => {
+      const created = await stub(page, '/v3/users', 201, {
+        data: { id: UNKNOWN_UUID, username: 'e2e_new_user', nickname: 'e2e_new_user', email, created_at: null },
+      });
+      await page.getByLabel('用户名(登录名)').fill('e2e_new_user');
+      await page.getByRole('button', { name: '下一步' }).click();
+
+      await expect(page.getByText('注册成功')).toBeVisible();
+      // 请求体:带 invite,不带邮箱(邮箱来自 invite,客户端不该提交)
+      const body = created[0].postDataJSON();
+      expect(body).toMatchObject({
+        invite: UNKNOWN_UUID,
+        username: 'e2e_new_user',
+        password: 'secret-pw',
+        password_confirmation: 'secret-pw',
+        lang: 'zh-Hans',
+      });
+      expect(body).not.toHaveProperty('email');
+      await shot(page, '07-signup-done');
+
+      await page.getByRole('button', { name: /登 *录/ }).click();
+      await expect(page).toHaveURL(/anonymous\/sign-in/);
+      log.dump('7. 完成');
+    });
+  });
+});
+
+test.describe('邀请注册 /anonymous/sign-up/:token', () => {
+  test('无效邀请、旧路径重定向、有效邀请建号', async ({ page }) => {
+    const log = collect(page);
+
+    await test.step('8. 无效邀请(真实后端 404)→ 提示页', async () => {
+      await page.goto(`anonymous/sign-up/${UNKNOWN_UUID}`, { waitUntil: 'networkidle' });
+      await expect(page.getByText('邀请无效或已被使用')).toBeVisible();
+      await shot(page, '08-invite-invalid');
+      log.dump('8. 无效邀请(预期 HTTP 404)');
+    });
+
+    await test.step('9. v2 邀请邮件的旧路径 /anonymous/users/sign-up/:token 重定向', async () => {
+      await page.goto(`anonymous/users/sign-up/${UNKNOWN_UUID}`, { waitUntil: 'networkidle' });
+      await expect(page).toHaveURL(new RegExp(`/anonymous/sign-up/${UNKNOWN_UUID}$`));
+      log.dump('9. 旧路径重定向(预期 HTTP 404)');
+    });
+
+    await test.step('10. 有效邀请(拦截)→ 显示邮箱 → 建号成功', async () => {
+      const email = unregisteredEmail();
+      await stub(page, `/v3/invites/${UNKNOWN_UUID}`, 200, {
+        data: { id: UNKNOWN_UUID, email, status: 'invited', created_at: null },
+      });
+      const created = await stub(page, '/v3/users', 201, {
+        data: { id: UNKNOWN_UUID, username: 'e2e_invited', nickname: '觉音', email, created_at: null },
+      });
+
+      await page.goto(`anonymous/sign-up/${UNKNOWN_UUID}`, { waitUntil: 'networkidle' });
+      await expect(page.getByText(email, { exact: true })).toBeVisible();
+
+      await page.getByLabel('用户名(登录名)').fill('e2e_invited');
+      await page.getByLabel('密码', { exact: true }).fill('secret-pw');
+      await page.getByLabel('确认密码').fill('secret-pw');
+      await page.getByLabel('昵称').fill('觉音');
+      await page.getByRole('button', { name: /提 *交/ }).click();
+
+      await expect(page.getByText('注册成功')).toBeVisible();
+      expect(created[0].postDataJSON()).toMatchObject({
+        invite: UNKNOWN_UUID,
+        username: 'e2e_invited',
+        nickname: '觉音',
+      });
+      await shot(page, '10-invite-done');
+      log.dump('10. 邀请建号');
+    });
+  });
+});
+
+test.describe('找回密码 /anonymous/forgot-password', () => {
+  test('前端校验、提交后提示(真实后端,不发信)', async ({ page }) => {
+    const log = collect(page);
+
+    await test.step('11. 邮箱格式错:前端拦下,不发请求', async () => {
+      await page.goto('anonymous/forgot-password', { waitUntil: 'networkidle' });
+      let calls = 0;
+      page.on('request', (r) => r.url().includes('/api/v3/password-resets') && calls++);
+
+      await page.getByLabel('电子邮箱').fill('not-an-email');
+      await page.getByRole('button', { name: /提 *交/ }).click();
+      await expect(page.locator('.ant-form-item-explain-error')).toBeVisible();
+      expect(calls).toBe(0);
+      log.dump('11. 前端校验');
+    });
+
+    await test.step('12. 未注册邮箱 → 204 → 不泄露是否注册的成功提示', async () => {
+      const response = page.waitForResponse('**/api/v3/password-resets');
+      await page.getByLabel('电子邮箱').fill(unregisteredEmail());
+      await page.getByRole('button', { name: /提 *交/ }).click();
+      expect((await response).status()).toBe(204);
+
+      await expect(page.locator('.ant-alert-success')).toContainText('如果该邮箱已注册');
+      await shot(page, '12-forgot-sent');
+      log.dump('12. 提交');
+    });
+  });
+});
+
+test.describe('重置密码 /anonymous/reset-password/:token', () => {
+  test('无效链接、前端校验、重置成功', async ({ page }) => {
+    const log = collect(page);
+
+    await test.step('13. 无效 token(真实后端 404)→ 提示页 + 重新申请入口', async () => {
+      await page.goto(`anonymous/reset-password/${UNKNOWN_RESET_TOKEN}`, { waitUntil: 'networkidle' });
+      await expect(page.getByText('链接无效或已过期')).toBeVisible();
+      await page.locator('.ant-result').getByRole('link', { name: '忘记密码' }).click();
+      await expect(page).toHaveURL(/anonymous\/forgot-password$/);
+      await shot(page, '13-reset-invalid');
+      log.dump('13. 无效 token(预期 HTTP 404)');
+    });
+
+    await test.step('14. 有效 token(拦截)→ 显示账号 → 两次密码不一致被拦', async () => {
+      await stub(page, `/v3/password-resets/${UNKNOWN_RESET_TOKEN}`, 200, {
+        data: { username: 'e2e_reset_user', expires_at: '2099-01-01T00:00:00Z' },
+      });
+      await page.goto(`anonymous/reset-password/${UNKNOWN_RESET_TOKEN}`, { waitUntil: 'networkidle' });
+      await expect(page.getByText('e2e_reset_user')).toBeVisible();
+
+      await page.getByLabel('密码', { exact: true }).fill('new-secret');
+      await page.getByLabel('确认密码').fill('new-secreT');
+      await page.getByRole('button', { name: /提 *交/ }).click();
+      await expectFieldError(page, '确认密码', '两次密码不一致');
+      log.dump('14. 前端校验');
+    });
+
+    await test.step('15. 提交(拦截 PATCH 204)→ 成功页 → 去登录', async () => {
+      // 同一 URL 的 GET 已被上一步拦截;这里按方法分流,PATCH 回 204
+      const patched: Request[] = [];
+      await page.route(`**/api/v3/password-resets/${UNKNOWN_RESET_TOKEN}`, (route) => {
+        if (route.request().method() !== 'PATCH') return route.fallback();
+        patched.push(route.request());
+        return route.fulfill({ status: 204, body: '' });
+      });
+
+      await page.getByLabel('确认密码').fill('new-secret');
+      await page.getByRole('button', { name: /提 *交/ }).click();
+
+      await expect(page.getByText('重置密码成功')).toBeVisible();
+      expect(patched[0].postDataJSON()).toEqual({
+        password: 'new-secret',
+        password_confirmation: 'new-secret',
+      });
+      await shot(page, '15-reset-done');
+
+      await page.getByRole('button', { name: /登 *录/ }).click();
+      await expect(page).toHaveURL(/anonymous\/sign-in/);
+      log.dump('15. 重置成功');
+    });
+  });
+});
+
+const USER = process.env.E2E_USER ?? 'test161';
+const PASS = process.env.E2E_PASS ?? '12345';
+
+/**
+ * 打开登录页。不等 networkidle:登录过的页面上有轮询请求,网络可能一直不空闲,
+ * 等的是登录表单本身出现。
+ */
+async function openSignIn(page: Page, query = '') {
+  await page.goto(`anonymous/sign-in${query}`);
+  await expect(page.locator('form input:visible').first()).toBeVisible();
+}
+
+/** 在登录页填表并提交 */
+async function submitSignIn(page: Page, login: string, password: string) {
+  const inputs = page.locator('form input:visible');
+  await inputs.nth(0).fill(login);
+  await inputs.nth(1).fill(password);
+  await page.getByRole('button', { name: /提 *交/ }).click();
+}
+
+test.describe('登录 /anonymous/sign-in', () => {
+  test('密码错误、一次请求登录、刷新恢复登录态', async ({ page }) => {
+    const log = collect(page);
+
+    await test.step('16. 密码错误(真实后端 422)→ 表单上方提示', async () => {
+      await openSignIn(page);
+      await submitSignIn(page, USER, 'definitely-wrong-password');
+      await expect(page.locator('.ant-alert-error')).toContainText('用户名或密码错误');
+      await expect(page).toHaveURL(/anonymous\/sign-in/);
+      await shot(page, '16-signin-wrong');
+      log.dump('16. 密码错误(预期 HTTP 422)');
+    });
+
+    await test.step('17. 登录只发一次 v3 请求,不再调 v2', async () => {
+      const calls: string[] = [];
+      page.on('request', (r) => {
+        const u = r.url();
+        if (/\/api\/v[23]\/(sign-in|auth\/current|sessions|me)(\?|$)/.test(u)) {
+          calls.push(`${r.method()} ${new URL(u).pathname}`);
+        }
+      });
+      await submitSignIn(page, USER, PASS);
+      await expect(page).toHaveURL(/\/workspace/);
+      expect(calls).toEqual(['POST /api/v3/sessions']);
+      log.dump('17. 登录');
+    });
+
+    await test.step('18. 刷新页面 → GET /v3/me 恢复登录态', async () => {
+      const me = page.waitForResponse('**/api/v3/me');
+      await page.reload({ waitUntil: 'networkidle' });
+      expect((await me).status()).toBe(200);
+      await expect(page).toHaveURL(/\/workspace/);
+      await shot(page, '18-signin-restored');
+      log.dump('18. 恢复登录态');
+    });
+  });
+
+  test('?url= 只接受同源地址', async ({ page }) => {
+    const log = collect(page);
+    const base = new URL(test.info().project.use.baseURL ?? 'http://127.0.0.1:4000/pcd-v2026/');
+    const b64 = (s: string) => Buffer.from(s).toString('base64');
+
+    // 登录接口按「账号 + IP」每分钟限 5 次。这里测的是前端跳转逻辑,只真实登录一次,
+    // 之后把这份真实结果(有效 token)回放给登录表单,后续页面的接口照常可用。
+    const real = await page.request.post(new URL('/api/v3/sessions', base).href, {
+      data: { login: USER, password: PASS },
+    });
+    expect(real.status()).toBe(201);
+    const session = await real.json();
+    await page.route('**/api/v3/sessions', (route) =>
+      route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(session) })
+    );
+
+    await test.step('19. 同源地址 → 登录后跳回', async () => {
+      const back = new URL('workspace/channel', base).href;
+      await openSignIn(page, `?url=${b64(back)}`);
+      await submitSignIn(page, USER, PASS);
+      await expect(page).toHaveURL(back);
+      log.dump('19. 同源跳回');
+    });
+
+    for (const [n, label, target] of [
+      ['20', '外站', 'https://example.com/phish'],
+      ['21', 'javascript:', 'javascript:document.title="pwned"'],
+    ] as const) {
+      await test.step(`${n}. ${label} → 不跳,进 workspace`, async () => {
+        await page.context().clearCookies();
+        await page.evaluate(() => {
+          localStorage.clear();
+          sessionStorage.clear();
+        });
+        await openSignIn(page, `?url=${b64(target)}`);
+        await submitSignIn(page, USER, PASS);
+        await expect(page).toHaveURL(/\/workspace$/);
+        expect(await page.title()).not.toBe('pwned');
+        log.dump(`${n}. ${label}`);
+      });
+    }
+  });
+});
+
+test.describe('已知缺陷', () => {
+  /**
+   * BUG-5 v3 接口的错误文案没有本地化:中文界面里显示英文。
+   *
+   * 后端 `SetLocale` 中间件只挂在 web 组,api 组没有语言协商,`__()` 永远取 en
+   * (v3-resource skill 第 11 条已记录)。前端 `src/api/client.ts` 也没有把界面语言
+   * 告诉后端。修复需要两边一起:v3 路由组挂语言协商 + client 中间件带上界面语言。
+   * 修复前保持红灯。
+   */
+  test('BUG-5 中文界面下,v3 的字段错误应为中文', async ({ page }) => {
+    await page.goto('anonymous/sign-up', { waitUntil: 'networkidle' });
+    await page.getByText('我已经了解基础版的功能限制').click();
+    await page.getByRole('button', { name: '下一步' }).click();
+
+    // 错误验证码走真实后端:不写库、不发信
+    await page.getByPlaceholder('电子邮箱').fill(unregisteredEmail());
+    await page.getByPlaceholder('请输入邮件里的 6 位验证码').fill('000000');
+    await page.getByRole('button', { name: '下一步' }).click();
+
+    const error = page.locator('.ant-form-item-explain-error').first();
+    await expect(error).toBeVisible();
+    await expect(error).toHaveText('验证码不正确或已过期。');
+  });
+});