# 忘记密码(Forgot Password)功能设计文档 - **创建日期**: 2026-07-09 - **状态**: 待评审 - **目标**: 给 `frontend/pages/login/login.vue` 的"忘记密码"入口增加完整的找回密码页面,UI 与注册页面一致,后端新增匿名重置密码接口(无需登录态、无需旧密码,仅凭手机号 + 短信验证码 + 新密码重置) --- ## 1. 背景与需求 ### 1.1 现状 [login.vue:138-140](frontend/pages/login/login.vue#L138-L140) 中的"忘记密码"目前是占位实现: ```js const handleForgotPassword = () => { uni.showToast({ title: "忘记密码功能开发中", icon: "none" }); }; ``` ### 1.2 现有后端能力 - `services/userService/service/sms_service.go` 已经支持 `scene: "register" / "password"` 两种场景,Redis key 严格隔离 - `docker/sql/migrations/migrate_create_sms_send_log_table.sql` 已经有 `scene` 字段(默认 'register')、可以记录 password 场景 - `frontend/utils/api.js` 已有 `sendCodeApi(mobile, scene)` / `verifyCodeApi(mobile, code, scene)`,支持任意 scene 字符串 - `services/userService/service/user_service.go#UpdatePassword` 已实现**已登录用户**改密流程(需要 AuthMiddleware + 旧密码 + verify_token) ### 1.3 核心矛盾 现有 `POST /api/v1/account/password` 接口有两个不适合「忘记密码」场景的限制: 1. **挂 `AuthMiddleware`** — 忘记密码的用户**未登录**,会被直接 401 拒掉 2. **要求 `OldPassword`** — 忘记密码的用户**不知道旧密码** ### 1.4 设计目标 提供一个**匿名**(无需登录)、**无旧密码**、**走 SMS 验证**的密码重置入口,UX 与注册保持一致。 --- ## 2. 方案选型 | 方案 | 后端 | 前端 | 评价 | |---|---|---|---| | **A. 新增独立重置接口(选用 ⭐)** | 新增 `POST /api/v1/auth/reset-password`(无 auth),参数 `{mobile, new_password, verify_token(scene=password)}` | 复制 `register.vue` 改为 `forgotPassword.vue` | 与现有「已登录改密」流程完全解耦,sms_send_log 通过 scene 字段自然区分,UX 一致 | | B. 复用 `/api/v1/account/password` + 去掉 old_password 校验 | 改 `UpdatePassword` 兼容两种调用,去掉 auth 中间件 | 复用 updatePasswordApi | ❌ 接口语义模糊(已登录改密 vs 忘记密码重置),AuthMiddleware 移除风险大,代码侵入广 | | C. 复用 register.vue,加 `?mode=reset` query | 无 | 单文件加分支 | ❌ register.vue 逻辑复杂化,可读性下降;违反用户「页面一摸一样」的字面需求 | **最终方案**: A,新增独立重置接口 + 复制 register.vue 改名为 forgotPassword.vue。 --- ## 3. 整体流程 ### 3.1 序列图 ``` 用户 login.vue forgotPassword.vue Gateway userService Redis │ │ │ │ │ │ │ 1.点击"忘记密码" │ │ │ │ │ ├───────────────────→│ │ │ │ │ │ │ uni.reLaunch │ │ │ │ │ ├─────────────────────────────────────────→ │ │ │ │ │ │ onLoad │ │ │ │ │ │ (此时无手机号, │ │ │ │ │ │ 不做检查) │ │ │ │ │ │ │ │ │ │ 2.用户输入手机号 │ │ │ │ │ ├─────────────────────────────────────────→│ │ │ │ │ │ │ handlePhoneInput │ │ │ │ │ │ checkmobileApi │ │ │ │ │ ├──────────────────→│ ─Dubbo→ │ │ │ │ │ │ CheckMobile │ │ │ │ │ │ 存在? │ │ │ │ │ 200 {exists:bool} │←───────────────┤ │ │ │ │←──────────────────┤ │ │ │ │ │ │ │ │ │ │ │ (若 exists=false) │ │ │ │ │ │ 弹"未注册"提示 │ │ │ │ │ │ 确认→ 跳 register │ │ │ │ │ │ │ │ │ │ 3.输入验证码+新密码+确认密码 │ │ │ │ ├─────────────────────────────────────────→│ │ │ │ │ │ │ │ │ │ │ 4.点"发送验证码" │ │ │ │ │ ├─────────────────────────────────────────→│ │ │ │ │ │ │ POST /auth/send-code │ │ │ │ │ {mobile, scene:"password"} │ │ │ │ ├──────────────────→│ ─Dubbo→ │ │ │ │ │ │ SendCode │ │ │ │ │ │ │ SaveSMSCode(60s) │ │ │ │ │ ├────────────────→│ │ │ │ 200 OK │ │ │ │ │ │←──────────────────┤←───────────────┤ │ │ │ │ 启动 60s 倒计时 │ │ │ │ │ │ │ │ │ │ 5.点"重置密码" │ │ │ │ │ ├─────────────────────────────────────────→│ │ │ │ │ │ │ POST /auth/verify-code │ │ │ │ │ {mobile, code, scene:"password"} │ │ │ │ ├──────────────────→│ ─Dubbo→ │ │ │ │ │ │ VerifyCode │ │ │ │ │ │ │ DeleteSMSCode │ │ │ │ │ ├────────────────→│ │ │ │ │ │ SaveVerifyToken │ │ │ │ │ │ (5 min TTL) │ │ │ │ │ ├────────────────→│ │ │ │ 200 {verify_token}│ │ │ │ │ │←──────────────────┤←───────────────┤ │ │ │ │ │ │ │ │ │ │ POST /auth/reset-password │ │ │ │ │ {mobile, new_password, │ │ │ │ │ verify_token} │ │ │ │ ├──────────────────→│ ─Dubbo→ │ │ │ │ │ │ ResetPassword │ │ │ │ │ │ - GetByMobile │ │ │ │ │ │ - VerifyToken │ GetVerifyToken │ │ │ │ │ │←─────────────────┤ │ │ │ │ - bcrypt新密码│ │ │ │ │ │ - 事务内更新 │ │ │ │ │ │ password_hash │ │ │ │ │ access_token=nil │ │ │ │ │ - ConsumeToken│DeleteVerifyToken│ │ │ │ 200 OK │ ├────────────────→│ │ │ │←──────────────────┤←───────────────┤ │ │ │ │ Toast + 1.5s 跳回 │ │ │ │ │ │ /pages/login/login│ │ │ │ │ │ (reLaunch) │ │ │ │ 6.用新密码重新登录 │ │ │ │ │ ├─────────────────────────────────────────────────────────────────────────────────→ (走 Login 流程) │ ``` ### 3.2 前端 UI 结构(与 register.vue 完全一致) ``` ┌──────────────────────────────────────┐ │ [← 返回] │ │ │ │ 找回密码 │ │ │ │ [138****1234 ] │ ← 手机号 │ [验证码 ] [发送验证码 60s] │ ← 验证码 + 倒计时按钮 │ [设置新密码 ] │ ← 新密码 │ [确认新密码 ] │ ← 确认密码 │ │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ← 分割线 │ │ │ [ 重置密码 ] │ ← 按钮文字与 register 区别 │ │ └──────────────────────────────────────┘ ``` **与 register.vue 的差异**(用户感官上「一摸一样」): - 标题: 手机号注册 → **找回密码** - 按钮: 注册 → **重置密码** - 进入页面时: 不显示「已注册」错误,反而**主动检查**手机号是否存在(不存在 → 弹「未注册」 → 引导回 register) - scene 字符串: "register" → "password" --- ## 4. 后端改动 ### 4.1 Proto 定义 **修改** [backend/proto/user.proto](backend/proto/user.proto) 在 `UserSocialService` rpc 列表中新增 `ResetPassword` rpc,新增请求/响应消息: ```protobuf service UserSocialService { // ... 已有 rpc // ResetPassword 匿名重置密码(忘记密码场景) // 鉴权:无 AuthMiddleware,通过 verify_token(scene=password) 验证身份 // 调用方: forgetPassword.vue (login 子页面) rpc ResetPassword(ResetPasswordRequest) returns (ResetPasswordResponse); } message ResetPasswordRequest { string mobile = 1; // 用户手机号 string new_password = 2; // 新密码 string verify_token = 3; // 短信验证 token(scene=password 下发的一次性 token) } message ResetPasswordResponse { topfans.common.BaseResponse base = 1; // 标准 base 响应 } ``` 重新生成 `user.pb.go` / `user.triple.go`(用项目内的 `gen-swagger.sh` / `make proto` 脚本)。 ### 4.2 Service 层 ResetPassword **新增** [backend/services/userService/service/user_service.go](backend/services/userService/service/user_service.go) ```go // ResetPassword 匿名重置密码(忘记密码场景) // 流程: // 1. 通过 mobile 查 user(必须存在) // 2. 校验 verify_token(scene=password) // 3. 校验 new_password 格式 // 4. 事务内更新 password_hash + 清空 access_token(强制重登) // 5. ConsumeVerifyToken(Plan A:业务成功后原子消费) // // 与 UpdatePassword 的区别: // - 无 userID(匿名) // - 无 OldPassword 校验(忘记密码场景不知道旧密码) // - 无 AuthMiddleware 依赖 func (s *userService) ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest) (*pb.ResetPasswordResponse, error) { // 0. 参数基础验证 if req.Mobile == "" { return nil, appErrors.ErrInvalidMobile } if req.VerifyToken == "" { return nil, appErrors.ErrInvalidVerifyToken } valid, msg := validator.ValidatePassword(req.NewPassword) if !valid { if msg == "password too short" { return nil, appErrors.ErrPasswordTooShort } return nil, fmt.Errorf("invalid password: %s", msg) } // 1. 通过 mobile 查 user user, err := s.userRepo.GetByMobile(req.Mobile) if err != nil { if errors.Is(err, appErrors.ErrUserNotFound) { return nil, appErrors.ErrUserNotFound } return nil, fmt.Errorf("failed to get user by mobile: %w", err) } // 2. 校验 verify_token(scene=password) if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil { return nil, appErrors.ErrInvalidVerifyToken } // 3. 加密新密码 newPasswordHash, err := repository.HashPassword(req.NewPassword) if err != nil { return nil, fmt.Errorf("failed to hash new password: %w", err) } // 4. 事务内更新密码 + 清 token // 关键语义:覆写 password_hash 后,旧密码的 bcrypt 哈希物理上不再存在。 // 任何用旧密码明文登录的请求都会因 bcrypt 校验失败被拒 → 旧密码立即失效, // 无需额外的「密码历史」机制。 err = s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(user).Updates(map[string]interface{}{ "password_hash": newPasswordHash, "updated_at": time.Now().UnixMilli(), "access_token": nil, "token_expires_at": nil, }).Error; err != nil { return fmt.Errorf("failed to update password: %w", err) } return nil }) if err != nil { return nil, err } // 5. (Plan A) 业务成功后原子消费 verify_token if err := ConsumeVerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil { logger.Logger.Error("failed to consume verify token after password reset (self-healing on retry)", zap.String("mobile", maskMobile(user.Mobile)), zap.Error(err)) } logger.Logger.Info("Reset password successful", zap.String("mobile", maskMobile(user.Mobile)), ) return &pb.ResetPasswordResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli(), }, }, nil } ``` **关键点**: - `s.userRepo.GetByMobile(mobile)`:复用现有 repo 方法(若未提供,需在 [user_repository.go](backend/services/userService/repository/user_repository.go) 中新增 `GetByMobile(mobile string) (*User, error)`) - **Plan A 沿用**:与 `UpdatePassword` 一致,`VerifyToken` 只校验不删,业务成功后 `ConsumeVerifyToken` 原子删除(Lua GET+COMPARE+DEL) - **token 自愈**:Consume 失败(Redis 抖动)仅记日志,业务已成功,密码已更新,token 未删,下次重试仍可成功 - **清 access_token**:重置后强制重新登录,与「已登录改密」行为一致 ### 4.3 Provider 层 **修改** [backend/services/userService/provider/unified_provider.go](backend/services/userService/provider/unified_provider.go) 参照 `UpdatePassword` 的委托模式(实际是 `UnifiedProvider` 委托给 `UserProvider`): ```go // ResetPassword 匿名重置密码(忘记密码场景) func (p *UnifiedProvider) ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest) (*pb.ResetPasswordResponse, error) { return p.userProvider.ResetPassword(ctx, req) } ``` **修改** [backend/services/userService/provider/user_provider.go](backend/services/userService/provider/user_provider.go) ```go // ResetPassword 匿名重置密码(无 userID,通过 mobile + verify_token 鉴权) func (p *UserProvider) ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest) (*pb.ResetPasswordResponse, error) { return p.userService.ResetPassword(ctx, req) } ``` **实施前必跑**: ```bash grep -n "func.*UpdatePassword" backend/services/userService/provider/*.go ``` 参照现有 `UpdatePassword` 的三件套签名(`UnifiedProvider` + `UserProvider`),保持风格一致。 ### 4.4 Controller 层 **新增** [backend/gateway/controller/auth_controller.go](backend/gateway/controller/auth_controller.go) 参照现有 `SendCode` 的实现风格(DTO 入参 + Dubbo 调用 + 业务码检查 + gin.H 响应): ```go // ResetPassword 匿名重置密码(忘记密码场景) // @Summary 匿名重置密码 // @Description 通过手机号+短信验证码(scene=password)+新密码重置密码,无需登录态 // @Tags auth // @Accept json // @Produce json // @Param request body dto.ResetPasswordRequest true "重置密码请求" // @Success 200 {object} response.Response // @Router /api/v1/auth/reset-password [post] func (ctrl *AuthController) ResetPassword(c *gin.Context) { var req dto.ResetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { logger.Logger.Warn("Invalid reset password request", zap.Error(err)) response.BadRequest(c, "参数错误") return } logger.Logger.Info("ResetPassword request received", zap.String("mobile", req.Mobile), ) // 调用 Dubbo 服务 ctx := context.Background() resp, err := ctrl.userServiceClient.ResetPassword(ctx, &pb.ResetPasswordRequest{ Mobile: req.Mobile, NewPassword: req.NewPassword, VerifyToken: req.VerifyToken, }) if err != nil { logger.Logger.Error("ResetPassword failed", zap.Error(err)) response.HandleError(c, err) return } // 检查业务错误 if resp.Base != nil && resp.Base.Code != uint32(codes.OK) { response.HandleError(c, &pbError{message: resp.Base.Message}) return } logger.Logger.Info("ResetPassword successful", zap.String("mobile", req.Mobile), ) response.Success(c, gin.H{}) } ``` ### 4.5 DTO 定义 **新增** [backend/gateway/dto/auth_sms_dto.go](backend/gateway/dto/auth_sms_dto.go) ```go // ResetPasswordRequest 重置密码请求 type ResetPasswordRequest struct { Mobile string `json:"mobile" binding:"required,len=11"` NewPassword string `json:"new_password" binding:"required,min=6"` VerifyToken string `json:"verify_token" binding:"required"` } // ResetPasswordResponse 重置密码响应 type ResetPasswordResponse struct { // 暂无业务字段,沿用标准 base } ``` ### 4.6 路由注册(无 AuthMiddleware) **修改** [backend/gateway/router/router.go](backend/gateway/router/router.go#L140-L149) **实际项目结构**:在 `router.go:140` 已经存在 `auth := v1.Group("/auth")`(无 auth 中间件,与 send-code / verify-code / register / login 同级),直接在该分组末尾追加新路由: ```go // 认证相关路由(公开) auth := v1.Group("/auth") { auth.POST("/register", authCtrl.Register) // 注册 auth.POST("/login", authCtrl.Login) // 登录 auth.POST("/validate", authCtrl.ValidateToken) // 验证 Token auth.POST("/check-nickname", authCtrl.CheckNickname) // 检查昵称是否被注册 auth.POST("/check-mobile", authCtrl.CheckMobile) // 检查手机号是否被注册 auth.POST("/send-code", authCtrl.SendCode) // 发送验证码 auth.POST("/verify-code", authCtrl.VerifyCode) // 验证验证码 auth.POST("/reset-password", authCtrl.ResetPassword) // ← 新增:匿名重置密码(忘记密码场景) } ``` **注意**:**不要**放在 `authProtected := v1.Group("/auth")`(router.go:165,该组挂 `middleware.AuthMiddleware()`),否则未登录用户无法访问。 ### 4.7 Repository 层:复用现有 GetByMobile **已存在** [backend/services/userService/repository/user_repository.go](backend/services/userService/repository/user_repository.go) 通过 grep 确认:`GetByMobile(mobile string) (*models.User, error)` 已在接口(行 22)和实现(行 88)中提供,无需新增。 ```go // 接口定义(已有) // GetByMobile 根据手机号查询 GetByMobile(mobile string) (*models.User, error) // ExistsByMobile 检查手机号是否存在 ExistsByMobile(mobile string) (bool, error) ``` `§4.2` 的 `s.userRepo.GetByMobile(req.Mobile)` 直接复用即可。 **实施前确认**: ```bash grep -n "GetByMobile\|ByMobile" backend/services/userService/repository/*.go ``` ### 4.8 单元测试 **新增** `backend/services/userService/service/user_service_reset_password_test.go` | # | 测试用例 | 输入 | 期望 | |---|---|---|---| | 1 | 正常路径 | 有效 token + 有效 new_password | 200,密码更新,token 通过 ConsumeVerifyToken 清空 | | 2 | mobile 缺失 | `""` | `ErrInvalidMobile` | | 3 | verify_token 缺失 | `""` | `ErrInvalidVerifyToken` | | 4 | verify_token 错误 | 任意非法字符串 | `ErrInvalidVerifyToken` | | 5 | mobile 不存在 | 不存在的手机号 | `ErrUserNotFound` | | 6 | 新密码太短 | "abcde" (5位) | `ErrPasswordTooShort` | | 7 | (Plan A) token 失败业务未跑 | mock `VerifyToken` 失败 | 返回 `ErrInvalidVerifyToken`,password_hash 未变 | | 8 | (Plan A) ConsumeVerifyToken 失败自愈 | mock `ConsumeVerifyToken` 失败 | 接口返回 200(业务已成功),日志 ERROR | | 9 | 事务回滚 | mock `tx.Updates` 失败 | 整事务回滚 | **Mock 方案**:与现有 `user_service_password_test.go` 一致(用 `testify/mock` + package-level var 注入)。 --- ## 5. 前端改动 ### 5.1 新建页面: pages/login/forgotPassword.vue **新增** [frontend/pages/login/forgotPassword.vue](frontend/pages/login/forgotPassword.vue) **实施策略**:直接复制 [register.vue](frontend/pages/register/register.vue) (~500 行),做以下差异性修改: | 字段 | register.vue 原始值 | forgotPassword.vue 改为 | |------|--------------------|----------------------| | 标题文字 | "手机号注册" | "找回密码" | | 按钮文字 | "注册" | "重置密码" | | 新密码 placeholder | "创建密码" | "设置新密码" | | 确认密码 placeholder | "确认密码" | "确认新密码" | | scene 参数 | "register" | "password" | | checkmobileApi 时机 | 用户输入时防抖检查"已注册" | handlePhoneInput 时检查"未注册"(不存在 → 弹窗引导回 register) | | 提交动作 | 跳 setNickname(带 temp_register_*) | 调 resetPasswordApi → Toast + 跳回 login | **完整结构**(与 register.vue 一致,字段差异如上表): ```vue ``` **关键设计**: - `handlePhoneInput` 复用 register.vue 的"输入时检查"模式,但语义反向:register 检查 `exists=true` 报错,forgotPassword 检查 `exists=false` 弹窗 - `showNotRegisteredDialog` 弹窗组件逻辑与 login.vue 中的 `showRegisterDialog` 几乎一致(参考 login.vue:67-80) - 不预存 mobile/password 到 storage(忘记密码流程结束后直接跳回 login,不留临时数据) ### 5.2 新增 API 函数 **修改** [frontend/utils/api.js](frontend/utils/api.js) 在 §5.1 中已经引用了 `resetPasswordApi`,这里给出实现: ```js // 重置密码接口(忘记密码场景,匿名调用,无需登录态) // 参数:mobile 手机号 / newPassword 新密码 / verifyToken scene=password 下发的一次性 token export function resetPasswordApi(mobile, newPassword, verifyToken) { return request({ url: '/api/v1/auth/reset-password', method: 'POST', data: { mobile, new_password: newPassword, verify_token: verifyToken } }); } ``` **放置位置**:与现有 `updatePasswordApi` 同区(参见 [api.js:300-311](frontend/utils/api.js#L300-L311))。 ### 5.3 修改 login.vue 入口 **修改** [frontend/pages/login/login.vue](frontend/pages/login/login.vue#L138-L140) ```js // 改前 const handleForgotPassword = () => { uni.showToast({ title: "忘记密码功能开发中", icon: "none" }); }; // 改后 const handleForgotPassword = () => { uni.reLaunch({ url: "/pages/login/forgotPassword" }); }; ``` ### 5.4 注册新页面到 pages.json **修改** [frontend/pages.json](frontend/pages.json) 在 login 相关路由后添加(顺序不影响功能,但建议紧邻 login.vue): ```json { "path": "pages/login/forgotPassword", "style": { "navigationStyle": "custom", "app-plus": { "bounce": "none" } } } ``` --- ## 6. 测试策略 ### 6.1 后端单测(新增 `user_service_reset_password_test.go`) 见 §4.8 表格(9 个用例)。 ### 6.2 前端手动验证 checklist | # | 场景 | 预期 | |---|---|---| | 1 | login.vue 点"忘记密码" | 成功跳到 forgotPassword | | 2 | forgotPassword 输入未注册手机号 | 弹"该手机号未注册"弹窗,确认跳 register | | 3 | forgotPassword 输入已注册手机号 | 不弹窗,可正常发送验证码 | | 4 | 点"发送验证码"(scene=password) | 后端收到 scene=password,sms_send_log 新增 scene='password' 记录 | | 5 | 60s 内连点"发送验证码" | 第二次按钮 disabled,显示倒计时 | | 6 | 输错验证码 | 后端拒绝,弹 toast,verify_token 不下发 | | 7 | 输正确验证码 | 显示"已验证"状态 | | 8 | 新密码 5 位 | 本地拒绝,提示"密码至少为6位" | | 9 | 新密码 ≠ 确认密码 | 提示"两次输入不一致" | | 10 | 重置成功 | Toast + 1.5s 跳回 login | | 11 | 用新密码登录 | 成功 | | 12 | 用旧密码登录 | 失败(已重置) | | 13 | 改密后该用户其他设备的 access_token 失效 | 是(access_token 已清空) | --- ## 7. 错误码映射 | 后端错误 | proto 状态码 | 前端 toast | 触发自动登出 | |---|---|---|---| | `ErrInvalidMobile`(mobile 空) | 400 | 请输入有效手机号 | ❌ | | `ErrPasswordTooShort` | 400 | 密码至少为6位 | ❌ | | `ErrInvalidVerifyToken` | 400 | 短信验证码无效或已过期 | ❌ | | `ErrUserNotFound`(mobile 不存在) | 404 | 该手机号未注册 | ❌(前端已主动拦截) | | 5xx(Redis 故障等) | 500 | 重置失败,请稍后重试 | ❌ | **注意**:`resetPasswordApi` 是匿名接口,无 AuthMiddleware 保护,所以 401 不会出现。`403` 也不出现(无封号逻辑)。仅需处理 400 / 404 / 500。 --- ## 8. 异常路径与降级 | 场景 | 行为 | |---|---| | Redis 不可用 | `VerifyToken` 报错 → 重置接口返回 500 → 前端 toast,弹窗保留 | | 短信发送失败(>10次/小时) | 后端返回 429 → 前端 toast "发送过于频繁" | | mobile 不存在 | 后端返回 404 → 前端 toast 兜底(虽然 handlePhoneInput 时已主动拦截) | | 多端同时重置 | 第一个请求业务成功后 `ConsumeVerifyToken` 删 token;第二个请求的 `VerifyToken` 仍能通过,但 `ConsumeVerifyToken` 发现 token 已被删 → 失败(防重放 ✅) | | verify_token 输错 → 修正重试 | **Plan A**:`VerifyToken` 只校验不删,token 保留在 Redis;用户修正验证码再次提交,`VerifyToken` 仍能通过、业务重跑、最终成功,**无需重新发短信** | | Consume 失败(Redis 抖动) | 业务已成功,密码已更新,日志记录 ERROR,token 未删 → 用户重试仍可成功(自愈 ✅) | | 重置后旧设备 token 失效 | 事务内 `access_token = nil` → 旧设备需重新登录 | --- ## 9. 安全考量 | 项 | 措施 | |---|---| | 鉴权 | 通过 verify_token(scene=password) 验证身份,5 分钟 TTL,一次性消费 | | 不暴露 mobile 是否存在 | 走 §3.1 中 `checkmobileApi` 的「不存在 → 弹窗」路径,统一文案,不通过 toast 区分 | | 限流 | 复用 `sms:limit:mobile:` 计数器,scene=password | | 新密码明文只传不存 | bcrypt(成本因子 10) | | 重置后清 access_token | 事务内 `access_token = nil` | | Plan A 原子消费 | Lua GET+COMPARE+DEL,业务成功后才删 token;业务失败时 token 保留可重试;Consume 失败(Redis 抖动)自愈 | | 不复用 `/api/v1/account/password` | 该接口有 AuthMiddleware,与「匿名重置」语义冲突;新接口完全独立 | --- ## 10. 文件变更总览 | 层 | 变更 | 文件 | |---|---|---| | Proto | 修改 | `backend/proto/user.proto`(新增 rpc + 消息) | | Proto | regen | `backend/pkg/proto/user/user.pb.go` | | Proto | regen | `backend/pkg/proto/user/user.triple.go` | | Service | 修改 | `backend/services/userService/service/user_service.go`(新增方法 `ResetPassword`) | | Service | 新增 | `backend/services/userService/service/user_service_reset_password_test.go`(§4.8 9 个用例) | | Provider | 修改 | `backend/services/userService/provider/unified_provider.go`(新增委托 `ResetPassword`) | | Provider | 修改 | `backend/services/userService/provider/user_provider.go`(新增委托 `ResetPassword`) | | Repository | 无需改动 | `GetByMobile` 已存在(user_repository.go:22, 88) | | DTO | 新增 | `backend/gateway/dto/auth_sms_dto.go#ResetPasswordRequest/Response` | | Controller | 修改 | `backend/gateway/controller/auth_controller.go`(新增方法 `ResetPassword`) | | Router | 修改 | `backend/gateway/router/router.go` (auth 公开组,行 140-149 末尾追加 `POST /reset-password`) | | Frontend page | 新增 | `frontend/pages/login/forgotPassword.vue`(基于 register.vue 复制) | | Frontend page | 修改 | `frontend/pages/login/login.vue#handleForgotPassword` | | Frontend utils | 修改 | `frontend/utils/api.js#resetPasswordApi` | | Frontend config | 修改 | `frontend/pages.json`(注册新页面) | --- ## 11. 部署/上线检查清单 - [ ] proto 重新生成后,所有引用 `ResetPasswordRequest` 的代码编译通过 - [ ] `userRepo.GetByMobile` 方法存在(已确认:user_repository.go:22, 88) - [ ] 新路由 `POST /api/v1/auth/reset-password` 挂在 `auth` 公开组(router.go:140,无 AuthMiddleware) - [ ] sms_send_log 表有 `scene='password'` 记录(可通过查表确认) - [ ] 后端单测全绿(§4.8 9 个用例) - [ ] 前端手动验证 13 条 checklist 通过 - [ ] Swagger 文档重新生成(`docs.go` 同步) - [ ] 灰度发布,先开 10% 流量观察错误率,重点监控 400/404/500 比例 --- ## 12. 与「已登录改密」的关系 | 维度 | 改密(已登录) | 重置密码(忘记密码) | |---|---|---| | 入口 | profile.vue 弹窗 | forgotPassword.vue 页面 | | 鉴权 | AuthMiddleware(JWT) | 无(匿名)+ verify_token | | 需要旧密码 | 是 | 否 | | API 路径 | `/api/v1/account/password` | `/api/v1/auth/reset-password`(新增) | | proto 方法 | `UpdatePassword` | `ResetPassword`(新增) | | scene 字符串 | "password" | "password"(一致) | | 改后行为 | 清 token,强制重登 | 清 token,强制重登(一致) | | Plan A 语义 | 沿用 | 沿用 | | 错误码映射 | 见 [change-password-design §7](2026-06-12-change-password-design.md) | 见本设计 §7 | **两个流程完全独立,但底层复用同一套 SMS 验证(verify_token 机制 + ConsumeVerifyToken Plan A)**。