topfans/docs/superpowers/specs/2026-06-12-change-password-design.md
zheng020 c875151daa docs(change-password): 修复合并 5 个 spec review issues
- 4.2: 明确 VerifyToken 函数改造为 (ctx, scene, mobile, token)
- 4.3: 补 scene="password" 传入,加 scene 一致性注释
- 5.3.4: 确认 sendCodeApi/verifyCodeApi 已支持 scene,updatePasswordApi 需扩展为 3 参数
- 7: 增加 401 业务码多义性说明,提示 res.message 必须稳定
- 10: auth_service.go 改动明确为 Register 中 VerifyToken 调用加 scene 参数
2026-06-12 12:36:29 +08:00

29 KiB

修改密码功能设计文档

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

1. 背景与需求

1.1 现状

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

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

updatePasswordApi 调后端时,旧密码错误(401)或新密码太短(400)会被 api.js:101-115 的统一拦截器视为登录过期,清 token + 跳登录页。用户体验事故:输错旧密码即被踢出。

🟡 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   │                    │
 │                            │                          │  - 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)

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.ErrInvalidPassword }

    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.ErrInvalidPassword
    }

    // 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")  // 新增
)

ToStatusCode() 新增映射:

  • ErrInvalidVerifyTokenSTATUS_UNAUTHORIZED (401)
  • ErrSameAsOldPasswordSTATUS_BAD_REQUEST (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 比对失败 ErrInvalidPassword
6 新密码太短 "abcde" (5位) ErrPasswordTooShort
7 新旧密码相同 old == new ErrSameAsOldPassword
8 用户不存在 不存在的 userID ErrUserNotFound
9 Redis 故障 mock VerifyToken error wrap 后抛出
10 事务回滚 mock tx.Updates 失败 整事务回滚

Mock 方案:testify/mock 包装 UserRepository;VerifyToken 是包级函数,用 package-level option 注入避免改签名。


5. 前端改动

5.1 BUG #1 修复:拦截器白名单

修改 frontend/utils/api.js

// 顶部新增
const NO_AUTO_LOGOUT_PATHS = [
    '/api/v1/account/password',   // 改密业务码 401/400 不是 token 过期
]

// 拦截器改造
const shouldAutoLogout = (code, url) => {
    if (code !== 401 && code !== 400 && code !== 403) return false
    return !NO_AUTO_LOGOUT_PATHS.some(p => url.includes(p))
} else if (shouldAutoLogout(res.data.code, options.url)) {
    // 跳登录页逻辑(原样)
} else if (res.data.code === 401 || res.data.code === 400 || res.data.code === 403) {
    // 业务错误:reject 让调用方处理
    reject(new Error(res.data.message || '请求失败'))
    return
}

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.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 {
            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 中给出实现;如有其他调用点需同步更新

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

7. 错误码映射

后端错误 proto 状态码 前端 toast
ErrInvalidVerifyToken(新) 401 短信验证码无效或已过期
ErrInvalidPassword(旧密码错) 401 旧密码错误
ErrPasswordTooShort 400 新密码至少6位
ErrSameAsOldPassword(新) 400 新密码不能与旧密码相同
ErrUserNotFound 404 用户不存在
ErrUserInactive 403 账号已被禁用
其他 5xx 500 修改失败,请稍后重试

注意:本端点 /api/v1/account/password 在 BUG #1 修复后(§5.1 白名单)即便返回 401/400 也不会触发"自动登出"。同一业务码 401 含义可能不同(可能是 ErrInvalidVerifyToken 也可能是 ErrInvalidPassword),前端只通过 res.message 文案区分,不走 status code 硬区分。实现阶段必须保证后端 err.Error() 文案稳定并与 §7 表格一致。


8. 异常路径与降级

场景 行为
Redis 不可用 VerifyToken 报错 → 改密接口返回 500 → 前端 toast,弹窗保留
短信发送失败(>10次/小时) 后端返回 429 → 前端 toast "发送过于频繁"
用户 token 已过期 AuthMiddleware 拦截 → 前端拦截器跳登录(合理)
多标签页同时改密 第二个请求因 VerifyToken 一次性消费失败(防重放 )

9. 安全考量

措施
短信 token 一次性 复用 DeleteVerifyToken 消费后即删
短信 token TTL 5 分钟(与 Register 一致)
限流 复用 sms:limit:mobile: 计数器,scene=password
旧密码明文只传不存 bcrypt(成本因子 10)
改密后清 token 事务内 access_token = nil
updated_at 同步更新 与 token 校验机制一致,旧 token 失效
不暴露 mobile 是否存在 短信发送成功/失败统一 200

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(新增 2 个错误码 + 状态映射)
Frontend utils 修改 frontend/utils/api.js(白名单 + verify_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% 流量观察错误率