Sfoglia il codice sorgente

feat: 注册迁到 v3(email-certifications / invites / users),dashboard-v6 切过去

取代 POST /v2/email-certification、GET /v2/invite/{id}、POST /v2/sign-up
(v2 原样保留,openapi 标 deprecated)。

- POST /v3/email-certifications:发 6 位验证码(30 分钟,试错 5 次作废),
  只在服务端比对,绝不回传;邮箱已注册 422(不区分大小写)
- POST /v3/invites {email, code}:验证码换 invite;验证前不写 invites 表
- GET /v3/invites/{invite}:邀请注册页读邮箱,已用或不存在 404
- POST /v3/users:凭 invite 建账号 + draft channel,数据形状与 v2 一致;
  服务端校验用户名格式与唯一
- v6:/anonymous/sign-up 自助注册四步、/anonymous/sign-up/:token 邀请注册;
  /anonymous/users/sign-up/:token 重定向(studio 邀请邮件仍由 v2 发出);
  删掉 users/utils.tsx 与 Auth.ts 里的 v2 注册类型

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 1 settimana fa
parent
commit
e16c5e55d8
46 ha cambiato i file con 2103 aggiunte e 189 eliminazioni
  1. 1 0
      CLAUDE.md
  2. 42 0
      api-v13/app/Http/Controllers/V3/EmailCertificationController.php
  3. 61 0
      api-v13/app/Http/Controllers/V3/InviteController.php
  4. 44 0
      api-v13/app/Http/Controllers/V3/UserController.php
  5. 38 0
      api-v13/app/Http/Requests/V3/StoreEmailCertificationRequest.php
  6. 27 0
      api-v13/app/Http/Requests/V3/StoreInviteRequest.php
  7. 36 0
      api-v13/app/Http/Requests/V3/StoreUserRequest.php
  8. 36 0
      api-v13/app/Http/Resources/V3/InviteResource.php
  9. 38 0
      api-v13/app/Http/Resources/V3/UserResource.php
  10. 45 0
      api-v13/app/Mail/V3/EmailCertificationMail.php
  11. 11 0
      api-v13/app/Providers/AppServiceProvider.php
  12. 201 0
      api-v13/app/Services/V3/SignUpService.php
  13. 1 0
      api-v13/resources/lang/en/auth.php
  14. 3 0
      api-v13/resources/lang/en/messages.php
  15. 1 0
      api-v13/resources/lang/zh-Hans/auth.php
  16. 3 0
      api-v13/resources/lang/zh-Hans/messages.php
  17. 1 0
      api-v13/resources/lang/zh-Hant/auth.php
  18. 3 0
      api-v13/resources/lang/zh-Hant/messages.php
  19. 17 0
      api-v13/routes/v3/auth.php
  20. 268 0
      api-v13/tests/Feature/SignUpV3Test.php
  21. 14 1
      dashboard-v6/src/Router.tsx
  22. 0 24
      dashboard-v6/src/api/Auth.ts
  23. 337 4
      dashboard-v6/src/api/schema.d.ts
  24. 69 0
      dashboard-v6/src/api/sign-up.ts
  25. 238 0
      dashboard-v6/src/components/users/SelfSignUp.tsx
  26. 128 121
      dashboard-v6/src/components/users/SignUp.tsx
  27. 25 0
      dashboard-v6/src/components/users/form-errors.ts
  28. 0 31
      dashboard-v6/src/components/users/utils.tsx
  29. 26 0
      dashboard-v6/src/locales/en-US/auth/index.ts
  30. 23 0
      dashboard-v6/src/locales/zh-Hans/auth/index.ts
  31. 23 0
      dashboard-v6/src/locales/zh-Hant/auth/index.ts
  32. 26 0
      dashboard-v6/src/pages/users/sign-up-invite.tsx
  33. 2 4
      dashboard-v6/src/pages/users/sign-up.tsx
  34. 8 0
      openapi/public/assets/protocol/main.yaml
  35. 8 0
      openapi/public/assets/protocol/overrides/v2-email-certification-email-certification.yaml
  36. 5 0
      openapi/public/assets/protocol/overrides/v2-email-certification.yaml
  37. 5 0
      openapi/public/assets/protocol/overrides/v2-invite-invite.yaml
  38. 7 0
      openapi/public/assets/protocol/overrides/v2-sign-up.yaml
  39. 6 1
      openapi/public/assets/protocol/resources/auto/v2-email-certification-email-certification.yaml
  40. 3 1
      openapi/public/assets/protocol/resources/auto/v2-email-certification.yaml
  41. 3 1
      openapi/public/assets/protocol/resources/auto/v2-invite-invite.yaml
  42. 5 1
      openapi/public/assets/protocol/resources/auto/v2-sign-up.yaml
  43. 49 0
      openapi/public/assets/protocol/resources/auto/v3-email-certifications.yaml
  44. 46 0
      openapi/public/assets/protocol/resources/auto/v3-invites-invite.yaml
  45. 75 0
      openapi/public/assets/protocol/resources/auto/v3-invites.yaml
  46. 95 0
      openapi/public/assets/protocol/resources/auto/v3-users.yaml

+ 1 - 0
CLAUDE.md

