topfans/docs/superpowers/specs/2026-06-12-change-password-design.md
2026-06-15 12:07:56 +08:00

51 KiB

修改密码功能设计文档

  • 创建日期: 2026-06-12
  • 状态: 待评审
  • 目标: 修复"修改密码"功能中已发现的 3 个 BUG,并升级为"旧密码 + 短信验证码"双保险的安全流程

1. 背景与需求

1.1 现状

修改密码功能后端全链路(proto → service → provider → gateway)、前端 API、入口、弹窗均已实现。但经代码审查发现:

🔴 BUG #1(严重):业务码 400 被前端误判为 token 过期,强制登出

api.js:101-115 的统一拦截器把业务码 401/400/403 一律视为登录过期,清 token + 跳登录页。但 400 应是"业务参数错误"(密码错、参数错),不应触发登出。配合 §4.5 把"旧密码错 / verify_token 错"从 401 改为 400 后,前端拦截器也必须同步从条件中移除 400,否则 401 业务码仍会触发登出(因为后端对"旧密码错"原本就返回 401)。用户体验事故:输错旧密码即被踢出。

🟡 BUG #2(中):"APP介绍" 入口是空壳

profile.vue:130-134 缺少 @tap 处理器,点击无任何反应。

🟡 BUG #3(中):前端缺失密码规则校验

confirmChangePassword 只检查了非空,没有调用 validator.js 已有的 validatePassword,没有"旧=新"校验,没有"确认新密码"输入框。

1.2 安全升级需求

将改密流程从"只校验旧密码"升级为"旧密码 + 短信验证码"双保险,符合用户对改密类操作的强安全预期(类比银行/支付 App)。


2. 方案选型

候选方案对比

方案 流程 后端改动 前端改动 评价
A. verify_token 模式(选用 ) sendCode → verifyCode → updatePassword(old, new, verify_token) proto 新增 verify_token 字段 + Service 头部校验 新增 4 个输入区 + 倒计时 与 Register 流程一致,复用 VerifyToken 一次性消费
B. 单接口自带 SMS 码 sendCode → updatePassword(mobile, sms_code, old, new) proto 新增 mobile/sms_code 字段 + Service 内部校验 同上 偏离现有模式,可扩展性差
C. 复用 register scene 同 A scene=register 同上 Redis key 污染,不可取

最终方案: A + BUG 修复 + 单元测试。


3. 整体流程

3.1 序列图

用户                     前端(profile.vue)            Gateway           userService              Redis
 │                            │                          │                  │                    │
 │ 1.点击"修改密码"            │                          │                  │                    │
 ├───────────────────────────→│                          │                  │                    │
 │                            │ 弹窗打开(5个UI区)        │                  │                    │
 │                            │                          │                  │                    │
 │ 2.点"获取验证码"            │                          │                  │                    │
 ├───────────────────────────→│                          │                  │                    │
 │                            │ POST /auth/send-code     │                  │                    │
 │                            │ {mobile, scene:"password"}│                  │                    │
 │                            ├─────────────────────────→│ ─Dubbo→          │                    │
 │                            │                          │ SendCode         │                    │
 │                            │                          │                  │ SaveSMSCode(60s)   │
 │                            │                          │                  ├───────────────────→│
 │                            │ 200 OK                    │                  │                    │
 │                            │←─────────────────────────┤←─────────────────┤                    │
 │                            │ 启动 60s 倒计时           │                  │                    │
 │                            │                          │                  │                    │
 │ 3.输入验证码+旧/新/确认密码 │                          │                  │                    │
 ├───────────────────────────→│                          │                  │                    │
 │                            │                          │                  │                    │
 │ 4.点"确认"                  │                          │                  │                    │
 │                            │ (本地校验5项)             │                  │                    │
 │                            │                          │                  │                    │
 │                            │ POST /auth/verify-code   │                  │                    │
 │                            │ {mobile, code, scene}    │                  │                    │
 │                            ├─────────────────────────→│ ─Dubbo→          │                    │
 │                            │                          │ VerifyCode       │ DeleteSMSCode      │
 │                            │                          │                  ├───────────────────→│
 │                            │                          │                  │ SaveVerifyToken(5m)│
 │                            │                          │                  ├───────────────────→│
 │                            │ 200 {verify_token}       │                  │                    │
 │                            │←─────────────────────────┤←─────────────────┤                    │
 │                            │                          │                  │                    │
 │                            │ POST /account/password   │                  │                    │
 │                            │ {old, new, verify_token} │                  │                    │
 │                            ├─────────────────────────→│ ─Dubbo→          │                    │
 │                            │                          │ UpdatePassword   │                    │
 │                            │                          │  - GetByID(uid)  │                    │
 │                            │                          │  - VerifyToken() │ GetVerifyToken     │  ← 仅校验(Plan A)
 │                            │                          │                  ├───────────────────→│
 │                            │                          │  - bcrypt 比对   │                    │
 │                            │                          │  - 事务内更新    │                    │
 │                            │                          │    password_hash │                    │
 │                            │                          │    access_token=nil│                  │
 │                            │                          │  - ConsumeToken()│ DeleteVerifyToken  │  ← 业务成功后原子消费(Lua)
 │                            │ 200 OK                    │                  │                    │
 │                            │←─────────────────────────┤←─────────────────┤                    │
 │                            │ 2s 后清本地+跳登录页      │                  │                    │
 │                            │                          │                  │                    │
 │ 5.重新登录                  │                          │                  │                    │
 ├───────────────────────────→│                          │                  │                    │

