- Task 1: proto 加 verify_token + regen - Task 2: errors.go 新增错误码 + ToStatusCode errors.Is 改造 - Task 3: SMS Redis key scene-aware 重构 - Task 4: SMS service SendCode/VerifyCode/VerifyToken 加 scene - Task 5: auth_service.Register 调用点更新 - Task 6: user_service.UpdatePassword 完整实现(TDD) - Task 7: password_test 10 个测试骨架 - Task 8: user_provider 透传 ctx - Task 9: api.js 拦截器去 400 自动登出 - Task 10: auth_service.Login BUG 修复 + 2 测试 - Task 11: profile.vue 改密弹窗 5 UI 区 + handler + CSS - Task 12: 回归 + 灰度发布 总览: - 12 个 task,每 task 含文件路径+步骤+命令 - TDD 优先,frequent commits - 任务依赖图清晰,可识别可并行项 - 验收标准 12 条
42 KiB
修改密码功能实现计划
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:
- 后端:
UpdatePasswordRequestproto 加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;新增getCodeCountdownTimerref
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/services/userService/provider/*(除 user_provider.go 外)backend/gateway/**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.go 和 user.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 修改 3 个 Redis 函数的签名
找到 SaveSMSCode / GetSMSCode / DeleteSMSCode / IncrementAttempts / SaveVerifyToken / GetVerifyToken / DeleteVerifyToken,所有签名都加 scene string 参数:
// 例:SaveSMSCode
func SaveSMSCode(ctx context.Context, scene, mobile, code string, ttl time.Duration) error {
// 内部 client.Set(ctx, smsCodeKey(scene, mobile), ...)
}
// 例:GetVerifyToken
func GetVerifyToken(ctx context.Context, scene, mobile string) (string, error) {
// 内部 client.Get(ctx, verifyTokenKey(scene, mobile))
}
确保所有函数内部都改用 smsCodeKey(scene, mobile) 和 verifyTokenKey(scene, mobile),而不是之前的 smsCodeKey(mobile)。
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 函数
找到 VerifyToken 函数(约 247 行起),签名加 scene:
func VerifyToken(ctx context.Context, scene, mobile, token string) error {
// GetVerifyToken 调用改成 GetVerifyToken(ctx, scene, mobile)
// DeleteVerifyToken 调用改成 DeleteVerifyToken(ctx, scene, mobile)
}
4.4 验证编译(预期失败)
cd backend
go build ./services/userService/service/...
预期:编译失败,提示 SendCode / VerifyCode 调用点参数不匹配。Task 5 修复。
4.5 提交
git add backend/services/userService/service/sms_service.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 调用时传的一致
if err := VerifyToken(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)
7.1 添加 9 个剩余测试
在 user_service_password_test.go 末尾追加以下测试(每个测试都先 setupVerifyTokenMock 或 显式 skip):
func TestUpdatePassword_MissingVerifyToken(t *testing.T) {
// mock GetByID 返用户
// req.VerifyToken = ""
// 期望:返 ErrInvalidVerifyToken
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_InvalidVerifyToken(t *testing.T) {
// setupVerifyTokenMock 返 error
// 期望:返 ErrInvalidVerifyToken
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_VerifyTokenExpired(t *testing.T) {
// setupVerifyTokenMock 返 "expired" 错误
// 期望:返 ErrInvalidVerifyToken
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_WrongOldPassword(t *testing.T) {
// mock VerifyPassword 返 false
// 期望:返 ErrInvalidOldPassword
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_PasswordTooShort(t *testing.T) {
// req.NewPassword = "abcde" (5 位)
// 期望:返 ErrPasswordTooShort
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_SameAsOld(t *testing.T) {
// req.OldPassword == req.NewPassword
// 期望:返 ErrSameAsOldPassword
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_UserNotFound(t *testing.T) {
// mock GetByID 返 ErrUserNotFound
// 期望:返 ErrUserNotFound
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_RedisFailure(t *testing.T) {
// setupVerifyTokenMock 返 wrapped error
// 期望:Service 内部 wrap 后抛 500
t.Skip("implement in iteration 2")
}
func TestUpdatePassword_TransactionRollback(t *testing.T) {
// 模拟事务失败(用真实 sqlite 或 sqlmock)
// 期望:密码未变,token 未清
t.Skip("implement in iteration 2")
}
每个 t.Skip 暂时跳过,后续按需启用并补充 mock。
7.2 至少运行 1 个测试确保编译通过
cd backend
go test -run TestUpdatePassword -v ./services/userService/service/
预期:编译成功,所有测试 skip(显示 --- SKIP)。
7.3 提交
git add backend/services/userService/service/user_service_password_test.go
git commit -m "test(backend): 改密 service 10 个测试骨架(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 (
"testing"
"github.com/stretchr/testify/assert"
appErrors "github.com/topfans/backend/pkg/errors"
pb "github.com/topfans/backend/pkg/proto/user"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/pkg/proto/common"
"context"
)
// 简化版:仅验证 ToStatusCode 映射行为(Login 流程本身需要复杂 mock,
// 完整测试留给后续迭代)
func TestAccountBanned_MapToForbidden(t *testing.T) {
reason := "spam"
err := appErrors.NewAccountBannedError(reason)
assert.True(t, appErrors.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, appErrors.Is(err, appErrors.ErrAccountFrozen))
assert.Equal(t, pbcommon.StatusCode_STATUS_FORBIDDEN, appErrors.ToStatusCode(err))
assert.Contains(t, err.Error(), "abuse")
}
如果 appErrors.Is 函数不存在(本项目没定义),用 errors.Is(err, target) 替代(标准库)。
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 行),补 sendCodeApi 和 verifyCodeApi:
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 部署清单执行:
- proto regen 后所有 service 编译通过
- Redis key 升级(老
sms:register:*/verify:register:*保留) - API 接口回归(Login/Register/RefreshToken/Logout/GetMyProfile/UpdateNickname)
- 登录态回归(401 不变/403 上升/500 下降)
- 后端单测全绿
- 前端手动验证 10 条 checklist 通过
- Swagger 文档重新生成
- 灰度 10% → 50% → 100%,每阶段观察 24h
任务依赖图
Task 1 (proto)
↓
Task 2 (errors.go) ── Task 3 (sms_redis) ── Task 4 (sms_service) ── Task 5 (auth_service.Register)
↓
Task 6 (user_service.UpdatePassword) ← Task 6.2 测试
↓
Task 7 (password_test 补全) Task 8 (provider ctx)
↓
Task 9 (api.js 拦截器) ←─────────────── Task 10 (auth_service Login BUG) ──── Task 11 (profile.vue)
↓
Task 12 (回归)
可并行:
- Task 2 单独做(不依赖 1)
- Task 9, 11 是前端,可独立推进(但需等 Task 6 后端 ready)
- Task 7 依赖 Task 6 写好 UpdatePassword
验收标准
- proto 已 regen,所有 service 编译通过
errors.goToStatusCode 改造完成,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 修复)
- 10 + 2 = 12 个后端单测全绿(或明确 skip 标记)
- 前端 10 条手动 checklist 通过
- Swagger 文档更新
- 灰度发布无异常