- 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 标注
1398 lines
45 KiB
Markdown
1398 lines
45 KiB
Markdown
# 修改密码功能实现计划
|
||
|
||
> **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`,找到 `<view class="password-modal" v-if="showPasswordModal">` 块(约第 159-184 行),**整体替换**为新弹窗(5 个 UI 区,见 spec §5.3.1):
|
||
|
||
```vue
|
||
<view class="password-modal" v-if="showPasswordModal" @tap="closePasswordModal">
|
||
<view class="modal-content" @tap.stop>
|
||
<view class="modal-title">修改密码</view>
|
||
|
||
<!-- 1. 手机号 + 获取验证码 -->
|
||
<view class="modal-row">
|
||
<input class="modal-input readonly" :value="displayMobile" disabled
|
||
placeholder-class="input-placeholder" />
|
||
<button class="btn-get-code" :disabled="codeCountdown > 0 || sendingCode"
|
||
@tap="handleSendCode">
|
||
{{ codeCountdown > 0 ? `${codeCountdown}s 后重发` : '获取验证码' }}
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 2. 短信验证码 -->
|
||
<view class="modal-password-wrapper">
|
||
<input class="modal-password-input" type="number" maxlength="6"
|
||
v-model="smsCode" placeholder="请输入短信验证码"
|
||
placeholder-class="input-placeholder" />
|
||
</view>
|
||
|
||
<!-- 3. 旧密码 -->
|
||
<view class="modal-password-wrapper">
|
||
<input class="modal-password-input"
|
||
:type="showOldPassword ? 'text' : 'password'"
|
||
v-model="oldPassword" placeholder="请输入旧密码"
|
||
placeholder-class="input-placeholder" />
|
||
<view class="modal-eye-icon" @click="showOldPassword = !showOldPassword">
|
||
<text class="eye-text">{{ showOldPassword ? '👁️' : '👁️🗨️' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 4. 新密码 -->
|
||
<view class="modal-password-wrapper">
|
||
<input class="modal-password-input"
|
||
:type="showNewPassword ? 'text' : 'password'"
|
||
v-model="newPassword" placeholder="请输入新密码(至少6位)"
|
||
placeholder-class="input-placeholder" />
|
||
<view class="modal-eye-icon" @click="showNewPassword = !showNewPassword">
|
||
<text class="eye-text">{{ showNewPassword ? '👁️' : '👁️🗨️' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 5. 确认新密码 -->
|
||
<view class="modal-password-wrapper">
|
||
<input class="modal-password-input"
|
||
:type="showConfirmPassword ? 'text' : 'password'"
|
||
v-model="confirmPassword" placeholder="请再次输入新密码"
|
||
placeholder-class="input-placeholder" />
|
||
<view class="modal-eye-icon" @click="showConfirmPassword = !showConfirmPassword">
|
||
<text class="eye-text">{{ showConfirmPassword ? '👁️' : '👁️🗨️' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="modal-buttons">
|
||
<button class="modal-btn-cancel" @tap="closePasswordModal">取消</button>
|
||
<button class="modal-btn-confirm" @tap="confirmChangePassword"
|
||
:disabled="changingPassword">
|
||
{{ changingPassword ? '修改中...' : '确认' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
```
|
||
|
||
### 11.2 修改 APP 介绍按钮(补 @tap)
|
||
|
||
找到第 130-134 行(原"APP介绍"按钮),补 `@tap="handleShowAppIntro"`:
|
||
|
||
```vue
|
||
<view class="service-button" @tap="handleShowAppIntro">
|
||
<image class="service-icon" src="/static/icon/edit-password.png" mode="aspectFit" />
|
||
<text class="service-text">APP介绍</text>
|
||
</view>
|
||
```
|
||
|
||
### 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
|
||
|
||
在 `<style scoped>` 块末尾(原 `.password-modal` 样式后)追加:
|
||
|
||
```css
|
||
.modal-row {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
margin-bottom: 40rpx;
|
||
}
|
||
.modal-input.readonly {
|
||
background: #f5f5f5;
|
||
color: #999;
|
||
flex: 1;
|
||
height: 88rpx;
|
||
border-radius: 20rpx;
|
||
padding: 0 30rpx;
|
||
font-size: 32rpx;
|
||
}
|
||
.btn-get-code {
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
padding: 0 24rpx;
|
||
border-radius: 20rpx;
|
||
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 100%);
|
||
color: #fff;
|
||
font-size: 26rpx;
|
||
border: none;
|
||
white-space: nowrap;
|
||
min-width: 180rpx;
|
||
}
|
||
.btn-get-code:disabled { opacity: 0.6; }
|
||
.btn-get-code::after { border: none; }
|
||
```
|
||
|
||
### 11.8 验证(可选)
|
||
|
||
```bash
|
||
cd frontend
|
||
# 启动 H5 跑一遍手动 checklist(见 spec §6.2)
|
||
npm run dev:h5
|
||
```
|
||
|
||
### 11.9 提交
|
||
|
||
```bash
|
||
git add frontend/pages/profile/profile.vue frontend/utils/api.js
|
||
git commit -m "feat(frontend): 改密弹窗加 5 UI 区+SMS 流程+APP介绍占位+清理 setInterval"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: 全量回归 + 部署准备
|
||
|
||
### 12.1 跑后端全量测试
|
||
|
||
```bash
|
||
cd backend
|
||
go test ./...
|
||
```
|
||
|
||
预期:所有现有测试通过(ToStatusCode 行为不变,errors.Is 改造兼容)。
|
||
|
||
### 12.2 Swagger 重生成
|
||
|
||
```bash
|
||
cd backend
|
||
bash update-swagger.sh 2>&1 | head -20
|
||
```
|
||
|
||
预期:生成 `docs/swagger.json` / `swagger.yaml`,包含新 `verify_token` 字段。
|
||
|
||
### 12.3 手动验证 10 条 checklist(spec §6.2)
|
||
|
||
按以下顺序跑(每条都要过):
|
||
|
||
| # | 场景 | 操作 |
|
||
|---|---|---|
|
||
| 1 | 弹窗打开字段全空 | 打开改密弹窗,直接点确认 |
|
||
| 2 | 输错旧密码 | 全部字段填对,旧密码故意输错 |
|
||
| 3 | 新密码 5 位 | 新密码/确认新密码 都填 "abcde" |
|
||
| 4 | 新密码 ≠ 确认 | 填两个不同值 |
|
||
| 5 | 短信码输错 | 输错 6 位数字 |
|
||
| 6 | 60s 内连点 | 60s 内点"获取验证码"两次 |
|
||
| 7 | 改密成功 | 全部填对点确认 |
|
||
| 8 | 弱网超时 | DevTools throttle "Slow 3G",点确认 |
|
||
| 9 | 关闭弹窗再开 | 关闭后再次进入 |
|
||
| 10 | APP介绍 | 点击 "APP介绍" 入口 |
|
||
|
||
每条记录预期结果,有问题立即修复。
|
||
|
||
### 12.4 最终提交(如有散落改动)
|
||
|
||
```bash
|
||
git status
|
||
# 如有未提交改动:
|
||
git add -A
|
||
git commit -m "chore: 部署前散落修复"
|
||
```
|
||
|
||
### 12.5 灰度发布
|
||
|
||
按 spec §11 部署清单执行:
|
||
1. proto regen 后所有 service 编译通过
|
||
2. Redis key 升级(老 `sms:register:*` / `verify:register:*` 保留)
|
||
3. API 接口回归(Login/Register/RefreshToken/Logout/GetMyProfile/UpdateNickname)
|
||
4. 登录态回归(401 不变/403 上升/500 下降)
|
||
5. 后端单测全绿
|
||
6. 前端手动验证 10 条 checklist 通过
|
||
7. Swagger 文档重新生成
|
||
8. 灰度 10% → 50% → 100%,每阶段观察 24h
|
||
|
||
---
|
||
|
||
## 任务依赖图
|
||
|
||
```
|
||
Task 1 (proto + regen)
|
||
↓
|
||
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 修复 + 2 测试)
|
||
↓
|
||
Task 11 (profile.vue 改密弹窗 5 UI 区)
|
||
↓
|
||
Task 12 (回归 + 灰度)
|
||
```
|
||
|
||
可并行:
|
||
- Task 2 单独做(不依赖 1)
|
||
- Task 9 (前端)可独立推进,不依赖 Task 6(只需 proto 已 regen,确认 API 字段)
|
||
- Task 11 依赖 Task 6 后端 ready(要拿到 verify_token 字段和具体接口)
|
||
- Task 12 在所有 Task 完成后做
|
||
|
||
---
|
||
|
||
## 验收标准
|
||
|
||
- [ ] proto 已 regen,所有 service 编译通过
|
||
- [ ] `errors.go` ToStatusCode 改造完成,`errors.Is` 全面识别 wrapped error
|
||
- [ ] SMS Redis key 场景化,Register/ChangePassword 互不污染
|
||
- [ ] UpdatePassword 服务实现完整,事务内正确清 token
|
||
- [ ] provider 透传 ctx
|
||
- [ ] api.js 拦截器不再误踢出登录(BUG #1 修复)
|
||
- [ ] profile.vue 改密弹窗 5 UI 区完整,APP介绍 入口有 handler
|
||
- [ ] Login 流程被冻结/封禁返 403 而非 500(§12 修复)
|
||
- [ ] 5 个核心后端单测 PASS + 5 个 SKIP 标记(待迭代 2 补全);2 个 Login 测试 PASS
|
||
- [ ] 前端 10 条手动 checklist 通过
|
||
- [ ] Swagger 文档更新
|
||
- [ ] 灰度发布无异常
|