3.2 前端弹窗 UI 结构

┌──────────────────────────────────┐
│         修改密码                  │
├──────────────────────────────────┤
│ [138****1234  ] [获取验证码 60s]  │  ← 手机号只读 + 倒计时按钮
│ [短信验证码  ______  ]            │  ← 6位数字
│ [旧密码       ____________  👁]   │  ← 小眼睛切换
│ [新密码       ____________  👁]   │  ← 至少6位
│ [确认新密码   ____________  👁]   │  ← 二次输入(防误输)
│                                  │
│   [ 取消 ]         [ 确认 ]       │
└──────────────────────────────────┘

4. 后端改动

4.1 proto 定义

修改 backend/proto/user.proto

message UpdatePasswordRequest {
  string old_password = 1;     // 旧密码
  string new_password = 2;     // 新密码
  string verify_token = 3;     // 短信验证 token(scene=password 下发的一次性 token)
}

重新生成 user.pb.go / user.triple.go(用 gen-swagger.sh)。

4.2 SMS Redis key 重构

修改 backend/services/userService/service/sms_redis.go

将硬编码的 register 改为基于 scene 动态拼:

const (
    SMSCodeKeyPrefix     = "sms:"          // 后跟 scene
    SMSVerifyTokenPrefix = "verify:"       // 后跟 scene
    SMSLimitMobilePrefix = "sms:limit:mobile:"
)

func smsCodeKey(scene, mobile string) string {
    return SMSCodeKeyPrefix + scene + ":" + mobile
}
func verifyTokenKey(scene, mobile string) string {
    return SMSVerifyTokenPrefix + scene + ":" + mobile
}

SaveVerifyToken / GetVerifyToken / DeleteVerifyToken 函数签名增加 scene string 参数,内部用新 key 函数。

VerifyToken(ctx, mobile, token)(定义在 sms_service.go:247)同步改造为 VerifyToken(ctx, scene, mobile, token),内部按 verify:{scene}:{mobile} 拼 key 做 Get 校验(不再做 Delete,删除逻辑下沉到新增的 ConsumeVerifyToken,详见 §4.2.1)。

4.2.1 VerifyToken 语义重构(Plan A: Compare-And-Delete)

问题:原 VerifyToken 是"调用即消费",业务失败(输错旧密码、密码太短、新旧相同等)也会消耗 token,用户每次都得重新发短信,UX 差。

方案:把 VerifyToken 拆成两个职责单一的函数:

// VerifyToken 仅校验 token 是否匹配(只读,无副作用),业务失败时 token 保留可重试
func VerifyToken(ctx context.Context, scene, mobile, token string) error {
    stored, err := GetVerifyToken(ctx, scene, mobile)
    if err != nil {
        return fmt.Errorf("failed to get verify token: %w", err)
    }
    if stored != token {
        return appErrors.ErrInvalidVerifyToken
    }
    return nil
}

// ConsumeVerifyToken 校验 + 原子删除(Lua 脚本保证 GET+COMPARE+DEL 原子性),
// 仅在业务成功后调用,实现"成功才消费"的语义。
func ConsumeVerifyToken(ctx context.Context, scene, mobile, token string) error {
    // KEYS[1] = verify token key, ARGV[1] = expected token
    const luaScript = `
        local current = redis.call('GET', KEYS[1])
        if current == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
    `
    key := verifyTokenKey(scene, mobile)
    result, err := redisClient.Eval(ctx, luaScript, []string{key}, token).Result()
    if err != nil {
        return fmt.Errorf("failed to consume verify token: %w", err)
    }
    if deleted, _ := result.(int64); deleted == 0 {
        return appErrors.ErrInvalidVerifyToken
    }
    return nil
}

