# 忘记密码(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 修复(便于回滚),不要混入本任务。