39 KiB
忘记密码(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 的位置)新增:
// ResetPassword 匿名重置密码(忘记密码场景)
// 鉴权:无 AuthMiddleware,通过 verify_token(scene=password) 验证身份
// 调用方: forgetPassword.vue (login 子页面)
rpc ResetPassword(ResetPasswordRequest) returns (ResetPasswordResponse);
在 user.proto 末尾(参考 UpdatePasswordRequest/Response 的位置)新增消息:
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 文件
cd backend && ./scripts/compile-proto.sh
Expected: 脚本输出"✅ 编译完成"且无错误,backend/pkg/proto/user/user.pb.go 和 user.triple.go 包含 ResetPassword / ResetPasswordRequest / ResetPasswordResponse 标识符。
- Step 3: 验证编译
cd backend && go build ./...
Expected: 编译通过,无错误(新消息类型不引用任何 Go 代码,不应影响编译)。
- Step 4: 提交(需用户确认)
注意:按 CLAUDE.md 规范,执行 commit 前必须获得用户口头确认。
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 位置
grep -n "func.*UpdatePassword\|appErrors.BuildBaseResponse" backend/services/userService/service/user_service.go | head -5
Expected: 找到 UpdatePassword 方法的结束位置(大约 615 行附近),用于紧邻其下添加 ResetPassword。
- Step 2: 新增 ResetPassword 方法
在 UpdatePassword 方法结束后的空行处,粘贴以下代码:
// 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: 验证编译
cd backend && go build ./services/userService/...
Expected: 编译通过。若报 undefined: appErrors.BuildBaseResponse,参考 user_service.go:108/95 的用法直接构造 Base: &pbCommon.BaseResponse{...}。
- Step 4: 提交(需用户确认)
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 测试文件作为模板
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):
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)
}
}
实施前必查:
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: 运行测试
cd backend && go test -run "TestResetPassword" -v ./services/userService/service/...
Expected: 9 个用例全部 PASS(若有 Redis skip 或 DB 不可用,部分用例 skip 是允许的)。
- Step 4: 提交(需用户确认)
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 行),在其后新增:
// 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 实现(参考现有模式),在其后新增:
// 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: 验证编译
cd backend && go build ./services/userService/...
Expected: 编译通过。
- Step 4: 提交(需用户确认)
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(新增ResetPasswordhandler)
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 的格式)新增:
// 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+ 行),在其后新增:
// 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: 验证编译
cd backend && go build ./gateway/...
Expected: 编译通过,无 undefined 错误。
- Step 4: 提交(需用户确认)
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 公开组
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 行
auth.POST("/reset-password", authCtrl.ResetPassword) // 匿名重置密码(忘记密码场景)
- Step 3: 验证编译
cd backend && go build ./gateway/...
Expected: 编译通过。
- Step 4: 验证未误挂 AuthMiddleware
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: 提交(需用户确认)
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 位置
grep -n "updatePasswordApi" frontend/utils/api.js | head -3
Expected: 找到 export function updatePasswordApi (约 301 行)。
- Step 2: 在该函数下方新增 resetPasswordApi
// 忘记密码场景 - 匿名重置密码接口
// 参数: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 错误
cd frontend && npx eslint utils/api.js 2>&1 | head -20 || echo "no eslint, skip"
Expected: 无错误(eslint 未配置时直接跳过)。
- Step 4: 提交(需用户确认)
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 路由位置
grep -n '"path": "pages/login/login"\|"path": "pages/login/quickLogin"' frontend/pages.json | head -3
Expected: 找到 login 和 quickLogin 路由(分别在 72 行和 90 行附近)。
- Step 2: 在 quickLogin 之后新增 forgotPassword 路由
{
"path": "pages/login/forgotPassword",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
- Step 3: 验证 JSON 合法
cd frontend && node -e "JSON.parse(require('fs').readFileSync('pages.json', 'utf8'))" && echo "JSON OK"
Expected: 输出 JSON OK。
- Step 4: 提交(需用户确认)
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 作为基础
cp frontend/pages/register/register.vue frontend/pages/login/forgotPassword.vue
- Step 2: 修改标题文字
打开新文件,把第 19 行(标题):
<text class="card-title">手机号注册</text>
改为:
<text class="card-title">找回密码</text>
- Step 3: 修改按钮文字
找到 <text class="next-btn-text">注册</text>,改为:
<text class="next-btn-text">重置密码</text>
- Step 4: 修改手机号 placeholder 和添加 @input
找到 <input class="input-field" type="number" v-model="form.phone" placeholder="输入您的手机号",在 maxlength="11" /> 前加 @input="handlePhoneInput",完整行:
<input class="input-field" type="number" v-model="form.phone" placeholder="输入您的手机号"
placeholder-class="input-placeholder" maxlength="11" @input="handlePhoneInput" />
- Step 5: 修改新密码 placeholder
找到 <input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="form.password" placeholder="创建密码",把 placeholder="创建密码" 改为 placeholder="设置新密码"。
- Step 6: 修改确认密码 placeholder
找到 <input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="confirmPassword" placeholder="确认密码",把 placeholder="确认密码" 改为 placeholder="确认新密码"。
- Step 7: 替换 script 逻辑
把整个 <script setup> 块整体替换为:
<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"); // unsent, countdown, resend, verified
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 togglePassword = () => {
showPassword.value = !showPassword.value;
};
// 发送验证码
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>
- Step 8: 在 template 末尾(register-card 之后)新增未注册弹窗
找到 </view> 关闭 register-card 之后,</view> 关闭 register-content 之前,新增:
<!-- 手机号未注册弹窗 -->
<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>
- Step 9: 在 style 块末尾(最后一行 } 之前)新增弹窗样式
新增:
/* 未注册弹窗(对照 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: 提交(需用户确认)
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
grep -n "handleForgotPassword\|忘记密码功能开发中" frontend/pages/login/login.vue | head -3
Expected: 找到第 138-140 行的 handleForgotPassword 函数。
- Step 2: 替换函数体
把:
// 忘记密码
const handleForgotPassword = () => {
uni.showToast({ title: "忘记密码功能开发中", icon: "none" });
};
替换为:
// 忘记密码
const handleForgotPassword = () => {
uni.reLaunch({ url: "/pages/login/forgotPassword" });
};
- Step 3: 提交(需用户确认)
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: 启动后端服务
cd backend && make build && make start-all
Expected: userService + gateway 正常启动,日志无错误。
- Step 2: 启动前端(本地 HBuilderX 或 CLI)
按项目现有流程启动(参见 frontend 启动文档)。
- Step 3: 执行设计文档 §6.2 全部 13 条 checklist
逐条执行并记录结果(参见 设计文档 §6.2):
| # | 场景 | 预期 | 通过 |
|---|---|---|---|
| 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 表记录
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 已更新
SELECT id, mobile, LEFT(password_hash, 20) AS hash_prefix, updated_at
FROM users
WHERE mobile = '你测试用的手机号';
Expected: updated_at 接近当前时间,password_hash 与重置前不同。
- Step 6: 若发现问题,创建修复任务(独立提交)
每个发现的问题都应作为独立 commit 修复(便于回滚),不要混入本任务。