为什么安全:

  • 反重放仍然成立:即使两个请求同时校验通过,Lua 脚本的 GET+COMPARE+DEL 是原子的,只有一个能成功 DEL,另一个收到 ErrInvalidVerifyToken(详见 §8 多标签页行)。
  • 攻击门槛未降低:攻击者必须同时拿到 verify_token(5 分钟 TTL)和旧密码(bcrypt)才能改密;业务校验仍由旧密码把关。
  • 幂等性自愈:若业务成功 + Consume 失败(Redis 抖动),密码已更新但 token 未删;前端重试时 VerifyToken 仍能通过、再次跑业务、再次 Consume —— 自愈

为什么 UX 更好:

  • 旧密码输错 → 用户在弹窗内修正 → 直接点确认,无需重新发短信(VerifyToken 通过 → 业务重跑 → 成功 → Consume)。
  • 新旧密码相同 / 新密码太短等本地已拦截,但若服务端校验失败亦同理可重试。

Register 流程同步改造:auth_service.goRegister 中原 VerifyToken(ctx, req.Mobile, req.VerifyToken) 拆为两步:

  1. VerifyToken(ctx, "register", mobile, token) —— 校验(用户记录创建之前)
  2. 用户记录 commit 成功后,调用 ConsumeVerifyToken(ctx, "register", mobile, token) —— 消费

这样 Register 在用户已存在 / 昵称冲突等竞态场景下,token 也能保留可重试,与改密行为一致。

影响面:

  • sms_service.goSendCode / VerifyCodescene 参数;VerifyTokenscene 参数但语义改为只校验(原 Get+Delete 拆为 VerifyToken + ConsumeVerifyToken)
  • sms_redis.go 新增 ConsumeVerifyToken(Lua 原子 GET+COMPARE+DEL);VerifyToken 改为只读
  • auth_service.goRegister 中原 VerifyToken(ctx, req.Mobile, req.VerifyToken) 拆为两步:① 校验 VerifyToken(ctx, "register", mobile, token);② 用户 commit 成功后 ConsumeVerifyToken(ctx, "register", mobile, token)
  • user_service.goUpdatePassword 同样拆为两步(详见 §4.3):① 校验;② 事务成功后消费
  • Register 仍用 scene="register",改密用 scene="password",两个 scene 的 Redis key 互不污染
  • 老数据兼容:老的 sms:register:* / verify:register:* 在过渡期可保留(只要还有未过期的 token),过渡期后自然过期(60s/300s TTL)

Call-site 审计(实施前必跑):

grep -rn "SaveVerifyToken\|GetVerifyToken\|DeleteVerifyToken\|VerifyToken(\|SaveSMSCode\|GetSMSCode\|VerifyCode(\|SendCode(" \
  --include="*.go" backend/

涉及文件(已通过 grep 确认):9 个 —— auth_service.go / sms_service.go / user.proto / user.pb.go / auth_provider.go / unified_provider.go / sms_redis.go / auth_controller.go / user.triple.go。其中 *.proto / *.pb.go / *.triple.go 是 regen 自动产物,人工改动集中在 auth_service.go / sms_service.go / auth_provider.go / auth_controller.go / unified_provider.go

4.3 Service 层 UpdatePassword

修改 backend/services/userService/service/user_service.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
    }
    // (Plan A) 仅校验 token,不删除。业务失败时 token 保留可重试,用户无需重新发短信
    // scene="password" 必须与前端 verifyCodeApi 调用时传的一致
    // 注意:user_service.go 本身在 service 包,直接调 VerifyToken,不要加 service. 前缀
    if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil {
        return nil, appErrors.ErrInvalidVerifyToken
    }

    // 1. 参数验证
    if !validator.ValidateUserID(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. 验证旧密码(Plan A: 此处失败 token 仍保留,前端可重试无需重新发短信)
    if !s.userRepo.VerifyPassword(user, req.OldPassword) {
        return nil, appErrors.ErrInvalidOldPassword
    }

    // 4. 加密新密码
    newPasswordHash, err := repository.HashPassword(req.NewPassword)
    if err != nil { ... }

    // 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(强制重新登录,旧 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 }

    // 6. (Plan A) 业务成功后原子消费 verify_token(Lua GET+COMPARE+DEL)
    //    Consume 失败(Redis 抖动)仅记日志,不返回错误:
    //    - 业务已成功,密码已更新,access_token 已清空,符合预期
    //    - token 未删,用户重试时 VerifyToken 仍能通过、自愈(详见 §4.2.1)
    if err := ConsumeVerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil {
        logger.Logger.Error("failed to consume verify token after password update (self-healing on retry)",
            "user_id", userID, "err", err.Error())
    }

    return &pb.UpdatePasswordResponse{}, nil
}

