From e5d5808a84ef6f7875ed5dab8982f3ced5edcc86 Mon Sep 17 00:00:00 2001 From: zheng020 Date: Fri, 12 Jun 2026 12:51:47 +0800 Subject: [PATCH] =?UTF-8?q?docs(change-password):=20=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E7=BA=A7=E4=BF=AE=E6=AD=A3=20-=20=E7=8A=B6=E6=80=81=E7=A0=81?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=9B=BF=E4=BB=A3=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户反馈,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 描述同步更新 --- .../2026-06-12-change-password-design.md | 118 ++++++++++++------ 1 file changed, 79 insertions(+), 39 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-change-password-design.md b/docs/superpowers/specs/2026-06-12-change-password-design.md index a07cd1c..e3a2287 100644 --- a/docs/superpowers/specs/2026-06-12-change-password-design.md +++ b/docs/superpowers/specs/2026-06-12-change-password-design.md @@ -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. 加密新密码 @@ -246,14 +246,29 @@ func (p *UserProvider) UpdatePassword(ctx context.Context, req *pb.UpdatePasswor ```go var ( - ErrInvalidVerifyToken = errors.New("invalid verify token") // 新增 - ErrSameAsOldPassword = errors.New("new password is same as old") // 新增 + 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) | ---