@@ -332,6 +332,7 @@ Resource / FormRequest / 测试同理;URL 只换版本前缀
 | (无对应 v2 端点) | `tipitaka-reading/{channel}` / `V3\TipitakaReadingController` | 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 `tipitaka-read-para` / `tipitaka-read-chapter`(两条),也短暂叫过 `channels/{c}/books/{b}/paragraphs\|chapters`,2026-09-24 并成一条 |
 | `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
 | `auth/forgot-password` + `auth/reset-password` / `ForgotPasswordController` + `ResetPasswordController` | `password-resets` (+`/{token}`) / `V3\PasswordResetController` | 三条并成一个资源:POST 发信、GET 看账号、PATCH 设密码(204)。库里只存 token 的 sha256、60 分钟过期、邮箱未注册也 204、不收 `dashboard` 参数(链接用 `mint.server.dashboard_v6_base_path` 拼到 v6 的 `/anonymous/reset-password/{token}`)、限流 |
+| `email-certification` + `invite/{id}`(读)+ `sign-up` / `EmailCertificationController` + `InviteController@show` + `SignUpController@store` | `email-certifications` + `invites` (+`/{invite}`) + `users` / `V3\EmailCertificationController` + `V3\InviteController` + `V3\UserController` | 注册。验证码改在服务端比对:v2 的 `GET email-certification/{id}`(把码交给前端)在 v3 没有对应端点,换成 `POST invites {email, code}` 签发 invite;自助与邀请注册都凭 invite 走 `POST users`。验证码 6 位、30 分钟、试错 5 次作废;验证通过前不写 invites 表。studio 发邀请(`POST /v2/invite`)不在此列,将来迁成 `studios/{studio}/invites` |
 
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -118,6 +118,17 @@ class AppServiceProvider extends ServiceProvider
                 ? Limit::perHour(5)->by('email:'.mb_strtolower((string) $request->input('email')))
                 : null,
         ]));
+
+        // 注册验证码同样往外发邮件,口径与找回密码一致
+        RateLimiter::for('email-certifications', fn (Request $request): array => array_filter([
+            Limit::perMinute(10)->by('ip:'.$request->ip()),
+            $request->filled('email')
+                ? Limit::perHour(5)->by('email:'.mb_strtolower((string) $request->input('email')))
+                : null,
+        ]));
+
+        // 比对验证码、建账号:按 IP 限,挡住对 6 位验证码的穷举(单个码另有 5 次试错上限)
+        RateLimiter::for('sign-up', fn (Request $request): Limit => Limit::perMinute(20)->by('ip:'.$request->ip()));
     }
 
     /**

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

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

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

@@ -16,5 +16,6 @@ return [
     'failed' => 'These credentials do not match our records.',
     'password' => 'The provided password is incorrect.',
     'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+    'email_certification_subject' => 'Verify your email for wikipali',
 
 ];

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

@@ -13,5 +13,8 @@ return [
     */
 
     'mail_send_failed' => 'The email could not be sent. Please try again later.',
+    'email_registered' => 'This email is already registered.',
+    'sign_up_code_invalid' => 'The verification code is incorrect or has expired.',
+    'invite_invalid' => 'This invitation is invalid or has already been used.',
 
 ];

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

@@ -16,5 +16,6 @@ return [
     'failed' => '用户名或密码错误。',
     'password' => '密码不正确。',
     'throttle' => '登录尝试次数过多,请在 :seconds 秒后重试。',
+    'email_certification_subject' => 'wikipali 注册验证码',
 
 ];

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

@@ -13,5 +13,8 @@ return [
     */
 
     'mail_send_failed' => '邮件发送失败,请稍后再试。',
+    'email_registered' => '该邮箱已注册。',
+    'sign_up_code_invalid' => '验证码不正确或已过期。',
+    'invite_invalid' => '邀请无效或已被使用。',
 
 ];

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

@@ -16,5 +16,6 @@ return [
     'failed' => '用戶名或密碼錯誤。',
     'password' => '密碼不正確。',
     'throttle' => '登入嘗試次數過多,請在 :seconds 秒後重試。',
+    'email_certification_subject' => 'wikipali 註冊驗證碼',
 
 ];

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

@@ -13,5 +13,8 @@ return [
     */
 
     'mail_send_failed' => '郵件發送失敗,請稍後再試。',
+    'email_registered' => '該郵箱已註冊。',
+    'sign_up_code_invalid' => '驗證碼不正確或已過期。',
+    'invite_invalid' => '邀請無效或已被使用。',
 
 ];

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

@@ -11,7 +11,10 @@
 |
 */
 
+use App\Http\Controllers\V3\EmailCertificationController;
+use App\Http\Controllers\V3\InviteController;
 use App\Http\Controllers\V3\PasswordResetController;
+use App\Http\Controllers\V3\UserController;
 use Illuminate\Support\Facades\Route;
 
 // 找回密码。token 是 64 位字母数字,约束挂在每条路由上(prefix 组上的 where 会互相顶掉)。
@@ -26,3 +29,17 @@ Route::patch('password-resets/{token}', [PasswordResetController::class, 'update
     ->where('token', '[A-Za-z0-9]{64}')
     ->middleware('throttle:password-resets')
     ->name('password-resets.update');
+
+// 注册:发验证码 → 验证码换 invite(或邀请邮件里自带 invite)→ 凭 invite 建账号
+Route::post('email-certifications', [EmailCertificationController::class, 'store'])
+    ->middleware('throttle:email-certifications')
+    ->name('email-certifications.store');
+Route::post('invites', [InviteController::class, 'store'])
+    ->middleware('throttle:sign-up')
+    ->name('invites.store');
+Route::get('invites/{invite}', [InviteController::class, 'show'])
+    ->whereUuid('invite')
+    ->name('invites.show');
+Route::post('users', [UserController::class, 'store'])
+    ->middleware('throttle:sign-up')
+    ->name('users.store');

+ 268 - 0
api-v13/tests/Feature/SignUpV3Test.php

@@ -0,0 +1,268 @@
+<?php
+
+use App\Mail\V3\EmailCertificationMail;
+use App\Models\Channel;
+use App\Models\Invite;
+use App\Models\UserInfo;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v3 注册。这里断言的是形状而非契约(spectator 未安装)。
+ *
+ * 端点:
+ *   POST /api/v3/email-certifications   发 6 位验证码
+ *   POST /api/v3/invites                验证码换 invite(服务端比对)
+ *   GET  /api/v3/invites/{invite}       查看可用的 invite(邀请注册页)
+ *   POST /api/v3/users                  凭 invite 建账号
+ */
+
+// 测试环境的 cache 是 array,每条测试重建应用时清零,验证码与限流计数不会串
+beforeEach(fn () => Mail::fake());
+
+/**
+ * 发一次验证码,从邮件里取回明文。
+ */
+function requestSignUpCode(string $email): string
+{
+    test()->postJson('/api/v3/email-certifications', ['email' => $email])->assertNoContent();
+
+    $code = null;
+    Mail::assertSent(EmailCertificationMail::class, function (EmailCertificationMail $mail) use ($email, &$code) {
+        if ($mail->hasTo($email)) {
+            $code = $mail->code;
+        }
+
+        return true;
+    });
+
+    return $code;
+}
+
+function makeInvite(string $email, string $status = 'invited', ?string $inviter = null): string
+{
+    $id = (string) Str::uuid();
+    (new Invite)->forceFill([
+        'id' => $id,
+        'email' => $email,
+        'status' => $status,
+        'user_uid' => $inviter ?? (string) Str::uuid(),
+    ])->save();
+
+    return $id;
+}
+
+/**
+ * @return array<string, string>
+ */
+function signUpPayload(string $invite, array $overrides = []): array
+{
+    return array_merge([
+        'invite' => $invite,
+        'username' => 'new_user_1',
+        'password' => 'secret-pw',
+        'password_confirmation' => 'secret-pw',
+        'lang' => 'zh-Hans',
+    ], $overrides);
+}
+
+// ── email-certifications ─────────────────────────────────────────────
+
+it('mails a 6 digit code and never returns it', function () {
+    $response = $this->postJson('/api/v3/email-certifications', ['email' => 'fresh@example.test', 'lang' => 'zh-Hant'])
+        ->assertNoContent();
+
+    expect($response->getContent())->toBe('');
+    Mail::assertSent(EmailCertificationMail::class, fn (EmailCertificationMail $mail) => $mail->hasTo('fresh@example.test')
+        && preg_match('/^\d{6}$/', $mail->code) === 1
+        && $mail->lang === 'zh-Hant');
+});
+
+it('refuses to send a code to a registered email, case-insensitively', function () {
+    makeStudio('taken');
+
+    $this->postJson('/api/v3/email-certifications', ['email' => 'TAKEN@example.test'])
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['email']]);
+
+    Mail::assertNothingSent();
+});
+
+it('rejects a malformed email', function () {
+    $this->postJson('/api/v3/email-certifications', ['email' => 'nope'])->assertStatus(422);
+});
+
+it('throttles codes for the same email', function () {
+    foreach (range(1, 5) as $_) {
+        $this->postJson('/api/v3/email-certifications', ['email' => 'flood@example.test'])->assertNoContent();
+    }
+
+    $this->postJson('/api/v3/email-certifications', ['email' => 'flood@example.test'])->assertStatus(429);
+});
+
+// ── invites ──────────────────────────────────────────────────────────
+
+it('exchanges a correct code for an invite, once', function () {
+    $code = requestSignUpCode('verify@example.test');
+
+    $data = $this->postJson('/api/v3/invites', ['email' => 'verify@example.test', 'code' => $code])
+        ->assertCreated()
+        ->json('data');
+
+    expect($data)->toHaveKeys(['id', 'email', 'status', 'created_at'])
+        ->and($data['email'])->toBe('verify@example.test')
+        ->and($data['status'])->toBe('invited')
+        ->and($data)->not->toHaveKey('user_uid')
+        ->and(Invite::where('id', $data['id'])->exists())->toBeTrue();
+
+    // 验证码一次性
+    $this->postJson('/api/v3/invites', ['email' => 'verify@example.test', 'code' => $code])
+        ->assertStatus(422);
+});
+
+it('rejects a wrong code without creating an invite', function () {
+    requestSignUpCode('wrong@example.test');
+
+    $this->postJson('/api/v3/invites', ['email' => 'wrong@example.test', 'code' => '000000'])
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['code']]);
+
+    expect(Invite::where('email', 'wrong@example.test')->exists())->toBeFalse();
+});
+
+it('burns the code after too many wrong attempts', function () {
+    $code = requestSignUpCode('brute@example.test');
+    $wrong = $code === '111111' ? '222222' : '111111';
+
+    foreach (range(1, 5) as $_) {
+        $this->postJson('/api/v3/invites', ['email' => 'brute@example.test', 'code' => $wrong])->assertStatus(422);
+    }
+
+    $this->postJson('/api/v3/invites', ['email' => 'brute@example.test', 'code' => $code])->assertStatus(422);
+});
+
+it('reuses an existing studio invite and keeps its inviter', function () {
+    $inviter = makeStudio('inviter');
+    $id = makeInvite('invited@example.test', 'invited', $inviter);
+    $code = requestSignUpCode('invited@example.test');
+
+    $data = $this->postJson('/api/v3/invites', ['email' => 'invited@example.test', 'code' => $code])
+        ->assertOk()
+        ->json('data');
+
+    expect($data['id'])->toBe($id)
+        ->and(Invite::find($id)->user_uid)->toBe($inviter);
+});
+
+it('shows a pending invite', function () {
+    $id = makeInvite('show@example.test');
+
+    $this->getJson("/api/v3/invites/{$id}")
+        ->assertOk()
+        ->assertJsonPath('data.email', 'show@example.test');
+});
+
+it('hides used, unknown and malformed invites', function () {
+    $used = makeInvite('used@example.test', 'sign-up');
+
+    $this->getJson("/api/v3/invites/{$used}")->assertNotFound();
+    $this->getJson('/api/v3/invites/'.Str::uuid())->assertNotFound();
+    $this->getJson('/api/v3/invites/not-a-uuid')->assertNotFound();
+});
+
+// ── users ────────────────────────────────────────────────────────────
+
+it('registers an account the same shape v2 would', function () {
+    $invite = makeInvite('member@example.test');
+
+    $data = $this->postJson('/api/v3/users', signUpPayload($invite))
+        ->assertCreated()
+        ->json('data');
+
+    expect($data)->toHaveKeys(['id', 'username', 'nickname', 'email', 'created_at'])
+        ->and($data)->not->toHaveKey('password')
+        ->and($data['email'])->toBe('member@example.test')
+        ->and($data['nickname'])->toBe('new_user_1');
+
+    $user = UserInfo::where('userid', $data['id'])->first();
+    expect($user->password)->toBe(md5('secret-pw'))
+        ->and(json_decode($user->role, true))->toBe(['basic']);
+
+    $draft = Channel::where('owner_uid', $user->userid)->first();
+    expect($draft->name)->toBe('draft')
+        ->and($draft->status)->toBe(5)
+        ->and($draft->lang)->toBe('zh-Hans')
+        ->and((int) $draft->editor_id)->toBe($user->id);
+
+    expect(Invite::find($invite)->status)->toBe('sign-up');
+
+    // v2 登录认这个账号(v4 / mobile 不受影响)
+    $this->postJson('/api/v2/sign-in', ['username' => 'new_user_1', 'password' => 'secret-pw'])
+        ->assertJsonPath('ok', true);
+});
+
+it('uses the given nickname', function () {
+    $invite = makeInvite('nick@example.test');
+
+    $this->postJson('/api/v3/users', signUpPayload($invite, ['nickname' => '  觉音  ']))
+        ->assertCreated()
+        ->assertJsonPath('data.nickname', '觉音');
+});
+
+it('rejects a used invite', function () {
+    $invite = makeInvite('twice@example.test');
+    $this->postJson('/api/v3/users', signUpPayload($invite))->assertCreated();
+
+    $this->postJson('/api/v3/users', signUpPayload($invite, ['username' => 'another_1']))
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['invite']]);
+});
+
+it('rejects an invite whose email is already registered', function () {
+    makeStudio('already');
+    $invite = makeInvite('already@example.test');
+
+    $this->postJson('/api/v3/users', signUpPayload($invite))
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['invite']]);
+});
+
+it('rejects a taken username', function () {
+    makeStudio('new_user_1');
+    $invite = makeInvite('dup@example.test');
+
+    $this->postJson('/api/v3/users', signUpPayload($invite))
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['username']]);
+});
+
+it('rejects malformed account fields', function (array $overrides, string $field) {
+    $invite = makeInvite('bad@example.test');
+
+    $this->postJson('/api/v3/users', signUpPayload($invite, $overrides))
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => [$field]]);
+
+    expect(Invite::find($invite)->status)->toBe('invited');
+})->with([
+    'username with non-ascii' => [['username' => 'abc中文def'], 'username'],
+    'username too short' => [['username' => 'abc'], 'username'],
+    'password mismatch' => [['password_confirmation' => 'other-pw'], 'password'],
+    'password too short' => [['password' => '12345', 'password_confirmation' => '12345'], 'password'],
+    'missing lang' => [['lang' => null], 'lang'],
+    'invite not a uuid' => [['invite' => 'abc'], 'invite'],
+]);
+
+it('walks the whole self sign-up flow', function () {
+    $code = requestSignUpCode('walk@example.test');
+    $invite = $this->postJson('/api/v3/invites', ['email' => 'walk@example.test', 'code' => $code])
+        ->assertCreated()
+        ->json('data.id');
+
+    $this->postJson('/api/v3/users', signUpPayload($invite, ['username' => 'walker_1']))
+        ->assertCreated()
+        ->assertJsonPath('data.email', 'walk@example.test');
+});

+ 14 - 1
dashboard-v6/src/Router.tsx

@@ -1,6 +1,6 @@
 // src/Router.tsx
 import { lazy } from "react";
-import { createBrowserRouter } from "react-router";
+import { createBrowserRouter, redirect } from "react-router";
 import { RouterProvider } from "react-router/dom";
 import { testRoutes } from "./routes/testRoutes";
 import { buildRouteConfig } from "./routes/buildRoutes";
@@ -27,6 +27,7 @@ const TestLayout = lazy(() => import("./layouts/test"));
 
 const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
 const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
+const UsersSignUpInvite = lazy(() => import("./pages/users/sign-up-invite"));
 const UsersForgotPassword = lazy(() => import("./pages/users/forgot-password"));
 const UsersResetPassword = lazy(() => import("./pages/users/reset-password"));
 const DashboardIndex = lazy(() => import("./pages/dashboard/index"));
@@ -57,6 +58,18 @@ const router = createBrowserRouter(
               Component: UsersSignUp,
               handle: { crumb: "sign-up" },
             },
+            {
+              path: "sign-up/:token",
+              Component: UsersSignUpInvite,
+              handle: { crumb: "sign-up" },
+            },
+            // studio 邀请邮件仍由 v2(POST /v2/invite → InviteMail)发出,链接是 v4 的
+            // 路径形状 /anonymous/users/sign-up/{uuid}。邀请创建迁 v3 之后删掉这条。
+            {
+              path: "users/sign-up/:token",
+              loader: ({ params }) =>
+                redirect(`/anonymous/sign-up/${params.token ?? ""}`),
+            },
             { path: "forgot-password", Component: UsersForgotPassword },
             // 找回密码邮件里的链接,由后端按 mint.server.dashboard_v6_base_path 拼出
             { path: "reset-password/:token", Component: UsersResetPassword },

+ 0 - 24
dashboard-v6/src/api/Auth.ts

@@ -25,24 +25,6 @@ export type TRole =
   | "assistant"
   | "unknown";
 
-export interface ISignUpRequest {
-  token: string;
-  username: string;
-  nickname: string;
-  email: string;
-  password: string;
-  lang: string;
-}
-export interface ISignUpVerifyResponse {
-  ok: boolean;
-  message: string | { email: boolean; username: boolean };
-  data: string;
-}
-export interface ISignInResponse {
-  ok: boolean;
-  message: string;
-  data: string;
-}
 export interface IUserRequest {
   id?: string;
   userName?: string;
@@ -130,10 +112,4 @@ export interface IInviteResponse {
   data: IInviteData;
 }
 
-export interface IEmailCertificationResponse {
-  ok: boolean;
-  message: string;
-  data: number;
-}
-
 export type TSoftwareEdition = "basic" | "pro";

+ 337 - 4
dashboard-v6/src/api/schema.d.ts

@@ -2755,7 +2755,8 @@ export interface paths {
         put?: never;
         /**
          * 新建 email-certification
-         * @description 实现:`EmailCertificationController@store`
+         * @deprecated
+         * @description 已被 `POST /v3/email-certifications` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
          */
         post: operations["post_api_v2_email_certification"];
         delete?: never;
@@ -2773,7 +2774,11 @@ export interface paths {
         };
         /**
          * 获取单个 email-certification/{email_certification}
-         * @description 实现:`EmailCertificationController@show`
+         * @deprecated
+         * @description v3 没有对应端点:验证码改在服务端比对(`POST /v3/invites`),不再回传给前端。
+         *     保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+         *
+         *     已知问题(v2 冻结不修):直接返回验证码,邮箱验证可被绕过。
          */
         get: operations["get_api_v2_email_certification_email_certification_"];
         /**
@@ -3317,7 +3322,8 @@ export interface paths {
         };
         /**
          * 获取单个 invite/{invite}
-         * @description 实现:`InviteController@show`
+         * @deprecated
+         * @description 已被 `GET /v3/invites/{invite}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
          */
         get: operations["get_api_v2_invite_invite_"];
         /**
@@ -5926,7 +5932,10 @@ export interface paths {
         put?: never;
         /**
          * 新建 sign-up
-         * @description 实现:`SignUpController@store`
+         * @deprecated
+         * @description 已被 `POST /v3/users` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+         *
+         *     已知问题(v2 冻结不修):invite 校验漏了 return,形同虚设。
          */
         post: operations["post_api_v2_sign_up"];
         delete?: never;