注意:GetByID 提到第 0 步(原本在第 2 步),因为新逻辑需要 user.Mobile 来校验 verify_token。

4.4 Provider 透传 ctx

修改 backend/services/userService/provider/user_provider.go

func (p *UserProvider) UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest) (*pb.UpdatePasswordResponse, error) {
    // ... (略)
    resp, err := p.userService.UpdatePassword(ctx, req, userID)  // 传 ctx
    // ...
}

4.5 新增错误码 + ToStatusCode 改造

修改 backend/pkg/errors/errors.go

4.5.1 新增错误变量

var (
    ErrInvalidVerifyToken  = errors.New("invalid verify token")  // 新增
    ErrSameAsOldPassword   = errors.New("new password is same as old")  // 新增
    ErrInvalidOldPassword  = errors.New("old password is incorrect")    // 新增
)

4.5.2 ToStatusCode() 改造:用 errors.Is 全面识别 wrapped error(为 §12.2 修复铺路),同步加 ErrInvalidOldPassword / ErrUserInactive / ErrAccountFrozen / ErrAccountBanned 等 case:

// 改造后(关键 case 完整列出,其他 case 沿用)
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),  // ← 新增 ErrInvalidOldPassword
        errors.Is(err, ErrInvalidStarID), errors.Is(err, ErrInvalidUserID),
        errors.Is(err, ErrMaxIdentitiesReached), errors.Is(err, ErrInvalidNickname),
        errors.Is(err, ErrInvalidVerifyToken),  // ← 新增:ErrInvalidVerifyToken -> 400
        errors.Is(err, ErrSameAsOldPassword),   // ← 新增
        errors.Is(err, ErrCannotAddSelf), errors.Is(err, ErrCannotSearchSelf),
        // ... 其他 400 业务错误同前
        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),  // ← 新增 ErrUserInactive 修复(此前 default 500)
        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
    }
}

关键变化:

  • switch errswitch { case errors.Is(...) }:所有 case 改用 errors.Is,使 fmt.Errorf("%w: ...", ErrXxx, "extra") 这种 wrapped error 也能正确分类
  • 显式加入 ErrInvalidOldPassword (400) 和 ErrInvalidVerifyToken (400) 两个新 case
  • 显式加入 ErrUserInactive (403) 修复(此前 default 500)
  • ErrAccountFrozen / ErrAccountBanned 在 wrapped 形式下也能被识别(为 §12 修复铺路)

错误映射表(实施后):

错误 旧映射 新映射 说明
ErrInvalidPassword(Login 用) 401 401 不变:登录场景下密码错 = 鉴权失败
ErrInvalidOldPassword(新,ChangePassword 用) 400 改密场景下旧密码错 = 业务校验(用户已通过 AuthMiddleware 鉴权)
ErrPasswordTooShort 400 400 不变
ErrInvalidVerifyToken(新) (旧方案 401) 400 改密场景下短信 token 错 = 业务校验(用户已通过 AuthMiddleware 鉴权)
ErrSameAsOldPassword(新) 400 400 不变
ErrUserInactive 500(实际落 default) 403 修复:此前 ToStatusCode 没有 case 落到 default 500,本次显式映射 403;配合 §12 Login 流程一起修复
ErrInvalidToken / ErrTokenExpired / ErrTokenMismatch 401 401 不变:真鉴权问题

关键设计原则:

  • 401 业务码只用于"token 鉴权失败"(用户登录态有问题,应登出)
  • 业务校验类错误(密码错、参数错) 统一走 400 业务码(应 toast 让用户重试)
  • 403 业务码只用于"账号被禁用/冻结"(应登出 + 显示封禁原因)
  • 用户已通过 AuthMiddleware 鉴权后,在已登录态下做的所有业务校验都应走 400,不踢出登录

4.6 单元测试

新增 backend/services/userService/service/user_service_password_test.go

