topfans/docs/superpowers/specs/2026-06-12-change-password-design.md
zheng020 e5d5808a84 docs(change-password): 架构级修正 - 状态码语义替代白名单方案
按用户反馈,BUG #1 修复应从状态码语义入手,而非前端 URL 白名单:

后端修订 (§4.5):
- ErrInvalidVerifyToken: 401 -> 400 (业务校验,非鉴权)
- 新增 ErrInvalidOldPassword: 400 (改密场景下旧密码错)
- 保留 ErrInvalidPassword (Login 用) = 401 不变
- 关键原则:已登录态下的业务校验都走 400

前端简化 (§5.1):
- 拦截器去掉 NO_AUTO_LOGOUT_PATHS 白名单
- 只对 401 (token 失效) / 403 (账号被封) 自动登出
- 400 类业务错误统一 toast,让用户重试

同步:
- §4.3 service 代码:用 ErrInvalidOldPassword 替代 ErrInvalidPassword
- §4.6 测试用例 #5:用 ErrInvalidOldPassword
- §6.2 手动测试 #2:旧密码错返回 400
- §7 错误码表:增加是否触发自动登出列,统一规则
- §10 文件列表:errors.go 新增 3 错误码,api.js 改为非白名单改造
- §1.1 BUG #1 描述同步更新
2026-06-12 12:51:47 +08:00

35 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.点击"修改密码"            │                          │                  │                    │
 ├───────────────────────────→│                          │                  │                    │
 │                            │ 弹窗打开(展示6个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() │ Get+DeleteVerifyTok│
 │                            │                          │                  ├───────────────────→│
 │                            │                          │  - bcrypt 比对   │                    │
 │                            │                          │  - 事务内更新    │                    │
 │                            │                          │    password_hash │                    │
 │                            │                          │    access_token=nil│                  │
 │                            │ 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 一次性消费。

影响面:

  • sms_service.goSendCode / VerifyCode / VerifyToken 全部加 scene 参数
  • auth_service.goRegisterVerifyToken(ctx, req.Mobile, req.VerifyToken)VerifyToken(ctx, "register", req.Mobile, req.VerifyToken)
  • 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. (新增) 验证短信 token
    user, err := s.userRepo.GetByID(userID)
    if err != nil { ... }

    if req.VerifyToken == "" {
        return nil, appErrors.ErrInvalidVerifyToken
    }
    // scene="password" 必须与前端 verifyCodeApi 调用时传的一致
    if err := service.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. 验证旧密码
    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 {
        // 原逻辑保持
    })
    ...
}

注意: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 新增错误码

修改 backend/pkg/errors/errors.go

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

ToStatusCode() 映射修订:

错误 旧映射 新映射 说明
ErrInvalidPassword(Login 用) 401 401 不变:登录场景下密码错 = 鉴权失败
ErrInvalidOldPassword(新,ChangePassword 用) 400 改密场景下旧密码错 = 业务校验(用户已通过 AuthMiddleware 鉴权)
ErrInvalidVerifyToken(新) (旧方案 401) 400 改密场景下短信 token 错 = 业务校验(用户已通过 AuthMiddleware 鉴权)
ErrSameAsOldPassword(新) 400 400 不变
ErrPasswordTooShort 400 400 不变
ErrUserInactive 403 403 不变:账号被禁仍应登出
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 清空
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 失败 整事务回滚

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 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
            const timer = setInterval(() => {
                codeCountdown.value--
                if (codeCountdown.value <= 0) clearInterval(timer)
            }, 1000)
        } else {
            uni.showToast({ title: res.message || '发送失败', icon: 'none' })
        }
    } catch (e) {
        uni.showToast({ title: e.message || '发送失败', icon: 'none' })
    } finally {
        sendingCode.value = false
    }
}

// 2. 关闭弹窗(清空所有)
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
}

// 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
        const res = await updatePasswordApi(
            oldPassword.value, newPassword.value, verifyToken
        )
        uni.hideLoading()

        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)
        } else {
            // 业务码 400(密码错、verify token 错等)走这里 toast,不会触发自动登出(详见 §5.1)
            // 业务码 401/403 会在 api.js 拦截器中被处理,这里 catch 不到
            uni.showToast({ title: res.message || '修改失败', icon: 'none' })
            changingPassword.value = false
        }
    } 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 拦截 → 前端拦截器跳登录(合理)
多标签页同时改密 第二个请求因 VerifyToken 一次性消费失败(防重放 )

9. 安全考量

措施
鉴权强制 路由 /api/v1/account/password 已挂 AuthMiddleware(见 router.go:185-189),token 无效直接 401,无需在 Service 重复校验 userID 来源
短信 token 一次性 复用 DeleteVerifyToken 消费后即删
短信 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 处)
Service 新增 backend/services/userService/service/user_service_password_test.go
Provider 修改 backend/services/userService/provider/user_provider.go(传 ctx)
Errors 修改 backend/pkg/errors/errors.go(新增 3 个错误码 ErrInvalidVerifyToken/ErrSameAsOldPassword/ErrInvalidOldPassword;ToStatusCodeErrInvalidVerifyToken 改映射 400)
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:* 独立
  • 后端单测全绿
  • 前端手动验证 10 条 checklist 通过
  • Swagger 文档重新生成
  • 灰度发布,先开 10% 流量观察错误率