# 修改密码功能实现计划 > **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` 字段: ```protobuf message UpdatePasswordRequest { string old_password = 1; // 旧密码 string new_password = 2; // 新密码 string verify_token = 3; // 短信验证 token (scene=password 下发的一次性 token) } ``` ### 1.2 重新生成 Go 代码 ```bash cd backend bash gen-swagger.sh 2>&1 | head -50 ``` 预期:生成 `user.pb.go` 和 `user.triple.go`,输出中包含 `UpdatePassword` 相关行。 如果 gen-swagger.sh 失败,手动执行: ```bash cd backend protoc --go_out=. --go-triple_out=. proto/user.proto ``` ### 1.3 验证编译 ```bash cd backend go build ./services/userService/... ``` 预期:编译成功,无错误。 ### 1.4 提交 ```bash git add backend/proto/user.proto backend/pkg/proto/user/user.pb.go backend/pkg/proto/user/user.triple.go git commit -m "feat(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: ```go 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: ```go // 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`): ```go 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 验证编译 ```bash cd backend go build ./pkg/errors/... go build ./... ``` 预期:编译成功。 ### 2.4 提交 ```bash 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 行的常量定义,改为: ```go 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 行),改为: ```go 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 函数尚未定义,新增: ```go func smsLimitMobileKey(scene, mobile string) string { return SMSLimitMobilePrefix + scene + ":" + mobile } func smsLimitIPKey(scene, ip string) string { return SMSLimitIPPrefix + scene + ":" + ip } ``` ### 3.4 验证编译(预期失败) ```bash cd backend go build ./services/userService/service/... ``` 预期:编译失败,提示 `SaveSMSCode` / `VerifyCode` 等函数调用点参数不匹配。**这是预期的**——Task 4 会修复。 ### 3.5 提交 ```bash 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` 参数: ```go 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`: ```go 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): ```go // 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` 参数。具体: ```go // 改前(原 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.go` 和 `auth_provider.go` 内部的 SMS 调用场景由调用方传入(`req.Scene`),不需要硬编码。 ### 4.5 验证编译 ```bash cd backend go build ./... ``` 预期:**编译成功**(所有 service + provider + controller + gateway)。 ### 4.6 提交 ```bash 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 行附近: **原代码:** ```go 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) } ``` **改为:** ```go 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 验证编译 ```bash cd backend go build ./services/userService/... ``` 预期:编译成功。 ### 5.3 提交 ```bash 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 行): **原代码:** ```go UpdatePassword(req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) ``` **改为:** ```go UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) ``` ### 6.2 写失败测试(先 TDD) **新建文件:** `backend/services/userService/service/user_service_password_test.go` ```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 行),整体替换为: ```go 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 验证编译 ```bash cd backend go build ./services/userService/... ``` 预期:编译成功。 ### 6.5 提交 ```bash 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): ```go 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 标记) ```go 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 跑测试 ```bash cd backend go test -v ./services/userService/service/ ``` 预期:5 个测试 PASS,5 个测试 SKIP,无 FAIL。 ### 7.4 提交 ```bash 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 行附近: **原代码:** ```go resp, err := p.userService.UpdatePassword(req, userID) ``` **改为:** ```go resp, err := p.userService.UpdatePassword(ctx, req, userID) ``` ### 8.2 验证编译 ```bash cd backend go build ./services/userService/... ``` 预期:编译成功。 ### 8.3 提交 ```bash 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 也踢出登录"从条件里去掉): **原代码:** ```js } 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 } ``` **改为:** ```js } 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 验证编译 ```bash cd frontend # 启动 H5 看是否有语法错误(可选) npm run dev:h5 2>&1 | head -20 ``` 或者: ```bash # 用 node 简单语法检查(虽然 .js 不是 .vue) node -c frontend/utils/api.js ``` ### 9.3 提交 ```bash 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 行):** ```go return nil, fmt.Errorf("账号已被封禁%s%s", map[bool]func() string{ true: func() string { if reason != "" { return ",原因:" + reason } return "" }, }[true](), "") ``` **改为:** ```go return nil, appErrors.NewAccountBannedError(reason) ``` **原代码(frozen 实际生效分支,约第 376-377 行):** ```go return nil, errors.New(errMsg) ``` **改为:** ```go return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil) ``` ### 10.2 写 2 个测试 **新建文件:** `backend/services/userService/service/auth_service_login_test.go` ```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 验证测试 ```bash cd backend go test -run TestAccount -v ./services/userService/service/ ``` 预期:2 个测试都 PASS。 ### 10.4 验证编译 ```bash cd backend go build ./services/userService/... ``` 预期:编译成功。 ### 10.5 提交 ```bash 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`,找到 `` 块(约第 159-184 行),**整体替换**为新弹窗(5 个 UI 区,见 spec §5.3.1): ```vue 修改密码 {{ showOldPassword ? '👁️' : '👁️‍🗨️' }} {{ showNewPassword ? '👁️' : '👁️‍🗨️' }} {{ showConfirmPassword ? '👁️' : '👁️‍🗨️' }} ``` ### 11.2 修改 APP 介绍按钮(补 @tap) 找到第 130-134 行(原"APP介绍"按钮),补 `@tap="handleShowAppIntro"`: ```vue APP介绍 ``` ### 11.3 替换 state refs(原 showPasswordModal 块下) 找到原 `// 修改密码弹窗` 注释下的 refs(约第 336-340 行),**整体替换**为: ```js // 旧密码 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 行),**整体替换**为: ```js // 处理修改密码 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`: ```js import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi, sendCodeApi, verifyCodeApi } from '@/utils/api'; ``` `validatePassword` import 已经在(原代码只导入了 `validateNickname`),改为: ```js import { validateNickname, validatePassword } from '@/utils/validator.js'; ``` ### 11.6 修改 `updatePasswordApi` 函数(api.js) 打开 `frontend/utils/api.js`,找到 `updatePasswordApi`(第 281-291 行),**整体替换**为: ```js 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 在 `