# 测试用例 输入 期望
1 正常路径 有效 token + 正确 old + 有效 new 200,密码更新,token 通过 ConsumeVerifyToken 清空
2 verify_token 缺失 "" ErrInvalidVerifyToken
3 verify_token 错误 任意非法字符串 ErrInvalidVerifyToken
4 verify_token 过期 mock VerifyToken 返回 expired 错误 ErrInvalidVerifyToken
5 旧密码错误 bcrypt 比对失败 ErrInvalidOldPassword
6 新密码太短 "abcde" (5位) ErrPasswordTooShort
7 新旧密码相同 old == new ErrSameAsOldPassword
8 用户不存在 不存在的 userID ErrUserNotFound
9 Redis 故障 mock VerifyToken error wrap 后抛出
10 事务回滚 mock tx.Updates 失败 整事务回滚
11 (Plan A) 旧密码错误后 token 保留可重试 第一次请求:有效 token + 错误 old + 有效 new;第二次请求:同一 token + 正确 old + 有效 new 第一次返回 ErrInvalidOldPassword 且 token 仍在 Redis;第二次成功(无需重新发短信)
12 (Plan A) 业务失败时 token 未消费 mock 事务提交失败 返回 wrap error,verify_token 仍在 Redis(下次重试可成功)
13 (Plan A) ConsumeVerifyToken 失败自愈 mock ConsumeVerifyToken 返回 Redis error,业务前序步骤均成功 接口返回 200(业务已成功),日志记录 ERROR,token 保留

Mock 方案:

  • UserRepository:用 testify/mock 包装,实现 UserRepository 接口,注入到 userService
  • VerifyToken(包级函数):用 package-level var 注入(标准 Go test 模式)
// sms_service.go(运行时)
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 逻辑
}

// user_service_password_test.go(测试时)
func TestUpdatePassword_VerifyTokenExpired(t *testing.T) {
    orig := VerifyTokenFn
    defer func() { VerifyTokenFn = orig }()
    VerifyTokenFn = func(ctx context.Context, scene, mobile, token string) error {
        return errors.New("verify token expired")
    }
    // ... 断言
}

Test #9 错误类型规约:当 VerifyToken 自身出错(Redis 故障、key 拼错等)时:

  • VerifyToken 返回的原始 error → wrap 为 fmt.Errorf("failed to verify token: %w", err) → 在 Provider 层映射为 500
  • 业务上的"token 不存在 / 已过期 / scene 不匹配" → 仍返回 ErrInvalidVerifyToken400(已修订)
  • §7 错误码表中"其他 5xx"行即对应此场景,前端 toast 通用提示

5. 前端改动

5.1 BUG #1 修复:拦截器按状态码语义区分

问题根因:api.js 把业务码 400 也当成了"鉴权/封号"错误一并踢出登录,但 400 应是"业务参数错误"(密码错、参数错),不应登出。

修改 frontend/utils/api.js

把"业务码 400 也踢出登录"从条件里去掉:

// 改前(老代码,有问题)
 else if (res.data.code === 401 || res.data.code === 400 || res.data.code === 403) {
    // 清 token + 跳登录页
}

// 改后
 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
}

为什么这个修复是根本性的(对比白名单方案):

维度 白名单方案(已弃) 状态码语义方案(采用 )
修复位置 前端拦截器 前端拦截器 + 后端错误码映射
修复点数量 仅前端 1 处 前端 1 处 + 后端 1 处(ToStatusCode 改映射)
健壮性 任何新增的"业务 400"接口都要加白名单 任何业务 400 错误都自动正确处理
状态码语义 401/400/403 都混合为"踢出登录" 401=鉴权、403=封号、400=业务校验,语义清晰
维护成本 高(新接口需加白名单) 低(新接口自动正确)
与 REST 规范契合度

与后端协同:本修复依赖后端 §4.5 的错误码映射修订 —— 旧密码错、verify_token 错必须返回业务码 400(而不是 401)。如果后端不改,前端单独改会导致:用户输错旧密码时,后端返回 401,前端仍然踢出登录,BUG #1 没修好。所以 §4.5 和 §5.1 必须同步修改

5.2 BUG #2 修复:补 APP介绍 handler

修改 frontend/pages/profile/profile.vue

<view class="service-button" @tap="handleShowAppIntro">  <!--  @tap -->
    <image class="service-icon" src="/static/icon/edit-password.png" mode="aspectFit" />
    <text class="service-text">APP介绍</text>
</view>
const handleShowAppIntro = () => {
    uni.showModal({
        title: 'APP介绍',
        content: '顶粉APP是一款粉丝专属应用,集成了身份管理、藏品展示、社交互动等功能。\n(更多介绍功能开发中...)',
        showCancel: false,
        confirmText: '我知道了'
    })
}

5.3 BUG #3 修复 + 弹窗结构升级

