docs(plan): 修复 4 个真实编译风险 + 1 个范围声明

- 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 标注
This commit is contained in:
zheng020 2026-06-12 15:18:40 +08:00
parent ae6dabba69
commit 3b14922e48

View File

@ -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 文档更新
- [ ] 灰度发布无异常