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:
parent
e52f46e50f
commit
397ebed805
@ -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` 模式)。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user