Sfoglia il codice sorgente

feat: 找回密码迁到 v3 password-resets,dashboard-v6 切过去

取代 POST /v2/auth/forgot-password、GET /v2/auth/reset-password/{token}、
POST /v2/auth/reset-password(v2 原样保留,openapi 标 deprecated)。

- POST /v3/password-resets:同步发信,邮箱未注册也 204;按 IP 与邮箱限流
- GET /v3/password-resets/{token}:返回 username、expires_at;无效或过期 404
- PATCH /v3/password-resets/{token}:设新密码,token 作废
- 库里只存 token 的 sha256,60 分钟过期;链接用服务端配置拼,不收客户端 dashboard
- 新增 mint.server.dashboard_v6_base_path(DASHBOARD_BASE_PATH 是 v4 地址,v6 加 -v2026)
- 写密码统一走 V3\PasswordHasher(仍是 md5,v4 下线后换 bcrypt)
- 新增语言包 messages.php(操作结果提示),CLAUDE.md 与 v3-resource skill 同步
- v6:重置页挪到 /anonymous/reset-password/:token(原挂在需要登录的 /dashboard 下)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 1 settimana fa
parent
commit
abd07a15a2
37 ha cambiato i file con 1243 aggiunte e 187 eliminazioni
  1. 5 4
      .claude/skills/v3-resource/SKILL.md
  2. 5 3
      CLAUDE.md
  3. 87 0
      api-v13/app/Http/Controllers/V3/PasswordResetController.php
  4. 34 0
      api-v13/app/Http/Requests/V3/StorePasswordResetRequest.php
  5. 29 0
      api-v13/app/Http/Requests/V3/UpdatePasswordResetRequest.php
  6. 30 0
      api-v13/app/Http/Resources/V3/PasswordResetResource.php
  7. 45 0
      api-v13/app/Mail/V3/PasswordResetMail.php
  8. 19 0
      api-v13/app/Providers/AppServiceProvider.php
  9. 20 0
      api-v13/app/Services/V3/PasswordHasher.php
  10. 120 0
      api-v13/app/Services/V3/PasswordResetService.php
  11. 6 2
      api-v13/config/mint.php
  12. 17 0
      api-v13/resources/lang/en/messages.php
  13. 1 0
      api-v13/resources/lang/en/passwords.php
  14. 17 0
      api-v13/resources/lang/zh-Hans/messages.php
  15. 1 0
      api-v13/resources/lang/zh-Hans/passwords.php
  16. 17 0
      api-v13/resources/lang/zh-Hant/messages.php
  17. 1 0
      api-v13/resources/lang/zh-Hant/passwords.php
  18. 1 0
      api-v13/routes/api.php
  19. 28 0
      api-v13/routes/v3/auth.php
  20. 186 0
      api-v13/tests/Feature/PasswordResetV3Test.php
  21. 4 10
      dashboard-v6/src/Router.tsx
  22. 64 0
      dashboard-v6/src/api/password-reset.ts
  23. 193 3
      dashboard-v6/src/api/schema.d.ts
  24. 19 52
      dashboard-v6/src/components/users/ForgotPassword.tsx
  25. 107 101
      dashboard-v6/src/components/users/ResetPassword.tsx
  26. 3 1
      dashboard-v6/src/locales/en-US/message.ts
  27. 3 1
      dashboard-v6/src/locales/zh-Hans/message.ts
  28. 3 1
      dashboard-v6/src/locales/zh-Hant/message.ts
  29. 10 6
      openapi/public/assets/protocol/main.yaml
  30. 7 0
      openapi/public/assets/protocol/overrides/v2-auth-forgot-password.yaml
  31. 5 0
      openapi/public/assets/protocol/overrides/v2-auth-reset-password-reset-password.yaml
  32. 7 0
      openapi/public/assets/protocol/overrides/v2-auth-reset-password.yaml
  33. 5 1
      openapi/public/assets/protocol/resources/auto/v2-auth-forgot-password.yaml
  34. 3 1
      openapi/public/assets/protocol/resources/auto/v2-auth-reset-password-reset-password.yaml
  35. 5 1
      openapi/public/assets/protocol/resources/auto/v2-auth-reset-password.yaml
  36. 85 0
      openapi/public/assets/protocol/resources/auto/v3-password-resets-token.yaml
  37. 51 0
      openapi/public/assets/protocol/resources/auto/v3-password-resets.yaml

+ 5 - 4
.claude/skills/v3-resource/SKILL.md

