# 忘记密码(Forgot Password)功能实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 给 `frontend/pages/login/login.vue` 的"忘记密码"入口增加完整的找回密码页面,后端新增匿名重置密码接口,沿用 Plan A 原子消费 verify_token 机制,旧密码立即失效。
**Architecture:** 后端新增 `POST /api/v1/auth/reset-password` 匿名接口(proto → service → provider → controller → router 全链路),前端基于 `register.vue` 复制 `forgotPassword.vue`,scene 字符串用 "password" 复用现有 SMS 基础设施。
**Tech Stack:**
- 后端: Go + GORM + Dubbo Triple + Redis
- 前端: UniApp 3.x + Vue 3 组合式 API + Vuex 4
- proto: protoc + protoc-gen-triple v3.0.0
## Global Constraints
- 沿用项目统一的 Plan A 语义:`VerifyToken` 只校验不删,业务成功后 `ConsumeVerifyToken` 原子消费(Lua GET+COMPARE+DEL)
- proto 重新生成命令:`cd backend && ./scripts/compile-proto.sh`
- scene 字符串使用 `"password"`,与已有改密流程一致
- **项目规范:未经用户明确指示,AI 严禁执行 `git commit`** — 每个任务的"Commit"步骤需用户口头确认后才能执行
- 后端单测必须包含正常路径 + 异常路径 + Plan A 自愈 + 事务回滚
- 前端无 Vitest,所有验证走手动 checklist(参见 §6.2 设计文档)
- API 入参/出参用 DTO,不允许直接用 pb 类型做 HTTP 入参(对照 `SendCode` 现有风格)
---
## Task 1: Proto 定义 - 新增 ResetPassword RPC 与消息
**Files:**
- Modify: `backend/proto/user.proto` (在 UserService rpc 列表 + 末尾消息定义区)
- Regenerate: `backend/pkg/proto/user/user.pb.go`
- Regenerate: `backend/pkg/proto/user/user.triple.go`
**Interfaces:**
- Produces: `pb.ResetPasswordRequest` / `pb.ResetPasswordResponse` — 后续所有层依赖这两个类型
- [ ] **Step 1: 修改 user.proto**
在 `backend/proto/user.proto` 中找到 `UserService` 服务定义,在已有 rpc 末尾(参考 `UpdatePassword` 的位置)新增:
```protobuf
// ResetPassword 匿名重置密码(忘记密码场景)
// 鉴权:无 AuthMiddleware,通过 verify_token(scene=password) 验证身份
// 调用方: forgetPassword.vue (login 子页面)
rpc ResetPassword(ResetPasswordRequest) returns (ResetPasswordResponse);
```
在 `user.proto` 末尾(参考 `UpdatePasswordRequest/Response` 的位置)新增消息:
```protobuf
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 响应
}
```
- [ ] **Step 2: 重新生成 pb/triple 文件**
```bash
cd backend && ./scripts/compile-proto.sh
```
Expected: 脚本输出"✅ 编译完成"且无错误,`backend/pkg/proto/user/user.pb.go` 和 `user.triple.go` 包含 `ResetPassword` / `ResetPasswordRequest` / `ResetPasswordResponse` 标识符。
- [ ] **Step 3: 验证编译**
```bash
cd backend && go build ./...
```
Expected: 编译通过,无错误(新消息类型不引用任何 Go 代码,不应影响编译)。
- [ ] **Step 4: 提交(需用户确认)**
**注意**:按 CLAUDE.md 规范,执行 commit 前必须获得用户口头确认。
```bash
git add backend/proto/user.proto backend/pkg/proto/user/user.pb.go backend/pkg/proto/user/user.triple.go
git commit -m "feat(proto): add ResetPasswordRequest/Response and rpc"
```
---
## Task 2: Service 层 - 实现 ResetPassword 方法
**Files:**
- Modify: `backend/services/userService/service/user_service.go` (新增 `ResetPassword` 方法)
- Verify: `sms_redis.go` 已存在 `VerifyToken` 和 `ConsumeVerifyToken`(行 222 已有 `ConsumeVerifyToken`)
**Interfaces:**
- Consumes: `pb.ResetPasswordRequest { Mobile, NewPassword, VerifyToken }` (Task 1 产物)
- Uses: `s.userRepo.GetByMobile(mobile)`, `VerifyToken(ctx, "password", ...)`, `ConsumeVerifyToken(ctx, "password", ...)`, `repository.HashPassword(p)`, `s.db.Transaction`
- Produces: `*pb.ResetPasswordResponse{Base: appErrors.BuildBaseResponse(...)}` — Provider/Controller 依赖
- [ ] **Step 1: 打开 user_service.go,定位 UpdatePassword 位置**
```bash
grep -n "func.*UpdatePassword\|appErrors.BuildBaseResponse" backend/services/userService/service/user_service.go | head -5
```
Expected: 找到 `UpdatePassword` 方法的结束位置(大约 615 行附近),用于紧邻其下添加 `ResetPassword`。
- [ ] **Step 2: 新增 ResetPassword 方法**
在 `UpdatePassword` 方法结束后的空行处,粘贴以下代码:
```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: appErrors.BuildBaseResponse(nil),
}, nil
}
```
**注**:
- `appErrors.BuildBaseResponse(nil)` 生成成功响应(参考 user_service.go 已有的成功分支用法,例如 `UpdatePassword` 返回 `Base: &pbCommon.BaseResponse{...}` 的位置;若 BuildBaseResponse(nil) 不存在,可改为手动构造 `&pbCommon.BaseResponse{Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli()}`)
- `maskMobile` 函数定义在同文件,直接可用
- `zap` 需要在 import 块中存在;若不存在,`go build` 会报错,届时补充
- [ ] **Step 3: 验证编译**
```bash
cd backend && go build ./services/userService/...
```
Expected: 编译通过。若报 `undefined: appErrors.BuildBaseResponse`,参考 user_service.go:108/95 的用法直接构造 `Base: &pbCommon.BaseResponse{...}`。
- [ ] **Step 4: 提交(需用户确认)**
```bash
git add backend/services/userService/service/user_service.go
git commit -m "feat(userService): add ResetPassword method for forgot-password flow"
```
---
## Task 3: Service 层 - 单元测试
**Files:**
- Create: `backend/services/userService/service/user_service_reset_password_test.go`
**Interfaces:**
- Uses: `SaveVerifyToken` / `ConsumeVerifyToken` (sms_redis.go,已存在)
- Uses: `setupTestDB` / `cleanupTestDB` / `createTestUser` / `deleteTestUser` (参照 user_service_password_test.go 既有 helper)
- Tests: `TestResetPassword_*` 9 个用例(参见设计 §4.8)
- [ ] **Step 1: 复制 UpdatePassword 测试文件作为模板**
```bash
cp backend/services/userService/service/user_service_password_test.go \
backend/services/userService/service/user_service_reset_password_test.go
```
- [ ] **Step 2: 重写测试用例**
打开新文件,**整体替换**为以下 9 个用例(参照现有 `TestUpdatePassword_Success` 的 helper 用法,不需要重新定义 setup/cleanup):
```go
package service
import (
"context"
"errors"
"testing"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/services/userService/repository"
)
// Test #1: 正常路径 — 有效 token + 有效 new_password → 200,密码更新
func TestResetPassword_Success(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
user := createTestUser(t, db, userRepo, "13800002001")
defer deleteTestUser(t, db, userRepo, user.ID)
if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil {
t.Skipf("Redis unavailable: %v", err)
}
svc := setupUserService(t, db)
req := &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "newpassword123",
VerifyToken: "valid_token",
}
resp, err := svc.ResetPassword(context.Background(), req)
if err != nil {
t.Fatalf("ResetPassword failed: %v", err)
}
if resp == nil || resp.Base == nil {
t.Fatal("expected non-nil response with base")
}
// 验证 password_hash 已更新
updated, _ := userRepo.GetByMobile(user.Mobile)
if updated.PasswordHash == user.PasswordHash {
t.Fatal("password_hash should be changed")
}
}
// Test #2: mobile 缺失 → ErrInvalidMobile
func TestResetPassword_MobileEmpty(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
svc := setupUserService(t, db)
req := &pb.ResetPasswordRequest{
Mobile: "",
NewPassword: "newpassword123",
VerifyToken: "any_token",
}
_, err := svc.ResetPassword(context.Background(), req)
if !errors.Is(err, appErrors.ErrInvalidMobile) {
t.Fatalf("expected ErrInvalidMobile, got %v", err)
}
}
// Test #3: verify_token 缺失 → ErrInvalidVerifyToken
func TestResetPassword_VerifyTokenEmpty(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
user := createTestUser(t, db, userRepo, "13800002002")
defer deleteTestUser(t, db, userRepo, user.ID)
svc := setupUserService(t, db)
req := &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "newpassword123",
VerifyToken: "",
}
_, err := svc.ResetPassword(context.Background(), req)
if !errors.Is(err, appErrors.ErrInvalidVerifyToken) {
t.Fatalf("expected ErrInvalidVerifyToken, got %v", err)
}
}
// Test #4: verify_token 错误 → ErrInvalidVerifyToken,且 password_hash 未变
func TestResetPassword_VerifyTokenInvalid(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
user := createTestUser(t, db, userRepo, "13800002003")
defer deleteTestUser(t, db, userRepo, user.ID)
// 故意不写入 token,或写入不同的 token
if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "correct_token", 300); err != nil {
t.Skipf("Redis unavailable: %v", err)
}
svc := setupUserService(t, db)
req := &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "newpassword123",
VerifyToken: "wrong_token",
}
_, err := svc.ResetPassword(context.Background(), req)
if !errors.Is(err, appErrors.ErrInvalidVerifyToken) {
t.Fatalf("expected ErrInvalidVerifyToken, got %v", err)
}
// 验证 password_hash 未变
updated, _ := userRepo.GetByMobile(user.Mobile)
if updated.PasswordHash != user.PasswordHash {
t.Fatal("password_hash should not be changed when token invalid")
}
}
// Test #5: mobile 不存在 → ErrUserNotFound
func TestResetPassword_MobileNotFound(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
svc := setupUserService(t, db)
req := &pb.ResetPasswordRequest{
Mobile: "13999999999",
NewPassword: "newpassword123",
VerifyToken: "any_token",
}
_, err := svc.ResetPassword(context.Background(), req)
if !errors.Is(err, appErrors.ErrUserNotFound) {
t.Fatalf("expected ErrUserNotFound, got %v", err)
}
}
// Test #6: 新密码太短 → ErrPasswordTooShort
func TestResetPassword_NewPasswordTooShort(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
user := createTestUser(t, db, userRepo, "13800002004")
defer deleteTestUser(t, db, userRepo, user.ID)
if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil {
t.Skipf("Redis unavailable: %v", err)
}
svc := setupUserService(t, db)
req := &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "abcde", // 5 位
VerifyToken: "valid_token",
}
_, err := svc.ResetPassword(context.Background(), req)
if !errors.Is(err, appErrors.ErrPasswordTooShort) {
t.Fatalf("expected ErrPasswordTooShort, got %v", err)
}
}
// Test #7: 旧密码不能登录新密码(覆盖关键业务语义)
func TestResetPassword_OldPasswordInvalidAfterReset(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
user := createTestUser(t, db, userRepo, "13800002005")
defer deleteTestUser(t, db, userRepo, user.ID)
oldHash := user.PasswordHash
if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil {
t.Skipf("Redis unavailable: %v", err)
}
svc := setupUserService(t, db)
_, err := svc.ResetPassword(context.Background(), &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "newpassword123",
VerifyToken: "valid_token",
})
if err != nil {
t.Fatalf("ResetPassword failed: %v", err)
}
updated, _ := userRepo.GetByMobile(user.Mobile)
if updated.PasswordHash == oldHash {
t.Fatal("password_hash should be different after reset")
}
// 旧明文密码不能通过 bcrypt 校验
if userRepo.VerifyPassword(updated, "old_plain_password") {
// 注意:此断言依赖 createTestUser 的明文密码(查看 helper 实现)
t.Fatal("old password should not be valid after reset")
}
}
// Test #8: 重置成功后 access_token 被清空
func TestResetPassword_AccessTokenCleared(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
user := createTestUser(t, db, userRepo, "13800002006")
defer deleteTestUser(t, db, userRepo, user.ID)
// 模拟已有 access_token
if err := db.Model(user).Update("access_token", "some_existing_token").Error; err != nil {
t.Fatalf("setup access_token failed: %v", err)
}
if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil {
t.Skipf("Redis unavailable: %v", err)
}
svc := setupUserService(t, db)
_, err := svc.ResetPassword(context.Background(), &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "newpassword123",
VerifyToken: "valid_token",
})
if err != nil {
t.Fatalf("ResetPassword failed: %v", err)
}
updated, _ := userRepo.GetByMobile(user.Mobile)
if updated.AccessToken != nil {
t.Fatalf("access_token should be nil, got %v", *updated.AccessToken)
}
}
// Test #9: 重置成功后 verify_token 被 Consume(不能再用)
func TestResetPassword_VerifyTokenConsumedAfterSuccess(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
user := createTestUser(t, db, userRepo, "13800002007")
defer deleteTestUser(t, db, userRepo, user.ID)
if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil {
t.Skipf("Redis unavailable: %v", err)
}
svc := setupUserService(t, db)
_, err := svc.ResetPassword(context.Background(), &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "newpassword123",
VerifyToken: "valid_token",
})
if err != nil {
t.Fatalf("first ResetPassword failed: %v", err)
}
// 第二次用同一 token 应该失败(token 已被 Consume)
_, err = svc.ResetPassword(context.Background(), &pb.ResetPasswordRequest{
Mobile: user.Mobile,
NewPassword: "anotherpassword",
VerifyToken: "valid_token",
})
if !errors.Is(err, appErrors.ErrInvalidVerifyToken) {
t.Fatalf("expected ErrInvalidVerifyToken on second use, got %v", err)
}
}
```
**实施前必查**:
```bash
grep -n "func setupUserService\|func skipIfNoTestEnv\|func createTestUser\|func deleteTestUser" \
backend/services/userService/service/user_service_password_test.go | head -5
```
若 `setupUserService` helper 不存在,需参照其他 test 文件(如 `auth_service_login_test.go`)的实现,创建一个返回 `*userService` 实例的 helper(注入 `db` 和 `userRepo`)。
- [ ] **Step 3: 运行测试**
```bash
cd backend && go test -run "TestResetPassword" -v ./services/userService/service/...
```
Expected: 9 个用例全部 PASS(若有 Redis skip 或 DB 不可用,部分用例 skip 是允许的)。
- [ ] **Step 4: 提交(需用户确认)**
```bash
git add backend/services/userService/service/user_service_reset_password_test.go
git commit -m "test(userService): add ResetPassword unit tests (9 cases)"
```
---
## Task 4: Provider 层 - 委托 ResetPassword
**Files:**
- Modify: `backend/services/userService/provider/unified_provider.go` (新增 `ResetPassword` 委托)
- Modify: `backend/services/userService/provider/user_provider.go` (新增 `ResetPassword` 调用 service)
**Interfaces:**
- Consumes: `*pb.ResetPasswordRequest` (Task 1 产物)
- Produces: `*pb.ResetPasswordResponse` — Controller 依赖
- [ ] **Step 1: 修改 unified_provider.go**
打开 `backend/services/userService/provider/unified_provider.go`,找到 `UpdatePassword` 委托方法(约 110 行),在其后新增:
```go
// ResetPassword 匿名重置密码(忘记密码场景)
func (p *UnifiedProvider) ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest) (*pb.ResetPasswordResponse, error) {
return p.userProvider.ResetPassword(ctx, req)
}
```
- [ ] **Step 2: 修改 user_provider.go**
打开 `backend/services/userService/provider/user_provider.go`,找到 `UpdatePassword` 实现(参考现有模式),在其后新增:
```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)
}
```
- [ ] **Step 3: 验证编译**
```bash
cd backend && go build ./services/userService/...
```
Expected: 编译通过。
- [ ] **Step 4: 提交(需用户确认)**
```bash
git add backend/services/userService/provider/unified_provider.go backend/services/userService/provider/user_provider.go
git commit -m "feat(provider): add ResetPassword delegates"
```
---
## Task 5: DTO + Controller - 实现 HTTP API
**Files:**
- Modify: `backend/gateway/dto/auth_sms_dto.go` (新增 `ResetPasswordRequest/Response`)
- Modify: `backend/gateway/controller/auth_controller.go` (新增 `ResetPassword` handler)
**Interfaces:**
- Consumes: `dto.ResetPasswordRequest` (HTTP 入参) → 转换为 `pb.ResetPasswordRequest`
- Produces: HTTP 200 + `gin.H{}` 空响应(成功);业务码错误由 `response.HandleError` 处理
- [ ] **Step 1: 在 auth_sms_dto.go 末尾新增 DTO**
打开 `backend/gateway/dto/auth_sms_dto.go`,在文件末尾(参考 `SendCodeRequest/Response` 的格式)新增:
```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 匿名重置密码响应(空,业务码通过 Base 表达)
type ResetPasswordResponse struct {
}
```
- [ ] **Step 2: 在 auth_controller.go 新增 handler**
打开 `backend/gateway/controller/auth_controller.go`,找到 `VerifyCode` 方法结束位置(约 460+ 行),在其后新增:
```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{})
}
```
**注**:`pbError` 是 `AuthController` 中已有的内部类型(参考 `SendCode` 的用法);若 import 缺失需补充 `codes` (Dubbo codes) / `context` / `gin`。
- [ ] **Step 3: 验证编译**
```bash
cd backend && go build ./gateway/...
```
Expected: 编译通过,无 `undefined` 错误。
- [ ] **Step 4: 提交(需用户确认)**
```bash
git add backend/gateway/dto/auth_sms_dto.go backend/gateway/controller/auth_controller.go
git commit -m "feat(gateway): add ResetPassword HTTP handler"
```
---
## Task 6: Router - 注册新路由
**Files:**
- Modify: `backend/gateway/router/router.go` (在 auth 公开组末尾追加 1 行)
**Interfaces:**
- Adds: `POST /api/v1/auth/reset-password` → `authCtrl.ResetPassword` (无 AuthMiddleware)
- [ ] **Step 1: 打开 router.go,定位 auth 公开组**
```bash
grep -n "auth.POST(\"/verify-code\"\|auth.POST(\"/send-code\"" backend/gateway/router/router.go
```
Expected: 找到 `auth.POST("/verify-code", authCtrl.VerifyCode) // 验证验证码` 行(约 148 行)。
- [ ] **Step 2: 在该行后追加 1 行**
```go
auth.POST("/reset-password", authCtrl.ResetPassword) // 匿名重置密码(忘记密码场景)
```
- [ ] **Step 3: 验证编译**
```bash
cd backend && go build ./gateway/...
```
Expected: 编译通过。
- [ ] **Step 4: 验证未误挂 AuthMiddleware**
```bash
sed -n '140,160p' backend/gateway/router/router.go
```
Expected: 看到新行在 `auth := v1.Group("/auth")` 块内(约 140-149 行),**不在** `authProtected := v1.Group("/auth")` 块内(约 165+ 行,有 `authProtected.Use(middleware.AuthMiddleware())`)。
- [ ] **Step 5: 提交(需用户确认)**
```bash
git add backend/gateway/router/router.go
git commit -m "feat(gateway): register POST /api/v1/auth/reset-password route"
```
---
## Task 7: 前端 API 函数
**Files:**
- Modify: `frontend/utils/api.js` (在 `updatePasswordApi` 附近新增 `resetPasswordApi`)
**Interfaces:**
- Produces: `resetPasswordApi(mobile, newPassword, verifyToken) → Promise<{code, ...}>` — forgotPassword.vue 依赖
- [ ] **Step 1: 定位 updatePasswordApi 位置**
```bash
grep -n "updatePasswordApi" frontend/utils/api.js | head -3
```
Expected: 找到 `export function updatePasswordApi` (约 301 行)。
- [ ] **Step 2: 在该函数下方新增 resetPasswordApi**
```js
// 忘记密码场景 - 匿名重置密码接口
// 参数:mobile 手机号 / newPassword 新密码 / verifyToken scene=password 下发的一次性 token
// 注意:无需 access_token(后端不挂 AuthMiddleware)
export function resetPasswordApi(mobile, newPassword, verifyToken) {
return request({
url: '/api/v1/auth/reset-password',
method: 'POST',
data: {
mobile,
new_password: newPassword,
verify_token: verifyToken
}
});
}
```
- [ ] **Step 3: 验证项目无 lint 错误**
```bash
cd frontend && npx eslint utils/api.js 2>&1 | head -20 || echo "no eslint, skip"
```
Expected: 无错误(eslint 未配置时直接跳过)。
- [ ] **Step 4: 提交(需用户确认)**
```bash
git add frontend/utils/api.js
git commit -m "feat(frontend): add resetPasswordApi for forgot-password flow"
```
---
## Task 8: 前端 pages.json 注册
**Files:**
- Modify: `frontend/pages.json` (在 `pages/login/login` 附近新增 1 个 entry)
**Interfaces:**
- Registers: `pages/login/forgotPassword` 路由 — login.vue 的 `uni.reLaunch` 依赖
- [ ] **Step 1: 定位 login 路由位置**
```bash
grep -n '"path": "pages/login/login"\|"path": "pages/login/quickLogin"' frontend/pages.json | head -3
```
Expected: 找到 login 和 quickLogin 路由(分别在 72 行和 90 行附近)。
- [ ] **Step 2: 在 quickLogin 之后新增 forgotPassword 路由**
```json
{
"path": "pages/login/forgotPassword",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
```
- [ ] **Step 3: 验证 JSON 合法**
```bash
cd frontend && node -e "JSON.parse(require('fs').readFileSync('pages.json', 'utf8'))" && echo "JSON OK"
```
Expected: 输出 `JSON OK`。
- [ ] **Step 4: 提交(需用户确认)**
```bash
git add frontend/pages.json
git commit -m "feat(frontend): register forgotPassword page route"
```
---
## Task 9: 前端 - 创建 forgotPassword.vue
**Files:**
- Create: `frontend/pages/login/forgotPassword.vue` (基于 `register.vue` 复制)
**Interfaces:**
- Calls: `checkmobileApi(mobile)`, `sendCodeApi(mobile, "password")`, `verifyCodeApi(mobile, code, "password")`, `resetPasswordApi(mobile, newPassword, verifyToken)` (Task 7 产物)
- Reuses: `LoginBackground` 组件, `validatePhone`, `validatePassword` 工具
- [ ] **Step 1: 复制 register.vue 作为基础**
```bash
cp frontend/pages/register/register.vue frontend/pages/login/forgotPassword.vue
```
- [ ] **Step 2: 修改标题文字**
打开新文件,把第 19 行(标题):
```html
手机号注册
```
改为:
```html
找回密码
```
- [ ] **Step 3: 修改按钮文字**
找到 `注册`,改为:
```html
重置密码
```
- [ ] **Step 4: 修改手机号 placeholder 和添加 @input**
找到 `` 前加 `@input="handlePhoneInput"`,完整行:
```html
```
- [ ] **Step 5: 修改新密码 placeholder**
找到 `` 块**整体替换**为:
```vue
```
- [ ] **Step 8: 在 template 末尾(register-card 之后)新增未注册弹窗**
找到 `` 关闭 register-card 之后,`` 关闭 register-content 之前,新增:
```vue
该手机号未注册
该手机号尚未注册,无法重置密码,是否前往注册页面创建账号?
取消
去注册
```
- [ ] **Step 9: 在 style 块末尾(最后一行 } 之前)新增弹窗样式**
新增:
```css
/* 未注册弹窗(对照 login.vue 的 showRegisterDialog 样式) */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-card {
width: 560rpx;
background: #fff;
border-radius: 32rpx;
padding: 48rpx 40rpx 36rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.dialog-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.dialog-message {
font-size: 28rpx;
color: #666;
text-align: center;
line-height: 1.6;
margin-bottom: 40rpx;
}
.dialog-actions {
display: flex;
gap: 24rpx;
width: 100%;
}
.dialog-btn {
flex: 1;
height: 80rpx;
border-radius: 58rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-btn-cancel {
background: #f5f5f5;
}
.dialog-btn-confirm {
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.28) 0%,
rgba(252, 100, 102, 0.58) 64%,
rgba(244, 88, 104, 0.58) 100%);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
}
.dialog-btn-text {
font-size: 30rpx;
color: #333;
}
.dialog-btn-confirm .dialog-btn-text {
color: #fff9e7;
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
}
```
- [ ] **Step 10: 提交(需用户确认)**
```bash
git add frontend/pages/login/forgotPassword.vue
git commit -m "feat(frontend): add forgotPassword page"
```
---
## Task 10: 前端 - 更新 login.vue 入口
**Files:**
- Modify: `frontend/pages/login/login.vue` (替换 `handleForgotPassword`)
**Interfaces:**
- Calls: `uni.reLaunch({ url: "/pages/login/forgotPassword" })` — 触发 Task 9 页面
- [ ] **Step 1: 打开 login.vue,定位 handleForgotPassword**
```bash
grep -n "handleForgotPassword\|忘记密码功能开发中" frontend/pages/login/login.vue | head -3
```
Expected: 找到第 138-140 行的 `handleForgotPassword` 函数。
- [ ] **Step 2: 替换函数体**
把:
```js
// 忘记密码
const handleForgotPassword = () => {
uni.showToast({ title: "忘记密码功能开发中", icon: "none" });
};
```
替换为:
```js
// 忘记密码
const handleForgotPassword = () => {
uni.reLaunch({ url: "/pages/login/forgotPassword" });
};
```
- [ ] **Step 3: 提交(需用户确认)**
```bash
git add frontend/pages/login/login.vue
git commit -m "feat(frontend): wire login forgot-password to forgotPassword page"
```
---
## Task 11: 端到端手动验证
**Files:** 无(纯验证)
**Interfaces:** 验证 Task 1-10 的所有改动在 app 端真实跑通
- [ ] **Step 1: 启动后端服务**
```bash
cd backend && make build && make start-all
```
Expected: userService + gateway 正常启动,日志无错误。
- [ ] **Step 2: 启动前端(本地 HBuilderX 或 CLI)**
按项目现有流程启动(参见 [frontend 启动文档](../README.md))。
- [ ] **Step 3: 执行设计文档 §6.2 全部 13 条 checklist**
逐条执行并记录结果(参见 [设计文档 §6.2](../../specs/2026-07-09-forgot-password-design.md#62-前端手动验证-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 | ☐ |
| 7 | 输正确验证码 | 显示"已验证"状态 | ☐ |
| 8 | 新密码 5 位 | 本地拒绝,提示"密码至少为6位" | ☐ |
| 9 | 新密码 ≠ 确认密码 | 提示"两次输入不一致" | ☐ |
| 10 | 重置成功 | Toast + 1.5s 跳回 login | ☐ |
| 11 | 用新密码登录 | 成功 | ☐ |
| 12 | **用旧密码登录** | **失败(已重置)**(关键业务语义) | ☐ |
| 13 | 改密后该用户其他设备的 access_token 失效 | 是(access_token 已清空) | ☐ |
- [ ] **Step 4: 验证 sms_send_log 表记录**
```sql
SELECT mobile, scene, status, send_time
FROM sms_send_log
WHERE scene = 'password'
ORDER BY send_time DESC
LIMIT 5;
```
Expected: 至少看到 1 条 scene='password' 记录。
- [ ] **Step 5: 验证 password_hash 已更新**
```sql
SELECT id, mobile, LEFT(password_hash, 20) AS hash_prefix, updated_at
FROM users
WHERE mobile = '你测试用的手机号';
```
Expected: `updated_at` 接近当前时间,`password_hash` 与重置前不同。
- [ ] **Step 6: 若发现问题,创建修复任务(独立提交)**
每个发现的问题都应作为独立 commit 修复(便于回滚),不要混入本任务。