# 忘记密码(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
←
找回密码
发送验证码
{{ countdown }}秒
已验证
{{ codeError }}
{{ errorMessage }}
重置密码
该手机号未注册
该手机号尚未注册,无法重置密码,是否前往注册页面创建账号?
取消
去注册
```
**关键设计**:
- `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)**。