diff --git a/docs/superpowers/specs/2026-06-12-status-code-refactor-design.md b/docs/superpowers/specs/2026-06-12-status-code-refactor-design.md new file mode 100644 index 0000000..4610504 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-status-code-refactor-design.md @@ -0,0 +1,621 @@ +# 状态码体系重构设计文档 + +- **创建日期**: 2026-06-12 +- **状态**: 待评审 +- **目标**: 将后端业务状态码从"HTTP 镜像自定义 enum"重构为"google.rpc.Code 标准 gRPC 语义",通过"双协议期"平滑迁移 + +--- + +## 1. 背景与需求 + +### 1.1 现状 + +当前后端 `backend/proto/common.proto:7-16` 定义的 `StatusCode` 是个**镜像 HTTP 状态码的自定义 enum**: + +```protobuf +enum StatusCode { + STATUS_OK = 200; + STATUS_BAD_REQUEST = 400; + STATUS_UNAUTHORIZED = 401; + STATUS_FORBIDDEN = 403; + STATUS_NOT_FOUND = 404; + STATUS_TOO_MANY_REQUESTS = 429; + STATUS_INTERNAL_ERROR = 500; +} +``` + +业务上由 [backend/pkg/errors/errors.go:74-109](backend/pkg/errors/errors.go#L74-L109) 的 `ToStatusCode()` 把 typed error 映射到这个 enum 数字。 + +### 1.2 问题 + +1. **不是真正的 gRPC 语义**:`google.rpc.Code` 才是 gRPC 生态事实标准,本项目用 HTTP 数字是"穿马甲" +2. **与 Dubbo-Triples 内部语义不一致**:Dubbo-Triples 内部走 `google.golang.org/grpc/status`,前端却要把响应里的数字当 HTTP 码理解,语义错位 +3. **混用引发 BUG**:`Login` 流程被冻结/封禁时用 generic `errors.New()`,落到 default 分支返回 500(应为 403);前端拦截器把 400/401/403 一律当 token 过期踢出登录(见 [change-password spec §1.1](../specs/2026-06-12-change-password-design.md)) +4. **跨语言/跨服务不通用**:未来若拆服务给非 Go 客户端,对方看 `STATUS_BAD_REQUEST = 400` 这种命名会觉得奇怪 + +### 1.3 目标 + +- **核心**:业务状态码与 `google.rpc.Code` 数字(0/3/5/7/8/13/16 等)对齐 +- **传输**:HTTP 状态码固定 200(只有 transport 错误才用 4xx/5xx) +- **过渡**:旧 enum(`STATUS_BAD_REQUEST` 等)在过渡期保留,响应体同时返 `code`(新 gRPC 数字)+ `legacy_code`(旧 HTTP 数字) +- **前端**:拦截器只判 `res.data.code`,无视 `res.statusCode` 与 `legacy_code`(过渡期兼容) + +--- + +## 2. 方案选型 + +### 候选方案对比 + +| 方案 | 实施 | 优点 | 缺点 | +|---|---|---|---| +| **A. 维持现状(假 gRPC)** | 保留 HTTP 镜像 enum,只新增 `biz_code` 子码 | 改动最小 | 没解决"穿马甲"和 Dubbo 语义不一致 | +| **B. 真正 gRPC codes + 双协议期(选用 ⭐)** | 改用 `google.rpc.Code`;响应同时返新旧码;Dubbo 透传 grpc status | 语义标准化、跨语言、Dubgo 友好 | 改动面中等 | +| **C. 真 gRPC + grpc-gateway** | 引入 grpc-gateway 替代 Dubbo HTTP 入口 | 工业标准 | 与 Dubbo-Triples 入口冲突,工作量大 | + +**最终方案**: B(真正 gRPC codes + grpc-go status 包 + 双协议期迁移) + +--- + +## 3. 目标架构 + +### 3.1 响应体形态(目标态) + +```json +{ + "code": 7, // google.rpc.Code: 0=OK, 3=INVALID_ARGUMENT, 5=NOT_FOUND, 7=PERMISSION_DENIED, 8=RESOURCE_EXHAUSTED, 13=INTERNAL, 16=UNAUTHENTICATED + "message": "账号已被封禁", + "timestamp": 1718179200000 +} +``` + +- HTTP 状态码: 固定 200(transport 错误除外) +- `code` 字段: google.rpc.Code 数字 +- 业务子码(可选,未来扩展): `biz_code`(在 `code` 之外单独表达业务子分类) + +### 3.2 响应体形态(双协议过渡期) + +```json +{ + "code": 7, // 新: google.rpc.Code 数字(0=OK) + "legacy_code": 403, // 旧: HTTP 镜像码,给未升级的前端 + "message": "账号已被封禁", + "timestamp": 1718179200000 +} +``` + +- 已升级前端:读 `code` 判业务类型 +- 未升级前端:仍可读 `legacy_code` 维持旧逻辑 +- 过渡期结束(预估 2-3 个 sprint):移除 `legacy_code`,更新所有前端 + +### 3.3 google.rpc.Code 映射表(最终态) + +| google.rpc.Code | 名称 | 含义 | 旧映射 | 触发条件举例 | +|---|---|---|---|---| +| 0 | `OK` | 成功 | 200 | — | +| 3 | `INVALID_ARGUMENT` | 客户端参数/数据错 | 400 | 密码太短、手机号格式错、昵称已存在 | +| 5 | `NOT_FOUND` | 资源不存在 | 404 | 用户/明星/订单找不到 | +| 7 | `PERMISSION_DENIED` | 权限不够 | 403 | 账号被封禁、资产无权访问 | +| 8 | `RESOURCE_EXHAUSTED` | 资源耗尽/限流 | 429 | 好友请求冷静期、短信发送超限 | +| 13 | `INTERNAL` | 服务端内部错 | 500 | DB/Redis 故障、未捕获 panic | +| 16 | `UNAUTHENTICATED` | 鉴权失败 | 401 | token 无效/过期/不匹配 | + +注: `FROZEN`(账号被冻结) 归入 `PERMISSION_DENIED`(7),与封禁同一语义等级(都是账号状态问题)。 + +### 3.4 完整响应流 + +``` +[Service Layer] + return status.Error(codes.PermissionDenied, "账号已被封禁") + │ + ▼ +[Provider Layer] + resp := &pb.UpdatePasswordResponse{ + Base: &pbCommon.BaseResponse{ + Code: uint32(codes.PermissionDenied), // = 7 + Message: "账号已被封禁", + Timestamp: time.Now().UnixMilli(), + }, + } + resp, err + │ + ▼ +[Dubbo-Triples 框架] + 将 gRPC status 序列化为 protobuf 响应 + │ + ▼ +[Gin Gateway 拦截器] + 中间件统一改写:无论 gRPC code 是什么,HTTP statusCode 强制 = 200 + c.JSON(200, resData) + │ + ▼ +[uni-app 前端拦截器] + 只判 res.data.code + if (res.data.code === 0) resolve() + else if (res.data.code === 16) logout() + else if (res.data.code === 7) showBanReason() + else reject() +``` + +--- + +## 4. 后端改动 + +### 4.1 proto 调整 + +**文件**:[backend/proto/common.proto](backend/proto/common.proto) + +引入 `google.rpc.Code` 作为新 `code` 字段语义来源。**proto 文件本身不需要枚举定义**(直接用 google 的),仅需 `BaseResponse` 字段保持兼容: + +```protobuf +syntax = "proto3"; +package topfans.common; + +option go_package = "github.com/topfans/backend/pkg/proto/common;common"; + +// 不再自定义 enum,直接用 google.rpc.Code 数字 +// BaseResponse.code 语义改为 google.rpc.Code 数字 +message BaseResponse { + uint32 code = 1; // google.rpc.Code: 0=OK, 3=INVALID_ARGUMENT, 7=PERMISSION_DENIED, 16=UNAUTHENTICATED, ... + string message = 2; + int64 timestamp = 3; + + // 过渡期(可选)保留旧字段,稳定后删除 + uint32 legacy_code = 4; // 旧 HTTP 镜像码 (200/400/401/403/404/429/500),仅过渡期使用 +} +``` + +> proto 中不直接 `import "google/rpc/status.proto"` 是因为我们要的是 code 数字常量(从 `google.golang.org/grpc/codes` 取),不强制通过 proto 类型传递,保持向后兼容。 + +### 4.2 errors.go 重写 + +**文件**:[backend/pkg/errors/errors.go](backend/pkg/errors/errors.go) + +```go +package errors + +import ( + "errors" + "fmt" + + "google.golang.org/grpc/codes" + pb "github.com/topfans/backend/pkg/proto/common" +) + +// 业务错误类型(同前,只改 ToGRPCCode 和 FormatErrorResponse) +var ( + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExists = errors.New("user already exists") + ErrInvalidPassword = errors.New("invalid password") + // ... 其他不变 +) + +// ToGRPCCode: 业务 error -> google.rpc.Code 数字 +// 同时返回旧 HTTP 镜像码,用于过渡期 +func ToGRPCCode(err error) codes.Code { + if err == nil { + return codes.OK + } + switch { + case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrFanProfileNotFound), errors.Is(err, ErrStarNotFound): + return codes.NotFound + case errors.Is(err, ErrUserAlreadyExists), errors.Is(err, ErrFanProfileAlreadyExists), + errors.Is(err, ErrNicknameAlreadyExists), errors.Is(err, ErrInvalidMobile), + errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrInvalidStarID), + errors.Is(err, ErrInvalidUserID), errors.Is(err, ErrMaxIdentitiesReached), + errors.Is(err, ErrInvalidNickname), errors.Is(err, ErrCannotAddSelf), + errors.Is(err, ErrCannotSearchSelf), errors.Is(err, ErrNotFanOfStar), + errors.Is(err, ErrAlreadyFriends), errors.Is(err, ErrRequestAlreadyPending), + errors.Is(err, ErrInvalidFriendUserID), errors.Is(err, ErrCannotProcessOwnRequest), + errors.Is(err, ErrRequestAlreadyProcessed), errors.Is(err, ErrRequestExpired), + errors.Is(err, ErrInvalidAction), errors.Is(err, ErrNotFriends), + errors.Is(err, ErrInsufficientCrystal), errors.Is(err, ErrInsufficientMintTimes), + errors.Is(err, ErrInvalidAssetStatus), errors.Is(err, ErrInvalidMintOrderStatus), + errors.Is(err, ErrInvalidAssetType): + return codes.InvalidArgument + case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken), + errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch): + return codes.Unauthenticated + case errors.Is(err, ErrAccountFrozen), errors.Is(err, ErrAccountBanned), + errors.Is(err, ErrAssetAccessDenied), errors.Is(err, ErrMintOrderAccessDenied), + errors.Is(err, ErrUserInactive): + return codes.PermissionDenied + case errors.Is(err, ErrRequestInCooldown): + return codes.ResourceExhausted + default: + return codes.Internal + } +} + +// ToLegacyCode: 业务 error -> 旧 HTTP 镜像码(过渡期用) +func ToLegacyCode(err error) uint32 { + if err == nil { + return 200 + } + switch ToGRPCCode(err) { + case codes.OK: + return 200 + case codes.InvalidArgument: + return 400 + case codes.Unauthenticated: + return 401 + case codes.PermissionDenied: + return 403 + case codes.NotFound: + return 404 + case codes.ResourceExhausted: + return 429 + default: + return 500 + } +} + +// FormatErrorResponse: 构造 BaseResponse,同时填 code(新) 和 legacy_code(旧) +func FormatErrorResponse(err error) *pb.BaseResponse { + return &pb.BaseResponse{ + Code: uint32(ToGRPCCode(err)), // 新 gRPC code + LegacyCode: ToLegacyCode(err), // 旧 HTTP 镜像码(过渡期) + Message: err.Error(), + Timestamp: getCurrentTimestamp(), + } +} + +// FormatSuccessResponse: 成功响应 +func FormatSuccessResponse() *pb.BaseResponse { + return &pb.BaseResponse{ + Code: uint32(codes.OK), + LegacyCode: 200, + Message: "", + Timestamp: getCurrentTimestamp(), + } +} + +// 保留 BuildBaseResponse 兼容 +func BuildBaseResponse(err error) *pb.BaseResponse { + return FormatErrorResponse(err) +} + +// NewError: 用 google.rpc.Code 构造业务错误 +// 推荐 service 层用 status.Error(codes.X, msg),但保留这个 helper 兼容 +func NewError(code codes.Code, message string) error { + return status.Error(code, message) +} +``` + +**关键点**: +- 删掉旧的 `ToStatusCode` 函数 +- 新增 `ToGRPCCode`(业务 → gRPC code) + `ToLegacyCode`(业务 → 旧 HTTP 码) +- `BaseResponse` 同时返两个码,过渡期两套前端都能用 +- 保留 `BuildBaseResponse` 别名(防外部包未及时更新) + +### 4.3 service 层调用点改造 + +**所有 service 文件的 `appErrors.ErrXxx` 调用保持不变**,但需要在 provider 层确保 `err` 信息正确传递。 + +**重点改造**:provider 层在 catch 错误时,不再手写 `STATUS_UNAUTHORIZED` 等硬编码,而是调 `appErrors.FormatErrorResponse(err)` 统一处理。 + +**示例**:[backend/services/userService/provider/user_provider.go](backend/services/userService/provider/user_provider.go) 中 30+ 处硬编码 status code 的地方: + +```go +// 改前 +return &pb.UpdatePasswordResponse{ + Base: &pbCommon.BaseResponse{ + Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, + Message: "user authentication required", + Timestamp: 0, + }, +}, err + +// 改后 +return &pb.UpdatePasswordResponse{ + Base: appErrors.FormatErrorResponse( + status.Error(codes.Unauthenticated, "user authentication required"), + ), +}, err +``` + +**Call-site 审计**(实施前必跑): + +```bash +grep -rn "StatusCode_STATUS_" --include="*.go" backend/services/ +grep -rn "StatusCode_STATUS_" --include="*.go" backend/gateway/ +``` + +涉及文件(初估):30+ 个 provider/service 文件。所有手写 `pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_X}` 的地方都要改造。 + +### 4.4 Dubbo 适配 + +**文件**:各 provider `*_provider.go` + +**重要事实**:Dubbo-Triples 内部已经走 `google.golang.org/grpc/status`,只要我们用 `status.Error(codes.X, msg)` 或 `FormatErrorResponse(err)` 构造响应,Dubbo 框架就会把 gRPC code 透传过去,**无需改 Dubbo 框架本身**。 + +但需要确保: +- Provider 函数返回 `nil, err` 时,Dubbo 会自动把 gRPC status 转 HTTP 响应 +- Provider 函数返回 `resp, nil`(显式响应)时,我们已经塞好 `BaseResponse.code = uint32(grpc code)` +- Gateway 不应该自己 wrap 一层 HTTP 状态码,直接透传 `resData` + HTTP 200 + +### 4.5 Gateway 拦截器:统一强制 HTTP 200 + +**新增文件**:`backend/gateway/middleware/grpc_status_interceptor.go` + +```go +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GRPCStatusInterceptor: 拦截所有 Dubbo 透传过来的响应,统一改写 HTTP 状态码为 200 +// 业务码在响应体 code 字段中 +// 仅 transport 错误(404 路由不存在、405 方法不允许、5xx 网关内部错)保留 HTTP 状态码 +func GRPCStatusInterceptor() gin.HandlerFunc { + return func(c *gin.Context) { + // 跳过非 Dubbo 透传的请求(直接由 Gin 处理的,如 /health、/swagger) + if !isDubboPath(c.Request.URL.Path) { + c.Next() + return + } + + // 拦截响应体 + writer := &responseRecorder{ResponseWriter: c.Writer, body: &bytes.Buffer{}} + c.Writer = writer + c.Next() + + // 强制改写 HTTP 状态码为 200 + writer.ResponseWriter.WriteHeader(http.StatusOK) + } +} + +func isDubboPath(path string) bool { + return strings.HasPrefix(path, "/api/") +} +``` + +实际上 Dubbo 框架**已经会自动把 gRPC status 转成 HTTP 状态码**(例如 `codes.Unauthenticated` → HTTP 401),这是**问题根源**——它把业务码泄漏到传输层。 + +需要在 gateway 这一层**剥掉 Dubbo 的自动转换**,强制所有 `/api/*` 走 HTTP 200。 + +### 4.6 新增 gRPC 子包依赖 + +**go.mod** 增加: +``` +google.golang.org/grpc v1.x.x +google.golang.org/genproto/googleapis/rpc v1.x.x +``` + +(项目应已通过 dubbo-go 间接依赖,但需要显式 import) + +--- + +## 5. 前端改动 + +### 5.1 拦截器按 gRPC code 重写 + +**修改** [frontend/utils/api.js](frontend/utils/api.js#L73-L133) + +```js +// 过渡期:双协议兼容 +// 优先读新 gRPC code (res.data.code),fallback 到旧 HTTP 业务码 (res.data.legacy_code) +const getBizCode = (res) => { + if (res.data && res.data.code !== undefined) { + return res.data.code // 新 gRPC code (uint32 数字) + } + return res.data?.legacy_code ?? res.statusCode // 兜底 +} + +if (res.statusCode === 200 || res.statusCode === 202) { + if (res.data && getBizCode(res) !== undefined) { + const bizCode = getBizCode(res) + if (bizCode === 0 || bizCode === 200) { // 0 = google.rpc.Code.OK; 200 = 旧 SUCCESS + resolve(res.data) + return + } + // 16 = Unauthenticated,401 = 旧 UNAUTHORIZED → 清 token + 跳登录 + if (bizCode === 16 || bizCode === 401) { + uni.removeStorageSync('access_token') + uni.removeStorageSync('user') + uni.reLaunch({ url: '/pages/login/login?error=' + encodeURIComponent(res.data.message || '登录已过期') }) + reject(new Error(res.data.message || '登录已过期')) + return + } + // 7 = PermissionDenied,403 = 旧 FORBIDDEN → 弹封禁原因 + 跳登录 + if (bizCode === 7 || bizCode === 403) { + uni.removeStorageSync('access_token') + uni.removeStorageSync('user') + uni.showToast({ title: res.data.message || '账号已被禁用', icon: 'none', duration: 3000 }) + setTimeout(() => uni.reLaunch({ url: '/pages/login/login' }), 1500) + reject(new Error(res.data.message || '账号已被禁用')) + return + } + // 13 = Internal, 500 → toast + // 3 = InvalidArgument, 400 → toast + // 5 = NotFound, 404 → toast + // 8 = ResourceExhausted, 429 → toast + reject(new Error(res.data.message || '请求失败')) + return + } + resolve(res.data) +} +``` + +### 5.2 BUG 修复自然吸收 + +§1.3 中提到的 BUG 在新架构下自动消失: +- 前端不再判 HTTP 400 → 不会误踢 +- 前端判 gRPC code 7/16 → 精准区分"封号"和"密码错" +- 后端 Login 流程用 `codes.PermissionDenied` 替代 `errors.New` → 403 不再是 500 + +### 5.3 过渡期兼容矩阵 + +| 旧前端(读 legacy_code) | 新前端(读 code) | 后端响应包含 | 行为 | +|---|---|---|---| +| 旧 | 旧 | `{code: 7, legacy_code: 403}` | 旧前端按 403 处理 ✅ | +| 旧 | 新 | `{code: 7, legacy_code: 403}` | 都正确 | +| 新 | 旧 | `{code: 7, legacy_code: 403}` | 都正确 | +| 新 | 新 | `{code: 7, legacy_code: 403}` | 都正确 | + +过渡期只要后端同时返两个字段,任何前端都能跑。 + +### 5.4 Call-site 审计 + +```bash +grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --include="*.js" --include="*.vue" frontend/ +``` + +涉及文件(初估):10+ 个 vue/js 文件中的业务判断。需要逐个迁移到 gRPC code 判断,或保留对 `legacy_code` 的兼容(过渡期内可接受)。 + +--- + +## 6. 双协议期迁移计划 + +### 6.1 阶段划分 + +| 阶段 | 后端 | 前端 | 时长 | +|---|---|---|---| +| **Phase 0: 准备** | 写 `ToGRPCCode` / `ToLegacyCode`,保留 `ToStatusCode` 别名 | 无变化 | 0.5 sprint | +| **Phase 1: 双协议期** | 所有响应同时返 `code`(gRPC) 和 `legacy_code`(HTTP) | 旧前端继续读 `legacy_code`;新前端读 `code`;拦截器支持两者 | 2-3 sprint | +| **Phase 2: 全量升级前端** | 仍双协议 | 全部前端改为读 `code`;移除 `legacy_code` 兜底 | 1 sprint | +| **Phase 3: 清理** | 移除 `legacy_code` 字段,proto 改回单字段 | 移除 `getBizCode` 兼容逻辑 | 0.5 sprint | + +### 6.2 风险控制 + +| 风险 | 缓解 | +|---|---| +| 过渡期前端误判(新旧字段混读) | `getBizCode` helper 统一处理,代码评审重点 | +| Dubbo 框架自动转换 HTTP 状态码未拦截 | gateway 强制 HTTP 200 拦截器必上,加 e2e 测试 | +| `google.rpc.Code` 数字与业务 error 多对多关系不清 | `ToGRPCCode` switch 集中映射,单测覆盖每个 case | +| 部分老业务调用方未升级 | 编译时 `BuildBaseResponse` 仍可用,只是新代码统一走 `FormatErrorResponse` | + +### 6.3 灰度发布 + +- **Day 1**: 后端全量上双协议 + gateway 拦截器;前端保持旧版不动 +- **Day 2-3**: 观察监控,确认没有 4xx/5xx 异常 +- **Day 4**: 前端拦截器升级到新逻辑(读 `code` 优先) +- **Day 5+**: 全量验证 1 周后,移除 `legacy_code` 字段 + +--- + +## 7. 测试策略 + +### 7.1 后端单测 + +**新增** `backend/pkg/errors/errors_test.go` + +| # | 测试用例 | 期望 | +|---|---|---| +| 1 | `nil` error | `ToGRPCCode = OK (0)`, `ToLegacyCode = 200` | +| 2 | `ErrUserNotFound` | `NotFound (5)`, legacy 404 | +| 3 | `ErrInvalidPassword` | `Unauthenticated (16)`, legacy 401 | +| 4 | `ErrAccountFrozen` (wrapped) | `PermissionDenied (7)`, legacy 403 | +| 5 | `ErrAccountBanned` (wrapped) | `PermissionDenied (7)`, legacy 403 | +| 6 | `ErrPasswordTooShort` | `InvalidArgument (3)`, legacy 400 | +| 7 | `ErrRequestInCooldown` | `ResourceExhausted (8)`, legacy 429 | +| 8 | `errors.New("unknown")` | `Internal (13)`, legacy 500 | +| 9 | wrapped error `fmt.Errorf("%w: extra", ErrXxx)` | `errors.Is` 仍能识别,code 正确 | +| 10 | `FormatErrorResponse(ErrXxx)` | BaseResponse.Code = gRPC 数字, LegacyCode = HTTP 数字 | + +**新增** gateway 拦截器单测 `backend/gateway/middleware/grpc_status_interceptor_test.go` + +| # | 测试用例 | 期望 | +|---|---|---| +| 1 | Dubbo 透传响应(gRPC code=7) | HTTP 200 + 业务体正确 | +| 2 | Dubbo 透传响应(gRPC code=16) | HTTP 200 + 业务体正确 | +| 3 | 直接 Gin 处理(非 /api/*) | 跳过拦截,行为不变 | + +### 7.2 端到端测试 + +**新增** `backend/gateway/e2e/status_code_test.go` + +模拟客户端发起请求,验证: +- 各种业务错误返回 HTTP 200 + 正确 gRPC code +- AuthMiddleware 仍正确拦截真鉴权失败(返回 HTTP 401) +- 路由不存在仍返回 HTTP 404(transport 错误) + +### 7.3 前端单测 / 集成测试 + +(项目无前端单测框架,采用手动 checklist) + +| # | 场景 | 预期 | +|---|---|---| +| 1 | 旧前端(读 legacy_code) | 行为不变 | +| 2 | 新前端(读 code) | 弹窗/toast/登出均正确 | +| 3 | 输错旧密码 | 不登出,toast "旧密码错误" | +| 4 | 账号被冻结 | 弹封禁原因,2s 后跳登录 | +| 5 | token 过期 | 立即跳登录 | +| 6 | 网络 500 | toast "网络异常",不登出 | + +--- + +## 8. 文件变更总览 + +### 后端 + +| 变更 | 文件 | +|---|---| +| 修改 | `backend/proto/common.proto`(BaseResponse 加 `legacy_code` 字段) | +| regen | `backend/pkg/proto/common/common.pb.go` | +| regen | `backend/pkg/proto/common/common.triple.go` | +| 重写 | `backend/pkg/errors/errors.go`(删 `ToStatusCode`,新增 `ToGRPCCode`/`ToLegacyCode`/`FormatErrorResponse`) | +| 新增 | `backend/pkg/errors/errors_test.go` | +| 修改 | `backend/services/*/provider/*.go`(30+ 文件,所有硬编码 `StatusCode_STATUS_X` 改为 `appErrors.FormatErrorResponse(status.Error(codes.X, msg))`) | +| 修改 | `backend/services/*/service/*.go`(Login 流程等改为 `status.Error(codes.PermissionDenied, ...)`) | +| 新增 | `backend/gateway/middleware/grpc_status_interceptor.go` | +| 新增 | `backend/gateway/middleware/grpc_status_interceptor_test.go` | +| 新增 | `backend/gateway/e2e/status_code_test.go` | +| 修改 | `backend/go.mod`(显式依赖 `google.golang.org/grpc`) | + +### 前端 + +| 变更 | 文件 | +|---|---| +| 修改 | `frontend/utils/api.js`(拦截器重写,支持 gRPC code + 兼容 legacy_code) | +| 修改 | `frontend/pages/**/*.vue`(10+ 文件,业务判断从 `res.data.code === 401/403` 改为 `=== 16/7` 或读 `getBizCode` helper) | + +### 总计 + +- 后端: 35+ 文件改动,3 个新文件 +- 前端: 12+ 文件改动 + +--- + +## 9. 与现有"修改密码"spec 的协同 + +[change-password spec](../specs/2026-06-12-change-password-design.md) 中的: + +| 项 | 关系 | +|---|---| +| §4.5 错误码映射(`ErrInvalidOldPassword` → 400) | 本次重构后,自动转为 `codes.InvalidArgument (3)`;`legacy_code=400` 保留给旧前端 | +| §5.1 拦截器修复 | 本次重构后,前端拦截器直接判 gRPC code,无需"按 401/403 自动登出"的特殊判断 | +| §12 Login BUG 修复(`errors.Is` 改造) | **本次重构的前置依赖**;`ToGRPCCode` 内部依赖 `errors.Is` | +| §11 部署清单 | 加上"双协议期开关" | + +执行顺序:**先实现 change-password spec(含 §12 Login BUG 修复)**,**再启动本次状态码重构**。 + +--- + +## 10. 部署/上线检查清单 + +- [ ] proto regen 后所有 service 编译通过 +- [ ] gateway 拦截器单元测试通过 +- [ ] E2E 测试覆盖所有 gRPC code 路径 +- [ ] 前端拦截器向后兼容测试(旧前端读 legacy_code 仍正常) +- [ ] Dubbo 框架未自动把 gRPC code 转 HTTP 状态码(已被 gateway 拦截器剥除) +- [ ] 监控埋点:HTTP 4xx/5xx 比例应降至接近 0(transport 错误除外) +- [ ] 灰度 10% → 50% → 100%,每阶段观察 24h +- [ ] 1-2 sprint 稳定后,移除 `legacy_code` 字段 +- [ ] 旧前端缓存清理(用户升级到新前端) + +--- + +## 11. 不在本次范围内 + +| 项 | 处理 | +|---|---| +| HTTP 状态码完全废弃(连 401/404 都用 200) | 暂不动,transport 错误仍用 HTTP 状态码 | +| 业务子码 `biz_code`(在 google.rpc.Code 之外再做细粒度区分) | 留待未来扩展,本次只做 gRPC code 标准化 | +| 老旧前端硬编码 `=== 401/403/...` 的判断全部迁移 | 跟随双协议期渐进迁移,不在本次单点爆破 | +| dubbo-go 内部错误机制调整 | 维持原状,只在外层包装 |