@@ -7696,6 +7705,29 @@ export interface paths {
         patch: operations["patch_api_v2_webhook_webhook_"];
         trace?: never;
     };
+    "/v3/email-certifications": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * 发送注册验证码
+         * @description 向邮箱发一个 6 位验证码,30 分钟内有效;重复申请会作废上一个。
+         *     邮箱已注册时返回 422。按 IP 与邮箱限流,超出返回 429。
+         *
+         *     实现:`V3\EmailCertificationController@store`
+         */
+        post: operations["post_api_v3_email_certifications"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/heartbeat": {
         parameters: {
             query?: never;
@@ -7726,6 +7758,51 @@ export interface paths {
         patch?: never;
         trace?: never;
     };
+    "/v3/invites": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * 用验证码换注册邀请
+         * @description 验证码正确则返回该邮箱的 invite(已有则复用,状态重置为未使用),验证码随即作废。
+         *     验证码错误、过期或试错 5 次后返回 422(errors.code)。
+         *
+         *     实现:`V3\InviteController@store`
+         */
+        post: operations["post_api_v3_invites"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/invites/{invite}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 查看一条可用的注册邀请
+         * @description 邀请注册页用它显示邮箱。不存在或已被使用都返回 404。
+         *
+         *     实现:`V3\InviteController@show`
+         */
+        get: operations["get_api_v3_invites_invite_"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/me/reactions": {
         parameters: {
             query?: never;
@@ -8097,6 +8174,30 @@ export interface paths {
         patch?: never;
         trace?: never;
     };
+    "/v3/users": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * 注册账号
+         * @description 凭 invite(邀请邮件里的,或 `POST /v3/invites` 换来的)建账号,同时建一个
+         *     私有的 draft 译文 channel,invite 标为已用。邮箱取自 invite。
+         *     注册不等于登录:返回 201 与新账号,之后用用户名/邮箱 + 密码走登录。
+         *
+         *     实现:`V3\UserController@store`
+         */
+        post: operations["post_api_v3_users"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
 }
 export type webhooks = Record<string, never>;
 export interface components {
@@ -34083,6 +34184,58 @@ export interface operations {
             422: components["responses"]["ValidationError"];
         };
     };
+    post_api_v3_email_certifications: {
+        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_heartbeat: {
         parameters: {
             query?: never;
@@ -34117,6 +34270,111 @@ export interface operations {
             };
         };
     };
+    post_api_v3_invites: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description 收到验证码的邮箱 */
+                    email: string;
+                    /**
+                     * @description 邮件里的 6 位验证码
+                     * @example 123456
+                     */
+                    code: string;
+                };
+            };
+        };
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            email?: string;
+                            status?: string;
+                            created_at?: string | null;
+                        };
+                    };
+                };
+            };
+            /** @description 首次为该邮箱签发 invite。响应体与 200 同形;复用已有 invite 时是 200 */
+            201: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            email?: string;
+                            status?: string;
+                            created_at?: string | null;
+                        };
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+            /** @description 请求过于频繁 */
+            429: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
+    get_api_v3_invites_invite_: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path: {
+                /** @description 邀请邮件链接里的 uuid */
+                invite: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            email?: string;
+                            status?: string;
+                            created_at?: string | null;
+                        };
+                    };
+                };
+            };
+            /** @description 邀请不存在或已被使用 */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
     get_api_v3_me_reactions: {
         parameters: {
             query?: {
@@ -34990,4 +35248,79 @@ export interface operations {
             };
         };
     };
+    post_api_v3_users: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description 注册邀请的 uuid */
+                    invite: string;
+                    /** @description 用户名,6–32 位字母、数字、下划线 */
+                    username: string;
+                    /** @description 昵称,不传或空白时用 username */
+                    nickname?: string;
+                    /** @description 密码,6–32 位 */
+                    password: string;
+                    /** @description 再输一次密码 */
+                    password_confirmation: string;
+                    /**
+                     * @description 常用译文语言,用作 draft channel 的语言
+                     * @example zh-Hans
+                     */
+                    lang: string;
+                };
+            };
+        };
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            username?: string;
+                            nickname?: string;
+                            email?: string;
+                            created_at?: string | null;
+                        };
+                    };
+                };
+            };
+            /** @description 已创建 */
+            201: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            username?: string;
+                            nickname?: string;
+                            email?: string;
+                            created_at?: string | null;
+                        };
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+            /** @description 请求过于频繁 */
+            429: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
 }

