topfans/docs/superpowers/plans/2026-06-12-change-password.md
zheng020 3b14922e48 docs(plan): 修复 4 个真实编译风险 + 1 个范围声明
- Task 3.3: 补 rate-limit 函数(IncrMobileCount/IncrIPCount 等)
  场景化,11 个函数完整列出
- Task 4: 加 4.4-4.6 修复 auth_provider.go / unified_provider.go /
  auth_controller.go 调用点(否则编译失败);修订'明确不动'列表
- Task 4.3: 引入 VerifyTokenFn package-level var DI 模式(让
  Task 6.2 的 setupVerifyTokenMock 可工作)
- Task 6.3: 改用 VerifyTokenFn 注入调用,匹配 DI 模式
- Task 7: 5 PASS + 5 SKIP 明确分工(避免验收标准误导)
- Task 10.2: 完整 import 块 + 用 errors.Is 标准库 + pbCommon 别名
- 依赖图:刷新编号,前端 Task 9/11 可独立推进说明
- 验收标准:5 PASS + 5 SKIP 标注
2026-06-12 15:18:40 +08:00

45 KiB
Raw Blame History

修改密码功能实现计划

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: 修复"修改密码"功能中已发现的 3 个 BUG,并升级为"旧密码 + 短信验证码"双保险的安全流程,同时顺带修复 Login 流程账号状态码 BUG(返 500 应为 403)。

Architecture:

  • 后端:UpdatePasswordRequest proto 加 verify_token 字段;Service 头部用 VerifyToken(ctx, "password", mobile, token) 一次性消费;SmsRedis 改为 scene-aware key,Register 流程和 ChangePassword 流程用不同 scene 互不污染;ToStatusCode 改用 errors.Is 全面识别 wrapped error
  • 前端:拦截器去掉 400 自动登出(只 401/403 触发);改密弹窗加 5 个输入区(手机号只读 + 获取验证码、短信码、3 个密码框);setInterval 倒计时增加 cleanup;新增 getCodeCountdownTimer ref

Tech Stack: Go 1.21+ (Dubbo-Triples, GORM, testify/mock),uni-app Vue 3, SCSS, joi-like 验证。

Spec: docs/superpowers/specs/2026-06-12-change-password-design.md


涉及文件

文件 改动 备注
backend/proto/user.proto 修改:加 verify_token 字段 Task 1
backend/pkg/proto/user/user.pb.go regen Task 1
backend/pkg/proto/user/user.triple.go regen Task 1
backend/pkg/errors/errors.go 重写:3 个新错误 + ToStatusCode errors.Is 改造 + 2 个 helper Task 2
backend/services/userService/service/sms_redis.go 重构:scene-aware key Task 3
backend/services/userService/service/sms_service.go 修改:SendCode/VerifyCode/VerifyToken 加 scene 参数 Task 4
backend/services/userService/service/auth_service.go 修改:Register 调用点 1 处 + Login 流程 2 处 Task 5 + Task 10
backend/services/userService/service/user_service.go 重写:UpdatePassword 函数 Task 6
backend/services/userService/service/user_service_password_test.go 新增:10 个测试 Task 7
backend/services/userService/service/auth_service_login_test.go 新增:2 个测试 Task 10
backend/services/userService/provider/user_provider.go 修改:UpdatePassword 传 ctx Task 8
frontend/utils/api.js 修改:拦截器去掉 400 自动登出 Task 9
frontend/pages/profile/profile.vue 修改:弹窗 + 状态 + handler + CSS Task 11

