docs(change-password): 自审修复 - 7 issues

🔴 高 (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'
This commit is contained in:
zheng020 2026-06-12 13:43:34 +08:00
parent e52f46e50f
commit 397ebed805

View File

@ -53,7 +53,7 @@
│ │ │ │ │ │ │ │ │ │
│ 1.点击"修改密码" │ │ │ │ │ 1.点击"修改密码" │ │ │ │
├───────────────────────────→│ │ │ │ ├───────────────────────────→│ │ │ │
│ │ 弹窗打开(展示6个UI区) │ │ │ │ │ 弹窗打开(展示5个UI区:手机号+验证码一行、短信码、3 个密码框) │ │ │
│ │ │ │ │ │ │ │ │ │
│ 2.点"获取验证码" │ │ │ │ │ 2.点"获取验证码" │ │ │ │
├───────────────────────────→│ │ │ │ ├───────────────────────────→│ │ │ │
@ -190,7 +190,8 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword
return nil, appErrors.ErrInvalidVerifyToken return nil, appErrors.ErrInvalidVerifyToken
} }
// scene="password" 必须与前端 verifyCodeApi 调用时传的一致 // 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 return nil, appErrors.ErrInvalidVerifyToken
} }
@ -220,7 +221,21 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword
// 5. 事务内更新 // 5. 事务内更新
err = s.db.Transaction(func(tx *gorm.DB) error { 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) **修改** [backend/pkg/errors/errors.go](backend/pkg/errors/errors.go)
**4.5.1 新增错误变量**
```go ```go
var ( var (
ErrInvalidVerifyToken = errors.New("invalid verify token") // 新增 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 鉴权) | | `ErrInvalidVerifyToken`(新) | (旧方案 401) | **400** | 改密场景下短信 token 错 = 业务校验(用户已通过 AuthMiddleware 鉴权) |
| `ErrSameAsOldPassword`(新) | 400 | 400 | 不变 | | `ErrSameAsOldPassword`(新) | 400 | 400 | 不变 |
| `ErrPasswordTooShort` | 400 | 400 | 不变 | | `ErrPasswordTooShort` | 400 | 400 | 不变 |
| `ErrUserInactive` | 403 | 403 | 不变:账号被禁仍应登出 | | `ErrUserInactive` | 500(实际落 default) | **403** | 修复:此前 ToStatusCode 没有 case 落到 default 500,本次显式映射 403;配合 §12 Login 流程一起修复 |
| `ErrInvalidToken` / `ErrTokenExpired` / `ErrTokenMismatch` | 401 | 401 | 不变:真鉴权问题 | | `ErrInvalidToken` / `ErrTokenExpired` / `ErrTokenMismatch` | 401 | 401 | 不变:真鉴权问题 |
**关键设计原则**: **关键设计原则**:
@ -474,6 +537,7 @@ const showConfirmPassword = ref(false)
const smsCode = ref('') const smsCode = ref('')
const sendingCode = ref(false) const sendingCode = ref(false)
const codeCountdown = ref(0) const codeCountdown = ref(0)
const codeCountdownTimer = ref(null) // 新增,用于清理 setInterval
// 防重 // 防重
const changingPassword = ref(false) const changingPassword = ref(false)
``` ```
@ -493,9 +557,14 @@ const handleSendCode = async () => {
if (res.code === 200) { if (res.code === 200) {
uni.showToast({ title: '验证码已发送', icon: 'success' }) uni.showToast({ title: '验证码已发送', icon: 'success' })
codeCountdown.value = 60 codeCountdown.value = 60
const timer = setInterval(() => { // 先清理可能存在的旧 timer
if (codeCountdownTimer.value) clearInterval(codeCountdownTimer.value)
codeCountdownTimer.value = setInterval(() => {
codeCountdown.value-- codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(timer) if (codeCountdown.value <= 0) {
clearInterval(codeCountdownTimer.value)
codeCountdownTimer.value = null
}
}, 1000) }, 1000)
} else { } else {
uni.showToast({ title: res.message || '发送失败', icon: 'none' }) uni.showToast({ title: res.message || '发送失败', icon: 'none' })
@ -507,7 +576,7 @@ const handleSendCode = async () => {
} }
} }
// 2. 关闭弹窗(清空所有) // 2. 关闭弹窗(清空所有,包括倒计时 interval)
const closePasswordModal = () => { const closePasswordModal = () => {
showPasswordModal.value = false showPasswordModal.value = false
oldPassword.value = '' oldPassword.value = ''
@ -518,6 +587,10 @@ const closePasswordModal = () => {
showNewPassword.value = false showNewPassword.value = false
showConfirmPassword.value = false showConfirmPassword.value = false
codeCountdown.value = 0 codeCountdown.value = 0
if (codeCountdownTimer.value) {
clearInterval(codeCountdownTimer.value)
codeCountdownTimer.value = null
}
} }
// 3. 确认改密 // 3. 确认改密
@ -551,11 +624,14 @@ const confirmChangePassword = async () => {
const verifyToken = vRes.data.verify_token const verifyToken = vRes.data.verify_token
// Step 2: update password // Step 2: update password
// 业务码 400(密码错、verify token 错等)会由 api.js 拦截器 reject,
// 跳到 catch 分支;成功(业务码 200)进入此分支
const res = await updatePasswordApi( const res = await updatePasswordApi(
oldPassword.value, newPassword.value, verifyToken oldPassword.value, newPassword.value, verifyToken
) )
uni.hideLoading() uni.hideLoading()
// res 此时已是 res.data(被 request() resolve 过的)
if (res.code === 200) { if (res.code === 200) {
uni.showToast({ title: '修改成功,请重新登录', icon: 'success' }) uni.showToast({ title: '修改成功,请重新登录', icon: 'success' })
setTimeout(() => { setTimeout(() => {
@ -571,12 +647,8 @@ const confirmChangePassword = async () => {
uni.removeStorageSync('temp_register_nickname') uni.removeStorageSync('temp_register_nickname')
uni.reLaunch({ url: '/pages/login/login' }) uni.reLaunch({ url: '/pages/login/login' })
}, 2000) }, 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) { } catch (e) {
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' }) uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' })
@ -887,4 +959,4 @@ if accountStatus.IsFrozen() {
本次修复是**最小变更**,仅让 Login 流程复用现有的 `ErrAccountFrozen` / `ErrAccountBanned` 语义,业务码到 HTTP 码的映射规则不变。 本次修复是**最小变更**,仅让 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` 模式)