From 397ebed805501a6aeac8b217c2640758e4a7cb4a Mon Sep 17 00:00:00 2001 From: zheng020 Date: Fri, 12 Jun 2026 13:43:34 +0800 Subject: [PATCH] =?UTF-8?q?docs(change-password):=20=E8=87=AA=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20-=207=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 高 (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' --- .../2026-06-12-change-password-design.md | 102 +++++++++++++++--- 1 file changed, 87 insertions(+), 15 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 49ce89e..8e0ed38 100644 --- a/docs/superpowers/specs/2026-06-12-change-password-design.md +++ b/docs/superpowers/specs/2026-06-12-change-password-design.md @@ -53,7 +53,7 @@ │ │ │ │ │ │ 1.点击"修改密码" │ │ │ │ ├───────────────────────────→│ │ │ │ - │ │ 弹窗打开(展示6个UI区) │ │ │ + │ │ 弹窗打开(展示5个UI区:手机号+验证码一行、短信码、3 个密码框) │ │ │ │ │ │ │ │ │ 2.点"获取验证码" │ │ │ │ ├───────────────────────────→│ │ │ │ @@ -190,7 +190,8 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword return nil, appErrors.ErrInvalidVerifyToken } // scene="password" 必须与前端 verifyCodeApi 调用时传的一致 - if err := service.VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil { + // 注意:user_service.go 本身在 service 包,直接调 VerifyToken,不要加 service. 前缀 + if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil { return nil, appErrors.ErrInvalidVerifyToken } @@ -220,7 +221,21 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword // 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 }) ... } @@ -240,10 +255,12 @@ func (p *UserProvider) UpdatePassword(ctx context.Context, req *pb.UpdatePasswor } ``` -### 4.5 新增错误码 +### 4.5 新增错误码 + ToStatusCode 改造 **修改** [backend/pkg/errors/errors.go](backend/pkg/errors/errors.go) +**4.5.1 新增错误变量** + ```go var ( ErrInvalidVerifyToken = errors.New("invalid verify token") // 新增 @@ -252,7 +269,53 @@ var ( ) ``` -**`ToStatusCode()` 映射修订**: +**4.5.2 `ToStatusCode()` 改造**:用 `errors.Is` 全面识别 wrapped error(为 §12.2 修复铺路),同步加 `ErrInvalidOldPassword` / `ErrUserInactive` / `ErrAccountFrozen` / `ErrAccountBanned` 等 case: + +```go +// 改造后(关键 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 | 不变:登录场景下密码错 = 鉴权失败 | | 错误 | 旧映射 | 新映射 | 说明 | |---|---|---|---| @@ -261,7 +324,7 @@ var ( | `ErrInvalidVerifyToken`(新) | (旧方案 401) | **400** | 改密场景下短信 token 错 = 业务校验(用户已通过 AuthMiddleware 鉴权) | | `ErrSameAsOldPassword`(新) | 400 | 400 | 不变 | | `ErrPasswordTooShort` | 400 | 400 | 不变 | -| `ErrUserInactive` | 403 | 403 | 不变:账号被禁仍应登出 | +| `ErrUserInactive` | 500(实际落 default) | **403** | 修复:此前 ToStatusCode 没有 case 落到 default 500,本次显式映射 403;配合 §12 Login 流程一起修复 | | `ErrInvalidToken` / `ErrTokenExpired` / `ErrTokenMismatch` | 401 | 401 | 不变:真鉴权问题 | **关键设计原则**: @@ -474,6 +537,7 @@ const showConfirmPassword = ref(false) const smsCode = ref('') const sendingCode = ref(false) const codeCountdown = ref(0) +const codeCountdownTimer = ref(null) // 新增,用于清理 setInterval // 防重 const changingPassword = ref(false) ``` @@ -493,9 +557,14 @@ const handleSendCode = async () => { if (res.code === 200) { uni.showToast({ title: '验证码已发送', icon: 'success' }) codeCountdown.value = 60 - const timer = setInterval(() => { + // 先清理可能存在的旧 timer + if (codeCountdownTimer.value) clearInterval(codeCountdownTimer.value) + codeCountdownTimer.value = setInterval(() => { codeCountdown.value-- - if (codeCountdown.value <= 0) clearInterval(timer) + if (codeCountdown.value <= 0) { + clearInterval(codeCountdownTimer.value) + codeCountdownTimer.value = null + } }, 1000) } else { uni.showToast({ title: res.message || '发送失败', icon: 'none' }) @@ -507,7 +576,7 @@ const handleSendCode = async () => { } } -// 2. 关闭弹窗(清空所有) +// 2. 关闭弹窗(清空所有,包括倒计时 interval) const closePasswordModal = () => { showPasswordModal.value = false oldPassword.value = '' @@ -518,6 +587,10 @@ const closePasswordModal = () => { showNewPassword.value = false showConfirmPassword.value = false codeCountdown.value = 0 + if (codeCountdownTimer.value) { + clearInterval(codeCountdownTimer.value) + codeCountdownTimer.value = null + } } // 3. 确认改密 @@ -551,11 +624,14 @@ const confirmChangePassword = async () => { 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(() => { @@ -571,12 +647,8 @@ const confirmChangePassword = async () => { uni.removeStorageSync('temp_register_nickname') 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 } + // 业务码 400/500 已被 api.js 拦截器 reject 跳到 catch 块,这里无 else } catch (e) { uni.hideLoading() uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' }) @@ -887,4 +959,4 @@ if accountStatus.IsFrozen() { 本次修复是**最小变更**,仅让 Login 流程复用现有的 `ErrAccountFrozen` / `ErrAccountBanned` 语义,业务码到 HTTP 码的映射规则不变。 -如果你后续要做"状态码体系重构"(见 `docs/superpowers/specs/2026-06-12-status-code-refactor-design.md` 草稿),本次修复的 `errors.Is` 改造会作为重构的前置工作存在,可以平滑迁移。 +配套的状态码体系重构(已 Approved)见 [2026-06-12-status-code-refactor-design.md](../specs/2026-06-12-status-code-refactor-design.md),本次修复的 `errors.Is` 改造会作为重构的前置工作存在,可以平滑迁移(重构的 `ToStatusCode` 函数直接受益于 `errors.Is` 模式)。