明确不动:

  • backend/gateway/**(纯 Dubbo HTTP 入口,与本任务无关)
  • frontend/pages/**(除 profile.vue 外)
  • frontend/store/**

实施说明

  • TDD 优先:每个 Service 层函数都先写失败测试,再写实现
  • 频繁提交:每个 Task 完成后立即 git commit,粒度小便于回滚
  • proto regen:Task 1 后必须 regen,所有引用 UpdatePasswordRequest 的代码(Task 6/7)都要等 regen 完成
  • commit prefix:feat(backend)/fix(backend)/feat(frontend)/test(backend)/docs 等

Task 1: Proto 改动 + 重新生成

Files:

  • Modify: backend/proto/user.proto:300-309
  • Regenerate: backend/pkg/proto/user/user.pb.go
  • Regenerate: backend/pkg/proto/user/user.triple.go

1.1 修改 proto 文件

打开 backend/proto/user.proto,找到 UpdatePasswordRequest 消息(约第 300-309 行),加一个 verify_token 字段:

message UpdatePasswordRequest {
  string old_password = 1;     // 旧密码
  string new_password = 2;     // 新密码
  string verify_token = 3;     // 短信验证 token (scene=password 下发的一次性 token)
}

1.2 重新生成 Go 代码

cd backend
bash gen-swagger.sh 2>&1 | head -50

预期:生成 user.pb.gouser.triple.go,输出中包含 UpdatePassword 相关行。

如果 gen-swagger.sh 失败,手动执行:

cd backend
protoc --go_out=. --go-triple_out=. proto/user.proto

1.3 验证编译

cd backend
go build ./services/userService/...

预期:编译成功,无错误。

1.4 提交

git add backend/proto/user.proto backend/pkg/proto/user/user.pb.go backend/pkg/proto/user/user.triple.go
git commit -m "feat(backend): UpdatePasswordRequest 新增 verify_token 字段"

Task 2: errors.go 新增错误码 + ToStatusCode 重写

Files:

  • Modify: backend/pkg/errors/errors.go(全文件重写)

2.1 新增错误变量

打开 backend/pkg/errors/errors.go,在 var (...) 块末尾(约第 71 行后)新增 3 个错误 + 2 个 helper:

var (
    // ... 原有错误保留
    
    // 新增:修改密码流程专用
    ErrInvalidVerifyToken  = errors.New("invalid verify token")
    ErrSameAsOldPassword   = errors.New("new password is same as old")
    ErrInvalidOldPassword  = errors.New("old password is incorrect")
)

在文件底部(在 NewRequestInCooldownError 之后)新增 2 个 helper:

// NewAccountBannedError returns ErrAccountBanned with reason wrapped into message
func NewAccountBannedError(reason string) error {
    if reason != "" {
        return fmt.Errorf("%w: %s", ErrAccountBanned, reason)
    }
    return ErrAccountBanned
}

// NewAccountFrozenError returns ErrAccountFrozen with reason + frozenUntil wrapped
func NewAccountFrozenError(reason string, frozenUntil *int64) error {
    parts := []string{}
    if reason != "" {
        parts = append(parts, "原因:"+reason)
    }
    if frozenUntil != nil {
        parts = append(parts, "解封时间:"+time.UnixMilli(*frozenUntil).Format("2006-01-02 15:04:05"))
    }
    if len(parts) > 0 {
        return fmt.Errorf("%w: %s", ErrAccountFrozen, strings.Join(parts, ", "))
    }
    return ErrAccountFrozen
}

确认 import 中有 "strings"(如有则不需加)。

2.2 重写 ToStatusCode 函数

将原 ToStatusCode 函数整体替换(用 errors.Is 全面识别 wrapped error,新增 3 个 case,显式加 ErrUserInactive):

func ToStatusCode(err error) pb.StatusCode {
    if err == nil {
        return pb.StatusCode_STATUS_OK
    }
    switch {
    case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrFanProfileNotFound), errors.Is(err, ErrStarNotFound):
        return pb.StatusCode_STATUS_NOT_FOUND
    case errors.Is(err, ErrUserAlreadyExists), errors.Is(err, ErrFanProfileAlreadyExists),
        errors.Is(err, ErrNicknameAlreadyExists), errors.Is(err, ErrInvalidMobile),
        errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrInvalidOldPassword),
        errors.Is(err, ErrInvalidStarID), errors.Is(err, ErrInvalidUserID),
        errors.Is(err, ErrMaxIdentitiesReached), errors.Is(err, ErrInvalidNickname),
        errors.Is(err, ErrInvalidVerifyToken), errors.Is(err, ErrSameAsOldPassword),
        errors.Is(err, ErrCannotAddSelf), errors.Is(err, ErrCannotSearchSelf),
        errors.Is(err, ErrNotFanOfStar), errors.Is(err, ErrAlreadyFriends),
        errors.Is(err, ErrRequestAlreadyPending), errors.Is(err, ErrInvalidFriendUserID),
        errors.Is(err, ErrCannotProcessOwnRequest), errors.Is(err, ErrRequestAlreadyProcessed),
        errors.Is(err, ErrRequestExpired), errors.Is(err, ErrInvalidAction),
        errors.Is(err, ErrNotFriends), errors.Is(err, ErrInsufficientCrystal),
        errors.Is(err, ErrInsufficientMintTimes), errors.Is(err, ErrInvalidAssetStatus),
        errors.Is(err, ErrInvalidMintOrderStatus), errors.Is(err, ErrInvalidAssetType):
        return pb.StatusCode_STATUS_BAD_REQUEST
    case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken),
        errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch):
        return pb.StatusCode_STATUS_UNAUTHORIZED
    case errors.Is(err, ErrAccountFrozen), errors.Is(err, ErrAccountBanned),
        errors.Is(err, ErrUserInactive), errors.Is(err, ErrAssetAccessDenied),
        errors.Is(err, ErrMintOrderAccessDenied):
        return pb.StatusCode_STATUS_FORBIDDEN
    case errors.Is(err, ErrRequestInCooldown):
        return pb.StatusCode_STATUS_TOO_MANY_REQUESTS
    default:
        return pb.StatusCode_STATUS_INTERNAL_ERROR
    }
}

2.3 验证编译

cd backend
go build ./pkg/errors/...
go build ./...

预期:编译成功。

2.4 提交

git add backend/pkg/errors/errors.go
git commit -m "feat(backend): 新增改密错误码 + ToStatusCode 改用 errors.Is"

Task 3: SMS Redis key 重构(scene-aware)

Files:

  • Modify: backend/services/userService/service/sms_redis.go(关键常量 + 3 个 key 函数)

3.1 修改 key 常量

打开 backend/services/userService/service/sms_redis.go,找到第 15-19 行的常量定义,改为:

const (
    SMSCodeKeyPrefix     = "sms:"          // 后跟 scene
    SMSVerifyTokenPrefix = "verify:"       // 后跟 scene
    SMSLimitMobilePrefix = "sms:limit:mobile:"
    SMSLimitIPPrefix     = "sms:limit:ip:send:"
    SMSBlacklistIPPrefix = "sms:blacklist:ip:"
)

3.2 修改 key 函数

找到 smsCodeKey / verifyTokenKey(约 50-58 行),改为:

func smsCodeKey(scene, mobile string) string {
    return SMSCodeKeyPrefix + scene + ":" + mobile
}

func verifyTokenKey(scene, mobile string) string {
    return SMSVerifyTokenPrefix + scene + ":" + mobile
}

3.3 修改所有相关 Redis 函数的签名

找到以下 11 个函数,所有签名都加 scene string 参数(注意:不只是 SMS 验证码相关的 7 个,rate-limit 计数器也要 scene 化,否则 Task 4 调用点会编译失败):

函数 改动
SaveSMSCode scene 参数,内部用 smsCodeKey(scene, mobile)
GetSMSCode 同上
DeleteSMSCode 同上
IncrementAttempts 同上
SaveVerifyToken scene,内部用 verifyTokenKey(scene, mobile)
GetVerifyToken 同上
DeleteVerifyToken 同上
IncrMobileCount scene 参数,内部用 smsLimitMobileKey(scene, mobile)(如果有该函数)
GetMobileCount 同上
IncrIPCount scene 参数,内部用 smsLimitIPKey(scene, ip)
GetIPCount 同上

函数内部全部改用 xxxKey(scene, ...) 而不是 xxxKey(mobile) 这种无 scene 形式。如果 smsLimitMobileKey / smsLimitIPKey helper 函数尚未定义,新增:

func smsLimitMobileKey(scene, mobile string) string {
    return SMSLimitMobilePrefix + scene + ":" + mobile
}

func smsLimitIPKey(scene, ip string) string {
    return SMSLimitIPPrefix + scene + ":" + ip
}

3.4 验证编译(预期失败)

cd backend
go build ./services/userService/service/...

预期:编译失败,提示 SaveSMSCode / VerifyCode 等函数调用点参数不匹配。这是预期的——Task 4 会修复。

3.5 提交

git add backend/services/userService/service/sms_redis.go
git commit -m "feat(backend): SMS Redis key 重构为 scene-aware"

Task 4: SMS service 加 scene 参数

Files:

  • Modify: backend/services/userService/service/sms_service.go(SendCode / VerifyCode / VerifyToken)

4.1 修改 SendCode 函数

打开 sms_service.go,找到 SendCode 函数(约 100-188 行),签名加 scene 参数:

func SendCode(ctx context.Context, mobile, ip, scene string) (int, error) {
    // 函数体内:
    // SaveSMSCode 调用改成 SaveSMSCode(ctx, scene, mobile, code, 60*time.Second)
    // IncrMobileCount 调用改成 IncrMobileCount(ctx, scene, mobile)
    // IncrIPCount 调用改成 IncrIPCount(ctx, scene, ip)
}

具体改动:

  • 第 169 行: SaveSMSCode(ctx, mobile, code, 60*time.Second)SaveSMSCode(ctx, scene, mobile, code, 60*time.Second)
  • 第 176 行: IncrMobileCount(ctx, mobile)IncrMobileCount(ctx, scene, mobile)
  • 第 181 行: IncrIPCount(ctx, ip)IncrIPCount(ctx, scene, ip)

4.2 修改 VerifyCode 函数

找到 VerifyCode 函数(约 191-244 行),签名加 scene:

func VerifyCode(ctx context.Context, scene, mobile, code string) (string, error) {
    // GetSMSCode 调用改成 GetSMSCode(ctx, scene, mobile)
    // DeleteSMSCode 调用改成 DeleteSMSCode(ctx, scene, mobile)
    // IncrementAttempts 调用改成 IncrementAttempts(ctx, scene, mobile)
    // SaveVerifyToken 调用改成 SaveVerifyToken(ctx, scene, mobile, verifyToken, 300*time.Second)
}

4.3 修改 VerifyToken 函数(同时引入 DI 模式)

找到 VerifyToken 函数(约 247 行起),做两件事:

  1. 签名加 scene
  2. 引入 package-level var 让 Service 层可注入(便于测试,见 Task 6.2/6.3):
// VerifyTokenFn 是包级变量,运行时指向 realVerifyToken,测试时可替换为 mock
var VerifyTokenFn func(ctx context.Context, scene, mobile, token string) error = realVerifyToken

func VerifyToken(ctx context.Context, scene, mobile, token string) error {
    return VerifyTokenFn(ctx, scene, mobile, token)
}

func realVerifyToken(ctx context.Context, scene, mobile, token string) error {
    // 实际 Get+DeleteVerifyToken 逻辑
    // GetVerifyToken(ctx, scene, mobile)
    // DeleteVerifyToken(ctx, scene, mobile)
}

为什么这样做:Task 6 的测试 setupVerifyTokenMock 需要替换 VerifyTokenFn 来 mock 行为。如果直接调 VerifyToken(...),测试需要 mock GetVerifyToken Redis 调用,复杂且侵入。包级 var DI 是 Go 测试的标准模式。

4.4 修复调用点(provider / controller)

为什么需要这一步:Task 4 改完 SMS 服务的函数签名后,以下文件会编译失败(它们也调用了 SendCode / VerifyCode):

  • backend/services/userService/provider/auth_provider.go(SendCode / VerifyCode 调用)
  • backend/services/userService/provider/unified_provider.go(同上)
  • backend/gateway/controller/auth_controller.go(同上)

打开每个文件,找到 SendCode(ctx, ...) / VerifyCode(ctx, ...),在调用处补 scene 参数。具体:

// 改前(原 auth_provider.go 中 SendCode 调用)
resp, err := s.smsService.SendCode(ctx, mobile, ip)

// 改为
resp, err := s.smsService.SendCode(ctx, mobile, ip, scene)

// 改前(VerifyCode 调用)
token, err := s.smsService.VerifyCode(ctx, mobile, code)

// 改为
token, err := s.smsService.VerifyCode(ctx, "register", mobile, code)
// 或根据具体场景:scene = "password" / "register" / "login" 等

注意:auth_controller.goauth_provider.go 内部的 SMS 调用场景由调用方传入(req.Scene),不需要硬编码。

4.5 验证编译

cd backend
go build ./...

预期:编译成功(所有 service + provider + controller + gateway)。

4.6 提交

git add backend/services/userService/service/sms_service.go
git add backend/services/userService/provider/auth_provider.go
git add backend/services/userService/provider/unified_provider.go
git add backend/gateway/controller/auth_controller.go
git commit -m "feat(backend): SMS SendCode/VerifyCode/VerifyToken 加 scene 参数 + 调用点更新"

Task 5: auth_service.go Register 调用点更新

Files:

  • Modify: backend/services/userService/service/auth_service.go(第 65-71 行)

5.1 修改 Register 中 VerifyToken 调用

打开 auth_service.go,找到 Register 函数(约第 63-262 行),第 66 行附近:

原代码:

if err := VerifyToken(ctx, req.Mobile, req.VerifyToken); err != nil {
    logger.Logger.Warn("Verify token validation failed", ...)
    return nil, fmt.Errorf("invalid verify_token: %w", err)
}

改为:

if err := VerifyToken(ctx, "register", req.Mobile, req.VerifyToken); err != nil {
    logger.Logger.Warn("Verify token validation failed", ...)
    return nil, fmt.Errorf("invalid verify_token: %w", err)
}

5.2 验证编译

cd backend
go build ./services/userService/...

预期:编译成功。

5.3 提交

git add backend/services/userService/service/auth_service.go
git commit -m "fix(backend): Register 流程 VerifyToken 调用传 scene='register'"

Task 6: user_service.go UpdatePassword 实现

Files:

  • Modify: backend/services/userService/service/user_service.go(第 484-587 行)

6.1 修改接口签名

打开 user_service.go,找到接口定义(约第 42-43 行):

原代码:

UpdatePassword(req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error)

改为:

UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error)

6.2 写失败测试(先 TDD)

新建文件: backend/services/userService/service/user_service_password_test.go

package service

import (
    "context"
    "errors"
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "github.com/topfans/backend/pkg/models"
    appErrors "github.com/topfans/backend/pkg/errors"
    pb "github.com/topfans/backend/pkg/proto/user"
    "gorm.io/gorm"
)

// MockUserRepository 是 UserRepository 的 mock 实现
type MockUserRepository struct {
    mock.Mock
}

func (m *MockUserRepository) GetByID(id int64) (*models.User, error) {
    args := m.Called(id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*models.User), args.Error(1)
}

func (m *MockUserRepository) GetByMobile(mobile string) (*models.User, error) {
    args := m.Called(mobile)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*models.User), args.Error(1)
}

func (m *MockUserRepository) VerifyPassword(user *models.User, password string) bool {
    args := m.Called(user, password)
    return args.Bool(0)
}

func (m *MockUserRepository) Update(user *models.User) error {
    return m.Called(user).Error(0)
}

func (m *MockUserRepository) UpdateToken(userID int64, token string, expiresAt int64) error {
    return m.Called(userID, token, expiresAt).Error(0)
}

func (m *MockUserRepository) ClearToken(userID int64) error {
    return m.Called(userID).Error(0)
}

func (m *MockUserRepository) Create(user *models.User) error                { return nil }
func (m *MockUserRepository) ExistsByMobile(mobile string) (bool, error)    { return false, nil }
func (m *MockUserRepository) UpdateAvatar(userID int64, url string) error   { return nil }
func (m *MockUserRepository) GetAccountStatus(id int64) (*models.UserAccountStatus, error) {
    return nil, nil
}

// setupVerifyTokenMock 替换 VerifyTokenFn 用于测试
func setupVerifyTokenMock(t *testing.T, fn func(scene, mobile, token string) error) {
    orig := VerifyTokenFn
    VerifyTokenFn = func(ctx context.Context, scene, mobile, token string) error {
        return fn(scene, mobile, token)
    }
    t.Cleanup(func() { VerifyTokenFn = orig })
}

func newTestUser(id int64, mobile string) *models.User {
    return &models.User{
        ID:        id,
        Mobile:    mobile,
        IsActive:  true,
    }
}

func TestUpdatePassword_Success(t *testing.T) {
    repo := &MockUserRepository{}
    user := newTestUser(100, "13800000001")
    repo.On("GetByID", int64(100)).Return(user, nil)
    repo.On("VerifyPassword", user, "oldPass").Return(true)

    // 替换 VerifyTokenFn
    setupVerifyTokenMock(t, func(scene, mobile, token string) error {
        assert.Equal(t, "password", scene)
        assert.Equal(t, "13800000001", mobile)
        return nil
    })

    s := &userService{userRepo: repo, db: nil}
    req := &pb.UpdatePasswordRequest{
        OldPassword: "oldPass",
        NewPassword: "newPass123",
        VerifyToken: "valid-token",
    }
    // 注意:此测试需要 mock db,见 Task 6.3 扩展
    t.Skip("needs db mock for transaction")
    _ = s
}

先只写第 1 个测试,跑通编译即可go test -run TestUpdatePassword_Success 会编译,但 t.Skip 让它通过(还没实现)。

6.3 重写 UpdatePassword 函数

打开 user_service.go,找到 UpdatePassword 函数(约第 484-587 行),整体替换为:

func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) {
    // 0. 查询用户(后续需 user.Mobile 校验 verify_token)
    user, err := s.userRepo.GetByID(userID)
    if err != nil {
        if errors.Is(err, appErrors.ErrUserNotFound) {
            return nil, appErrors.ErrUserNotFound
        }
        return nil, fmt.Errorf("failed to get user: %w", err)
    }

    if req.VerifyToken == "" {
        return nil, appErrors.ErrInvalidVerifyToken
    }
    // scene="password" 必须与前端 verifyCodeApi 调用时传的一致
    // 注意:通过 VerifyTokenFn 注入,便于测试
    if err := VerifyTokenFn(ctx, "password", user.Mobile, req.VerifyToken); err != nil {
        return nil, appErrors.ErrInvalidVerifyToken
    }

    // 1. 参数验证
    if !validator.ValidateUserID(userID) {
        return nil, fmt.Errorf("invalid user_id: %d", userID)
    }
    if req.OldPassword == "" {
        return nil, appErrors.ErrInvalidOldPassword
    }

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

    // 2. 新旧密码一致性
    if req.OldPassword == req.NewPassword {
        return nil, appErrors.ErrSameAsOldPassword
    }

    // 3. 验证旧密码
    if !s.userRepo.VerifyPassword(user, req.OldPassword) {
        return nil, appErrors.ErrInvalidOldPassword
    }

    // 4. 加密新密码
    newPasswordHash, err := repository.HashPassword(req.NewPassword)
    if err != nil {
        return nil, fmt.Errorf("failed to hash password: %w", err)
    }

    // 5. 事务内更新
    err = s.db.Transaction(func(tx *gorm.DB) error {
        // 5.1 更新密码 hash 和 updated_at
        if err := tx.Model(user).Updates(map[string]interface{}{
            "password_hash": newPasswordHash,
            "updated_at":    time.Now().UnixMilli(),
        }).Error; err != nil {
            return fmt.Errorf("failed to update password: %w", err)
        }
        // 5.2 清除 access_token
        if err := tx.Model(user).Updates(map[string]interface{}{
            "access_token":     nil,
            "token_expires_at": nil,
        }).Error; err != nil {
            return fmt.Errorf("failed to clear token: %w", err)
        }
        return nil
    })
    if err != nil {
        return nil, err
    }

    logger.Logger.Info("Update password successful", zap.Int64("user_id", userID))

    return &pb.UpdatePasswordResponse{
        Base: &pbCommon.BaseResponse{
            Code:      pbCommon.StatusCode_STATUS_OK,
            Message:   "",
            Timestamp: time.Now().UnixMilli(),
        },
    }, nil
}

6.4 验证编译

cd backend
go build ./services/userService/...

预期:编译成功。

6.5 提交

git add backend/services/userService/service/user_service.go
git commit -m "feat(backend): UpdatePassword 改用 ctx + verify_token 校验 + 新错误码"

Task 7: user_service_password_test.go 完整 10 个测试

Files:

  • Modify: backend/services/userService/service/user_service_password_test.go(接续 Task 6.2,实现 9 个剩余测试)

重要说明:本 Task 实施时,至少完整实现 5 个核心测试(不 skip),覆盖最关键路径:Success / MissingVerifyToken / InvalidVerifyToken / WrongOldPassword / PasswordTooShort。剩余 5 个(SameAsOld / UserNotFound / RedisFailure / TransactionRollback / VerifyTokenExpired)可以 t.Skip 但需写好 mock 设置代码,便于后续工程师补全。

7.1 实施 5 个核心测试(必做,需 PASS)

完整实现以下测试,每个都按 TDD 跑通(必须 PASS):

func TestUpdatePassword_Success(t *testing.T) {
    // mock GetByID 返用户
    // setupVerifyTokenMock 返 nil
    // mock VerifyPassword 返 true
    // 注意:事务部分需 sqlite 内存库或 sqlmock
    // 期望:返 nil err,响应 Code = STATUS_OK
}

func TestUpdatePassword_MissingVerifyToken(t *testing.T) {
    // mock GetByID 返用户
    // req.VerifyToken = ""
    // 期望:err 满足 errors.Is(err, appErrors.ErrInvalidVerifyToken)
}

func TestUpdatePassword_InvalidVerifyToken(t *testing.T) {
    // mock GetByID 返用户
    // setupVerifyTokenMock 返 errors.New("invalid token")
    // 期望:err 满足 errors.Is(err, appErrors.ErrInvalidVerifyToken)
}

func TestUpdatePassword_WrongOldPassword(t *testing.T) {
    // mock GetByID 返用户
    // setupVerifyTokenMock 返 nil
    // mock VerifyPassword 返 false
    // 期望:err 满足 errors.Is(err, appErrors.ErrInvalidOldPassword)
}

func TestUpdatePassword_PasswordTooShort(t *testing.T) {
    // mock GetByID 返用户
    // setupVerifyTokenMock 返 nil
    // req.NewPassword = "abcde" (5 位)
    // 期望:err 满足 errors.Is(err, appErrors.ErrPasswordTooShort)
}

7.2 剩余 5 个测试(写好 mock 代码但 t.Skip 标记)

func TestUpdatePassword_VerifyTokenExpired(t *testing.T) { t.Skip("iteration 2") }
func TestUpdatePassword_SameAsOld(t *testing.T)            { t.Skip("iteration 2") }
func TestUpdatePassword_UserNotFound(t *testing.T)         { t.Skip("iteration 2") }
func TestUpdatePassword_RedisFailure(t *testing.T)        { t.Skip("iteration 2") }
func TestUpdatePassword_TransactionRollback(t *testing.T)  { t.Skip("iteration 2") }

7.3 跑测试

cd backend
go test -v ./services/userService/service/

预期:5 个测试 PASS,5 个测试 SKIP,无 FAIL。

7.4 提交

git add backend/services/userService/service/user_service_password_test.go
git commit -m "test(backend): 改密 service 10 个测试(5 PASS + 5 SKIP 标记)"

Task 8: user_provider.go UpdatePassword 传 ctx

Files:

  • Modify: backend/services/userService/provider/user_provider.go(第 341-388 行)

8.1 修改 provider 函数

打开 user_provider.go,找到 UpdatePassword 函数(约第 341-388 行),第 362 行附近:

原代码:

resp, err := p.userService.UpdatePassword(req, userID)

改为:

resp, err := p.userService.UpdatePassword(ctx, req, userID)

8.2 验证编译

cd backend
go build ./services/userService/...

预期:编译成功。

8.3 提交

git add backend/services/userService/provider/user_provider.go
git commit -m "fix(backend): UpdatePassword provider 透传 ctx"

Task 9: 前端 api.js 拦截器修复 BUG #1

Files:

  • Modify: frontend/utils/api.js(第 101-115 行附近)

9.1 修改拦截器

打开 frontend/utils/api.js,找到第 101-115 行的拦截器逻辑(把"业务码 400 也踢出登录"从条件里去掉):

原代码:

} else if (res.data.code === 401 || res.data.code === 400 || res.data.code === 403) {
    // 业务状态码401/400/403未授权/冻结/封号),清除缓存并跳转到登录页
    uni.removeStorageSync('access_token')
    uni.removeStorageSync('user')
    const errorMsg = res.data.message || '登录已过期,请重新登录'
    uni.reLaunch({ url: '/pages/login/login?error=' + encodeURIComponent(errorMsg) })
    reject(new Error(errorMsg))
    return
}

改为:

} else if (res.data.code === 401 || res.data.code === 403) {
    // 只有 401(token 失效)或 403(账号被封)才清 token + 跳登录页
    // 400 是业务校验错误(密码错、参数错、verify_token 错),reject 让调用方 toast
    uni.removeStorageSync('access_token')
    uni.removeStorageSync('user')
    const errorMsg = res.data.message || '登录已过期,请重新登录'
    uni.reLaunch({ url: '/pages/login/login?error=' + encodeURIComponent(errorMsg) })
    reject(new Error(errorMsg))
    return
} else {
    // 其他业务错误(包括 400 参数错),reject 让调用方处理
    reject(new Error(res.data.message || '请求失败'))
    return
}

9.2 验证编译

cd frontend
# 启动 H5 看是否有语法错误(可选)
npm run dev:h5 2>&1 | head -20

或者:

# 用 node 简单语法检查(虽然 .js 不是 .vue)
node -c frontend/utils/api.js

9.3 提交

git add frontend/utils/api.js
git commit -m "fix(frontend): 拦截器去掉 400 自动登出(BUG #1 修复)"

Task 10: auth_service.go Login 流程 BUG 修复

Files:

  • Modify: backend/services/userService/service/auth_service.go(第 327-377 行 Login 流程)
  • New: backend/services/userService/service/auth_service_login_test.go(2 个测试)

10.1 修改 Login 流程

打开 auth_service.go,找到 Login 函数中 banned / frozen 判断(约第 327-377 行),将两处 return 改用 typed error helper:

原代码(banned,约第 337-344 行):

return nil, fmt.Errorf("账号已被封禁%s%s", map[bool]func() string{
    true: func() string {
        if reason != "" {
            return ",原因:" + reason
        }
        return ""
    },
}[true](), "")

改为:

return nil, appErrors.NewAccountBannedError(reason)

原代码(frozen 实际生效分支,约第 376-377 行):

return nil, errors.New(errMsg)

改为:

return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil)

10.2 写 2 个测试

新建文件: backend/services/userService/service/auth_service_login_test.go

package service

import (
    "context"
    "errors"
    "testing"

    "github.com/stretchr/testify/assert"
    appErrors "github.com/topfans/backend/pkg/errors"
    pbCommon "github.com/topfans/backend/pkg/proto/common"
)

// 简化版:仅验证 ToStatusCode 映射行为(Login 流程本身需要复杂 mock,
// 完整测试留给后续迭代)。errors.Is 是标准库函数,不依赖项目定义。

func TestAccountBanned_MapToForbidden(t *testing.T) {
    reason := "spam"
    err := appErrors.NewAccountBannedError(reason)
    assert.True(t, errors.Is(err, appErrors.ErrAccountBanned))
    assert.Equal(t, pbCommon.StatusCode_STATUS_FORBIDDEN, appErrors.ToStatusCode(err))
    assert.Contains(t, err.Error(), "spam")
}

func TestAccountFrozen_MapToForbidden(t *testing.T) {
    reason := "abuse"
    err := appErrors.NewAccountFrozenError(reason, nil)
    assert.True(t, errors.Is(err, appErrors.ErrAccountFrozen))
    assert.Equal(t, pbCommon.StatusCode_STATUS_FORBIDDEN, appErrors.ToStatusCode(err))
    assert.Contains(t, err.Error(), "abuse")
}

关键说明:

  • errors.Is(标准库)而非 appErrors.Is(项目可能未定义)
  • pbCommon(标准 Go 包别名,即 github.com/topfans/backend/pkg/proto/common)
  • 不用 pb(避免与 user proto 冲突)

10.3 验证测试

cd backend
go test -run TestAccount -v ./services/userService/service/

预期:2 个测试都 PASS。

10.4 验证编译

cd backend
go build ./services/userService/...

预期:编译成功。

10.5 提交

git add backend/services/userService/service/auth_service.go
git add backend/services/userService/service/auth_service_login_test.go
git commit -m "fix(backend): Login 流程被冻结/封禁返 403 而非 500(§12 BUG 修复)"

Task 11: profile.vue 改密弹窗 + handler + CSS

Files:

  • Modify: frontend/pages/profile/profile.vue(多区域)

11.1 修改 password-modal 弹窗模板

打开 profile.vue,找到 <view class="password-modal" v-if="showPasswordModal"> 块(约第 159-184 行),整体替换为新弹窗(5 个 UI 区,见 spec §5.3.1):

<view class="password-modal" v-if="showPasswordModal" @tap="closePasswordModal">
    <view class="modal-content" @tap.stop>
        <view class="modal-title">修改密码</view>

        <!-- 1. 手机号 + 获取验证码 -->
        <view class="modal-row">
            <input class="modal-input readonly" :value="displayMobile" disabled
                placeholder-class="input-placeholder" />
            <button class="btn-get-code" :disabled="codeCountdown > 0 || sendingCode"
                @tap="handleSendCode">
                {{ codeCountdown > 0 ? `${codeCountdown}s 后重发` : '获取验证码' }}
            </button>
        </view>

        <!-- 2. 短信验证码 -->
        <view class="modal-password-wrapper">
            <input class="modal-password-input" type="number" maxlength="6"
                v-model="smsCode" placeholder="请输入短信验证码"
                placeholder-class="input-placeholder" />
        </view>

        <!-- 3. 旧密码 -->
        <view class="modal-password-wrapper">
            <input class="modal-password-input"
                :type="showOldPassword ? 'text' : 'password'"
                v-model="oldPassword" placeholder="请输入旧密码"
                placeholder-class="input-placeholder" />
            <view class="modal-eye-icon" @click="showOldPassword = !showOldPassword">
                <text class="eye-text">{{ showOldPassword ? '👁️' : '👁️‍🗨️' }}</text>
            </view>
        </view>

        <!-- 4. 新密码 -->
        <view class="modal-password-wrapper">
            <input class="modal-password-input"
                :type="showNewPassword ? 'text' : 'password'"
                v-model="newPassword" placeholder="请输入新密码(至少6位)"
                placeholder-class="input-placeholder" />
            <view class="modal-eye-icon" @click="showNewPassword = !showNewPassword">
                <text class="eye-text">{{ showNewPassword ? '👁️' : '👁️‍🗨️' }}</text>
            </view>
        </view>

        <!-- 5. 确认新密码 -->
        <view class="modal-password-wrapper">
            <input class="modal-password-input"
                :type="showConfirmPassword ? 'text' : 'password'"
                v-model="confirmPassword" placeholder="请再次输入新密码"
                placeholder-class="input-placeholder" />
            <view class="modal-eye-icon" @click="showConfirmPassword = !showConfirmPassword">
                <text class="eye-text">{{ showConfirmPassword ? '👁️' : '👁️‍🗨️' }}</text>
            </view>
        </view>

        <view class="modal-buttons">
            <button class="modal-btn-cancel" @tap="closePasswordModal">取消</button>
            <button class="modal-btn-confirm" @tap="confirmChangePassword"
                :disabled="changingPassword">
                {{ changingPassword ? '修改中...' : '确认' }}
            </button>
        </view>
    </view>
</view>

11.2 修改 APP 介绍按钮(补 @tap)

找到第 130-134 行(原"APP介绍"按钮),补 @tap="handleShowAppIntro":

<view class="service-button" @tap="handleShowAppIntro">
    <image class="service-icon" src="/static/icon/edit-password.png" mode="aspectFit" />
    <text class="service-text">APP介绍</text>
</view>

11.3 替换 state refs(原 showPasswordModal 块下)

找到原 // 修改密码弹窗 注释下的 refs(约第 336-340 行),整体替换为:

// 旧密码
const oldPassword = ref('')
const showOldPassword = ref(false)
// 新密码 + 确认
const newPassword = ref('')
const confirmPassword = ref('')
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
// 短信
const smsCode = ref('')
const sendingCode = ref(false)
const codeCountdown = ref(0)
const codeCountdownTimer = ref(null)
// 防重
const changingPassword = ref(false)

11.4 替换 3 个 handler

找到 handleChangePassword / closePasswordModal / confirmChangePassword(约第 645-733 行),整体替换为:

// 处理修改密码
const handleChangePassword = () => {
    oldPassword.value = ''
    newPassword.value = ''
    confirmPassword.value = ''
    smsCode.value = ''
    showOldPassword.value = false
    showNewPassword.value = false
    showConfirmPassword.value = false
    codeCountdown.value = 0
    if (codeCountdownTimer.value) {
        clearInterval(codeCountdownTimer.value)
        codeCountdownTimer.value = null
    }
    showPasswordModal.value = true
}

// 关闭修改密码弹窗(清空所有,包括倒计时 interval)
const closePasswordModal = () => {
    showPasswordModal.value = false
    oldPassword.value = ''
    newPassword.value = ''
    confirmPassword.value = ''
    smsCode.value = ''
    showOldPassword.value = false
    showNewPassword.value = false
    showConfirmPassword.value = false
    codeCountdown.value = 0
    if (codeCountdownTimer.value) {
        clearInterval(codeCountdownTimer.value)
        codeCountdownTimer.value = null
    }
}

// 发送验证码
const handleSendCode = async () => {
    if (sendingCode.value || codeCountdown.value > 0) return
    if (!mobile.value) {
        return uni.showToast({ title: '未获取到手机号', icon: 'none' })
    }
    try {
        sendingCode.value = true
        const res = await sendCodeApi(mobile.value, 'password')
        if (res.code === 200) {
            uni.showToast({ title: '验证码已发送', icon: 'success' })
            codeCountdown.value = 60
            if (codeCountdownTimer.value) clearInterval(codeCountdownTimer.value)
            codeCountdownTimer.value = setInterval(() => {
                codeCountdown.value--
                if (codeCountdown.value <= 0) {
                    clearInterval(codeCountdownTimer.value)
                    codeCountdownTimer.value = null
                }
            }, 1000)
        } else {
            uni.showToast({ title: res.message || '发送失败', icon: 'none' })
        }
    } catch (e) {
        uni.showToast({ title: e.message || '发送失败', icon: 'none' })
    } finally {
        sendingCode.value = false
    }
}

// 确认改密
const confirmChangePassword = async () => {
    // 本地校验
    if (!smsCode.value.match(/^\d{6}$/)) {
        return uni.showToast({ title: '请输入6位短信验证码', icon: 'none' })
    }
    if (!oldPassword.value.trim()) {
        return uni.showToast({ title: '请输入旧密码', icon: 'none' })
    }
    if (oldPassword.value === newPassword.value) {
        return uni.showToast({ title: '新密码不能与旧密码相同', icon: 'none' })
    }
    const v = validatePassword(newPassword.value)
    if (!v.valid) return uni.showToast({ title: v.message, icon: 'none' })
    if (newPassword.value !== confirmPassword.value) {
        return uni.showToast({ title: '两次输入的新密码不一致', icon: 'none' })
    }

    if (changingPassword.value) return
    changingPassword.value = true
    uni.showLoading({ title: '修改中...', mask: true })

    try {
        const vRes = await verifyCodeApi(mobile.value, smsCode.value, 'password')
        if (vRes.code !== 200 || !vRes.data?.verify_token) {
            throw new Error(vRes.message || '短信验证码错误')
        }
        const verifyToken = vRes.data.verify_token

        const res = await updatePasswordApi(
            oldPassword.value, newPassword.value, verifyToken
        )
        uni.hideLoading()

        if (res.code === 200) {
            uni.showToast({ title: '修改成功,请重新登录', icon: 'success' })
            setTimeout(() => {
                store.dispatch('user/logout')
                uni.removeStorageSync('access_token')
                uni.removeStorageSync('user')
                uni.removeStorageSync('nickname')
                uni.removeStorageSync('temp_register_mobile')
                uni.removeStorageSync('temp_register_password')
                uni.removeStorageSync('temp_register_nickname')
                uni.reLaunch({ url: '/pages/login/login' })
            }, 2000)
        }
    } catch (e) {
        uni.hideLoading()
        uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' })
        changingPassword.value = false
    }
}

// APP介绍占位
const handleShowAppIntro = () => {
    uni.showModal({
        title: 'APP介绍',
        content: '顶粉APP是一款粉丝专属应用,集成了身份管理、藏品展示、社交互动等功能。\n(更多介绍功能开发中...)',
        showCancel: false,
        confirmText: '我知道了'
    })
}

11.5 修改 import 块

找到 import { ... } from '@/utils/api';(约第 313 行),补 sendCodeApiverifyCodeApi:

import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi, sendCodeApi, verifyCodeApi } from '@/utils/api';

validatePassword import 已经在(原代码只导入了 validateNickname),改为:

import { validateNickname, validatePassword } from '@/utils/validator.js';

11.6 修改 updatePasswordApi 函数(api.js)

打开 frontend/utils/api.js,找到 updatePasswordApi(第 281-291 行),整体替换为:

export function updatePasswordApi(oldPassword, newPassword, verifyToken) {
    return request({
        url: '/api/v1/account/password',
        method: 'POST',
        data: {
            old_password: oldPassword,
            new_password: newPassword,
            verify_token: verifyToken,
        }
    })
}

11.7 添加 CSS

<style scoped> 块末尾(原 .password-modal 样式后)追加:

.modal-row {
    display: flex;
    gap: 20rpx;
    margin-bottom: 40rpx;
}
.modal-input.readonly {
    background: #f5f5f5;
    color: #999;
    flex: 1;
    height: 88rpx;
    border-radius: 20rpx;
    padding: 0 30rpx;
    font-size: 32rpx;
}
.btn-get-code {
    height: 88rpx;
    line-height: 88rpx;
    padding: 0 24rpx;
    border-radius: 20rpx;
    background: linear-gradient(165deg, #F0E4B1 0%, #F08399 100%);
    color: #fff;
    font-size: 26rpx;
    border: none;
    white-space: nowrap;
    min-width: 180rpx;
}
.btn-get-code:disabled { opacity: 0.6; }
.btn-get-code::after { border: none; }

11.8 验证(可选)

cd frontend
# 启动 H5 跑一遍手动 checklist(见 spec §6.2)
npm run dev:h5

11.9 提交

git add frontend/pages/profile/profile.vue frontend/utils/api.js
git commit -m "feat(frontend): 改密弹窗加 5 UI 区+SMS 流程+APP介绍占位+清理 setInterval"

Task 12: 全量回归 + 部署准备

12.1 跑后端全量测试

cd backend
go test ./...

预期:所有现有测试通过(ToStatusCode 行为不变,errors.Is 改造兼容)。

12.2 Swagger 重生成

cd backend
bash update-swagger.sh 2>&1 | head -20

预期:生成 docs/swagger.json / swagger.yaml,包含新 verify_token 字段。

12.3 手动验证 10 条 checklist(spec §6.2)

按以下顺序跑(每条都要过):

# 场景 操作
1 弹窗打开字段全空 打开改密弹窗,直接点确认
2 输错旧密码 全部字段填对,旧密码故意输错
3 新密码 5 位 新密码/确认新密码 都填 "abcde"
4 新密码 ≠ 确认 填两个不同值
5 短信码输错 输错 6 位数字
6 60s 内连点 60s 内点"获取验证码"两次
7 改密成功 全部填对点确认
8 弱网超时 DevTools throttle "Slow 3G",点确认
9 关闭弹窗再开 关闭后再次进入
10 APP介绍 点击 "APP介绍" 入口

每条记录预期结果,有问题立即修复。

12.4 最终提交(如有散落改动)

git status
# 如有未提交改动:
git add -A
git commit -m "chore: 部署前散落修复"

12.5 灰度发布

按 spec §11 部署清单执行:

  1. proto regen 后所有 service 编译通过
  2. Redis key 升级(老 sms:register:* / verify:register:* 保留)
  3. API 接口回归(Login/Register/RefreshToken/Logout/GetMyProfile/UpdateNickname)
  4. 登录态回归(401 不变/403 上升/500 下降)
  5. 后端单测全绿
  6. 前端手动验证 10 条 checklist 通过
  7. Swagger 文档重新生成
  8. 灰度 10% → 50% → 100%,每阶段观察 24h

任务依赖图

Task 1 (proto + regen)
    ↓
Task 2 (errors.go 重写)        Task 3 (sms_redis 11 函数加 scene)
                                     ↓
                                  Task 4 (sms_service 加 scene + 修复 auth_provider/unified_provider/auth_controller 调用点)
                                     ↓
                                  Task 5 (auth_service.Register 调用点)
                                     ↓
                                  Task 6 (user_service.UpdatePassword 实现,含 VerifyTokenFn DI)
                                     ↓
                                  Task 7 (password_test 5 PASS + 5 SKIP)    Task 8 (provider ctx)
                                                                              ↓
                                                                              Task 9 (api.js 拦截器)
                                                                              ↓
                                                                              Task 10 (auth_service.Login BUG 修复 + 2 测试)
                                                                              ↓
                                                                              Task 11 (profile.vue 改密弹窗 5 UI 区)
                                                                              ↓
                                                                              Task 12 (回归 + 灰度)

可并行:

  • Task 2 单独做(不依赖 1)
  • Task 9 (前端)可独立推进,不依赖 Task 6(只需 proto 已 regen,确认 API 字段)
  • Task 11 依赖 Task 6 后端 ready(要拿到 verify_token 字段和具体接口)
  • Task 12 在所有 Task 完成后做

验收标准

  • proto 已 regen,所有 service 编译通过
  • errors.go ToStatusCode 改造完成,errors.Is 全面识别 wrapped error
  • SMS Redis key 场景化,Register/ChangePassword 互不污染
  • UpdatePassword 服务实现完整,事务内正确清 token
  • provider 透传 ctx
  • api.js 拦截器不再误踢出登录(BUG #1 修复)
  • profile.vue 改密弹窗 5 UI 区完整,APP介绍 入口有 handler
  • Login 流程被冻结/封禁返 403 而非 500(§12 修复)
  • 5 个核心后端单测 PASS + 5 个 SKIP 标记(待迭代 2 补全);2 个 Login 测试 PASS
  • 前端 10 条手动 checklist 通过
  • Swagger 文档更新
  • 灰度发布无异常