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 描述同步更新
This commit is contained in:
zheng020 2026-06-12 12:51:47 +08:00
parent 0a3d8e0afc
commit e5d5808a84

View File

@ -12,9 +12,9 @@
修改密码功能后端全链路(proto → service → provider → gateway)、前端 API、入口、弹窗均已实现。但经代码审查发现:
**🔴 BUG #1(严重):错误码 401/400 会被前端误判为 token 过期,强制登出**
**🔴 BUG #1(严重):业务码 400 被前端误判为 token 过期,强制登出**
`updatePasswordApi` 调后端时,旧密码错误(401)或新密码太短(400)会被 [api.js:101-115](frontend/utils/api.js#L101-L115) 的统一拦截器视为登录过期,**清 token + 跳登录页**。用户体验事故:输错旧密码即被踢出。
[api.js:101-115](frontend/utils/api.js#L101-L115) 的统一拦截器把业务码 401/400/403 **一律视为登录过期,清 token + 跳登录页**。但 400 应是"业务参数错误"(密码错、参数错),不应触发登出。配合 §4.5 把"旧密码错 / verify_token 错"从 401 改为 400 后,前端拦截器也必须同步从条件中移除 400,否则 401 业务码仍会触发登出(因为后端对"旧密码错"原本就返回 401)。用户体验事故:输错旧密码即被踢出。
**🟡 BUG #2(中):"APP介绍" 入口是空壳**
@ -196,7 +196,7 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword
// 1. 参数验证
if !validator.ValidateUserID(userID) { ... }
if req.OldPassword == "" { return nil, appErrors.ErrInvalidPassword }
if req.OldPassword == "" { return nil, appErrors.ErrInvalidOldPassword }
valid, msg := validator.ValidatePassword(req.NewPassword)
if !valid {
@ -211,7 +211,7 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword
// 3. 验证旧密码
if !s.userRepo.VerifyPassword(user, req.OldPassword) {
return nil, appErrors.ErrInvalidPassword
return nil, appErrors.ErrInvalidOldPassword
}
// 4. 加密新密码
@ -248,12 +248,27 @@ func (p *UserProvider) UpdatePassword(ctx context.Context, req *pb.UpdatePasswor
var (
ErrInvalidVerifyToken = errors.New("invalid verify token") // 新增
ErrSameAsOldPassword = errors.New("new password is same as old") // 新增
ErrInvalidOldPassword = errors.New("old password is incorrect") // 新增
)
```
`ToStatusCode()` 新增映射:
- `ErrInvalidVerifyToken``STATUS_UNAUTHORIZED` (401)
- `ErrSameAsOldPassword``STATUS_BAD_REQUEST` (400)
**`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 单元测试
@ -265,7 +280,7 @@ var (
| 2 | verify_token 缺失 | `""` | `ErrInvalidVerifyToken` |
| 3 | verify_token 错误 | 任意非法字符串 | `ErrInvalidVerifyToken` |
| 4 | verify_token 过期 | mock `VerifyToken` 返回 expired 错误 | `ErrInvalidVerifyToken` |
| 5 | 旧密码错误 | bcrypt 比对失败 | `ErrInvalidPassword` |
| 5 | 旧密码错误 | bcrypt 比对失败 | `ErrInvalidOldPassword` |
| 6 | 新密码太短 | "abcde" (5位) | `ErrPasswordTooShort` |
| 7 | 新旧密码相同 | old == new | `ErrSameAsOldPassword` |
| 8 | 用户不存在 | 不存在的 userID | `ErrUserNotFound` |
@ -301,36 +316,53 @@ func TestUpdatePassword_VerifyTokenExpired(t *testing.T) {
**Test #9 错误类型规约**:当 `VerifyToken` 自身出错(Redis 故障、key 拼错等)时:
- `VerifyToken` 返回的原始 error → wrap 为 `fmt.Errorf("failed to verify token: %w", err)` → 在 Provider 层映射为 500
- 业务上的"token 不存在 / 已过期" → 仍返回 `ErrInvalidVerifyToken`401
- 业务上的"token 不存在 / 已过期 / scene 不匹配" → 仍返回 `ErrInvalidVerifyToken`**400**(已修订)
- §7 错误码表中"其他 5xx"行即对应此场景,前端 toast 通用提示
---
## 5. 前端改动
### 5.1 BUG #1 修复:拦截器白名单
### 5.1 BUG #1 修复:拦截器按状态码语义区分
**问题根因**:`api.js` 把业务码 400 也当成了"鉴权/封号"错误一并踢出登录,但 400 应是"业务参数错误"(密码错、参数错),不应登出。
**修改** [frontend/utils/api.js](frontend/utils/api.js#L101-L115)
```js
// 顶部新增
const NO_AUTO_LOGOUT_PATHS = [
'/api/v1/account/password', // 改密业务码 401/400 不是 token 过期
]
把"业务码 400 也踢出登录"从条件里去掉:
// 拦截器改造
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 || '请求失败'))
```js
// 改前(老代码,有问题)
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](frontend/pages/profile/profile.vue#L130-L134)
@ -540,6 +572,8 @@ const confirmChangePassword = async () => {
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
}
@ -637,7 +671,7 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) {
| # | 场景 | 预期 |
|---|---|---|
| 1 | 弹窗打开,字段全空,点确认 | 提示"请输入6位短信验证码" |
| 2 | 输错旧密码(其他字段都对) | 后端 401,弹 toast "旧密码错误",**不跳登录** |
| 2 | 输错旧密码(其他字段都对) | 后端 400(§4.5 修订后),弹 toast "旧密码错误",**不跳登录** |
| 3 | 新密码 5 位 | 本地拒绝,提示"密码至少为6位" |
| 4 | 新密码 ≠ 确认密码 | 提示"两次输入不一致" |
| 5 | 短信码输错 | 后端拒绝,弹 toast,不跳登录 |
@ -651,17 +685,23 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) {
## 7. 错误码映射
| 后端错误 | proto 状态码 | 前端 toast |
|---|---|---|
| `ErrInvalidVerifyToken`(新) | 401 | 短信验证码无效或已过期 |
| `ErrInvalidPassword`(旧密码错) | 401 | 旧密码错误 |
| `ErrPasswordTooShort` | 400 | 新密码至少6位 |
| `ErrSameAsOldPassword`(新) | 400 | 新密码不能与旧密码相同 |
| `ErrUserNotFound` | 404 | 用户不存在 |
| `ErrUserInactive` | 403 | 账号已被禁用 |
| 其他 5xx | 500 | 修改失败,请稍后重试 |
| 后端错误 | 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 | 修改失败,请稍后重试 | ❌ 否 |
**注意**:本端点 `/api/v1/account/password` 在 BUG #1 修复后(§5.1 白名单)即便返回 401/400 也不会触发"自动登出"。同一业务码 401 含义可能不同(可能是 `ErrInvalidVerifyToken` 也可能是 `ErrInvalidPassword`),前端只通过 `res.message` 文案区分,不走 status code 硬区分。实现阶段必须保证后端 `err.Error()` 文案稳定并与 §7 表格一致。
**统一规则**:
- 业务码 401 / 403 → 自动登出(分别:token 失效 / 账号被封)
- 业务码 400 / 404 / 5xx → toast 提示,不登出
- 实现阶段必须保证后端 `err.Error()` 文案稳定并与本表格一致,前端只信任 `res.message` 字段做 toast
---
@ -704,8 +744,8 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) {
| 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) |
| Errors | 修改 | `backend/pkg/errors/errors.go`(新增 3 个错误码 `ErrInvalidVerifyToken`/`ErrSameAsOldPassword`/`ErrInvalidOldPassword`;`ToStatusCode` 中 `ErrInvalidVerifyToken` 改映射 400) |
| Frontend utils | 修改 | `frontend/utils/api.js`(拦截器去掉 400 自动登出,保留 401/403 + `updatePasswordApi``verify_token` 字段) |
| Frontend pages | 修改 | `frontend/pages/profile/profile.vue`(弹窗 + 状态 + handler + CSS) |
---