From e8061c7d05a683136938276d8ba356f1f7681891 Mon Sep 17 00:00:00 2001 From: zheng020 Date: Fri, 12 Jun 2026 11:51:09 +0800 Subject: [PATCH] =?UTF-8?q?docs(change-password):=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3=20-=20=E6=97=A7=E5=AF=86=E7=A0=81+=E7=9F=AD?= =?UTF-8?q?=E4=BF=A1=E5=8F=8C=E4=BF=9D=E9=99=A9+=E4=BF=AE=E5=A4=8D3=20BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:proto 加 verify_token,SMS Redis key 场景化,Service 加 verify_token 校验+新旧密码一致性 - 前端:BUG#1 拦截器白名单修复、BUG#2 补 APP介绍 handler、BUG#3 加前端校验 - 新增 2 个错误码:ErrInvalidVerifyToken、ErrSameAsOldPassword - 后端单测覆盖矩阵 10 条 - 前端手动验证 checklist 10 条 --- .../2026-06-12-change-password-design.md | 654 ++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-change-password-design.md diff --git a/docs/superpowers/specs/2026-06-12-change-password-design.md b/docs/superpowers/specs/2026-06-12-change-password-design.md new file mode 100644 index 0000000..074f11f --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-change-password-design.md @@ -0,0 +1,654 @@ +# 修改密码功能设计文档 + +- **创建日期**: 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](frontend/utils/api.js#L101-L115) 的统一拦截器视为登录过期,**清 token + 跳登录页**。用户体验事故:输错旧密码即被踢出。 + +**🟡 BUG #2(中):"APP介绍" 入口是空壳** + +[profile.vue:130-134](frontend/pages/profile/profile.vue#L130-L134) 缺少 `@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](backend/proto/user.proto) + +```protobuf +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](backend/services/userService/service/sms_redis.go) + +将硬编码的 `register` 改为基于 `scene` 动态拼: + +```go +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 函数。 + +**影响面**:`sms_service.go` 中的 3 处调用同步加 `scene` 参数(Register 仍用 `scene="register"`,改密用 `scene="password"`)。 + +### 4.3 Service 层 UpdatePassword + +**修改** [backend/services/userService/service/user_service.go](backend/services/userService/service/user_service.go#L484-L587) + +```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 + } + if err := service.VerifyToken(ctx, 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](backend/services/userService/provider/user_provider.go#L341-L388) + +```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](backend/pkg/errors/errors.go) + +```go +var ( + ErrInvalidVerifyToken = errors.New("invalid verify token") // 新增 + ErrSameAsOldPassword = errors.New("new password is same as old") // 新增 +) +``` + +`ToStatusCode()` 新增映射: +- `ErrInvalidVerifyToken` → `STATUS_UNAUTHORIZED` (401) +- `ErrSameAsOldPassword` → `STATUS_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](frontend/utils/api.js#L101-L115) + +```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](frontend/pages/profile/profile.vue#L130-L134) + +```vue + + + APP介绍 + +``` + +```js +const handleShowAppIntro = () => { + uni.showModal({ + title: 'APP介绍', + content: '顶粉APP是一款粉丝专属应用,集成了身份管理、藏品展示、社交互动等功能。\n(更多介绍功能开发中...)', + showCancel: false, + confirmText: '我知道了' + }) +} +``` + +### 5.3 BUG #3 修复 + 弹窗结构升级 + +**修改** [frontend/pages/profile/profile.vue](frontend/pages/profile/profile.vue) + +#### 5.3.1 弹窗模板 + +替换原 [profile.vue:159-184](frontend/pages/profile/profile.vue#L159-L184) 的 password-modal: + +```vue + + + 修改密码 + + + + + + + + + + + + + + + + + {{ showOldPassword ? '👁️' : '👁️‍🗨️' }} + + + + + + + + {{ showNewPassword ? '👁️' : '👁️‍🗨️' }} + + + + + + + + {{ showConfirmPassword ? '👁️' : '👁️‍🗨️' }} + + + + + + + + + +``` + +#### 5.3.2 状态变量(替换原 refs) + +```js +// 旧密码 +const oldPassword = ref('') +const showOldPassword = ref(false) +// 新密码 + 确认 +const newPassword = ref('') +const confirmPassword = ref('') +const showNewPassword = ref(false) +const showConfirmPassword = ref(false) +// 短信 +const smsCode = ref('') +const sendingCode = ref(false) +const codeCountdown = ref(0) +// 防重 +const changingPassword = ref(false) +``` + +#### 5.3.3 三个核心 handler + +```js +// 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 + +```js +import { sendCodeApi, verifyCodeApi, updatePasswordApi, ... } from '@/utils/api' +import { validatePassword } from '@/utils/validator.js' // 已有 +``` + +#### 5.3.5 API 函数扩展 + +[frontend/utils/api.js](frontend/utils/api.js#L281-L291) + +```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 + +```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 | 修改失败,请稍后重试 | + +--- + +## 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`(SMS 调用加 scene) | +| 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% 流量观察错误率