SignUpService.php 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Exceptions\BusinessException;
  4. use App\Facades\RedisCache;
  5. use App\Mail\V3\EmailCertificationMail;
  6. use App\Models\Channel;
  7. use App\Models\Invite;
  8. use App\Models\UserInfo;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Facades\Log;
  11. use Illuminate\Support\Facades\Mail;
  12. use Illuminate\Support\Str;
  13. use Illuminate\Validation\ValidationException;
  14. use Throwable;
  15. /**
  16. * 注册:发邮箱验证码 → 用验证码换邀请 → 凭邀请建账号。
  17. *
  18. * 自助注册与邀请注册在「邀请」这一步汇合:
  19. *
  20. * - 邀请注册:studio 发出的邀请邮件里就带着 invite id。
  21. * - 自助注册:验证码比对通过后,服务端为该邮箱签发(或复用)一条 invite。
  22. *
  23. * 之后两条路都是 `POST /v3/users {invite, …}`。invite id 是只有邮箱主人才拿得到的
  24. * uuid,持有它就等于证明了邮箱归属。
  25. *
  26. * 与 v2 的区别(v2 的 `EmailCertificationController` / `SignUpController` 原样保留,
  27. * 不共用代码,见 CLAUDE.md 铁律 2):
  28. *
  29. * - 验证码只在服务端比对,绝不回传;v2 用 `GET /v2/email-certification/{id}` 把码交给前端比。
  30. * - 验证码 6 位、30 分钟有效、最多试 5 次;Cache 里只存它的 sha256。
  31. * - 验证通过之前不写 invites 表;v2 一发码就建 invite 行。
  32. * - 建账号时 invite 必须存在且未被使用;v2 漏了 return,invite 校验形同虚设。
  33. */
  34. class SignUpService
  35. {
  36. /** 验证码有效期(分钟),与邮件模板里写的「三十分钟」一致 */
  37. public const CODE_TTL_MINUTES = 30;
  38. /** 同一个验证码最多试几次,超过作废 */
  39. public const CODE_MAX_ATTEMPTS = 5;
  40. /** 存在对应模板的语言(`resources/views/emails/certification/*`),其余回落 en */
  41. private const MAIL_LOCALES = ['en', 'en-US', 'zh-Hans', 'zh-Hant'];
  42. /** invites.status:已发出、未使用 */
  43. private const INVITE_PENDING = 'invited';
  44. /** invites.status:已用于注册(与 v2 的取值一致) */
  45. private const INVITE_USED = 'sign-up';
  46. public function __construct(private readonly PasswordHasher $hasher) {}
  47. /**
  48. * 给邮箱发验证码。重复申请会作废上一个码。
  49. *
  50. * 邮箱是否已注册由 FormRequest 先挡掉。
  51. *
  52. * @throws BusinessException 邮件发送失败(503)
  53. */
  54. public function sendCode(string $email, ?string $lang): void
  55. {
  56. $code = (string) random_int(100000, 999999);
  57. RedisCache::put($this->codeKey($email), [
  58. 'digest' => hash('sha256', $code),
  59. 'attempts' => 0,
  60. ], now()->addMinutes(self::CODE_TTL_MINUTES));
  61. $locale = in_array($lang, self::MAIL_LOCALES, true) ? $lang : 'en';
  62. try {
  63. Mail::to($email)->send(new EmailCertificationMail($code, $locale));
  64. } catch (Throwable $e) {
  65. Log::error('v3 email certification mail failed', ['message' => $e->getMessage()]);
  66. throw new BusinessException(__('messages.mail_send_failed'), 503, 'mail-send-failed');
  67. }
  68. }
  69. /**
  70. * 比对验证码,通过则为该邮箱签发一条可用的 invite。
  71. *
  72. * 该邮箱已有 invite(例如 studio 早先邀请过)时复用那一行,保留原邀请人。
  73. *
  74. * @throws ValidationException 验证码不对、过期或试错次数用完
  75. */
  76. public function verifyCode(string $email, string $code): Invite
  77. {
  78. $key = $this->codeKey($email);
  79. $entry = RedisCache::get($key);
  80. if (! is_array($entry) || ! hash_equals($entry['digest'], hash('sha256', $code))) {
  81. if (is_array($entry)) {
  82. $attempts = $entry['attempts'] + 1;
  83. $attempts >= self::CODE_MAX_ATTEMPTS
  84. ? RedisCache::forget($key)
  85. : RedisCache::put($key, [...$entry, 'attempts' => $attempts], now()->addMinutes(self::CODE_TTL_MINUTES));
  86. }
  87. throw ValidationException::withMessages(['code' => __('messages.sign_up_code_invalid')]);
  88. }
  89. RedisCache::forget($key);
  90. $invite = Invite::whereRaw('lower(email) = ?', [mb_strtolower($email)])->first()
  91. ?? (new Invite)->forceFill([
  92. 'id' => (string) Str::uuid(),
  93. 'email' => $email,
  94. 'user_uid' => config('mint.admin.root_uuid'),
  95. ]);
  96. $invite->status = self::INVITE_PENDING;
  97. $invite->save();
  98. return $invite;
  99. }
  100. /**
  101. * 查一条可用的 invite(邀请注册页用它显示邮箱)。已用过或不存在都 404。
  102. */
  103. public function findPendingInvite(string $id): Invite
  104. {
  105. $invite = Invite::where('id', $id)->where('status', self::INVITE_PENDING)->first();
  106. if (! $invite) {
  107. abort(404, __('messages.invite_invalid'));
  108. }
  109. return $invite;
  110. }
  111. /**
  112. * 凭 invite 建账号:user_infos 一行 + 一个私有的 draft 译文 channel,invite 标为已用。
  113. *
  114. * 与 v2 `SignUpController@store` 建出的数据形状一致(role=basic、draft channel
  115. * status=5),v4 读这些行不会有差别。
  116. *
  117. * @param array{invite: string, username: string, nickname?: string|null, password: string, lang: string} $input
  118. *
  119. * @throws ValidationException invite 不可用,或其邮箱已被注册
  120. */
  121. public function register(array $input): UserInfo
  122. {
  123. $invite = Invite::where('id', $input['invite'])->where('status', self::INVITE_PENDING)->first();
  124. if (! $invite) {
  125. throw ValidationException::withMessages(['invite' => __('messages.invite_invalid')]);
  126. }
  127. if ($this->emailRegistered($invite->email)) {
  128. throw ValidationException::withMessages(['invite' => __('messages.email_registered')]);
  129. }
  130. return DB::transaction(function () use ($invite, $input): UserInfo {
  131. $nowMs = now()->getTimestampMs();
  132. $nickname = trim((string) ($input['nickname'] ?? ''));
  133. $user = (new UserInfo)->forceFill([
  134. 'userid' => (string) Str::uuid(),
  135. 'username' => $input['username'],
  136. 'nickname' => $nickname !== '' ? $nickname : $input['username'],
  137. 'email' => $invite->email,
  138. 'password' => $this->hasher->hash($input['password']),
  139. 'role' => json_encode(['basic']),
  140. 'create_time' => $nowMs,
  141. 'modify_time' => $nowMs,
  142. ]);
  143. $user->save();
  144. (new Channel)->forceFill([
  145. 'id' => app('snowflake')->id(),
  146. 'uid' => (string) Str::uuid(),
  147. 'name' => 'draft',
  148. 'owner_uid' => $user->userid,
  149. 'type' => 'translation',
  150. 'lang' => $input['lang'],
  151. 'status' => 5,
  152. 'editor_id' => $user->id,
  153. 'create_time' => $nowMs,
  154. 'modify_time' => $nowMs,
  155. ])->save();
  156. $invite->status = self::INVITE_USED;
  157. $invite->save();
  158. return $user;
  159. });
  160. }
  161. /**
  162. * 邮箱是否已注册。user_infos.email 的唯一索引区分大小写,这里不区分:
  163. * `Foo@x.org` 与 `foo@x.org` 是同一个邮箱。
  164. */
  165. public function emailRegistered(string $email): bool
  166. {
  167. return UserInfo::whereRaw('lower(email) = ?', [mb_strtolower($email)])->exists();
  168. }
  169. private function codeKey(string $email): string
  170. {
  171. return 'v3:email-certification:'.hash('sha256', mb_strtolower($email));
  172. }
  173. }