From ae6dabba6991a2ea944f1c3c9dd422d5925762c7 Mon Sep 17 00:00:00 2001 From: zheng020 Date: Fri, 12 Jun 2026 15:13:53 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E4=BF=AE=E6=94=B9=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= =?UTF-8?q?=20-=2012=20=E4=B8=AA=20Task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 条 --- .../plans/2026-06-12-change-password.md | 1342 +++++++++++++++++ 1 file changed, 1342 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-change-password.md diff --git a/docs/superpowers/plans/2026-06-12-change-password.md b/docs/superpowers/plans/2026-06-12-change-password.md new file mode 100644 index 0000000..96398d7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-change-password.md @@ -0,0 +1,1342 @@ +# 修改密码功能实现计划 + +> **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 + +在 `