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 e3a2287..49ce89e 100644 --- a/docs/superpowers/specs/2026-06-12-change-password-design.md +++ b/docs/superpowers/specs/2026-06-12-change-password-design.md @@ -744,7 +744,7 @@ 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`(新增 3 个错误码 `ErrInvalidVerifyToken`/`ErrSameAsOldPassword`/`ErrInvalidOldPassword`;`ToStatusCode` 中 `ErrInvalidVerifyToken` 改映射 400) | +| Errors | 修改 | `backend/pkg/errors/errors.go`(新增 3 个错误码 `ErrInvalidVerifyToken`/`ErrSameAsOldPassword`/`ErrInvalidOldPassword`;`ToStatusCode` 中 `ErrInvalidVerifyToken` 改映射 400;`ToStatusCode` 改用 `errors.Is` 全面识别 wrapped error;新增 `NewAccountBannedError` / `NewAccountFrozenError` 2 个 helper) | | Frontend utils | 修改 | `frontend/utils/api.js`(拦截器去掉 400 自动登出,保留 401/403 + `updatePasswordApi` 增 `verify_token` 字段) | | Frontend pages | 修改 | `frontend/pages/profile/profile.vue`(弹窗 + 状态 + handler + CSS) | @@ -758,3 +758,133 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) { - [ ] 前端手动验证 10 条 checklist 通过 - [ ] Swagger 文档重新生成 - [ ] 灰度发布,先开 10% 流量观察错误率 + +--- + +## 12. 关联修复:Login 流程账号状态码 BUG(顺带修) + +### 12.1 问题 + +§7 设计的统一规则是"`ErrAccountFrozen` / `ErrAccountBanned` → 403 → 自动登出"。但实际 Login 流程并未使用这两个 typed error,导致: + +[auth_service.go:327-377](backend/services/userService/service/auth_service.go#L327-L377) 在 banned / frozen 情况下: + +```go +if accountStatus.IsBanned() { + return nil, fmt.Errorf("账号已被封禁%s%s", ...) // ← 不是 ErrAccountBanned +} +if accountStatus.IsFrozen() { + return nil, errors.New(errMsg) // ← 不是 ErrAccountFrozen +} +``` + +这两个 error 都是 generic 的,**不匹配 `ToStatusCode()` 中 `case ErrAccountFrozen, ErrAccountBanned:` 分支**,落到 `default` → `STATUS_INTERNAL_ERROR` (500)。 + +**实际行为**:用户账号被冻结/封禁后尝试登录,后端返回 500(而非 403),前端按 §5.1 规则不会自动登出,只 toast 一个"修改失败"类的提示,体验不一致。 + +### 12.2 修复 + +**修改** [backend/services/userService/service/auth_service.go](backend/services/userService/service/auth_service.go#L327-L377) + +引入两个新的辅助函数,把 reason / frozenUntil 信息塞进 error message 但保留 typed error 身份: + +```go +// 新增到 errors.go +// NewAccountBannedError returns ErrAccountBanned with reason wrapped into message +func NewAccountBannedError(reason string) error { + if reason != "" { + return fmt.Errorf("%w: %s", ErrAccountBanned, reason) + } + return ErrAccountBanned +} + +// NewAccountFrozenError returns ErrAccountFrozen with reason + frozenUntil wrapped into message +func NewAccountFrozenError(reason string, frozenUntil *int64) error { + parts := []string{} + if reason != "" { + parts = append(parts, "原因:"+reason) + } + if frozenUntil != nil { + parts = append(parts, "解封时间:"+time.UnixMilli(*frozenUntil).Format("2006-01-02 15:04:05")) + } + if len(parts) > 0 { + return fmt.Errorf("%w: %s", ErrAccountFrozen, strings.Join(parts, ", ")) + } + return ErrAccountFrozen +} +``` + +**关键技巧**:`fmt.Errorf("%w: ...", ErrAccountBanned, ...)` 用 `%w` 保留 wrap 关系,使 `errors.Is(err, ErrAccountBanned)` 仍能识别。同时 `ToStatusCode()` 的 `switch err` 不识别 wrapped error,所以需要扩展 `ToStatusCode()` 用 `errors.Is`: + +```go +// errors.go +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): + 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): + return pb.StatusCode_STATUS_FORBIDDEN + // ... 后续同样改用 errors.Is + default: + return pb.StatusCode_STATUS_INTERNAL_ERROR + } +} +``` + +**auth_service.go Login 流程**改造: + +```go +if accountStatus.IsBanned() { + reason := "" + if accountStatus.Reason != nil { + reason = *accountStatus.Reason + } + logger.Logger.Warn("User account is banned", ...) + return nil, appErrors.NewAccountBannedError(reason) // ← 改用 typed error +} + +if accountStatus.IsFrozen() { + if accountStatus.FrozenUntil != nil && time.Now().UnixMilli() > *accountStatus.FrozenUntil { + // 冻结已过期,放行 + logger.Logger.Info("User account frozen but expired", ...) + } else { + reason := "" + if accountStatus.Reason != nil { + reason = *accountStatus.Reason + } + logger.Logger.Warn("User account is frozen", ...) + return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil) // ← 改用 typed error + } +} +``` + +### 12.3 影响面 + +| 项 | 变化 | +|---|---| +| `errors.go` | 新增 `NewAccountBannedError` / `NewAccountFrozenError` 2 个 helper;`ToStatusCode` 改用 `errors.Is` 全面识别 wrapped error(1 处 switch 大改造) | +| `auth_service.go` | Login 流程的 2 处 `return` 改用 helper | +| 其他服务 | `ToStatusCode` 改用 `errors.Is` 后,其他 service 中已有的 `fmt.Errorf("%w", ErrXxx)` 自动受益,**无需逐个改动** | +| 前端 | 无需改动:403 业务码已在 §5.1 拦截器白名单中被正确处理(自动登出 + 跳登录页) | + +### 12.4 单测追加 + +在 `auth_service_login_test.go`(新建,或追加到现有 test)加 2 个用例: + +| # | 场景 | 期望 | +|---|---|---| +| 1 | 账号被封禁 | 业务码 403(不再是 500),`errors.Is(err, ErrAccountBanned)` 为 true | +| 2 | 账号被冻结 | 业务码 403(不再是 500),`errors.Is(err, ErrAccountFrozen)` 为 true | + +### 12.5 与"状态码重构"的关系 + +本次修复是**最小变更**,仅让 Login 流程复用现有的 `ErrAccountFrozen` / `ErrAccountBanned` 语义,业务码到 HTTP 码的映射规则不变。 + +如果你后续要做"状态码体系重构"(见 `docs/superpowers/specs/2026-06-12-status-code-refactor-design.md` 草稿),本次修复的 `errors.Is` 改造会作为重构的前置工作存在,可以平滑迁移。