@@ -645,10 +645,11 @@ v3 端点可以自由改:v4 那两处 v3 引用(`ChatInput.tsx:127`、`agent
    `lang/`,本项目把 langPath 指到了 resources 下)。支持 8 个语言:
    `lang/`,本项目把 langPath 指到了 resources 下)。支持 8 个语言:
    `en`、`zh-Hans`、`zh-Hant`、`my`、`th`、`si`、`vi`、`lo`。
    `en`、`zh-Hans`、`zh-Hant`、`my`、`th`、`si`、`vi`、`lo`。
 
 
-   **不要新建语言文件,合并进已有的那 11 个**:`site`、`labels`、`buttons`、
-   `home`、`library`、`auth`、`validation`、`passwords`、`pagination`、
-   `grammar`、`language`。(注意没有 `messages.php`。)
-   API 的服务状态类文案放 **`site.php`**,界面标签放 `labels.php`,
+   **不要新建语言文件,合并进已有的那 12 个**:`site`、`messages`、`labels`、
+   `buttons`、`home`、`library`、`auth`、`validation`、`passwords`、`pagination`、
+   `grammar`、`language`。
+   API 的服务状态类文案(停机维护、404 等)放 **`site.php`**,操作结果提示
+   (如「邮件发送失败」)放 **`messages.php`**,界面标签放 `labels.php`,
    按钮文字放 `buttons.php`,各就各位。
    按钮文字放 `buttons.php`,各就各位。
 
 
    新增文案至少补 `en` 与 `zh-Hans` 两份,其余语言缺失会自动回退到 `en`,
    新增文案至少补 `en` 与 `zh-Hans` 两份,其余语言缺失会自动回退到 `en`,

+ 5 - 3
CLAUDE.md

@@ -49,9 +49,10 @@ wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。
   测试连独立的 `mint_test` 库(不要让 `RefreshDatabase` 打到开发库)。
   测试连独立的 `mint_test` 库(不要让 `RefreshDatabase` 打到开发库)。
 - `storage/resources` 是 git submodule(clove)。
 - `storage/resources` 是 git submodule(clove)。
 - 面向用户的文案一律走 `__()`。翻译文件在 **`resources/lang/{locale}/`**(不是
 - 面向用户的文案一律走 `__()`。翻译文件在 **`resources/lang/{locale}/`**(不是
-  `lang/`),共 8 个语言。**不要新建语言文件,合并进已有的 11 个**
-  (`site`/`labels`/`buttons`/`home`/`library`/`auth`/… 没有 `messages.php`);
-  服务状态类文案放 `site.php`。至少补 `en` 与 `zh-Hans`,其余自动回退 en。
+  `lang/`),共 8 个语言。**不要新建语言文件,合并进已有的 12 个**
+  (`site`/`messages`/`labels`/`buttons`/`home`/`library`/`auth`/…);
+  服务状态类文案(停机维护、404 等)放 `site.php`,操作结果提示(如「邮件发送失败」)
+  放 `messages.php`。至少补 `en` 与 `zh-Hans`,其余自动回退 en。
 
 
 ## 前端 dashboard-v6
 ## 前端 dashboard-v6
 
 
@@ -330,6 +331,7 @@ Resource / FormRequest / 测试同理;URL 只换版本前缀
 | `like` / `LikeController` | `reactions` / `V3\ReactionController` 等 | `likes` 表早已不只存点赞:`type` 的实际取值有 `like`、`dislike`、`favorite`、`watch`、`bookmark`、`download` 六种 |
 | `like` / `LikeController` | `reactions` / `V3\ReactionController` 等 | `likes` 表早已不只存点赞:`type` 的实际取值有 `like`、`dislike`、`favorite`、`watch`、`bookmark`、`download` 六种 |
 | (无对应 v2 端点) | `tipitaka-reading/{channel}` / `V3\TipitakaReadingController` | 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 `tipitaka-read-para` / `tipitaka-read-chapter`(两条),也短暂叫过 `channels/{c}/books/{b}/paragraphs\|chapters`,2026-09-24 并成一条 |
 | (无对应 v2 端点) | `tipitaka-reading/{channel}` / `V3\TipitakaReadingController` | 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 `tipitaka-read-para` / `tipitaka-read-chapter`(两条),也短暂叫过 `channels/{c}/books/{b}/paragraphs\|chapters`,2026-09-24 并成一条 |
 | `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
 | `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
+| `auth/forgot-password` + `auth/reset-password` / `ForgotPasswordController` + `ResetPasswordController` | `password-resets` (+`/{token}`) / `V3\PasswordResetController` | 三条并成一个资源:POST 发信、GET 看账号、PATCH 设密码(204)。库里只存 token 的 sha256、60 分钟过期、邮箱未注册也 204、不收 `dashboard` 参数(链接用 `mint.server.dashboard_v6_base_path` 拼到 v6 的 `/anonymous/reset-password/{token}`)、限流 |
 
 
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;

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

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

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

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

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

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

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

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

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

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

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

@@ -10,9 +10,12 @@ use App\View\Composers\BlogViewComposer;
 use Carbon\CarbonImmutable;
 use Carbon\CarbonImmutable;
 use Godruoyi\Snowflake\LaravelSequenceResolver;
 use Godruoyi\Snowflake\LaravelSequenceResolver;
 use Godruoyi\Snowflake\Snowflake;
 use Godruoyi\Snowflake\Snowflake;
+use Illuminate\Cache\RateLimiting\Limit;
 use Illuminate\Database\Query\Builder as QueryBuilder;
 use Illuminate\Database\Query\Builder as QueryBuilder;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Date;
 use Illuminate\Support\Facades\Date;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\RateLimiter;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Validation\Rules\Password;
 use Illuminate\Validation\Rules\Password;
@@ -99,6 +102,22 @@ class AppServiceProvider extends ServiceProvider
         */
         */
         View::composer('blog.*', BlogViewComposer::class);
         View::composer('blog.*', BlogViewComposer::class);
         View::composer('layouts.blog', BlogViewComposer::class);
         View::composer('layouts.blog', BlogViewComposer::class);
+
+        /*
+        |--------------------------------------------------------------------------
+        | Rate Limiters
+        |--------------------------------------------------------------------------
+        |
+        | 找回密码会往外发邮件:按 IP 限住撞库式的批量请求,按邮箱限住对同一个人的
+        | 邮件轰炸。设新密码的请求不带 email,只按 IP 计。
+        |
+        */
+        RateLimiter::for('password-resets', fn (Request $request): array => array_filter([
+            Limit::perMinute(10)->by('ip:'.$request->ip()),
+            $request->filled('email')
+                ? Limit::perHour(5)->by('email:'.mb_strtolower((string) $request->input('email')))
+                : null,
+        ]));
     }
     }
 
 
     /**
     /**

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

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

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

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

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

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

+ 17 - 0
api-v13/resources/lang/en/messages.php

@@ -0,0 +1,17 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Messages
+    |--------------------------------------------------------------------------
+    |
+    | 操作结果的提示文案:某个动作失败或成功时告诉用户发生了什么。
+    | 站点固定文案与服务状态(停机维护、404 等)仍在 site.php。
+    |
+    */
+
+    'mail_send_failed' => 'The email could not be sent. Please try again later.',
+
+];

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

@@ -19,4 +19,5 @@ return [
     'token' => 'This password reset token is invalid.',
     'token' => 'This password reset token is invalid.',
     'user' => "We can't find a user with that email address.",
     'user' => "We can't find a user with that email address.",
 
 
+    'subject' => 'Reset your wikipali password',
 ];
 ];

+ 17 - 0
api-v13/resources/lang/zh-Hans/messages.php

@@ -0,0 +1,17 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Messages
+    |--------------------------------------------------------------------------
+    |
+    | 操作结果的提示文案:某个动作失败或成功时告诉用户发生了什么。
+    | 站点固定文案与服务状态(停机维护、404 等)仍在 site.php。
+    |
+    */
+
+    'mail_send_failed' => '邮件发送失败,请稍后再试。',
+
+];

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

@@ -19,4 +19,5 @@ return [
     'token' => '密码重置令牌无效。',
     'token' => '密码重置令牌无效。',
     'user' => '找不到使用该邮箱地址的用户。',
     'user' => '找不到使用该邮箱地址的用户。',
 
 
+    'subject' => '重置 wikipali 密码',
 ];
 ];

+ 17 - 0
api-v13/resources/lang/zh-Hant/messages.php

@@ -0,0 +1,17 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Messages
+    |--------------------------------------------------------------------------
+    |
+    | 操作结果的提示文案:某个动作失败或成功时告诉用户发生了什么。
+    | 站点固定文案与服务状态(停机维护、404 等)仍在 site.php。
+    |
+    */
+
+    'mail_send_failed' => '郵件發送失敗,請稍後再試。',
+
+];

+ 1 - 0
api-v13/resources/lang/zh-Hant/passwords.php

@@ -19,4 +19,5 @@ return [
     'token' => '密碼重置令牌無效。',
     'token' => '密碼重置令牌無效。',
     'user' => '找不到使用該信箱地址的用戶。',
     'user' => '找不到使用該信箱地址的用戶。',
 
 
+    'subject' => '重設 wikipali 密碼',
 ];
 ];

+ 1 - 0
api-v13/routes/api.php

@@ -353,4 +353,5 @@ Route::prefix('v3')->as('v3.')->group(function () {
     require __DIR__.'/v3/tipitaka.php';
     require __DIR__.'/v3/tipitaka.php';
     require __DIR__.'/v3/channel.php';
     require __DIR__.'/v3/channel.php';
     require __DIR__.'/v3/interaction.php';
     require __DIR__.'/v3/interaction.php';
+    require __DIR__.'/v3/auth.php';
 });
 });

+ 28 - 0
api-v13/routes/v3/auth.php

@@ -0,0 +1,28 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| v3 · auth —— 账号:找回密码、注册、登录
+|--------------------------------------------------------------------------
+|
+| 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
+|
+| 登录(sessions / me)还在 v2,见根目录 CLAUDE.md「认证迁移待办」。
+|
+*/
+
+use App\Http\Controllers\V3\PasswordResetController;
+use Illuminate\Support\Facades\Route;
+
+// 找回密码。token 是 64 位字母数字,约束挂在每条路由上(prefix 组上的 where 会互相顶掉)。
+// 只注册 PATCH 不注册 PUT:完成重置只改密码这一项,不是整体替换。
+Route::post('password-resets', [PasswordResetController::class, 'store'])
+    ->middleware('throttle:password-resets')
+    ->name('password-resets.store');
+Route::get('password-resets/{token}', [PasswordResetController::class, 'show'])
+    ->where('token', '[A-Za-z0-9]{64}')
+    ->name('password-resets.show');
+Route::patch('password-resets/{token}', [PasswordResetController::class, 'update'])
+    ->where('token', '[A-Za-z0-9]{64}')
+    ->middleware('throttle:password-resets')
+    ->name('password-resets.update');

+ 186 - 0
api-v13/tests/Feature/PasswordResetV3Test.php

@@ -0,0 +1,186 @@
+<?php
+
+use App\Mail\V3\PasswordResetMail;
+use App\Models\UserInfo;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Mail;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v3 找回密码。这里断言的是形状而非契约(spectator 未安装)。
+ *
+ * 端点:
+ *   POST  /api/v3/password-resets           发重置邮件(任何邮箱都 204)
+ *   GET   /api/v3/password-resets/{token}   查看:给哪个账号、何时过期
+ *   PATCH /api/v3/password-resets/{token}   设新密码,token 作废
+ */
+// 测试环境的 cache 是 array,每条测试重建应用时清零,限流计数不会串到下一条
+beforeEach(fn () => Mail::fake());
+
+/**
+ * 走一遍申请流程,从发出的邮件里取回明文 token。
+ */
+function requestResetToken(string $email): string
+{
+    test()->postJson('/api/v3/password-resets', ['email' => $email])->assertNoContent();
+
+    $token = null;
+    Mail::assertSent(PasswordResetMail::class, function (PasswordResetMail $mail) use (&$token) {
+        $token = substr($mail->url, strrpos($mail->url, '/') + 1);
+
+        return true;
+    });
+
+    return $token;
+}
+
+it('mails a reset link pointing at dashboard-v6 and stores only a digest', function () {
+    makeStudio('reset-me');
+
+    $this->postJson('/api/v3/password-resets', ['email' => 'reset-me@example.test', 'lang' => 'zh-Hans'])
+        ->assertNoContent();
+
+    Mail::assertSent(PasswordResetMail::class, function (PasswordResetMail $mail) {
+        $token = substr($mail->url, strrpos($mail->url, '/') + 1);
+        $stored = UserInfo::where('username', 'reset-me')->value('reset_password_token');
+
+        return $mail->hasTo('reset-me@example.test')
+            && str_starts_with($mail->url, rtrim(config('mint.server.dashboard_v6_base_path'), '/').'/anonymous/reset-password/')
+            && $mail->lang === 'zh-Hans'
+            && $stored === hash('sha256', $token)
+            && $stored !== $token;
+    });
+});
+
+it('falls back to en for a language without a mail template', function () {
+    makeStudio('reset-lang');
+
+    $this->postJson('/api/v3/password-resets', ['email' => 'reset-lang@example.test', 'lang' => 'my'])
+        ->assertNoContent();
+
+    Mail::assertSent(PasswordResetMail::class, fn (PasswordResetMail $mail) => $mail->lang === 'en');
+});
+
+it('answers 204 for an unknown email without sending anything', function () {
+    $this->postJson('/api/v3/password-resets', ['email' => 'nobody@example.test'])
+        ->assertNoContent();
+
+    Mail::assertNothingSent();
+});
+
+it('rejects a malformed email', function () {
+    $this->postJson('/api/v3/password-resets', ['email' => 'not-an-email'])
+        ->assertStatus(422)
+        ->assertJsonPath('errors.email.0', fn ($m) => is_string($m));
+});
+
+it('ignores a client supplied dashboard url', function () {
+    makeStudio('reset-host');
+
+    $this->postJson('/api/v3/password-resets', [
+        'email' => 'reset-host@example.test',
+        'dashboard' => 'https://evil.example',
+    ])->assertNoContent();
+
+    Mail::assertSent(PasswordResetMail::class, fn (PasswordResetMail $mail) => ! str_contains($mail->url, 'evil.example'));
+});
+
+it('throttles repeated requests for the same email', function () {
+    makeStudio('reset-flood');
+
+    foreach (range(1, 5) as $_) {
+        $this->postJson('/api/v3/password-resets', ['email' => 'reset-flood@example.test'])->assertNoContent();
+    }
+
+    $this->postJson('/api/v3/password-resets', ['email' => 'reset-flood@example.test'])->assertStatus(429);
+});
+
+it('shows which account a token resets', function () {
+    makeStudio('reset-show');
+    $token = requestResetToken('reset-show@example.test');
+
+    $data = $this->getJson("/api/v3/password-resets/{$token}")
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveKeys(['username', 'expires_at'])
+        ->and($data['username'])->toBe('reset-show')
+        ->and($data['expires_at'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/')
+        ->and($data)->not->toHaveKey('password');
+});
+
+it('answers 404 for an unknown token', function () {
+    $this->getJson('/api/v3/password-resets/'.str_repeat('a', 64))
+        ->assertNotFound()
+        ->assertHeader('Content-Type', 'application/problem+json');
+});
+
+it('answers 404 for an expired token', function () {
+    makeStudio('reset-old');
+    $token = requestResetToken('reset-old@example.test');
+
+    $this->travel(61)->minutes();
+
+    $this->getJson("/api/v3/password-resets/{$token}")->assertNotFound();
+    $this->patchJson("/api/v3/password-resets/{$token}", [
+        'password' => 'new-secret',
+        'password_confirmation' => 'new-secret',
+    ])->assertNotFound();
+});
+
+it('does not accept the raw uuid tokens written by v2', function () {
+    $uid = makeStudio('reset-v2');
+    UserInfo::where('userid', $uid)->update([
+        'reset_password_token' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+        'reset_password_sent_at' => now(),
+    ]);
+
+    $this->getJson('/api/v3/password-resets/'.str_repeat('a', 64))->assertNotFound();
+});
+
+it('sets the new password and burns the token', function () {
+    makeStudio('reset-done');
+    $token = requestResetToken('reset-done@example.test');
+
+    $this->patchJson("/api/v3/password-resets/{$token}", [
+        'password' => 'new-secret',
+        'password_confirmation' => 'new-secret',
+    ])->assertNoContent();
+
+    $user = UserInfo::where('username', 'reset-done')->first();
+    expect($user->password)->toBe(md5('new-secret'))
+        ->and($user->reset_password_token)->toBeNull();
+
+    // v2 登录仍认这个新密码(md5 比对),v4 / mobile 不受影响
+    $this->postJson('/api/v2/sign-in', ['username' => 'reset-done', 'password' => 'new-secret'])
+        ->assertJsonPath('ok', true);
+
+    $this->getJson("/api/v3/password-resets/{$token}")->assertNotFound();
+});
+
+it('rejects a mismatched confirmation', function () {
+    makeStudio('reset-typo');
+    $token = requestResetToken('reset-typo@example.test');
+
+    $this->patchJson("/api/v3/password-resets/{$token}", [
+        'password' => 'new-secret',
+        'password_confirmation' => 'new-secreT',
+    ])->assertStatus(422)->assertJsonStructure(['errors' => ['password']]);
+
+    expect(UserInfo::where('username', 'reset-typo')->value('password'))->toBe('x');
+});
+
+it('rejects a too short password', function () {
+    makeStudio('reset-short');
+    $token = requestResetToken('reset-short@example.test');
+
+    $this->patchJson("/api/v3/password-resets/{$token}", [
+        'password' => '12345',
+        'password_confirmation' => '12345',
+    ])->assertStatus(422);
+});
+
+it('does not register PUT', function () {
+    $this->putJson('/api/v3/password-resets/'.str_repeat('a', 64), [])->assertStatus(405);
+});

+ 4 - 10
dashboard-v6/src/Router.tsx

@@ -58,20 +58,14 @@ const router = createBrowserRouter(
               handle: { crumb: "sign-up" },
               handle: { crumb: "sign-up" },
             },
             },
             { path: "forgot-password", Component: UsersForgotPassword },
             { path: "forgot-password", Component: UsersForgotPassword },
+            // 找回密码邮件里的链接,由后端按 mint.server.dashboard_v6_base_path 拼出
+            { path: "reset-password/:token", Component: UsersResetPassword },
           ],
           ],
         },
         },
         {
         {
           path: "dashboard",
           path: "dashboard",
           Component: DashboardLayout,
           Component: DashboardLayout,
-          children: [
-            { index: true, Component: DashboardIndex },
-            {
-              path: "users",
-              children: [
-                { path: "reset-password", Component: UsersResetPassword },
-              ],
-            },
-          ],
+          children: [{ index: true, Component: DashboardIndex }],
         },
         },
         {
         {
           path: "workspace",
           path: "workspace",
@@ -110,7 +104,7 @@ const router = createBrowserRouter(
       ],
       ],
     },
     },
   ],
   ],
-  { basename: import.meta.env.BASE_URL }
+  { basename: import.meta.env.BASE_URL },
 );
 );
 
 
 const Widget = () => {
 const Widget = () => {

+ 64 - 0
dashboard-v6/src/api/password-reset.ts

@@ -0,0 +1,64 @@
+// src/api/password-reset.ts
+//
+// 找回密码。**已完全切到 v3**(取代 /v2/auth/forgot-password 与
+// /v2/auth/reset-password),没有 v2 分支,要回退就 revert 那个 commit。
+
+import { api, unwrapQuiet, unwrapVoid } from "./client";
+import type { paths } from "./schema";
+
+type RequestBody = NonNullable<
+  paths["/v3/password-resets"]["post"]["requestBody"]
+>["content"]["application/json"];
+
+type MailLang = NonNullable<RequestBody["lang"]>;
+
+/** 一次待完成的重置:给哪个账号、何时过期 */
+export type PasswordReset = NonNullable<
+  paths["/v3/password-resets/{token}"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
+
+const MAIL_LANGS: readonly MailLang[] = ["en", "en-US", "zh-Hans", "zh-Hant"];
+
+/** 界面语言没有对应邮件模板时不传,后端回落 en */
+const toMailLang = (lang: string): MailLang | undefined =>
+  MAIL_LANGS.find((l) => l === lang);
+
+/**
+ * 申请发送重置邮件。邮箱是否注册都会成功(后端不泄露账号是否存在)。
+ */
+export const requestPasswordReset = async (
+  email: string,
+  uiLang: string,
+): Promise<void> =>
+  unwrapVoid(
+    await api.POST("/v3/password-resets", {
+      body: { email, lang: toMailLang(uiLang) },
+    }),
+  );
+
+/**
+ * 凭邮件里的 token 查看待重置的账号。
+ *
+ * 用 unwrapQuiet:token 无效 / 过期(404)由重置页自己渲染成提示,不该再弹窗。
+ */
+export const fetchPasswordReset = async (
+  token: string,
+): Promise<PasswordReset> =>
+  unwrapQuiet(
+    await api.GET("/v3/password-resets/{token}", {
+      params: { path: { token } },
+    }),
+  ).data ?? {};
+
+/** 设新密码,成功后 token 作废 */
+export const completePasswordReset = async (
+  token: string,
+  password: string,
+  passwordConfirmation: string,
+): Promise<void> =>
+  unwrapVoid(
+    await api.PATCH("/v3/password-resets/{token}", {
+      params: { path: { token } },
+      body: { password, password_confirmation: passwordConfirmation },
+    }),
+  );

+ 193 - 3
dashboard-v6/src/api/schema.d.ts

@@ -963,7 +963,10 @@ export interface paths {
         put?: never;
         put?: never;
         /**
         /**
          * 新建 auth/forgot-password
          * 新建 auth/forgot-password
-         * @description 实现:`ForgotPasswordController@store`
+         * @deprecated
+         * @description 已被 `POST /v3/password-resets` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+         *
+         *     已知问题(v2 冻结不修):`dashboard` 参数原样拼进邮件链接;token 不过期;邮箱未注册时回 404。
          */
          */
         post: operations["post_api_v2_auth_forgot_password"];
         post: operations["post_api_v2_auth_forgot_password"];
         delete?: never;
         delete?: never;
@@ -1019,7 +1022,10 @@ export interface paths {
         put?: never;
         put?: never;
         /**
         /**
          * 新建 auth/reset-password
          * 新建 auth/reset-password
-         * @description 实现:`ResetPasswordController@store`
+         * @deprecated
+         * @description 已被 `PATCH /v3/password-resets/{token}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+         *
+         *     已知问题(v2 冻结不修):成功时 `data` 是 user_infos 整行,含密码哈希。
          */
          */
         post: operations["post_api_v2_auth_reset_password"];
         post: operations["post_api_v2_auth_reset_password"];
         delete?: never;
         delete?: never;
@@ -1037,7 +1043,8 @@ export interface paths {
         };
         };
         /**
         /**
          * 根据token获取用户名.
          * 根据token获取用户名.
-         * @description 实现:`ResetPasswordController@show`
+         * @deprecated
+         * @description 已被 `GET /v3/password-resets/{token}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
          */
          */
         get: operations["get_api_v2_auth_reset_password_reset_password_"];
         get: operations["get_api_v2_auth_reset_password_reset_password_"];
         /**
         /**
@@ -7776,6 +7783,59 @@ export interface paths {
         patch?: never;
         patch?: never;
         trace?: never;
         trace?: never;
     };
     };
+    "/v3/password-resets": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * 申请重置密码(发邮件)
+         * @description 无论邮箱是否注册都返回 204,不泄露账号是否存在。邮件是同步发出的,
+         *     返回时已经发完,所以是 204 而不是 202。邮件里的链接指向
+         *     dashboard-v6 的 `/anonymous/reset-password/{token}`,60 分钟内有效。
+         *     按 IP 与邮箱限流,超出返回 429。
+         *
+         *     实现:`V3\PasswordResetController@store`
+         */
+        post: operations["post_api_v3_password_resets"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/password-resets/{token}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 查看一次待完成的重置
+         * @description 重置页用它显示「正在为哪个账号设置新密码」。token 不存在或已过期一律 404。
+         *
+         *     实现:`V3\PasswordResetController@show`
+         */
+        get: operations["get_api_v3_password_resets_token_"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        /**
+         * 设置新密码
+         * @description 成功后 token 作废,返回 204 空体;之后用新密码走登录。
+         *
+         *     实现:`V3\PasswordResetController@update`
+         */
+        patch: operations["patch_api_v3_password_resets_token_"];
+        trace?: never;
+    };
     "/v3/progress": {
     "/v3/progress": {
         parameters: {
         parameters: {
             query?: never;
             query?: never;
@@ -34228,6 +34288,136 @@ export interface operations {
             422: components["responses"]["ProblemValidation"];
             422: components["responses"]["ProblemValidation"];
         };
         };
     };
     };
+    post_api_v3_password_resets: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /**
+                     * @description 账号邮箱
+                     * @example someone@example.com
+                     */
+                    email: string;
+                    /**
+                     * @description 邮件语言,没有对应模板的回落 en
+                     * @enum {string}
+                     */
+                    lang?: "en" | "en-US" | "zh-Hans" | "zh-Hant";
+                };
+            };
+        };
+        responses: {
+            /** @description 已发送(或邮箱未注册),无响应体 */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            422: components["responses"]["ProblemValidation"];
+            /** @description 请求过于频繁 */
+            429: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            /** @description 邮件发送失败 */
+            503: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    get_api_v3_password_resets_token_: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                /** @description 邮件链接里的 token */
+                token: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            username?: string;
+                            expires_at?: string;
+                        };
+                    };
+                };
+            };
+            /** @description token 无效或已过期 */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    patch_api_v3_password_resets_token_: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                /** @description 邮件链接里的 token */
+                token: string;
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description 新密码,6–32 位 */
+                    password: string;
+                    /** @description 再输一次新密码 */
+                    password_confirmation: string;
+                };
+            };
+        };
+        responses: {
+            /** @description 已重置,无响应体 */
+            204: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content?: never;
+            };
+            /** @description token 无效或已过期 */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
     get_api_v3_progress: {
     get_api_v3_progress: {
         parameters: {
         parameters: {
             query: {
             query: {

+ 19 - 52
dashboard-v6/src/components/users/ForgotPassword.tsx

@@ -1,68 +1,35 @@
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
-import { Alert, type AlertProps } from "antd";
-
-import { post } from "../../request";
+import { Alert } from "antd";
 import { useState } from "react";
 import { useState } from "react";
+
+import { requestPasswordReset } from "../../api/password-reset";
 import { get as getUiLang } from "../../locales";
 import { get as getUiLang } from "../../locales";
-import { fullUrl } from "../../utils";
 
 
-interface IForgotPasswordRequest {
-  email: string;
-  lang: string;
-  dashboard: string;
-}
 interface IFormData {
 interface IFormData {
   email: string;
   email: string;
 }
 }
-interface IForgotPasswordResponse {
-  ok: boolean;
-  message: string;
-  data: string;
-}
+
 const Widget = () => {
 const Widget = () => {
   const intl = useIntl();
   const intl = useIntl();
-  const [notify, setNotify] = useState<string>(
-    intl.formatMessage({
-      id: "message.send.reset.email",
-    })
-  );
-  const [type, setType] = useState<AlertProps["type"]>("info");
+  const [sent, setSent] = useState(false);
+
   return (
   return (
     <>
     <>
-      {notify ? <Alert message={notify} type={type} showIcon /> : <></>}
+      <Alert
+        title={intl.formatMessage({
+          id: sent
+            ? "message.send.reset.email.successful"
+            : "message.send.reset.email",
+        })}
+        type={sent ? "success" : "info"}
+        showIcon
+      />
       <ProForm<IFormData>
       <ProForm<IFormData>
         onFinish={async (values: IFormData) => {
         onFinish={async (values: IFormData) => {
-          console.debug(values);
-          const user: IForgotPasswordRequest = {
-            email: values.email,
-            lang: getUiLang(),
-            dashboard: fullUrl(""),
-          };
-          const url = "/api/v2/auth/forgot-password";
-          console.info("forgot password url", url, user);
-          try {
-            const result = await post<
-              IForgotPasswordRequest,
-              IForgotPasswordResponse
-            >(url, user);
-            if (result.ok) {
-              console.debug("token", result.data);
-              setNotify(
-                intl.formatMessage({
-                  id: "message.send.reset.email.successful",
-                })
-              );
-              setType("success");
-            } else {
-              setType("error");
-              setNotify(result.message);
-            }
-          } catch (error) {
-            setType("error");
-            setNotify("服务器内部错误");
-            console.error(error);
-          }
+          // 失败由 unwrapVoid 弹提示并抛出,这里只写成功路径
+          await requestPasswordReset(values.email.trim(), getUiLang());
+          setSent(true);
         }}
         }}
       >
       >
         <ProForm.Group>
         <ProForm.Group>
@@ -73,7 +40,7 @@ const Widget = () => {
             label={intl.formatMessage({
             label={intl.formatMessage({
               id: "forms.fields.email.label",
               id: "forms.fields.email.label",
             })}
             })}
-            rules={[{ required: true, type: "email", max: 255, min: 6 }]}
+            rules={[{ required: true, type: "email", max: 256 }]}
           />
           />
         </ProForm.Group>
         </ProForm.Group>
       </ProForm>
       </ProForm>

+ 107 - 101
dashboard-v6/src/components/users/ResetPassword.tsx

@@ -4,122 +4,116 @@ import {
   type ProFormInstance,
   type ProFormInstance,
   ProFormText,
   ProFormText,
 } from "@ant-design/pro-components";
 } from "@ant-design/pro-components";
-import { Alert, type AlertProps, message } from "antd";
+import { Alert, Button, Result, Skeleton } from "antd";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
+import { useEffect, useRef, useState } from "react";
+import { Link, useNavigate } from "react-router";
 
 
-import { get, post } from "../../request";
-import { useRef, useState } from "react";
-import type { RuleObject } from "antd/lib/form";
-import type { StoreValue } from "antd/lib/form/interface";
-import LoginButton from "../auth/LoginButton";
+import {
+  completePasswordReset,
+  fetchPasswordReset,
+  type PasswordReset,
+} from "../../api/password-reset";
+import { fieldErrorsOf } from "../../api/error";
+import { TO_SIGN_IN } from "../../reducers/current-user";
 
 
 interface IFormData {
 interface IFormData {
-  username: string;
-  token?: string | null;
-  password?: string;
-  confirmPassword?: string;
-}
-interface IResetPasswordResponse {
-  ok: boolean;
-  message: string;
-  data: { username: string };
+  password: string;
+  password_confirmation: string;
 }
 }
 
 
+type TState =
+  | { kind: "loading" }
+  | { kind: "invalid" }
+  | { kind: "ready"; reset: PasswordReset }
+  | { kind: "done" };
+
 interface IWidget {
 interface IWidget {
-  token?: string | null;
+  token?: string;
 }
 }
 const Widget = ({ token }: IWidget) => {
 const Widget = ({ token }: IWidget) => {
   const intl = useIntl();
   const intl = useIntl();
-  const [notify, setNotify] = useState<React.ReactNode>();
-  const [type, setType] = useState<AlertProps["type"]>("info");
-  const formRef = useRef<ProFormInstance | undefined>(undefined);
-  const [ok, setOk] = useState(false);
+  const navigate = useNavigate();
+  const formRef = useRef<ProFormInstance<IFormData> | undefined>(undefined);
+  const [state, setState] = useState<TState>(
+    token ? { kind: "loading" } : { kind: "invalid" },
+  );
 
 
-  const checkPass2 = (
-    _rule: RuleObject,
-    value: StoreValue,
-    callback: (error?: string) => void
-  ) => {
-    if (value && value !== formRef.current?.getFieldValue("password")) {
-      callback(
-        intl.formatMessage({
-          id: "message.confirm-password.validate.fail",
-        })
-      );
-    } else {
-      callback();
+  useEffect(() => {
+    if (!token) {
+      return;
     }
     }
-  };
+    let active = true;
+    fetchPasswordReset(token)
+      .then((reset) => active && setState({ kind: "ready", reset }))
+      .catch(() => active && setState({ kind: "invalid" }));
+    return () => {
+      active = false;
+    };
+  }, [token]);
+
+  if (state.kind === "loading") {
+    return <Skeleton active />;
+  }
+
+  if (state.kind === "invalid") {
+    return (
+      <Result
+        status="warning"
+        title={intl.formatMessage({ id: "message.reset.link.invalid" })}
+        extra={
+          <Link to="/anonymous/forgot-password">
+            {intl.formatMessage({ id: "buttons.forgot.password" })}
+          </Link>
+        }
+      />
+    );
+  }
+
+  if (state.kind === "done") {
+    return (
+      <Result
+        status="success"
+        title={intl.formatMessage({ id: "message.password.reset.successful" })}
+        extra={
+          <Button type="primary" onClick={() => navigate(TO_SIGN_IN)}>
+            {intl.formatMessage({ id: "buttons.sign-in" })}
+          </Button>
+        }
+      />
+    );
+  }
 
 
   return (
   return (
     <>
     <>
-      {notify ? (
-        <Alert
-          message={notify}
-          type={type}
-          showIcon
-          action={ok ? <LoginButton /> : undefined}
-        />
-      ) : (
-        <></>
-      )}
+      <Alert
+        title={intl.formatMessage({ id: "message.password.reset" })}
+        type="info"
+        showIcon
+      />
       <ProForm<IFormData>
       <ProForm<IFormData>
         formRef={formRef}
         formRef={formRef}
         onFinish={async (values: IFormData) => {
         onFinish={async (values: IFormData) => {
-          if (!token) {
-            return;
-          }
-          console.debug(values);
-          values["token"] = token;
-          const url = "/api/v2/auth/reset-password";
-          console.info("reset password url", url, values);
-          const result = await post<IFormData, IResetPasswordResponse>(
-            url,
-            values
-          );
-          if (result.ok) {
-            console.log("token", result.data);
-            setType("success");
-            setNotify(
-              intl.formatMessage({
-                id: "message.password.reset.successful",
-              })
-            );
-            setOk(true);
-            message.success(intl.formatMessage({ id: "flashes.success" }));
-          } else {
-            setType("error");
-            setNotify(result.message);
-          }
-        }}
-        request={async () => {
-          const url = `/api/v2/auth/reset-password/${token}`;
-          console.log("url", url);
           try {
           try {
-            const res = await get<IResetPasswordResponse>(url);
-            console.log("ResetPassword get", res);
-            if (res.ok) {
-              setType("info");
-              setNotify(intl.formatMessage({ id: "message.password.reset" }));
-              return {
-                username: res.data.username,
-              };
-            } else {
-              return {
-                username: "",
-              };
-            }
-          } catch (err) {
-            //error
-            if (err === 404) {
-              setNotify(intl.formatMessage({ id: "获取token失败" }));
-              setType("error");
+            await completePasswordReset(
+              token ?? "",
+              values.password,
+              values.password_confirmation,
+            );
+          } catch (e) {
+            // 提示已由 unwrapVoid 弹过;422 时把字段级错误挂到对应输入框上
+            const fields = fieldErrorsOf(e);
+            if (fields) {
+              formRef.current?.setFields(
+                Object.entries(fields).map(([name, errors]) => ({
+                  name: name as keyof IFormData,
+                  errors,
+                })),
+              );
             }
             }
-            console.error(err);
+            return;
           }
           }
-          return {
-            username: "",
-          };
+          setState({ kind: "done" });
         }}
         }}
       >
       >
         <ProForm.Group>
         <ProForm.Group>
@@ -127,11 +121,10 @@ const Widget = ({ token }: IWidget) => {
             width="md"
             width="md"
             name="username"
             name="username"
             readonly
             readonly
-            required
+            initialValue={state.reset.username}
             label={intl.formatMessage({
             label={intl.formatMessage({
               id: "forms.fields.username.label",
               id: "forms.fields.username.label",
             })}
             })}
-            rules={[{ required: true, max: 255, min: 2 }]}
           />
           />
         </ProForm.Group>
         </ProForm.Group>
         <ProForm.Group>
         <ProForm.Group>
@@ -139,7 +132,6 @@ const Widget = ({ token }: IWidget) => {
             width="md"
             width="md"
             name="password"
             name="password"
             fieldProps={{
             fieldProps={{
-              type: "password",
               iconRender: (visible) =>
               iconRender: (visible) =>
                 visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
                 visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
             }}
             }}
@@ -153,9 +145,9 @@ const Widget = ({ token }: IWidget) => {
         <ProForm.Group>
         <ProForm.Group>
           <ProFormText.Password
           <ProFormText.Password
             width="md"
             width="md"
-            name="password2"
+            name="password_confirmation"
+            dependencies={["password"]}
             fieldProps={{
             fieldProps={{
-              type: "password",
               iconRender: (visible) =>
               iconRender: (visible) =>
                 visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
                 visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
             }}
             }}
@@ -163,7 +155,21 @@ const Widget = ({ token }: IWidget) => {
             label={intl.formatMessage({
             label={intl.formatMessage({
               id: "forms.fields.confirm-password.label",
               id: "forms.fields.confirm-password.label",
             })}
             })}
-            rules={[{ required: true, max: 32, min: 6, validator: checkPass2 }]}
+            rules={[
+              { required: true },
+              ({ getFieldValue }) => ({
+                validator: (_, value) =>
+                  !value || value === getFieldValue("password")
+                    ? Promise.resolve()
+                    : Promise.reject(
+                        new Error(
+                          intl.formatMessage({
+                            id: "message.confirm-password.validate.fail",
+                          }),
+                        ),
+                      ),
+              }),
+            ]}
           />
           />
         </ProForm.Group>
         </ProForm.Group>
       </ProForm>
       </ProForm>

+ 3 - 1
dashboard-v6/src/locales/en-US/message.ts

@@ -11,9 +11,11 @@ const items = {
   "message.send.reset.email":
   "message.send.reset.email":
     "The system will send a link containing the information necessary to reset your password to your registered email address. Please enter your registered email address and ensure that the email is capable of receiving messages.",
     "The system will send a link containing the information necessary to reset your password to your registered email address. Please enter your registered email address and ensure that the email is capable of receiving messages.",
   "message.send.reset.email.successful":
   "message.send.reset.email.successful":
-    "The password for reset email has been sent to your inbox.",
+    "If this email is registered, a password reset link has been sent. Please use it within 60 minutes.",
   "message.password.reset.successful": "password reset successful",
   "message.password.reset.successful": "password reset successful",
   "message.password.reset": "please set new password",
   "message.password.reset": "please set new password",
+  "message.reset.link.invalid":
+    "This link is invalid or has expired. Please request a new password reset.",
   "message.get.token.fail": "get token fail",
   "message.get.token.fail": "get token fail",
   "message.confirm-password.validate.fail": "password validate fail",
   "message.confirm-password.validate.fail": "password validate fail",
   "message.delete.success": "delete record {count}",
   "message.delete.success": "delete record {count}",

+ 3 - 1
dashboard-v6/src/locales/zh-Hans/message.ts

@@ -11,9 +11,11 @@ const items = {
   "message.result": "{count} 条结果",
   "message.result": "{count} 条结果",
   "message.send.reset.email":
   "message.send.reset.email":
     "系统将向您的注册邮箱发送邮件。请输入您的注册邮箱。并确保该邮箱可以接受邮件。",
     "系统将向您的注册邮箱发送邮件。请输入您的注册邮箱。并确保该邮箱可以接受邮件。",
-  "message.send.reset.email.successful": "重置密码的邮件已经发送到您的邮箱",
+  "message.send.reset.email.successful":
+    "如果该邮箱已注册,重置密码的邮件已经发出,请在 60 分钟内点击邮件中的链接。",
   "message.password.reset.successful": "重置密码成功",
   "message.password.reset.successful": "重置密码成功",
   "message.password.reset": "请设置新的密码",
   "message.password.reset": "请设置新的密码",
+  "message.reset.link.invalid": "链接无效或已过期,请重新申请重置密码。",
   "message.get.token.fail": "获取token失败",
   "message.get.token.fail": "获取token失败",
   "message.confirm-password.validate.fail": "两次密码不一致",
   "message.confirm-password.validate.fail": "两次密码不一致",
   "message.delete.success": "成功删除{count}条数据",
   "message.delete.success": "成功删除{count}条数据",

+ 3 - 1
dashboard-v6/src/locales/zh-Hant/message.ts

@@ -11,9 +11,11 @@ const items = {
   "message.result": "{count} 條結果",
   "message.result": "{count} 條結果",
   "message.send.reset.email":
   "message.send.reset.email":
     "系統將向您的註冊郵箱傳送郵件。請輸入您的註冊郵箱。並確保該郵箱可以接受郵件。",
     "系統將向您的註冊郵箱傳送郵件。請輸入您的註冊郵箱。並確保該郵箱可以接受郵件。",
-  "message.send.reset.email.successful": "重置密碼的郵件已經發送到您的郵箱",
+  "message.send.reset.email.successful":
+    "如果該郵箱已註冊,重置密碼的郵件已經發出,請在 60 分鐘內點擊郵件中的連結。",
   "message.password.reset.successful": "重置密碼成功",
   "message.password.reset.successful": "重置密碼成功",
   "message.password.reset": "請設定新的密碼",
   "message.password.reset": "請設定新的密碼",
+  "message.reset.link.invalid": "連結無效或已過期,請重新申請重置密碼。",
   "message.get.token.fail": "獲取token失敗",
   "message.get.token.fail": "獲取token失敗",
   "message.confirm-password.validate.fail": "兩次密碼不一致",
   "message.confirm-password.validate.fail": "兩次密碼不一致",
   "message.delete.success": "成功刪除{count}條資料",
   "message.delete.success": "成功刪除{count}條資料",

+ 10 - 6
openapi/public/assets/protocol/main.yaml

@@ -7,7 +7,7 @@ info:
 
 
     由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。
     由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。
     响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
     响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
-  version: 2026.9.28
+  version: 2026.10.8
 servers:
 servers:
   -
   -
     url: /api
     url: /api
@@ -18,18 +18,18 @@ servers:
   -
   -
     url: 'https://staging.wikipali.org/api'
     url: 'https://staging.wikipali.org/api'
     description: 测试
     description: 测试
-  -
-    url: 'https://www.wikipali.org/api'
-    description: 生产
   -
   -
     url: 'https://next.wikipali.org/api'
     url: 'https://next.wikipali.org/api'
     description: 生产预览
     description: 生产预览
   -
   -
-    url: 'https://www.wikipali.cc/api'
-    description: 生产(中国)
+    url: 'https://www.wikipali.org/api'
+    description: 生产
   -
   -
     url: 'https://next.wikipali.cc/api'
     url: 'https://next.wikipali.cc/api'
     description: 生产预览(中国)
     description: 生产预览(中国)
+  -
+    url: 'https://www.wikipali.cc/api'
+    description: 生产(中国)
 components:
 components:
   securitySchemes:
   securitySchemes:
     bearerAuth:
     bearerAuth:
@@ -687,6 +687,10 @@ paths:
     $ref: ./resources/auto/v3-me-reactions.yaml
     $ref: ./resources/auto/v3-me-reactions.yaml
   '/v3/me/reactions/{reaction}':
   '/v3/me/reactions/{reaction}':
     $ref: ./resources/auto/v3-me-reactions-reaction.yaml
     $ref: ./resources/auto/v3-me-reactions-reaction.yaml
+  /v3/password-resets:
+    $ref: ./resources/auto/v3-password-resets.yaml
+  '/v3/password-resets/{token}':
+    $ref: ./resources/auto/v3-password-resets-token.yaml
   /v3/progress:
   /v3/progress:
     $ref: ./resources/auto/v3-progress.yaml
     $ref: ./resources/auto/v3-progress.yaml
   /v3/reactions:
   /v3/reactions:

+ 7 - 0
openapi/public/assets/protocol/overrides/v2-auth-forgot-password.yaml

@@ -0,0 +1,7 @@
+# 手写补充:标记已被 v3 取代。
+post:
+  deprecated: true
+  description: |
+    已被 `POST /v3/password-resets` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):`dashboard` 参数原样拼进邮件链接;token 不过期;邮箱未注册时回 404。

+ 5 - 0
openapi/public/assets/protocol/overrides/v2-auth-reset-password-reset-password.yaml

@@ -0,0 +1,5 @@
+# 手写补充:标记已被 v3 取代。
+get:
+  deprecated: true
+  description: |
+    已被 `GET /v3/password-resets/{token}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。

+ 7 - 0
openapi/public/assets/protocol/overrides/v2-auth-reset-password.yaml

@@ -0,0 +1,7 @@
+# 手写补充:标记已被 v3 取代。
+post:
+  deprecated: true
+  description: |
+    已被 `PATCH /v3/password-resets/{token}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):成功时 `data` 是 user_infos 整行,含密码哈希。

+ 5 - 1
openapi/public/assets/protocol/resources/auto/v2-auth-forgot-password.yaml

@@ -69,7 +69,10 @@ post:
   tags:
   tags:
     - auth
     - auth
   operationId: post_api_v2_auth_forgot_password
   operationId: post_api_v2_auth_forgot_password
-  description: 实现:`ForgotPasswordController@store`
+  description: |
+    已被 `POST /v3/password-resets` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):`dashboard` 参数原样拼进邮件链接;token 不过期;邮箱未注册时回 404。
   requestBody:
   requestBody:
     required: false
     required: false
     content:
     content:
@@ -101,3 +104,4 @@ post:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true

+ 3 - 1
openapi/public/assets/protocol/resources/auto/v2-auth-reset-password-reset-password.yaml

@@ -4,7 +4,8 @@ get:
   tags:
   tags:
     - auth
     - auth
   operationId: get_api_v2_auth_reset_password_reset_password_
   operationId: get_api_v2_auth_reset_password_reset_password_
-  description: 实现:`ResetPasswordController@show`
+  description: |
+    已被 `GET /v3/password-resets/{token}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
   parameters:
   parameters:
     -
     -
       name: reset_password
       name: reset_password
@@ -30,6 +31,7 @@ get:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true
 put:
 put:
   summary: '更新 auth/reset-password/{reset_password}'
   summary: '更新 auth/reset-password/{reset_password}'
   tags:
   tags:

+ 5 - 1
openapi/public/assets/protocol/resources/auto/v2-auth-reset-password.yaml

@@ -69,7 +69,10 @@ post:
   tags:
   tags:
     - auth
     - auth
   operationId: post_api_v2_auth_reset_password
   operationId: post_api_v2_auth_reset_password
-  description: 实现:`ResetPasswordController@store`
+  description: |
+    已被 `PATCH /v3/password-resets/{token}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):成功时 `data` 是 user_infos 整行,含密码哈希。
   requestBody:
   requestBody:
     required: false
     required: false
     content:
     content:
@@ -101,3 +104,4 @@ post:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true

+ 85 - 0
openapi/public/assets/protocol/resources/auto/v3-password-resets-token.yaml

@@ -0,0 +1,85 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-password-resets-token.yaml
+get:
+  summary: 查看一次待完成的重置
+  tags:
+    - password-resets
+  operationId: get_api_v3_password_resets_token_
+  description: |-
+    重置页用它显示「正在为哪个账号设置新密码」。token 不存在或已过期一律 404。
+
+    实现:`V3\PasswordResetController@show`
+  parameters:
+    -
+      name: token
+      in: path
+      required: true
+      schema:
+        type: string
+      description: '邮件链接里的 token'
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  username:
+                    type: string
+                  expires_at:
+                    type: string
+    404:
+      description: 'token 无效或已过期'
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'
+patch:
+  summary: 设置新密码
+  tags:
+    - password-resets
+  operationId: patch_api_v3_password_resets_token_
+  description: |-
+    成功后 token 作废,返回 204 空体;之后用新密码走登录。
+
+    实现:`V3\PasswordResetController@update`
+  parameters:
+    -
+      name: token
+      in: path
+      required: true
+      schema:
+        type: string
+      description: '邮件链接里的 token'
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          properties:
+            password:
+              type: string
+              description: '新密码,6–32 位'
+            password_confirmation:
+              type: string
+              description: 再输一次新密码
+          required:
+            - password
+            - password_confirmation
+  responses:
+    204:
+      description: 已重置,无响应体
+    404:
+      description: 'token 无效或已过期'
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'

+ 51 - 0
openapi/public/assets/protocol/resources/auto/v3-password-resets.yaml

@@ -0,0 +1,51 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-password-resets.yaml
+post:
+  summary: 申请重置密码(发邮件)
+  tags:
+    - password-resets
+  operationId: post_api_v3_password_resets
+  description: |-
+    无论邮箱是否注册都返回 204,不泄露账号是否存在。邮件是同步发出的,
+    返回时已经发完,所以是 204 而不是 202。邮件里的链接指向
+    dashboard-v6 的 `/anonymous/reset-password/{token}`,60 分钟内有效。
+    按 IP 与邮箱限流,超出返回 429。
+
+    实现:`V3\PasswordResetController@store`
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          properties:
+            email:
+              type: string
+              description: 账号邮箱
+              example: someone@example.com
+            lang:
+              type: string
+              description: '邮件语言,没有对应模板的回落 en'
+              enum:
+                - en
+                - en-US
+                - zh-Hans
+                - zh-Hant
+          required:
+            - email
+  responses:
+    204:
+      description: 已发送(或邮箱未注册),无响应体
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'
+    429:
+      description: 请求过于频繁
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'
+    503:
+      description: 邮件发送失败
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'