修改 frontend/pages/profile/profile.vue

5.3.1 弹窗模板

替换原 profile.vue:159-184 的 password-modal:

<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>

5.3.2 状态变量(替换原 refs)

// 旧密码
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)  // 新增,用于清理 setInterval
// 防重
const changingPassword = ref(false)

5.3.3 三个核心 handler

// 1. 发送验证码
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
            // 先清理可能存在的旧 timer
            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
    }
}

// 2. 关闭弹窗(清空所有,包括倒计时 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
    }
}

// 3. 确认改密
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 {
        // Step 1: verify SMS code → verify_token
        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

        // Step 2: update password
        // 业务码 400(密码错、verify token 错等)会由 api.js 拦截器 reject,
        // 跳到 catch 分支;成功(业务码 200)进入此分支
        const res = await updatePasswordApi(
            oldPassword.value, newPassword.value, verifyToken
        )
        uni.hideLoading()

        // res 此时已是 res.data(被 request() resolve 过的)
        if (res.code === 200) {
            uni.showToast({ title: '修改成功,请重新登录', icon: 'success' })
            setTimeout(() => {
                // 改密后清空所有登录态 + 注册临时数据
                // 备注:store 的 'user/logout' 实际只 commit CLEAR_AUTH(清 access_token/user/star_id/login_mobile/gallery_owner_id),
                // 不会清 temp_register_*,所以这里需要手动清;后续若 store 升级,本处可同步收敛
                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)
        }
        // 业务码 400/500 已被 api.js 拦截器 reject 跳到 catch 块,这里无 else
    } catch (e) {
        uni.hideLoading()
        uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' })
        changingPassword.value = false
    }
}

5.3.4 新增 import

// 已有 sendCodeApi(mobile, scene) / verifyCodeApi(mobile, code, scene),支持任意 scene 字符串
// 已有 updatePasswordApi,本节中扩展为 3 参数(老调用点需同步更新)
import { sendCodeApi, verifyCodeApi, updatePasswordApi, ... } from '@/utils/api'
import { validatePassword } from '@/utils/validator.js'  // 已有

API 函数兼容性确认:

  • sendCodeApi(mobile, scene):见 api.js:187-196,已支持任意 scene 字符串(后端在 SendCode 中按 scene 路由),无需改动
  • verifyCodeApi(mobile, code, scene):见 api.js:199-209,已支持任意 scene,无需改动
  • updatePasswordApi(oldPassword, newPassword, verifyToken):新签名,§5.3.5 中给出实现

updatePasswordApi 调用点审计(实施前必跑):

grep -rn "updatePasswordApi" --include="*.vue" --include="*.js" frontend/

经 grep 确认:仅 2 个调用点 —— frontend/utils/api.js(定义处)+ frontend/pages/profile/profile.vue(使用处)。本 PR 内一并更新即可,无连锁影响。

5.3.5 API 函数扩展

frontend/utils/api.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,
        }
    })
}

5.4 新增 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; }

6. 测试策略

6.1 后端单测(新增 user_service_password_test.go)

见 §4.6 表格。

6.2 前端手动验证 checklist

# 场景 预期
1 弹窗打开,字段全空,点确认 提示"请输入6位短信验证码"
2 输错旧密码(其他字段都对) 后端 400(§4.5 修订后),弹 toast "旧密码错误",不跳登录
3 新密码 5 位 本地拒绝,提示"密码至少为6位"
4 新密码 ≠ 确认密码 提示"两次输入不一致"
5 短信码输错 后端拒绝,弹 toast,不跳登录
6 60s 内连点"获取验证码" 第二次按钮 disabled,显示倒计时
7 改密成功 toast + 2s 后清 token + 跳登录页
8 弱网超时 toast "网络异常",弹窗保留
9 关闭弹窗再开 所有字段已清空,倒计时已重置
10 APP介绍 入口 弹模态框 "功能开发中" 提示

7. 错误码映射

后端错误 proto 状态码 前端 toast 是否触发自动登出
ErrInvalidOldPassword(新,旧密码错) 400 旧密码错误 否(toast 让用户重输)
ErrInvalidVerifyToken(新) 400 短信验证码无效或已过期
ErrPasswordTooShort 400 新密码至少6位
ErrSameAsOldPassword(新) 400 新密码不能与旧密码相同
ErrUserNotFound 404 用户不存在
ErrUserInactive 403 账号已被禁用
ErrAccountFrozen 403 账号已被冻结
ErrAccountBanned 403 账号已被封禁
ErrInvalidToken / ErrTokenExpired 401 登录已过期 (从 AuthMiddleware 返回)
其他 5xx 500 修改失败,请稍后重试

