topfans/docs/superpowers/plans/2026-07-09-forgot-password-implementation.md
2026-07-09 19:00:03 +08:00

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.gouser.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 已存在 VerifyTokenConsumeVerifyToken(行 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(注入 dbuserRepo)。

  • 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 (新增 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 的格式)新增:

// 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{})
}

:pbErrorAuthController 中已有的内部类型(参考 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-passwordauthCtrl.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 修复(便于回滚),不要混入本任务。