939 lines
43 KiB
Markdown
939 lines
43 KiB
Markdown
# 忘记密码(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
|
|
<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](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)**。
|