🔴 高 (4): 1. §3.1 序列图'6 UI 区' -> '5 UI 区'(实际是 1+1+3=5) 2. §4.3 'service.VerifyToken' 错误引用 -> 'VerifyToken' (同包) 3. §5.3.3 confirmChangePassword else 分支是死代码 (api.js 业务码 400 时 reject 跳到 catch),删除 else 分支 4. §5.3.3 setInterval 倒计时无 cleanup - 新增 codeCountdownTimer ref - handleSendCode 存 timer,清旧 timer - closePasswordModal 清 timer 🟡 中 (2): 5. §4.3 事务内代码 // 原逻辑保持 占位 -> 补完整 (password_hash + updated_at + access_token=nil 分两步) 6. §4.5 ErrUserInactive 描述'不变'不准 -> 改为'修复' (此前 default 500,本次 403),并补完整 ToStatusCode 函数代码 🟢 低 (1): 7. §12.5 状态码重构 spec 描述'草稿' -> '已 Approved'
45 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区:手机号+验证码一行、短信码、3 个密码框) │ │ │
│ │ │ │ │
│ 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 定义
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.go中SendCode / VerifyCode / VerifyToken全部加scene参数auth_service.go的Register中VerifyToken(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 调用时传的一致
// 注意: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. 验证旧密码
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
})
...
}
注意: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 err→switch { 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 | 不变:登录场景下密码错 = 鉴权失败 |
| 错误 | 旧映射 | 新映射 | 说明 |
|---|---|---|---|
ErrInvalidPassword(Login 用) |
401 | 401 | 不变:登录场景下密码错 = 鉴权失败 |
ErrInvalidOldPassword(新,ChangePassword 用) |
— | 400 | 改密场景下旧密码错 = 业务校验(用户已通过 AuthMiddleware 鉴权) |
ErrInvalidVerifyToken(新) |
(旧方案 401) | 400 | 改密场景下短信 token 错 = 业务校验(用户已通过 AuthMiddleware 鉴权) |
ErrSameAsOldPassword(新) |
400 | 400 | 不变 |
ErrPasswordTooShort |
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 清空 |
| 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接口,注入到userServiceVerifyToken(包级函数):用 package-levelvar注入(标准 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 不匹配" → 仍返回
ErrInvalidVerifyToken→ 400(已修订) - §7 错误码表中"其他 5xx"行即对应此场景,前端 toast 通用提示
5. 前端改动
5.1 BUG #1 修复:拦截器按状态码语义区分
问题根因:api.js 把业务码 400 也当成了"鉴权/封号"错误一并踢出登录,但 400 应是"业务参数错误"(密码错、参数错),不应登出。
把"业务码 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 函数扩展
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;ToStatusCode 中 ErrInvalidVerifyToken 改映射 400;ToStatusCode 改用 errors.Is 全面识别 wrapped error;新增 NewAccountBannedError / NewAccountFrozenError 2 个 helper) |
| Frontend utils | 修改 | frontend/utils/api.js(拦截器去掉 400 自动登出,保留 401/403 + updatePasswordApi 增 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% 流量观察错误率
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: 分支,落到 default → STATUS_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 模式)。