+ 69 - 0
dashboard-v6/src/api/sign-up.ts

@@ -0,0 +1,69 @@
+// src/api/sign-up.ts
+//
+// 注册。**已完全切到 v3**(取代 /v2/email-certification、/v2/invite/{id}、
+// /v2/sign-up),没有 v2 分支,要回退就 revert 那个 commit。
+//
+// 自助注册:sendSignUpCode → exchangeCodeForInvite → createAccount
+// 邀请注册:fetchInvite(邮件链接里的 invite)→ createAccount
+
+import { api, unwrap, unwrapQuiet, unwrapVoid } from "./client";
+import type { paths } from "./schema";
+
+type CodeBody = NonNullable<
+  paths["/v3/email-certifications"]["post"]["requestBody"]
+>["content"]["application/json"];
+
+type MailLang = NonNullable<CodeBody["lang"]>;
+
+/** 注册凭据:id 就是 createAccount 要的 invite */
+export type Invite = NonNullable<
+  paths["/v3/invites/{invite}"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
+
+export type AccountInput = NonNullable<
+  paths["/v3/users"]["post"]["requestBody"]
+>["content"]["application/json"];
+
+export type Account = NonNullable<
+  paths["/v3/users"]["post"]["responses"][201]["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);
+
+/** 给待注册的邮箱发 6 位验证码。邮箱已注册时 422(errors.email) */
+export const sendSignUpCode = async (
+  email: string,
+  uiLang: string,
+): Promise<void> =>
+  unwrapVoid(
+    await api.POST("/v3/email-certifications", {
+      body: { email, lang: toMailLang(uiLang) },
+    }),
+  );
+
+/** 服务端比对验证码,通过则拿到 invite。验证码不对时 422(errors.code) */
+export const exchangeCodeForInvite = async (
+  email: string,
+  code: string,
+): Promise<Invite> =>
+  unwrap(await api.POST("/v3/invites", { body: { email, code } })).data ?? {};
+
+/**
+ * 邀请注册页:凭邮件链接里的 invite 取邮箱。
+ *
+ * 用 unwrapQuiet:邀请无效 / 已用过(404)由页面自己渲染成提示,不该再弹窗。
+ */
+export const fetchInvite = async (invite: string): Promise<Invite> =>
+  unwrapQuiet(
+    await api.GET("/v3/invites/{invite}", {
+      params: { path: { invite } },
+    }),
+  ).data ?? {};
+
+/** 凭 invite 建账号。注册不等于登录,之后走登录页 */
+export const createAccount = async (input: AccountInput): Promise<Account> =>
+  unwrap(await api.POST("/v3/users", { body: input })).data ?? {};

+ 238 - 0
dashboard-v6/src/components/users/SelfSignUp.tsx

@@ -0,0 +1,238 @@
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import { Alert, Button, message } from "antd";
+import {
+  CheckCard,
+  ProFormCaptcha,
+  ProFormCheckbox,
+  type ProFormInstance,
+  ProFormText,
+  StepsForm,
+} from "@ant-design/pro-components";
+import {
+  CheckOutlined,
+  CloseOutlined,
+  LockOutlined,
+  MailOutlined,
+} from "@ant-design/icons";
+
+import {
+  createAccount,
+  exchangeCodeForInvite,
+  type Invite,
+  sendSignUpCode,
+} from "../../api/sign-up";
+import { get as getUiLang } from "../../locales";
+import { applyFieldErrors } from "./form-errors";
+import { type AccountFormValues, AccountFields, SignUpSuccess } from "./SignUp";
+
+interface IEmailStep {
+  email: string;
+  code: string;
+}
+
+/** 版本说明里的功能清单:[locale id, 该版本是否可用] */
+type TFeatures = [string, boolean][];
+
+const GUEST: TFeatures = [
+  ["reading", true],
+  ["dict", true],
+  ["search", true],
+  ["translate", false],
+  ["join-course", false],
+];
+const BASIC: TFeatures = [
+  ["wbw", true],
+  ["translate", true],
+  ["join-course", true],
+  ["publish-translation", false],
+  ["publish-term", false],
+  ["create-course", false],
+  ["create-group", false],
+];
+const PRO: TFeatures = BASIC.map(([id]) => [id, true]);
+
+const FeatureList = ({ features }: { features: TFeatures }) => {
+  const intl = useIntl();
+  return (
+    <div>
+      {features.map(([id, ok]) => (
+        <div key={id}>
+          {ok ? (
+            <CheckOutlined style={{ color: "#52c41a" }} />
+          ) : (
+            <CloseOutlined style={{ color: "#ff4d4f" }} />
+          )}{" "}
+          {intl.formatMessage({ id: `auth.sign-up.feature.${id}` })}
+        </div>
+      ))}
+    </div>
+  );
+};
+
+/**
+ * 自助注册四步:版本说明 → 邮箱验证 → 账号信息 → 完成。
+ *
+ * 邮箱验证由服务端比对验证码,通过后拿到 invite,第三步凭它建账号。
+ */
+const SelfSignUp = () => {
+  const intl = useIntl();
+  const emailFormRef = useRef<ProFormInstance<IEmailStep> | undefined>(
+    undefined,
+  );
+  const accountFormRef = useRef<ProFormInstance | undefined>(undefined);
+  const [agree, setAgree] = useState(false);
+  const [invite, setInvite] = useState<Invite>();
+
+  return (
+    <StepsForm
+      submitter={{
+        render(props, dom) {
+          if (props.step === 0) {
+            return (
+              <Button
+                type="primary"
+                disabled={!agree}
+                onClick={() => props.onSubmit?.()}
+              >
+                {intl.formatMessage({ id: "buttons.next" })}
+              </Button>
+            );
+          }
+          // 最后一步只是结果页,没有可提交的东西
+          return props.step === 3 ? null : dom;
+        },
+      }}
+    >
+      <StepsForm.StepForm
+        name="welcome"
+        title={intl.formatMessage({ id: "labels.sign-up" })}
+        stepProps={{
+          description: intl.formatMessage({
+            id: "auth.sign-up.basic.description",
+          }),
+        }}
+      >
+        <Alert
+          title={intl.formatMessage({ id: "auth.sign-up.guest.notice" })}
+          style={{ marginBottom: 8 }}
+        />
+        <CheckCard.Group
+          defaultValue="basic"
+          style={{ width: "100%" }}
+          size="small"
+        >
+          <CheckCard
+            title={intl.formatMessage({ id: "labels.software.edition.guest" })}
+            description={<FeatureList features={GUEST} />}
+            value="guest"
+            disabled
+          />
+          <CheckCard
+            title={intl.formatMessage({ id: "labels.software.edition.basic" })}
+            description={<FeatureList features={BASIC} />}
+            value="basic"
+          />
+          <CheckCard
+            title={intl.formatMessage({ id: "labels.software.edition.pro" })}
+            description={<FeatureList features={PRO} />}
+            value="pro"
+            disabled
+          />
+        </CheckCard.Group>
+        <ProFormCheckbox.Group
+          name="understand"
+          layout="horizontal"
+          options={[
+            {
+              label: intl.formatMessage({ id: "auth.sign-up.understand" }),
+              value: "yes",
+            },
+          ]}
+          fieldProps={{
+            onChange: (checked) => setAgree(checked.length > 0),
+          }}
+        />
+      </StepsForm.StepForm>
+
+      <StepsForm.StepForm<IEmailStep>
+        name="email"
+        formRef={emailFormRef}
+        title={intl.formatMessage({ id: "auth.sign-up.email-certification" })}
+        onFinish={async ({ email, code }) => {
+          try {
+            setInvite(await exchangeCodeForInvite(email.trim(), code.trim()));
+          } catch (e) {
+            applyFieldErrors(emailFormRef.current, e);
+            return false;
+          }
+          return true;
+        }}
+      >
+        <ProFormText
+          name="email"
+          fieldProps={{ size: "large", prefix: <MailOutlined /> }}
+          placeholder={intl.formatMessage({ id: "forms.fields.email.label" })}
+          rules={[{ required: true, type: "email", max: 256 }]}
+        />
+        <ProFormCaptcha
+          name="code"
+          phoneName="email"
+          fieldProps={{ size: "large", prefix: <LockOutlined /> }}
+          captchaProps={{ size: "large" }}
+          placeholder={intl.formatMessage({
+            id: "auth.sign-up.code.placeholder",
+          })}
+          captchaTextRender={(timing, count) =>
+            timing
+              ? intl.formatMessage(
+                  { id: "auth.sign-up.code.countdown" },
+                  { count },
+                )
+              : intl.formatMessage({ id: "auth.sign-up.code.get" })
+          }
+          rules={[{ required: true, pattern: /^\d{6}$/ }]}
+          // phoneName 让组件先校验 email 字段,通过后才调这里;抛错则不开始倒计时
+          onGetCaptcha={async (email: string) => {
+            try {
+              await sendSignUpCode(email.trim(), getUiLang());
+            } catch (e) {
+              applyFieldErrors(emailFormRef.current, e);
+              throw e;
+            }
+            message.success(
+              intl.formatMessage({ id: "auth.sign-up.code.sent" }),
+            );
+          }}
+        />
+      </StepsForm.StepForm>
+
+      <StepsForm.StepForm<AccountFormValues>
+        name="account"
+        formRef={accountFormRef}
+        title={intl.formatMessage({ id: "auth.sign-up.info" })}
+        initialValues={{ lang: "zh-Hans" }}
+        onFinish={async (values) => {
+          try {
+            await createAccount({ ...values, invite: invite?.id ?? "" });
+          } catch (e) {
+            applyFieldErrors(accountFormRef.current, e);
+            return false;
+          }
+          return true;
+        }}
+      >
+        <AccountFields email={invite?.email} />
+      </StepsForm.StepForm>
+
+      <StepsForm.StepForm
+        name="done"
+        title={intl.formatMessage({ id: "labels.done" })}
+      >
+        <SignUpSuccess />
+      </StepsForm.StepForm>
+    </StepsForm>
+  );
+};
+
+export default SelfSignUp;

+ 128 - 121
dashboard-v6/src/components/users/SignUp.tsx

@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import { useIntl } from "react-intl";
 import {
   ProForm,
@@ -6,68 +6,58 @@ import {
   type ProFormInstance,
   ProFormText,
 } from "@ant-design/pro-components";
-import { Button, message, Result } from "antd";
+import { Button, Result, Skeleton } from "antd";
 import { useNavigate } from "react-router";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
 
-import { get } from "../../request";
-
 import LangSelect from "../general/LangSelect";
-import type { IInviteResponse } from "../../api/Auth";
-import { onSignIn } from "./utils";
+import {
+  type AccountInput,
+  createAccount,
+  fetchInvite,
+  type Invite,
+} from "../../api/sign-up";
 import { TO_SIGN_IN } from "../../reducers/current-user";
+import { applyFieldErrors } from "./form-errors";
 
-export interface IAccountForm {
-  email: string;
-  username: string;
-  nickname: string;
-  password: string;
-  password2: string;
-  lang: string;
-}
-interface IAccountInfo {
-  email?: boolean;
+/** 账号信息表单的字段;invite 由调用方补上 */
+export type AccountFormValues = Omit<AccountInput, "invite">;
+
+interface IAccountFields {
+  /** 只读展示的邮箱(来自 invite,不可改) */
+  email?: string;
 }
-export const AccountInfo = ({ email = true }: IAccountInfo) => {
+/**
+ * 账号信息字段:自助注册与邀请注册共用。规则与后端 StoreUserRequest 一致。
+ */
+export const AccountFields = ({ email }: IAccountFields) => {
   const intl = useIntl();
-  const [nickname, setNickname] = useState<string>();
+  const eyeIcon = (visible: boolean) =>
+    visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />;
 
   return (
     <>
       {email ? (
-        <ProForm.Group>
-          <ProFormText
-            width="md"
-            name="email"
-            required
-            label={intl.formatMessage({
-              id: "forms.fields.email.label",
-            })}
-            rules={[{ required: true, max: 255, min: 4 }]}
-            disabled
-          />
-        </ProForm.Group>
-      ) : (
-        <></>
-      )}
+        // 不给 name:只展示,不进表单值,也就不会跟着提交
+        <ProForm.Item
+          label={intl.formatMessage({ id: "forms.fields.email.label" })}
+        >
+          {email}
+        </ProForm.Item>
+      ) : undefined}
       <ProForm.Group>
         <ProFormText
           width="md"
           name="username"
           required
-          fieldProps={{
-            onChange: (event) => {
-              setNickname(event.target.value);
-            },
-          }}
-          label={intl.formatMessage({
-            id: "forms.fields.username.label",
-          })}
+          label={intl.formatMessage({ id: "forms.fields.username.label" })}
           rules={[
-            { required: true, max: 32, min: 6 },
+            { required: true, min: 6, max: 32 },
             {
-              pattern: new RegExp("^[0-9a-zA-Z_]{1,}", "g"),
-              message: "只允许数字,字母,下划线",
+              pattern: /^[A-Za-z0-9_]+$/,
+              message: intl.formatMessage({
+                id: "auth.sign-up.username.pattern",
+              }),
             },
           ]}
         />
@@ -76,61 +66,57 @@ export const AccountInfo = ({ email = true }: IAccountInfo) => {
         <ProFormText.Password
           width="md"
           name="password"
-          fieldProps={{
-            type: "password",
-
-            iconRender: (visible) =>
-              visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
-          }}
+          fieldProps={{ iconRender: eyeIcon }}
           required
-          label={intl.formatMessage({
-            id: "forms.fields.password.label",
-          })}
-          rules={[{ required: true, max: 32, min: 6 }]}
+          label={intl.formatMessage({ id: "forms.fields.password.label" })}
+          rules={[{ required: true, min: 6, max: 32 }]}
         />
       </ProForm.Group>
       <ProForm.Group>
         <ProFormText.Password
           width="md"
-          name="password2"
-          fieldProps={{
-            type: "password",
-            iconRender: (visible) =>
-              visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />,
-          }}
+          name="password_confirmation"
+          dependencies={["password"]}
+          fieldProps={{ iconRender: eyeIcon }}
           required
           label={intl.formatMessage({
             id: "forms.fields.confirm-password.label",
           })}
-          rules={[{ required: true, max: 32, min: 6 }]}
+          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>
         <ProFormDependency name={["username"]}>
-          {({ username }) => {
-            return (
-              <ProFormText
-                width="md"
-                fieldProps={{
-                  placeholder: username,
-                  value: nickname ? nickname : username,
-                  onChange: (event) => {
-                    setNickname(event.target.value);
-                  },
-                }}
-                name="nickname"
-                required
-                label={intl.formatMessage({
-                  id: "forms.fields.nickname.label",
-                })}
-                rules={[{ required: false, max: 32, min: 4 }]}
-              />
-            );
-          }}
+          {({ username }) => (
+            <ProFormText
+              width="md"
+              name="nickname"
+              // 不填就用用户名,后端同样处理
+              fieldProps={{ placeholder: username }}
+              label={intl.formatMessage({ id: "forms.fields.nickname.label" })}
+              rules={[{ max: 32 }]}
+            />
+          )}
         </ProFormDependency>
       </ProForm.Group>
       <ProForm.Group>
-        <LangSelect label="常用的译文语言" />
+        <LangSelect
+          label={intl.formatMessage({ id: "auth.sign-up.lang.label" })}
+        />
       </ProForm.Group>
     </>
   );
@@ -142,61 +128,82 @@ export const SignUpSuccess = () => {
   return (
     <Result
       status="success"
-      title="注册成功"
-      subTitle={
+      title={intl.formatMessage({ id: "auth.sign-up.success" })}
+      subTitle={intl.formatMessage({ id: "auth.sign-up.success.hint" })}
+      extra={
         <Button type="primary" onClick={() => navigate(TO_SIGN_IN)}>
-          {intl.formatMessage({
-            id: "buttons.sign-in",
-          })}
+          {intl.formatMessage({ id: "buttons.sign-in" })}
         </Button>
       }
     />
   );
 };
 
-interface IWidget {
+type TInviteState =
+  | { kind: "loading" }
+  | { kind: "invalid" }
+  | { kind: "ready"; invite: Invite }
+  | { kind: "done" };
+
+interface IInviteSignUp {
+  /** 邀请邮件链接里的 invite id */
   token?: string;
 }
-const SignUpWidget = ({ token }: IWidget) => {
-  const [success, setSuccess] = useState(false);
+/**
+ * 邀请注册:凭邮件链接里的 invite 直接填账号信息,邮箱已由邀请确定。
+ */
+const InviteSignUp = ({ token }: IInviteSignUp) => {
+  const intl = useIntl();
   const formRef = useRef<ProFormInstance | undefined>(undefined);
-  return success ? (
-    <SignUpSuccess />
-  ) : (
-    <ProForm<IAccountForm>
+  const [state, setState] = useState<TInviteState>(
+    token ? { kind: "loading" } : { kind: "invalid" },
+  );
+
+  useEffect(() => {
+    if (!token) {
+      return;
+    }
+    let active = true;
+    fetchInvite(token)
+      .then((invite) => active && setState({ kind: "ready", invite }))
+      .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: "auth.sign-up.invite.invalid" })}
+      />
+    );
+  }
+  if (state.kind === "done") {
+    return <SignUpSuccess />;
+  }
+
+  return (
+    <ProForm<AccountFormValues>
       formRef={formRef}
-      onFinish={async (values: IAccountForm) => {
-        if (typeof token === "undefined") {
+      initialValues={{ lang: "zh-Hans" }}
+      onFinish={async (values) => {
+        try {
+          await createAccount({ ...values, invite: state.invite.id ?? "" });
+        } catch (e) {
+          applyFieldErrors(formRef.current, e);
           return;
         }
-        const signUp = await onSignIn(token, values);
-        if (signUp) {
-          if (signUp.ok) {
-            setSuccess(true);
-          } else {
-            message.error(signUp.message);
-          }
-        }
-      }}
-      request={async () => {
-        const url = `/api/v2/invite/${token}`;
-        console.info("api request", url);
-        const res = await get<IInviteResponse>(url);
-        console.debug("api response", res.data);
-        return {
-          id: res.data.id,
-          username: "",
-          nickname: "",
-          password: "",
-          password2: "",
-          email: res.data.email,
-          lang: "zh-Hans",
-        };
+        setState({ kind: "done" });
       }}
     >
-      <AccountInfo />
+      <AccountFields email={state.invite.email} />
     </ProForm>
   );
 };
 
-export default SignUpWidget;
+export default InviteSignUp;

+ 25 - 0
dashboard-v6/src/components/users/form-errors.ts

@@ -0,0 +1,25 @@
+import type { ProFormInstance } from "@ant-design/pro-components";
+
+import { fieldErrorsOf } from "../../api/error";
+
+/**
+ * 把 v3 422 的字段级错误挂到表单输入框上。提示已由 unwrap() 弹过,这里只管定位。
+ *
+ * @param rename 后端字段名与表单字段名不同时的映射
+ */
+export const applyFieldErrors = (
+  form: ProFormInstance | undefined,
+  e: unknown,
+  rename: Record<string, string> = {},
+): void => {
+  const fields = fieldErrorsOf(e);
+  if (!form || !fields) {
+    return;
+  }
+  form.setFields(
+    Object.entries(fields).map(([name, errors]) => ({
+      name: rename[name] ?? name,
+      errors,
+    })),
+  );
+};

+ 0 - 31
dashboard-v6/src/components/users/utils.tsx

@@ -1,31 +0,0 @@
-import { Modal } from "antd";
-import type { IAccountForm } from "./SignUp";
-import { post } from "../../request";
-import type { ISignInResponse, ISignUpRequest } from "../../api/Auth";
-
-export const onSignIn = async (token: string, values: IAccountForm) => {
-  if (values.password !== values.password2) {
-    //TODO remove Modal
-    Modal.error({ title: "两次密码不同" });
-    return false;
-  }
-  const url = "/api/v2/sign-up";
-  const data = {
-    token: token,
-    username: values.username,
-    nickname:
-      values.nickname && values.nickname.trim() !== ""
-        ? values.nickname
-        : values.username,
-    email: values.email,
-    password: values.password,
-    lang: values.lang,
-  };
-  console.info("api request", url, data);
-  const signUp = await post<ISignUpRequest, ISignInResponse>(
-    "/api/v2/sign-up",
-    data
-  );
-  console.info("api response", signUp);
-  return signUp;
-};

+ 26 - 0
dashboard-v6/src/locales/en-US/auth/index.ts

@@ -14,6 +14,32 @@ const items = {
   "auth.type.group": "group",
   "auth.sign-up.email-certification": "E-Mail certification",
   "auth.sign-up.info": "完善个人信息",
+  "auth.sign-up.basic.description": "Sign up for wikipali Basic",
+  "auth.sign-up.guest.notice":
+    "Reading, the dictionary and search work without an account.",
+  "auth.sign-up.feature.reading": "Read the texts",
+  "auth.sign-up.feature.dict": "Dictionary",
+  "auth.sign-up.feature.search": "Search the texts",
+  "auth.sign-up.feature.wbw": "Word-by-word analysis",
+  "auth.sign-up.feature.translate": "Translate",
+  "auth.sign-up.feature.join-course": "Join courses",
+  "auth.sign-up.feature.publish-translation":
+    "Publish translations and word-by-word analysis",
+  "auth.sign-up.feature.publish-term": "Publish user dictionaries and terms",
+  "auth.sign-up.feature.create-course": "Create courses",
+  "auth.sign-up.feature.create-group": "Create groups",
+  "auth.sign-up.understand": "I understand the limits of the Basic edition",
+  "auth.sign-up.code.placeholder": "Enter the 6-digit code from the email",
+  "auth.sign-up.code.get": "Get code",
+  "auth.sign-up.code.countdown": "Resend in {count}s",
+  "auth.sign-up.code.sent":
+    "Code sent. Check your inbox; it is valid for 30 minutes.",
+  "auth.sign-up.username.pattern": "Letters, digits and underscores only",
+  "auth.sign-up.lang.label": "Main translation language",
+  "auth.sign-up.success": "Account created",
+  "auth.sign-up.success.hint": "Sign in with your username or email.",
+  "auth.sign-up.invite.invalid":
+    "This invitation is invalid or has already been used",
 };
 
 export default items;

+ 23 - 0
dashboard-v6/src/locales/zh-Hans/auth/index.ts

@@ -14,6 +14,29 @@ const items = {
   "auth.type.group": "群组",
   "auth.sign-up.email-certification": "邮箱验证",
   "auth.sign-up.info": "完善个人信息",
+  "auth.sign-up.basic.description": "注册 wikipali 基础版",
+  "auth.sign-up.guest.notice":
+    "wikipali 的阅读、字典、搜索功能无需注册就能使用。",
+  "auth.sign-up.feature.reading": "经文阅读",
+  "auth.sign-up.feature.dict": "字典",
+  "auth.sign-up.feature.search": "经文搜索",
+  "auth.sign-up.feature.wbw": "逐词解析",
+  "auth.sign-up.feature.translate": "翻译",
+  "auth.sign-up.feature.join-course": "参加课程",
+  "auth.sign-up.feature.publish-translation": "公开发布译文和逐词解析",
+  "auth.sign-up.feature.publish-term": "公开发布用户字典和术语",
+  "auth.sign-up.feature.create-course": "建立课程",
+  "auth.sign-up.feature.create-group": "建立群组",
+  "auth.sign-up.understand": "我已经了解基础版的功能限制",
+  "auth.sign-up.code.placeholder": "请输入邮件里的 6 位验证码",
+  "auth.sign-up.code.get": "获取验证码",
+  "auth.sign-up.code.countdown": "{count} 秒后重新获取",
+  "auth.sign-up.code.sent": "验证码已发送,请查收邮件(30 分钟内有效)。",
+  "auth.sign-up.username.pattern": "只允许字母、数字、下划线",
+  "auth.sign-up.lang.label": "常用的译文语言",
+  "auth.sign-up.success": "注册成功",
+  "auth.sign-up.success.hint": "请用用户名或邮箱登录。",
+  "auth.sign-up.invite.invalid": "邀请无效或已被使用",
 };
 
 export default items;

+ 23 - 0
dashboard-v6/src/locales/zh-Hant/auth/index.ts

@@ -14,6 +14,29 @@ const items = {
   "auth.type.group": "群組",
   "auth.sign-up.email-certification": "郵箱驗證",
   "auth.sign-up.info": "完善個人資訊",
+  "auth.sign-up.basic.description": "註冊 wikipali 基礎版",
+  "auth.sign-up.guest.notice":
+    "wikipali 的閱讀、字典、搜尋功能無需註冊就能使用。",
+  "auth.sign-up.feature.reading": "經文閱讀",
+  "auth.sign-up.feature.dict": "字典",
+  "auth.sign-up.feature.search": "經文搜尋",
+  "auth.sign-up.feature.wbw": "逐詞解析",
+  "auth.sign-up.feature.translate": "翻譯",
+  "auth.sign-up.feature.join-course": "參加課程",
+  "auth.sign-up.feature.publish-translation": "公開發佈譯文和逐詞解析",
+  "auth.sign-up.feature.publish-term": "公開發佈用戶字典和術語",
+  "auth.sign-up.feature.create-course": "建立課程",
+  "auth.sign-up.feature.create-group": "建立群組",
+  "auth.sign-up.understand": "我已經瞭解基礎版的功能限制",
+  "auth.sign-up.code.placeholder": "請輸入郵件裡的 6 位驗證碼",
+  "auth.sign-up.code.get": "獲取驗證碼",
+  "auth.sign-up.code.countdown": "{count} 秒後重新獲取",
+  "auth.sign-up.code.sent": "驗證碼已發送,請查收郵件(30 分鐘內有效)。",
+  "auth.sign-up.username.pattern": "只允許字母、數字、底線",
+  "auth.sign-up.lang.label": "常用的譯文語言",
+  "auth.sign-up.success": "註冊成功",
+  "auth.sign-up.success.hint": "請用用戶名或郵箱登入。",
+  "auth.sign-up.invite.invalid": "邀請無效或已被使用",
 };
 
 export default items;

+ 26 - 0
dashboard-v6/src/pages/users/sign-up-invite.tsx

@@ -0,0 +1,26 @@
+import { Card } from "antd";
+import { useIntl } from "react-intl";
+import { useParams } from "react-router";
+import SharedLinks from "../../components/users/NonSignInSharedLinks";
+import InviteSignUp from "../../components/users/SignUp";
+
+const Widget = () => {
+  const intl = useIntl();
+  const { token } = useParams();
+
+  return (
+    <>
+      <title>{intl.formatMessage({ id: "nut.users.sign-up.title" })}</title>
+      <Card
+        title={intl.formatMessage({
+          id: "buttons.sign-up",
+        })}
+      >
+        <InviteSignUp token={token} />
+        <SharedLinks />
+      </Card>
+    </>
+  );
+};
+
+export default Widget;

+ 2 - 4
dashboard-v6/src/pages/users/sign-up.tsx

@@ -1,12 +1,10 @@
 import { Card } from "antd";
 import { useIntl } from "react-intl";
-import { useParams } from "react-router";
 import SharedLinks from "../../components/users/NonSignInSharedLinks";
-import SignUp from "../../components/users/SignUp";
+import SelfSignUp from "../../components/users/SelfSignUp";
 
 const Widget = () => {
   const intl = useIntl();
-  const { token } = useParams(); //url 参数
 
   return (
     <>
@@ -16,7 +14,7 @@ const Widget = () => {
           id: "buttons.sign-up",
         })}
       >
-        <SignUp token={token} />
+        <SelfSignUp />
         <SharedLinks />
       </Card>
     </>

+ 8 - 0
openapi/public/assets/protocol/main.yaml

@@ -681,8 +681,14 @@ paths:
     $ref: ./resources/auto/v2-webhook.yaml
   '/v2/webhook/{webhook}':
     $ref: ./resources/auto/v2-webhook-webhook.yaml
+  /v3/email-certifications:
+    $ref: ./resources/auto/v3-email-certifications.yaml
   /v3/heartbeat:
     $ref: ./resources/auto/v3-heartbeat.yaml
+  /v3/invites:
+    $ref: ./resources/auto/v3-invites.yaml
+  '/v3/invites/{invite}':
+    $ref: ./resources/auto/v3-invites-invite.yaml
   /v3/me/reactions:
     $ref: ./resources/auto/v3-me-reactions.yaml
   '/v3/me/reactions/{reaction}':
@@ -713,3 +719,5 @@ paths:
     $ref: ./resources/auto/v3-tipitaka-related-paragraphs-aggregate-book-name.yaml
   /v3/upgrade:
     $ref: ./resources/auto/v3-upgrade.yaml
+  /v3/users:
+    $ref: ./resources/auto/v3-users.yaml

+ 8 - 0
openapi/public/assets/protocol/overrides/v2-email-certification-email-certification.yaml

@@ -0,0 +1,8 @@
+# 手写补充:标记已被 v3 取代。
+get:
+  deprecated: true
+  description: |
+    v3 没有对应端点:验证码改在服务端比对(`POST /v3/invites`),不再回传给前端。
+    保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):直接返回验证码,邮箱验证可被绕过。

+ 5 - 0
openapi/public/assets/protocol/overrides/v2-email-certification.yaml

@@ -0,0 +1,5 @@
+# 手写补充:标记已被 v3 取代。
+post:
+  deprecated: true
+  description: |
+    已被 `POST /v3/email-certifications` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。

+ 5 - 0
openapi/public/assets/protocol/overrides/v2-invite-invite.yaml

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

+ 7 - 0
openapi/public/assets/protocol/overrides/v2-sign-up.yaml

@@ -0,0 +1,7 @@
+# 手写补充:标记已被 v3 取代。
+post:
+  deprecated: true
+  description: |
+    已被 `POST /v3/users` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):invite 校验漏了 return,形同虚设。

+ 6 - 1
openapi/public/assets/protocol/resources/auto/v2-email-certification-email-certification.yaml

@@ -4,7 +4,11 @@ get:
   tags:
     - email-certification
   operationId: get_api_v2_email_certification_email_certification_
-  description: 实现:`EmailCertificationController@show`
+  description: |
+    v3 没有对应端点:验证码改在服务端比对(`POST /v3/invites`),不再回传给前端。
+    保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):直接返回验证码,邮箱验证可被绕过。
   parameters:
     -
       name: email_certification
@@ -30,6 +34,7 @@ get:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true
 put:
   summary: '更新 email-certification/{email_certification}'
   tags:

+ 3 - 1
openapi/public/assets/protocol/resources/auto/v2-email-certification.yaml

@@ -69,7 +69,8 @@ post:
   tags:
     - email-certification
   operationId: post_api_v2_email_certification
-  description: 实现:`EmailCertificationController@store`
+  description: |
+    已被 `POST /v3/email-certifications` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
   requestBody:
     required: false
     content:
@@ -102,3 +103,4 @@ post:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true

+ 3 - 1
openapi/public/assets/protocol/resources/auto/v2-invite-invite.yaml

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

+ 5 - 1
openapi/public/assets/protocol/resources/auto/v2-sign-up.yaml

@@ -69,7 +69,10 @@ post:
   tags:
     - sign-up
   operationId: post_api_v2_sign_up
-  description: 实现:`SignUpController@store`
+  description: |
+    已被 `POST /v3/users` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除。
+
+    已知问题(v2 冻结不修):invite 校验漏了 return,形同虚设。
   requestBody:
     required: false
     content:
@@ -109,3 +112,4 @@ post:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true

+ 49 - 0
openapi/public/assets/protocol/resources/auto/v3-email-certifications.yaml

@@ -0,0 +1,49 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-email-certifications.yaml
+post:
+  summary: 发送注册验证码
+  tags:
+    - email-certifications
+  operationId: post_api_v3_email_certifications
+  description: |-
+    向邮箱发一个 6 位验证码,30 分钟内有效;重复申请会作废上一个。
+    邮箱已注册时返回 422。按 IP 与邮箱限流,超出返回 429。
+
+    实现:`V3\EmailCertificationController@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'

+ 46 - 0
openapi/public/assets/protocol/resources/auto/v3-invites-invite.yaml

@@ -0,0 +1,46 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-invites-invite.yaml
+get:
+  summary: 查看一条可用的注册邀请
+  tags:
+    - invites
+  operationId: get_api_v3_invites_invite_
+  description: |-
+    邀请注册页用它显示邮箱。不存在或已被使用都返回 404。
+
+    实现:`V3\InviteController@show`
+  parameters:
+    -
+      name: invite
+      in: path
+      required: true
+      schema:
+        type: string
+      description: '邀请邮件链接里的 uuid'
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  email:
+                    type: string
+                  status:
+                    type: string
+                  created_at:
+                    type: string
+                    nullable: true
+    404:
+      description: 邀请不存在或已被使用
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'

+ 75 - 0
openapi/public/assets/protocol/resources/auto/v3-invites.yaml

@@ -0,0 +1,75 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-invites.yaml
+post:
+  summary: 用验证码换注册邀请
+  tags:
+    - invites
+  operationId: post_api_v3_invites
+  description: |-
+    验证码正确则返回该邮箱的 invite(已有则复用,状态重置为未使用),验证码随即作废。
+    验证码错误、过期或试错 5 次后返回 422(errors.code)。
+
+    实现:`V3\InviteController@store`
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          properties:
+            email:
+              type: string
+              description: 收到验证码的邮箱
+            code:
+              type: string
+              description: '邮件里的 6 位验证码'
+              example: '123456'
+          required:
+            - email
+            - code
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  email:
+                    type: string
+                  status:
+                    type: string
+                  created_at:
+                    type: string
+                    nullable: true
+    201:
+      description: '首次为该邮箱签发 invite。响应体与 200 同形;复用已有 invite 时是 200'
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  email:
+                    type: string
+                  status:
+                    type: string
+                  created_at:
+                    type: string
+                    nullable: true
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'
+    429:
+      description: 请求过于频繁
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'

+ 95 - 0
openapi/public/assets/protocol/resources/auto/v3-users.yaml

@@ -0,0 +1,95 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-users.yaml
+post:
+  summary: 注册账号
+  tags:
+    - users
+  operationId: post_api_v3_users
+  description: |-
+    凭 invite(邀请邮件里的,或 `POST /v3/invites` 换来的)建账号,同时建一个
+    私有的 draft 译文 channel,invite 标为已用。邮箱取自 invite。
+    注册不等于登录:返回 201 与新账号,之后用用户名/邮箱 + 密码走登录。
+
+    实现:`V3\UserController@store`
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          properties:
+            invite:
+              type: string
+              description: '注册邀请的 uuid'
+            username:
+              type: string
+              description: '用户名,6–32 位字母、数字、下划线'
+            nickname:
+              type: string
+              description: '昵称,不传或空白时用 username'
+            password:
+              type: string
+              description: '密码,6–32 位'
+            password_confirmation:
+              type: string
+              description: 再输一次密码
+            lang:
+              type: string
+              description: '常用译文语言,用作 draft channel 的语言'
+              example: zh-Hans
+          required:
+            - invite
+            - username
+            - password
+            - password_confirmation
+            - lang
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  username:
+                    type: string
+                  nickname:
+                    type: string
+                  email:
+                    type: string
+                  created_at:
+                    type: string
+                    nullable: true
+    201:
+      description: 已创建
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  username:
+                    type: string
+                  nickname:
+                    type: string
+                  email:
+                    type: string
+                  created_at:
+                    type: string
+                    nullable: true
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'
+    429:
+      description: 请求过于频繁
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'