统一规则:

  • 业务码 401 / 403 → 自动登出(分别:token 失效 / 账号被封)
  • 业务码 400 / 404 / 5xx → toast 提示,不登出
  • 实现阶段必须保证后端 err.Error() 文案稳定并与本表格一致,前端只信任 res.message 字段做 toast

8. 异常路径与降级

场景 行为
Redis 不可用 VerifyToken 报错 → 改密接口返回 500 → 前端 toast,弹窗保留
短信发送失败(>10次/小时) 后端返回 429 → 前端 toast "发送过于频繁"
用户 token 已过期 AuthMiddleware 拦截 → 前端拦截器跳登录(合理)
多标签页同时改密 第一个标签业务成功后 ConsumeVerifyToken 删 token;第二个标签的 VerifyToken 仍能通过,但 ConsumeVerifyToken 发现 token 已被删 → 失败(防重放 )
旧密码输错 → 修正重试 Plan A:VerifyToken 只校验不删,token 保留在 Redis;用户修正旧密码再次提交,VerifyToken 仍能通过、业务重跑、最终成功,无需重新发短信

9. 安全考量

措施
鉴权强制 路由 /api/v1/account/password 已挂 AuthMiddleware(见 router.go:185-189),token 无效直接 401,无需在 Service 重复校验 userID 来源
短信 token 一次性(Plan A) VerifyToken 仅校验不删除;ConsumeVerifyToken(Lua 原子 GET+COMPARE+DEL)在业务成功后调用;业务失败时 token 保留,用户可重试无需重新发短信;Consume 失败(Redis 抖动)自愈:业务已成功,密码已更新,token 未删,下次重试仍可成功
短信 token TTL 5 分钟(与 Register 一致)
限流 复用 sms:limit:mobile: 计数器,scene=password
旧密码明文只传不存 bcrypt(成本因子 10)
改密后清 token 事务内 access_token = nil
updated_at 同步更新 与 token 校验机制一致,旧 token 失效
不暴露 mobile 是否存在 短信发送成功/失败统一 200(继承 Register 的 anti-enumeration 行为)

10. 文件变更总览

变更 文件
Proto 修改 backend/proto/user.proto
Proto regen backend/pkg/proto/user/user.pb.go
Proto regen backend/pkg/proto/user/user.triple.go
Service 修改 backend/services/userService/service/sms_redis.go
Service 修改 backend/services/userService/service/sms_service.go
Service 修改 backend/services/userService/service/user_service.go
Service 修改 backend/services/userService/service/auth_service.go(Register 中 VerifyToken(ctx, mobile, token)VerifyToken(ctx, "register", mobile, token),共 1 处;§12 修复 Login 流程 2 处 return 改用 typed error)
Service 新增 backend/services/userService/service/user_service_password_test.go(§4.6 10 个用例)
Service 新增 backend/services/userService/service/auth_service_login_test.go(§12.4 2 个用例,banned/frozen 返 403)
Provider 修改 backend/services/userService/provider/user_provider.go(传 ctx)
Errors 修改 backend/pkg/errors/errors.go(新增 3 个错误码 ErrInvalidVerifyToken/ErrSameAsOldPassword/ErrInvalidOldPassword;ToStatusCodeErrInvalidVerifyToken 改映射 400;ToStatusCode 改用 errors.Is 全面识别 wrapped error;新增 NewAccountBannedError / NewAccountFrozenError 2 个 helper)
Frontend utils 修改 frontend/utils/api.js(拦截器去掉 400 自动登出,保留 401/403 + updatePasswordApiverify_token 字段)
Frontend pages 修改 frontend/pages/profile/profile.vue(弹窗 + 状态 + handler + CSS)

11. 部署/上线检查清单

  • proto 重新生成后,所有引用 UpdatePasswordRequest 的代码编译通过
  • Redis key 升级:老的 sms:register:* / verify:register:* 数据保留(Register 仍用 scene=register);新加的 sms:password:* / verify:password:* 独立
  • API 接口回归: Login / Register / RefreshToken / Logout / GetMyProfile / UpdateNickname 等所有走 ToStatusCode 的接口,在 errors.Is 改造后行为不变(单测 + 灰度验证)
  • 登录态回归: 在修复 BUG #1(拦截器去掉 400 自动登出)后,跑一遍以下场景确认无回归:
    • 输错密码登录(Login 接口返 401)→ 前端拦截器跳登录
    • 账号被冻结登录(§12 修复后应返 403,而非 500)→ 弹封禁原因 + 跳登录
    • 业务校验错(改密输错旧密码,本次返 400)→ toast 提示,不跳登录
  • 后端单测全绿(含 §4.6 10 个 + §12.4 2 个)
  • 前端手动验证 10 条 checklist 通过
  • Swagger 文档重新生成
  • 灰度发布,先开 10% 流量观察错误率,重点监控 401/403/500 比例(应:401 不变,403 上升,500 下降)

