topfans/docs/superpowers/specs/2026-07-09-forgot-password-design.md
2026-07-09 19:00:03 +08:00

43 KiB

忘记密码(Forgot Password)功能设计文档

  • 创建日期: 2026-07-09
  • 状态: 待评审
  • 目标: 给 frontend/pages/login/login.vue 的"忘记密码"入口增加完整的找回密码页面,UI 与注册页面一致,后端新增匿名重置密码接口(无需登录态、无需旧密码,仅凭手机号 + 短信验证码 + 新密码重置)

1. 背景与需求

1.1 现状

login.vue:138-140 中的"忘记密码"目前是占位实现:

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

UserSocialService rpc 列表中新增 ResetPassword rpc,新增请求/响应消息:

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

// 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 中新增 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

参照 UpdatePassword 的委托模式(实际是 UnifiedProvider 委托给 UserProvider):

// 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

// ResetPassword 匿名重置密码(无 userID,通过 mobile + verify_token 鉴权)
func (p *UserProvider) ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest) (*pb.ResetPasswordResponse, error) {
    return p.userService.ResetPassword(ctx, req)
}

实施前必跑:

grep -n "func.*UpdatePassword" backend/services/userService/provider/*.go

参照现有 UpdatePassword 的三件套签名(UnifiedProvider + UserProvider),保持风格一致。

4.4 Controller 层

新增 backend/gateway/controller/auth_controller.go

参照现有 SendCode 的实现风格(DTO 入参 + Dubbo 调用 + 业务码检查 + gin.H 响应):

// 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

// 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

实际项目结构:在 router.go:140 已经存在 auth := v1.Group("/auth")(无 auth 中间件,与 send-code / verify-code / register / login 同级),直接在该分组末尾追加新路由:

// 认证相关路由(公开)
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

通过 grep 确认:GetByMobile(mobile string) (*models.User, error) 已在接口(行 22)和实现(行 88)中提供,无需新增。

// 接口定义(已有)
// GetByMobile 根据手机号查询
GetByMobile(mobile string) (*models.User, error)

// ExistsByMobile 检查手机号是否存在
ExistsByMobile(mobile string) (bool, error)

§4.2s.userRepo.GetByMobile(req.Mobile) 直接复用即可。

实施前确认:

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

实施策略:直接复制 register.vue (~500 行),做以下差异性修改:

字段 register.vue 原始值 forgotPassword.vue 改为
标题文字 "手机号注册" "找回密码"
按钮文字 "注册" "重置密码"
新密码 placeholder "创建密码" "设置新密码"
确认密码 placeholder "确认密码" "确认新密码"
scene 参数 "register" "password"
checkmobileApi 时机 用户输入时防抖检查"已注册" handlePhoneInput 时检查"未注册"(不存在 → 弹窗引导回 register)
提交动作 跳 setNickname(带 temp_register_*) 调 resetPasswordApi → Toast + 跳回 login

完整结构(与 register.vue 一致,字段差异如上表):

<template>
  <LoginBackground :show-logo="true">
    <view class="forgot-content">
      <view class="avatar-wrapper">
        <image class="avatar-image" src="/static/login/portal/register-avatar.png" mode="aspectFit"></image>
      </view>

      <view class="forgot-card">
        <view class="nav-back" @tap="goBack">
          <text class="nav-back-icon"></text>
        </view>

        <view class="card-title-wrapper">
          <text class="card-title">找回密码</text>
        </view>

        <view class="form-area">
          <!-- 手机号 -->
          <view class="input-group">
            <view class="input-outer">
              <view class="input-inner">
                <input class="input-field" type="number" v-model="form.phone"
                  placeholder="输入您的手机号" placeholder-class="input-placeholder"
                  maxlength="11" @input="handlePhoneInput" />
              </view>
            </view>
          </view>

          <!-- 验证码 -->
          <view class="input-group code-group">
            <view class="input-outer code-outer">
              <view class="input-inner code-inner">
                <input class="input-field" type="number" v-model="form.code"
                  placeholder="输入验证码" placeholder-class="input-placeholder"
                  maxlength="6" :disabled="codeStatus === 'verified'" />
              </view>
            </view>
            <view class="send-code-btn" :class="{
              countdown: codeStatus === 'countdown',
              verified: codeStatus === 'verified',
            }" @tap="handleSendCode">
              <text v-if="codeStatus === 'unsent' || codeStatus === 'resend'">发送验证码</text>
              <text v-else-if="codeStatus === 'countdown'">{{ countdown }}秒</text>
              <text v-else-if="codeStatus === 'verified'">已验证</text>
            </view>
          </view>

          <view v-if="codeError" class="error-message">
            <text>{{ codeError }}</text>
          </view>

          <!-- 新密码 -->
          <view class="input-group">
            <view class="input-outer">
              <view class="input-inner">
                <input class="input-field" :type="showPassword ? 'text' : 'password'"
                  v-model="form.password" placeholder="设置新密码"
                  placeholder-class="input-placeholder" />
              </view>
            </view>
          </view>

          <!-- 确认密码 -->
          <view class="input-group">
            <view class="input-outer">
              <view class="input-inner">
                <input class="input-field" :type="showPassword ? 'text' : 'password'"
                  v-model="confirmPassword" placeholder="确认新密码"
                  placeholder-class="input-placeholder" />
              </view>
            </view>
          </view>

          <view v-if="errorMessage" class="error-message">
            <text>{{ errorMessage }}</text>
          </view>
        </view>

        <view class="divider"></view>

        <view class="next-btn-wrapper">
          <view class="next-btn" @tap="handleReset">
            <text class="next-btn-text">重置密码</text>
          </view>
        </view>
      </view>

      <!-- 手机号未注册弹窗 -->
      <view v-if="showNotRegisteredDialog" class="dialog-overlay" @tap="closeNotRegisteredDialog">
        <view class="dialog-card" @tap.stop>
          <view class="dialog-title">该手机号未注册</view>
          <view class="dialog-message">该手机号尚未注册,无法重置密码,是否前往注册页面创建账号?</view>
          <view class="dialog-actions">
            <view class="dialog-btn dialog-btn-cancel" @tap="closeNotRegisteredDialog">
              <text class="dialog-btn-text">取消</text>
            </view>
            <view class="dialog-btn dialog-btn-confirm" @tap="goToRegister">
              <text class="dialog-btn-text">去注册</text>
            </view>
          </view>
        </view>
      </view>
    </view>
  </LoginBackground>
</template>

<script setup>
import { ref } from "vue";
// 注:本页面无需 onLoad(用户未输入手机号时无法检查是否存在),
// 因此不引入 @dcloudio/uni-app 的 onLoad,与 register.vue 一致。
import LoginBackground from "@/components/LoginBackground.vue";
import { validatePhone, validatePassword } from "@/utils/validator";
import { checkmobileApi, sendCodeApi, verifyCodeApi, resetPasswordApi } from "@/utils/api";

const form = ref({ phone: "", password: "", code: "" });
const confirmPassword = ref("");
const showPassword = ref(false);
const errorMessage = ref("");
const codeStatus = ref("unsent");
const countdown = ref(60);
const codeError = ref("");
const verifyToken = ref("");
const countdownTimer = ref(null);
const isVerifying = ref(false);
const showNotRegisteredDialog = ref(false);
const submitting = ref(false);

// goBack 跳回 login 页
const goBack = () => {
  uni.reLaunch({ url: "/pages/login/login" });
};

// 用户输入手机号时,检查是否已注册
const handlePhoneInput = async (e) => {
  const phone = e.detail.value;
  if (!validatePhone(phone).valid || phone.length !== 11) {
    showNotRegisteredDialog.value = false;
    errorMessage.value = "";
    return;
  }
  try {
    const res = await checkmobileApi(phone);
    if (res.code === 0 && res.data && !res.data.exists) {
      // 手机号未注册 → 弹窗(用户继续编辑的话会再检查)
      showNotRegisteredDialog.value = true;
    } else {
      showNotRegisteredDialog.value = false;
      errorMessage.value = "";
    }
  } catch (err) {
    // 网络错误,不做强提示,允许用户继续操作
  }
};

const closeNotRegisteredDialog = () => {
  showNotRegisteredDialog.value = false;
};

const goToRegister = () => {
  showNotRegisteredDialog.value = false;
  uni.reLaunch({ url: "/pages/register/register" });
};

// 发送验证码
const handleSendCode = async () => {
  const phoneValidation = validatePhone(form.value.phone);
  if (!phoneValidation.valid) {
    errorMessage.value = phoneValidation.message;
    return;
  }
  try {
    const res = await sendCodeApi(form.value.phone, "password");
    if (res.code === 0) {
      codeStatus.value = "countdown";
      countdown.value = res.expires_in || 60;
      startCountdown();
      uni.showToast({ title: "验证码已发送", icon: "success" });
    }
  } catch (error) {
    codeError.value = error.message || "发送失败,请重试";
  }
};

const startCountdown = () => {
  if (countdownTimer.value) clearInterval(countdownTimer.value);
  countdownTimer.value = setInterval(() => {
    countdown.value--;
    if (countdown.value <= 0) {
      clearInterval(countdownTimer.value);
      codeStatus.value = "resend";
    }
  }, 1000);
};

// 验证验证码
const handleVerifyCode = async () => {
  if (!form.value.code || form.value.code.length !== 6) {
    codeError.value = "请输入6位验证码";
    return;
  }
  isVerifying.value = true;
  codeError.value = "";
  try {
    const res = await verifyCodeApi(form.value.phone, form.value.code, "password");
    if (res.code === 0 && res.data && res.data.verified) {
      verifyToken.value = res.data.verify_token;
      codeStatus.value = "verified";
      uni.showToast({ title: "验证成功", icon: "success" });
    }
  } catch (error) {
    codeError.value = error.message || "验证失败";
  } finally {
    isVerifying.value = false;
  }
};

// 重置密码
const handleReset = async () => {
  if (submitting.value) return;
  const phoneValidation = validatePhone(form.value.phone);
  if (!phoneValidation.valid) {
    errorMessage.value = phoneValidation.message;
    uni.showToast({ title: phoneValidation.message, icon: "none" });
    return;
  }
  const passwordValidation = validatePassword(form.value.password);
  if (!passwordValidation.valid) {
    errorMessage.value = passwordValidation.message;
    uni.showToast({ title: passwordValidation.message, icon: "none" });
    return;
  }
  if (form.value.password !== confirmPassword.value) {
    errorMessage.value = "两次密码输入不一致";
    uni.showToast({ title: "两次密码输入不一致", icon: "none" });
    return;
  }
  if (!form.value.code || form.value.code.length !== 6) {
    errorMessage.value = "请输入6位验证码";
    return;
  }
  if (codeStatus.value !== "verified") {
    await handleVerifyCode();
    if (codeStatus.value !== "verified") return;
  }

  errorMessage.value = "";
  submitting.value = true;
  uni.showLoading({ title: "重置中...", mask: true });

  try {
    const res = await resetPasswordApi(
      form.value.phone,
      form.value.password,
      verifyToken.value,
    );
    uni.hideLoading();
    if (res.code === 0) {
      uni.showToast({ title: "密码重置成功,请登录", icon: "success", duration: 1500 });
      setTimeout(() => {
        uni.reLaunch({ url: "/pages/login/login" });
      }, 1500);
    }
  } catch (error) {
    uni.hideLoading();
    errorMessage.value = error.message || "重置失败,请重试";
    uni.showToast({ title: errorMessage.value, icon: "none" });
  } finally {
    submitting.value = false;
  }
};
</script>

<style scoped>
/* 与 register.vue 完全一致(把所有 .register- 改名为 .forgot-) */
.forgot-content { ... }
.forgot-card { ... }
/* ... 完整复制 register.vue 的 style,只改 class 前缀 */
</style>

关键设计:

  • 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

在 §5.1 中已经引用了 resetPasswordApi,这里给出实现:

// 重置密码接口(忘记密码场景,匿名调用,无需登录态)
// 参数: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)。

5.3 修改 login.vue 入口

修改 frontend/pages/login/login.vue

// 改前
const handleForgotPassword = () => {
  uni.showToast({ title: "忘记密码功能开发中", icon: "none" });
};

// 改后
const handleForgotPassword = () => {
  uni.reLaunch({ url: "/pages/login/forgotPassword" });
};

5.4 注册新页面到 pages.json

修改 frontend/pages.json

在 login 相关路由后添加(顺序不影响功能,但建议紧邻 login.vue):

{
  "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 见本设计 §7

两个流程完全独立,但底层复用同一套 SMS 验证(verify_token 机制 + ConsumeVerifyToken Plan A)