From 3b14922e482fad230549add3c24ac21fc2c52283 Mon Sep 17 00:00:00 2001 From: zheng020 Date: Fri, 12 Jun 2026 15:18:40 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E4=BF=AE=E5=A4=8D=204=20?= =?UTF-8?q?=E4=B8=AA=E7=9C=9F=E5=AE=9E=E7=BC=96=E8=AF=91=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=20+=201=20=E4=B8=AA=E8=8C=83=E5=9B=B4=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task 3.3: 补 rate-limit 函数(IncrMobileCount/IncrIPCount 等) 场景化,11 个函数完整列出 - Task 4: 加 4.4-4.6 修复 auth_provider.go / unified_provider.go / auth_controller.go 调用点(否则编译失败);修订'明确不动'列表 - Task 4.3: 引入 VerifyTokenFn package-level var DI 模式(让 Task 6.2 的 setupVerifyTokenMock 可工作) - Task 6.3: 改用 VerifyTokenFn 注入调用,匹配 DI 模式 - Task 7: 5 PASS + 5 SKIP 明确分工(避免验收标准误导) - Task 10.2: 完整 import 块 + 用 errors.Is 标准库 + pbCommon 别名 - 依赖图:刷新编号,前端 Task 9/11 可独立推进说明 - 验收标准:5 PASS + 5 SKIP 标注 --- .../plans/2026-06-12-change-password.md | 239 +++++++++++------- 1 file changed, 147 insertions(+), 92 deletions(-) diff --git a/docs/superpowers/plans/2026-06-12-change-password.md b/docs/superpowers/plans/2026-06-12-change-password.md index 96398d7..80bcd47 100644 --- a/docs/superpowers/plans/2026-06-12-change-password.md +++ b/docs/superpowers/plans/2026-06-12-change-password.md @@ -33,8 +33,7 @@ | `frontend/pages/profile/profile.vue` | 修改:弹窗 + 状态 + handler + CSS | Task 11 | **明确不动:** -- `backend/services/userService/provider/*`(除 user_provider.go 外) -- `backend/gateway/**` +- `backend/gateway/**`(纯 Dubbo HTTP 入口,与本任务无关) - `frontend/pages/**`(除 profile.vue 外) - `frontend/store/**` @@ -244,24 +243,36 @@ func verifyTokenKey(scene, mobile string) string { } ``` -### 3.3 修改 3 个 Redis 函数的签名 +### 3.3 修改所有相关 Redis 函数的签名 -找到 `SaveSMSCode` / `GetSMSCode` / `DeleteSMSCode` / `IncrementAttempts` / `SaveVerifyToken` / `GetVerifyToken` / `DeleteVerifyToken`,所有签名都加 `scene string` 参数: +找到以下 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 -// 例:SaveSMSCode -func SaveSMSCode(ctx context.Context, scene, mobile, code string, ttl time.Duration) error { - // 内部 client.Set(ctx, smsCodeKey(scene, mobile), ...) +func smsLimitMobileKey(scene, mobile string) string { + return SMSLimitMobilePrefix + scene + ":" + mobile } -// 例:GetVerifyToken -func GetVerifyToken(ctx context.Context, scene, mobile string) (string, error) { - // 内部 client.Get(ctx, verifyTokenKey(scene, mobile)) +func smsLimitIPKey(scene, ip string) string { + return SMSLimitIPPrefix + scene + ":" + ip } ``` -确保所有函数内部都改用 `smsCodeKey(scene, mobile)` 和 `verifyTokenKey(scene, mobile)`,而不是之前的 `smsCodeKey(mobile)`。 - ### 3.4 验证编译(预期失败) ```bash @@ -316,31 +327,74 @@ func VerifyCode(ctx context.Context, scene, mobile, code string) (string, error) } ``` -### 4.3 修改 `VerifyToken` 函数 +### 4.3 修改 `VerifyToken` 函数(同时引入 DI 模式) -找到 `VerifyToken` 函数(约 247 行起),签名加 `scene`: +找到 `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 { - // GetVerifyToken 调用改成 GetVerifyToken(ctx, scene, mobile) - // DeleteVerifyToken 调用改成 DeleteVerifyToken(ctx, scene, mobile) + 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) } ``` -### 4.4 验证编译(预期失败) +**为什么这样做**: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 ./services/userService/service/... +go build ./... ``` -预期:编译失败,提示 `SendCode` / `VerifyCode` 调用点参数不匹配。Task 5 修复。 +预期:**编译成功**(所有 service + provider + controller + gateway)。 -### 4.5 提交 +### 4.6 提交 ```bash git add backend/services/userService/service/sms_service.go -git commit -m "feat(backend): SMS SendCode/VerifyCode/VerifyToken 加 scene 参数" +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 参数 + 调用点更新" ``` --- @@ -536,7 +590,8 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword return nil, appErrors.ErrInvalidVerifyToken } // scene="password" 必须与前端 verifyCodeApi 调用时传的一致 - if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil { + // 注意:通过 VerifyTokenFn 注入,便于测试 + if err := VerifyTokenFn(ctx, "password", user.Mobile, req.VerifyToken); err != nil { return nil, appErrors.ErrInvalidVerifyToken } @@ -624,88 +679,77 @@ git commit -m "feat(backend): UpdatePassword 改用 ctx + verify_token 校验 + --- -## Task 7: user_service_password_test.go 补全 10 个测试 +## Task 7: user_service_password_test.go 完整 10 个测试 **Files:** -- Modify: `backend/services/userService/service/user_service_password_test.go`(接续 Task 6.2) +- Modify: `backend/services/userService/service/user_service_password_test.go`(接续 Task 6.2,实现 9 个剩余测试) -### 7.1 添加 9 个剩余测试 +**重要说明**:本 Task 实施时,**至少完整实现 5 个核心测试**(不 skip),覆盖最关键路径:Success / MissingVerifyToken / InvalidVerifyToken / WrongOldPassword / PasswordTooShort。剩余 5 个(SameAsOld / UserNotFound / RedisFailure / TransactionRollback / VerifyTokenExpired)可以 `t.Skip` 但需写好 mock 设置代码,便于后续工程师补全。 -在 `user_service_password_test.go` 末尾追加以下测试(每个测试都先 setupVerifyTokenMock 或 显式 skip): +### 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 = "" - // 期望:返 ErrInvalidVerifyToken - t.Skip("implement in iteration 2") + // 期望:err 满足 errors.Is(err, appErrors.ErrInvalidVerifyToken) } 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") + // 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 - // 期望:返 ErrInvalidOldPassword - t.Skip("implement in iteration 2") + // 期望:err 满足 errors.Is(err, appErrors.ErrInvalidOldPassword) } func TestUpdatePassword_PasswordTooShort(t *testing.T) { + // mock GetByID 返用户 + // setupVerifyTokenMock 返 nil // 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") + // 期望:err 满足 errors.Is(err, appErrors.ErrPasswordTooShort) } ``` -每个 `t.Skip` 暂时跳过,后续按需启用并补充 mock。 +### 7.2 剩余 5 个测试(写好 mock 代码但 t.Skip 标记) -### 7.2 至少运行 1 个测试确保编译通过 +```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 -run TestUpdatePassword -v ./services/userService/service/ +go test -v ./services/userService/service/ ``` -预期:编译成功,所有测试 skip(显示 `--- SKIP`)。 +预期:5 个测试 PASS,5 个测试 SKIP,无 FAIL。 -### 7.3 提交 +### 7.4 提交 ```bash git add backend/services/userService/service/user_service_password_test.go -git commit -m "test(backend): 改密 service 10 个测试骨架(skip 待实现)" +git commit -m "test(backend): 改密 service 10 个测试(5 PASS + 5 SKIP 标记)" ``` --- @@ -855,37 +899,39 @@ return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil) package service import ( + "context" + "errors" "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" + 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, appErrors.Is(err, appErrors.ErrAccountBanned)) - assert.Equal(t, pbcommon.StatusCode_STATUS_FORBIDDEN, appErrors.ToStatusCode(err)) + 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, appErrors.Is(err, appErrors.ErrAccountFrozen)) - assert.Equal(t, pbcommon.StatusCode_STATUS_FORBIDDEN, appErrors.ToStatusCode(err)) + assert.True(t, errors.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)` 替代(标准库)。 +**关键说明**: +- 用 `errors.Is`(标准库)而非 `appErrors.Is`(项目可能未定义) +- 用 `pbCommon`(标准 Go 包别名,即 `github.com/topfans/backend/pkg/proto/common`) +- 不用 `pb`(避免与 user proto 冲突) ### 10.3 验证测试 @@ -1306,23 +1352,32 @@ git commit -m "chore: 部署前散落修复" ## 任务依赖图 ``` -Task 1 (proto) +Task 1 (proto + regen) ↓ -Task 2 (errors.go) ── Task 3 (sms_redis) ── Task 4 (sms_service) ── Task 5 (auth_service.Register) - ↓ - Task 6 (user_service.UpdatePassword) ← Task 6.2 测试 - ↓ - Task 7 (password_test 补全) Task 8 (provider ctx) +Task 2 (errors.go 重写) Task 3 (sms_redis 11 函数加 scene) + ↓ + Task 4 (sms_service 加 scene + 修复 auth_provider/unified_provider/auth_controller 调用点) + ↓ + Task 5 (auth_service.Register 调用点) + ↓ + Task 6 (user_service.UpdatePassword 实现,含 VerifyTokenFn DI) + ↓ + Task 7 (password_test 5 PASS + 5 SKIP) Task 8 (provider ctx) ↓ -Task 9 (api.js 拦截器) ←─────────────── Task 10 (auth_service Login BUG) ──── Task 11 (profile.vue) + Task 9 (api.js 拦截器) ↓ - Task 12 (回归) + Task 10 (auth_service.Login BUG 修复 + 2 测试) + ↓ + Task 11 (profile.vue 改密弹窗 5 UI 区) + ↓ + Task 12 (回归 + 灰度) ``` 可并行: - Task 2 单独做(不依赖 1) -- Task 9, 11 是前端,可独立推进(但需等 Task 6 后端 ready) -- Task 7 依赖 Task 6 写好 UpdatePassword +- Task 9 (前端)可独立推进,不依赖 Task 6(只需 proto 已 regen,确认 API 字段) +- Task 11 依赖 Task 6 后端 ready(要拿到 verify_token 字段和具体接口) +- Task 12 在所有 Task 完成后做 --- @@ -1336,7 +1391,7 @@ Task 9 (api.js 拦截器) ←─────────────── Task - [ ] api.js 拦截器不再误踢出登录(BUG #1 修复) - [ ] profile.vue 改密弹窗 5 UI 区完整,APP介绍 入口有 handler - [ ] Login 流程被冻结/封禁返 403 而非 500(§12 修复) -- [ ] 10 + 2 = 12 个后端单测全绿(或明确 skip 标记) +- [ ] 5 个核心后端单测 PASS + 5 个 SKIP 标记(待迭代 2 补全);2 个 Login 测试 PASS - [ ] 前端 10 条手动 checklist 通过 - [ ] Swagger 文档更新 - [ ] 灰度发布无异常