- 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 参数
669 lines
29 KiB
Markdown
669 lines
29 KiB
Markdown
# 修改密码功能设计文档
|
|
|
|
- **创建日期**: 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 函数。
|
|
|
|
`VerifyToken(ctx, mobile, token)`(定义在 [sms_service.go:247](backend/services/userService/service/sms_service.go#L247))同步改造为 `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)
|
|
|
|
### 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
|
|
}
|
|
// 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](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
|
|
<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>
|
|
```
|
|
|
|
```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
|
|
<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)
|
|
|
|
```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
|
|
// 已有 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](frontend/utils/api.js#L187-L196),已支持任意 `scene` 字符串(后端在 SendCode 中按 scene 路由),无需改动
|
|
- `verifyCodeApi(mobile, code, scene)`:见 [api.js:199-209](frontend/utils/api.js#L199-L209),已支持任意 `scene`,无需改动
|
|
- `updatePasswordApi(oldPassword, newPassword, verifyToken)`:**新签名**,§5.3.5 中给出实现;如有其他调用点需同步更新
|
|
|
|
#### 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 | 修改失败,请稍后重试 |
|
|
|
|
**注意**:本端点 `/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% 流量观察错误率
|