# 修改密码功能实现计划 > **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/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` 字段: ```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 修改 3 个 Redis 函数的签名 找到 `SaveSMSCode` / `GetSMSCode` / `DeleteSMSCode` / `IncrementAttempts` / `SaveVerifyToken` / `GetVerifyToken` / `DeleteVerifyToken`,所有签名都加 `scene string` 参数: ```go // 例: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 验证编译(预期失败) ```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` 函数 找到 `VerifyToken` 函数(约 247 行起),签名加 `scene`: ```go func VerifyToken(ctx context.Context, scene, mobile, token string) error { // GetVerifyToken 调用改成 GetVerifyToken(ctx, scene, mobile) // DeleteVerifyToken 调用改成 DeleteVerifyToken(ctx, scene, mobile) } ``` ### 4.4 验证编译(预期失败) ```bash cd backend go build ./services/userService/service/... ``` 预期:编译失败,提示 `SendCode` / `VerifyCode` 调用点参数不匹配。Task 5 修复。 ### 4.5 提交 ```bash 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 行附近: **原代码:** ```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 调用时传的一致 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 验证编译 ```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) ### 7.1 添加 9 个剩余测试 在 `user_service_password_test.go` 末尾追加以下测试(每个测试都先 setupVerifyTokenMock 或 显式 skip): ```go 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 个测试确保编译通过 ```bash cd backend go test -run TestUpdatePassword -v ./services/userService/service/ ``` 预期:编译成功,所有测试 skip(显示 `--- SKIP`)。 ### 7.3 提交 ```bash 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 行附近: **原代码:** ```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 ( "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 验证测试 ```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 在 `