From d4a9389fbcb351d8d509312522b090e20bd4e17e Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 19 Apr 2026 16:10:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A6=96=E9=A1=B5=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E6=89=BE=E5=9B=9E=E5=AF=86=E7=A0=81=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/PasswordResetController.php | 153 +++++++++ app/Http/Requests/ResetPasswordRequest.php | 56 ++++ .../Requests/SendPasswordResetLinkRequest.php | 51 +++ app/Http/Requests/UpdateProfileRequest.php | 4 +- app/Models/User.php | 9 + .../ResetUserPasswordNotification.php | 62 ++++ resources/views/index.blade.php | 11 +- resources/views/password-forgot.blade.php | 299 ++++++++++++++++++ resources/views/password-reset.blade.php | 218 +++++++++++++ routes/web.php | 9 + tests/Feature/PasswordResetControllerTest.php | 142 +++++++++ 11 files changed, 1011 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/PasswordResetController.php create mode 100644 app/Http/Requests/ResetPasswordRequest.php create mode 100644 app/Http/Requests/SendPasswordResetLinkRequest.php create mode 100644 app/Notifications/ResetUserPasswordNotification.php create mode 100644 resources/views/password-forgot.blade.php create mode 100644 resources/views/password-reset.blade.php create mode 100644 tests/Feature/PasswordResetControllerTest.php diff --git a/app/Http/Controllers/PasswordResetController.php b/app/Http/Controllers/PasswordResetController.php new file mode 100644 index 0000000..193d819 --- /dev/null +++ b/app/Http/Controllers/PasswordResetController.php @@ -0,0 +1,153 @@ + Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧', + 'smtpEnabled' => $this->isPasswordResetMailEnabled(), + ]); + } + + /** + * 发送邮箱找回密码链接。 + */ + public function storeLink(SendPasswordResetLinkRequest $request): JsonResponse + { + if (! $this->isPasswordResetMailEnabled()) { + return response()->json([ + 'status' => 'error', + 'message' => '系统暂未开启邮箱发信服务,当前无法通过邮箱找回密码。', + ], 403); + } + + $email = trim((string) $request->string('email')); + + // 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。 + if (User::query()->where('email', $email)->count() > 1) { + return response()->json([ + 'status' => 'error', + 'message' => '该邮箱绑定了多个账号,暂不支持自助找回,请联系管理员处理。', + ], 422); + } + + $status = Password::sendResetLink(['email' => $email]); + + if ($status === Password::RESET_LINK_SENT) { + return response()->json([ + 'status' => 'success', + 'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。链接 60 分钟内有效,请注意查收。', + ]); + } + + if ($status === Password::RESET_THROTTLED) { + return response()->json([ + 'status' => 'error', + 'message' => '发送过于频繁,请稍后再试。', + ], 429); + } + + if ($status === Password::INVALID_USER) { + return response()->json([ + 'status' => 'success', + 'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。请检查收件箱与垃圾邮件箱。', + ]); + } + + return response()->json([ + 'status' => 'error', + 'message' => '找回密码邮件发送失败,请稍后重试。', + ], 500); + } + + /** + * 展示独立的重置密码页面。 + */ + public function edit(Request $request, string $token): View + { + return view('password-reset', [ + 'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧', + 'token' => $token, + 'email' => (string) $request->query('email', ''), + ]); + } + + /** + * 提交新的登录密码并完成重置。 + */ + public function update(ResetPasswordRequest $request): RedirectResponse + { + $credentials = $request->validated(); + + $status = Password::reset( + $credentials, + function (User $user, #[\SensitiveParameter] string $password): void { + // 重置成功后同步刷新 remember_token,避免旧设备继续沿用旧令牌。 + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + if ($status === Password::PASSWORD_RESET) { + return redirect()->route('home')->with('status', '密码已重置成功,请使用原昵称和新密码重新登录。'); + } + + return back() + ->withInput($request->except('password', 'password_confirmation')) + ->withErrors([ + 'email' => $this->resolveResetFailureMessage($status), + ]); + } + + /** + * 判断系统是否已开启邮箱发信服务。 + */ + private function isPasswordResetMailEnabled(): bool + { + return Sysparam::where('alias', 'smtp_enabled')->value('body') === '1'; + } + + /** + * 将 Laravel 密码重置状态码转换为中文错误提示。 + */ + private function resolveResetFailureMessage(string $status): string + { + return match ($status) { + Password::INVALID_TOKEN => '重置链接无效或已过期,请重新申请邮箱找回。', + Password::INVALID_USER => '该邮箱未绑定可重置的账号,请确认后再试。', + default => '密码重置失败,请重新获取重置链接后再试。', + }; + } +} diff --git a/app/Http/Requests/ResetPasswordRequest.php b/app/Http/Requests/ResetPasswordRequest.php new file mode 100644 index 0000000..eb6fbd5 --- /dev/null +++ b/app/Http/Requests/ResetPasswordRequest.php @@ -0,0 +1,56 @@ +|string> + */ + public function rules(): array + { + return [ + 'token' => ['required', 'string'], + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'string', 'min:6', 'confirmed'], + ]; + } + + /** + * 定义重置密码请求的中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'token.required' => '重置凭证缺失,请重新从邮件中的链接进入。', + 'email.required' => '邮箱不能为空。', + 'email.email' => '邮箱格式不正确。', + 'password.required' => '请输入新的登录密码。', + 'password.min' => '新密码长度至少需要 6 位。', + 'password.confirmed' => '两次输入的新密码不一致。', + ]; + } +} diff --git a/app/Http/Requests/SendPasswordResetLinkRequest.php b/app/Http/Requests/SendPasswordResetLinkRequest.php new file mode 100644 index 0000000..7a9beec --- /dev/null +++ b/app/Http/Requests/SendPasswordResetLinkRequest.php @@ -0,0 +1,51 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + ]; + } + + /** + * 定义邮箱找回密码请求的中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'email.required' => '请输入已绑定账号的邮箱地址。', + 'email.email' => '邮箱格式不正确,请重新输入。', + 'email.max' => '邮箱长度不能超过 255 个字符。', + ]; + } +} diff --git a/app/Http/Requests/UpdateProfileRequest.php b/app/Http/Requests/UpdateProfileRequest.php index 5892aff..f0785dd 100644 --- a/app/Http/Requests/UpdateProfileRequest.php +++ b/app/Http/Requests/UpdateProfileRequest.php @@ -11,6 +11,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class UpdateProfileRequest extends FormRequest { @@ -33,7 +34,7 @@ class UpdateProfileRequest extends FormRequest 'sex' => ['required', 'in:0,1,2'], 'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif 'sign' => ['nullable', 'string', 'max:255'], - 'email' => ['nullable', 'email', 'max:255'], + 'email' => ['nullable', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->user()?->id)], 'question' => ['nullable', 'string', 'max:100'], 'answer' => ['nullable', 'string', 'max:100'], ]; @@ -44,6 +45,7 @@ class UpdateProfileRequest extends FormRequest return [ 'sex.in' => '性别选项无效(0=保密 1=男 2=女)。', 'headface.required' => '必须选择一个头像。', + 'email.unique' => '该邮箱已被其他账号绑定,请更换一个邮箱。', ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 36aa2bd..32aebf4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -12,6 +12,7 @@ namespace App\Models; +use App\Notifications\ResetUserPasswordNotification; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -168,6 +169,14 @@ class User extends Authenticatable } } + /** + * 发送邮箱找回密码通知。 + */ + public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void + { + $this->notify(new ResetUserPasswordNotification($token)); + } + /** * 关联:用户所属的 VIP 会员等级 */ diff --git a/app/Notifications/ResetUserPasswordNotification.php b/app/Notifications/ResetUserPasswordNotification.php new file mode 100644 index 0000000..d636e33 --- /dev/null +++ b/app/Notifications/ResetUserPasswordNotification.php @@ -0,0 +1,62 @@ + + */ + public function via(mixed $notifiable): array + { + return ['mail']; + } + + /** + * 构建找回密码邮件内容。 + */ + public function toMail(mixed $notifiable): MailMessage + { + return (new MailMessage) + ->subject('和平聊吧 - 邮箱找回密码') + ->greeting('您好,'.$notifiable->username.':') + ->line('系统收到了这次密码找回申请,请点击下方按钮重新设置登录密码。') + ->line('该链接仅适用于当前绑定邮箱的账号,请勿转发给他人。') + ->action('立即重置密码', $this->buildResetUrl($notifiable)) + ->line('重置链接将在 '.config('auth.passwords.'.config('auth.defaults.passwords').'.expire').' 分钟后失效。') + ->line('如果这不是您本人发起的操作,直接忽略本邮件即可。'); + } + + /** + * 生成前台独立重置密码页面地址。 + */ + private function buildResetUrl(mixed $notifiable): string + { + return url(route('password.reset', [ + 'token' => $this->token, + 'email' => $notifiable->getEmailForPasswordReset(), + ], false)); + } +} diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index 74ecaaa..88883bb 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -9,6 +9,8 @@ $roomCount = count($rooms); $defaultRoom = $rooms[0] ?? null; $heroImage = asset('images/veteran-hero.jpg'); + $flashMessage = session('status') ?? session('success') ?? session('error'); + $flashType = session()->has('error') ? 'error' : ($flashMessage ? 'success' : null); @endphp @@ -826,7 +828,12 @@

主视觉已固定,验证通过后直接进入房间。

-
+
{{ $flashMessage }}