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 接口有两个不适合「忘记密码」场景的限制:
- 挂
AuthMiddleware— 忘记密码的用户未登录,会被直接 401 拒掉 - 要求
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 定义
在 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.2 的 s.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 函数
在 §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
在 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)。