12. 关联修复:Login 流程账号状态码 BUG(顺带修)

12.1 问题

§7 设计的统一规则是"ErrAccountFrozen / ErrAccountBanned → 403 → 自动登出"。但实际 Login 流程并未使用这两个 typed error,导致:

auth_service.go:327-377 在 banned / frozen 情况下:

if accountStatus.IsBanned() {
    return nil, fmt.Errorf("账号已被封禁%s%s", ...)  // ← 不是 ErrAccountBanned
}
if accountStatus.IsFrozen() {
    return nil, errors.New(errMsg)                    // ← 不是 ErrAccountFrozen
}

这两个 error 都是 generic 的,不匹配 ToStatusCode()case ErrAccountFrozen, ErrAccountBanned: 分支,落到 defaultSTATUS_INTERNAL_ERROR (500)。

实际行为:用户账号被冻结/封禁后尝试登录,后端返回 500(而非 403),前端按 §5.1 规则不会自动登出,只 toast 一个"修改失败"类的提示,体验不一致。

12.2 修复

修改 backend/services/userService/service/auth_service.go

引入两个新的辅助函数,把 reason / frozenUntil 信息塞进 error message 但保留 typed error 身份:

// 新增到 errors.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 into message
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
}

关键技巧:fmt.Errorf("%w: ...", ErrAccountBanned, ...)%w 保留 wrap 关系,使 errors.Is(err, ErrAccountBanned) 仍能识别。同时 ToStatusCode()switch err 不识别 wrapped error,所以需要扩展 ToStatusCode()errors.Is:

// errors.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):
        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):
        return pb.StatusCode_STATUS_FORBIDDEN
    // ... 后续同样改用 errors.Is
    default:
        return pb.StatusCode_STATUS_INTERNAL_ERROR
    }
}

auth_service.go Login 流程改造:

if accountStatus.IsBanned() {
    reason := ""
    if accountStatus.Reason != nil {
        reason = *accountStatus.Reason
    }
    logger.Logger.Warn("User account is banned", ...)
    return nil, appErrors.NewAccountBannedError(reason)  // ← 改用 typed error
}

if accountStatus.IsFrozen() {
    if accountStatus.FrozenUntil != nil && time.Now().UnixMilli() > *accountStatus.FrozenUntil {
        // 冻结已过期,放行
        logger.Logger.Info("User account frozen but expired", ...)
    } else {
        reason := ""
        if accountStatus.Reason != nil {
            reason = *accountStatus.Reason
        }
        logger.Logger.Warn("User account is frozen", ...)
        return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil)  // ← 改用 typed error
    }
}

12.3 影响面

变化
errors.go 新增 NewAccountBannedError / NewAccountFrozenError 2 个 helper;ToStatusCode 改用 errors.Is 全面识别 wrapped error(1 处 switch 大改造)
auth_service.go Login 流程的 2 处 return 改用 helper
其他服务 ToStatusCode 改用 errors.Is 后,其他 service 中已有的 fmt.Errorf("%w", ErrXxx) 自动受益,无需逐个改动
前端 无需改动:403 业务码已在 §5.1 拦截器白名单中被正确处理(自动登出 + 跳登录页)

12.4 单测追加

auth_service_login_test.go(新建,或追加到现有 test)加 2 个用例:

# 场景 期望
1 账号被封禁 业务码 403(不再是 500),errors.Is(err, ErrAccountBanned) 为 true
2 账号被冻结 业务码 403(不再是 500),errors.Is(err, ErrAccountFrozen) 为 true

12.5 与"状态码重构"的关系

本次修复是最小变更,仅让 Login 流程复用现有的 ErrAccountFrozen / ErrAccountBanned 语义,业务码到 HTTP 码的映射规则不变。

配套的状态码体系重构(已 Approved)见 2026-06-12-status-code-refactor-design.md,本次修复的 errors.Is 改造会作为重构的前置工作存在,可以平滑迁移(重构的 ToStatusCode 函数直接受益于 errors.Is 模式)。