# 状态码体系重构设计文档 - **创建日期**: 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) - **迁移策略**:**一次性全量更新**,无平滑过渡期。后端 `BaseResponse.code` 字段类型从 `StatusCode` enum 改为 `uint32`(gRPC code 数字),前端拦截器和业务代码同步升级,统一读 gRPC code 语义 - **影响面**:`=== 401` 之类的判断改为 `=== 16`(`Unauthenticated`),`=== 403` 改为 `=== 7`(`PermissionDenied`),以此类推 --- ## 2. 方案选型 ### 候选方案对比 | 方案 | 实施 | 优点 | 缺点 | |---|---|---|---| | **A. 维持现状(假 gRPC)** | 保留 HTTP 镜像 enum,只新增 `biz_code` 子码 | 改动最小 | 没解决"穿马甲"和 Dubbo 语义不一致 | | **B. 双协议期平滑迁移** | 改用 `google.rpc.Code`;响应同时返新旧码;Dubbo 透传 grpc status | 老前端零改动 | 字段冗余、过渡期管理复杂、需要 2-3 sprint 收尾 | | **C. 真 gRPC + grpc-gateway** | 引入 grpc-gateway 替代 Dubbo HTTP 入口 | 工业标准 | 与 Dubbo-Triples 入口冲突,工作量大 | | **D. 真正 gRPC codes + 一次性全量更新(选用 ⭐)** | 改用 `google.rpc.Code`;`code` 字段直接改为 gRPC 数字;Dubbo 透传 grpc status;前后端同步升级 | 语义标准化、跨语言、Dubbo 友好、无过渡期脏数据 | 前后端需要同步发版,前端老逻辑需逐处迁移 | **最终方案**: D(真正 gRPC codes + grpc-go status 包 + 一次性全量更新) **决策依据**: 项目当前正处于业务快速迭代期,客户端发版频率与后端同步,过渡期方案(B)反而引入"双字段并存"的长期脏数据。前端硬编码 `=== 401/403/...` 的位置数量可控(初估 30-50 处,见 §5.4),一次性改干净反而比维护过渡期更省事。**今天发版,明天所有客户端都是新代码,无兼容包袱**。 --- ## 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 数字(0/3/5/7/8/13/16 等) - **唯一字段,无 `grpc_code` 平行字段** ### 3.2 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.3 完整响应流 ``` [Service Layer] return status.Error(codes.PermissionDenied, "账号已被封禁") │ ▼ [Provider Layer] resp := &pb.UpdatePasswordResponse{ Base: appErrors.FormatErrorResponse( status.Error(codes.PermissionDenied, "账号已被封禁"), ), } // BaseResponse.Code = 7 (codes.PermissionDenied) ← gRPC 数字 │ ▼ [Dubbo-Triples 框架] 将 gRPC status 序列化为 protobuf 响应 │ ▼ [Gin Gateway GRPCStatusInterceptor] 用 responseRecorder 捕获 body,强制 HTTP 200,body 原样写回 body = {code: 7, message: "...", timestamp: ...} │ ▼ [uni-app 前端拦截器] 读 res.data.code (= 7),按 gRPC 语义处理 if (res.data.code === 16) logout() else if (res.data.code === 7) showBanReason() else toast ``` ### 3.4 前后端数字映射约定 | 旧 HTTP 数字 | 新 gRPC 数字 | 名称 | 含义 | |---|---|---|---| | 200 | 0 | `OK` | 成功 | | 400 | 3 | `INVALID_ARGUMENT` | 参数/数据错 | | 401 | 16 | `UNAUTHENTICATED` | 鉴权失败 | | 403 | 7 | `PERMISSION_DENIED` | 权限不够 | | 404 | 5 | `NOT_FOUND` | 资源不存在 | | 429 | 8 | `RESOURCE_EXHAUSTED` | 限流 | | 500 | 13 | `INTERNAL` | 内部错 | **注意 401→16 / 403→7 是非常规映射**(gRPC 习惯里鉴权失败排在权限不够之后)。前端迁移时必须按新数字判断,不能写 `=== 401`。 --- ## 4. 后端改动 ### 4.1 proto 调整 **文件**:[backend/proto/common.proto](backend/proto/common.proto) **关键变更**:`BaseResponse.code` 字段类型从 `StatusCode` enum 改为 `uint32`,`StatusCode` enum 整个删除(不再需要 HTTP 镜像命名): ```protobuf syntax = "proto3"; package topfans.common; option go_package = "github.com/topfans/backend/pkg/proto/common;common"; message BaseResponse { uint32 code = 1; // google.rpc.Code 数字: 0=OK, 3=INVALID_ARGUMENT, 5=NOT_FOUND, 7=PERMISSION_DENIED, 8=RESOURCE_EXHAUSTED, 13=INTERNAL, 16=UNAUTHENTICATED string message = 2; int64 timestamp = 3; } ``` **字段含义**: - `code`: gRPC code 数字常量(从 `google.golang.org/grpc/codes` 取),直接对应 `google.rpc.Code` 标准 > proto 中不直接 `import "google/rpc/status.proto"` 是因为我们要的是 code 数字常量(从 `google.golang.org/grpc/codes` 取),不强制通过 proto 类型传递,保持向后兼容。 **破坏性变更**:`StatusCode` enum 整个删除,所有 `BaseResponse.Code: pb.StatusCode_STATUS_XXX` 引用全部失效。需在 PR 中配套修改所有调用方(见 §4.3)。 ### 4.2 errors.go 重写 **文件**:[backend/pkg/errors/errors.go](backend/pkg/errors/errors.go) ```go package errors import ( "errors" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // 业务错误类型(同前;新增 ErrInvalidOldPassword 由 change-password spec 引入) var ( ErrUserNotFound = errors.New("user not found") ErrUserAlreadyExists = errors.New("user already exists") ErrInvalidPassword = errors.New("invalid password") ErrInvalidOldPassword = errors.New("old password is incorrect") // 新增,change-password spec // ... 其他不变 ) // ToGRPCCode: 业务 error -> google.rpc.Code 数字 // 替代原 ToStatusCode(已删除),直接返 gRPC code 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, ErrInvalidOldPassword), // ← 新增 errors.Is(err, ErrInvalidStarID), errors.Is(err, ErrInvalidUserID), errors.Is(err, ErrMaxIdentitiesReached), errors.Is(err, ErrInvalidNickname), // ... 其他业务错误同前 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, ErrUserInactive), // ← 此前 fall into default 500,现修复为 PermissionDenied errors.Is(err, ErrAssetAccessDenied), errors.Is(err, ErrMintOrderAccessDenied): return codes.PermissionDenied case errors.Is(err, ErrRequestInCooldown): return codes.ResourceExhausted default: return codes.Internal } } // FormatErrorResponse: 构造 BaseResponse,code 字段填 gRPC 数字 func FormatErrorResponse(err error) *pb.BaseResponse { return &pb.BaseResponse{ Code: uint32(ToGRPCCode(err)), Message: err.Error(), Timestamp: getCurrentTimestamp(), } } // FormatSuccessResponse: 成功响应 func FormatSuccessResponse() *pb.BaseResponse { return &pb.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: getCurrentTimestamp(), } } // 保留 BuildBaseResponse 别名(防外部包未及时更新) func BuildBaseResponse(err error) *pb.BaseResponse { return FormatErrorResponse(err) } // NewError: 业务 error -> google.rpc.status.Error // 推荐 service 层用 status.Error(codes.X, msg);保留 helper 兼容 func NewError(code codes.Code, message string) error { return status.Error(code, message) } ``` **关键点**: - `ToStatusCode` 函数**删除**(不再需要 HTTP 镜像命名) - 唯一映射函数 `ToGRPCCode`,所有业务 error 直接映射到 gRPC code - `BaseResponse.Code` 填 `uint32(codes.X)`,类型从 `StatusCode` 改为 `uint32` - 旧 `BaseResponse{Code: ToStatusCode(err)}` 调用方需改为 `appErrors.FormatErrorResponse(err)` 或 `BaseResponse{Code: uint32(appErrors.ToGRPCCode(err))}` ### 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 # 1. 找硬编码 StatusCode enum 引用(应全部清零,0 命中) grep -rn "StatusCode_STATUS_" --include="*.go" backend/services/ backend/gateway/ # 2. 找 ToStatusCode 调用点(已删除,应全部清零,0 命中) grep -rn "ToStatusCode" --include="*.go" backend/ # 3. 找 raw 数字赋值的 Code: 字段(可能漏掉,需逐一确认数字是否对应 §3.4 gRPC code) grep -rn "Code:\s*uint32(\?\(0\|3\|5\|7\|8\|13\|16\)" --include="*.go" backend/services/ backend/gateway/ # 4. 找残留 HTTP 数字字面量(可能误用,需全部改为 gRPC 数字) grep -rn "Code:\s*\(200\|400\|401\|403\|404\|429\|500\)" --include="*.go" backend/ ``` 涉及文件(初估):30+ 个 provider/service 文件。所有手写 `pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_X}` 的地方都要改用 `appErrors.FormatErrorResponse(err)`。 ### 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" "net/http" "strings" "github.com/gin-gonic/gin" ) // responseRecorder: 拦截下游 Writer,捕获 body 用于重写 type responseRecorder struct { gin.ResponseWriter body *bytes.Buffer } func (r *responseRecorder) Write(b []byte) (int, error) { return r.body.Write(b) } func (r *responseRecorder) WriteString(s string) (int, error) { return r.body.WriteString(s) } // 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 } // 拦截响应体到 recorder recorder := &responseRecorder{ResponseWriter: c.Writer, body: &bytes.Buffer{}} c.Writer = recorder c.Next() // 把捕获的 body 原样写回,但强制 HTTP 200 c.Writer.WriteHeader(http.StatusOK) _, _ = recorder.body.WriteTo(c.Writer) } } func isDubboPath(path string) bool { return strings.HasPrefix(path, "/api/") } ``` **关键说明**:Dubbo 框架**会自动把 gRPC status 转成 HTTP 状态码**(例如 `codes.Unauthenticated` → HTTP 401),这是**问题根源**——它把业务码泄漏到传输层。 需要在 gateway 这一层**剥掉 Dubbo 的自动转换**,强制所有 `/api/*` 走 HTTP 200。body 本身不需要修改——它已经包含 `code`(gRPC 数字)字段。 **与 AuthMiddleware 顺序**:AuthMiddleware 是路由级中间件(`/api/v1/account/*` 等挂在 AuthGroup 上),在 GRPCStatusInterceptor 之后执行;若 AuthMiddleware 返回 401,响应已经设置 HTTP 401,在 GRPCStatusInterceptor 捕获时仍为 401(因为 recorder 看到的是下游已写的 body,但此时 header 还没写)。需注意:AuthMiddleware 的 401 响应也要经本拦截器改 200,可在 AuthMiddleware 内显式设置响应后再走,或者 AuthMiddleware 不挂在 GRPCStatusInterceptor 之后(挂在它**之前**,这样 AuthMiddleware 直接返回 401 时不会经过 GRPCStatusInterceptor)。**实施时确认中间件链顺序**。 ### 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) **改造策略**:拦截器内部所有 `=== 401/403/...` 数字判断改为 gRPC code 数字(7/16/...)。**一次性全量更新,无兼容期**: ```js // 改前(基于 HTTP 数字) if (res.data.code === 401) { // token 过期/鉴权失败 -> 跳登录 logout() } else if (res.data.code === 403) { // 权限不够/账号封禁 -> 弹封禁原因 showBanReason() } // 改后(基于 gRPC code 数字) if (res.data.code === 16) { // UNAUTHENTICATED: token 无效/过期 -> 跳登录 logout() } else if (res.data.code === 7) { // PERMISSION_DENIED: 账号封禁/权限不够 -> 弹封禁原因 showBanReason() } ``` **完整 gRPC code 数字映射**(参考 §3.4): - `0` `OK` 成功 - `3` `INVALID_ARGUMENT` 参数/数据错 → toast "请检查输入" - `5` `NOT_FOUND` 资源不存在 → toast "资源不存在" - `7` `PERMISSION_DENIED` 权限不够/封禁 → 弹封禁原因或 toast - `8` `RESOURCE_EXHAUSTED` 限流 → toast "操作太频繁,请稍后再试" - `13` `INTERNAL` 内部错 → toast "服务异常,请稍后再试" - `16` `UNAUTHENTICATED` 鉴权失败 → 跳登录 **新业务代码**:用 `res.data.code === 16` 判断"鉴权失败"、`res.data.code === 7` 判断"封禁"。不再保留 HTTP 数字 401/403/... 的判断。 ### 5.2 业务代码同步迁移 所有 `res.data.code === 401/403/400/404/429/500` 的位置全部改为 gRPC code 数字(16/7/3/5/8/13)。 **特别注意**: - `=== 401` → `=== 16`(`Unauthenticated`) - `=== 403` → `=== 7`(`PermissionDenied`) - 顺序不要写反:401 是老 HTTP 数字,**不是** gRPC code;16 才是 gRPC 鉴权失败 **新增 helper 集中管理**(可选,推荐): ```js // frontend/utils/grpc_code.js export const GrpcCode = { OK: 0, INVALID_ARGUMENT: 3, NOT_FOUND: 5, PERMISSION_DENIED: 7, RESOURCE_EXHAUSTED: 8, INTERNAL: 13, UNAUTHENTICATED: 16, } // 业务代码里: // import { GrpcCode } from '@/utils/grpc_code' // if (res.data.code === GrpcCode.UNAUTHENTICATED) { ... } ``` 业务代码统一引 `GrpcCode.XXX` 数字,避免散落的裸数字。 ### 5.3 BUG 修复自然吸收 §1.3 中提到的 BUG 在新架构下**依赖 change-password spec 的 §12 Login BUG 修复**(errors.Is 改造 + ErrInvalidOldPassword)才能完全消失: - change-password spec §5.1 修复后,前端拦截器已不再判 HTTP 400 自动登出(改为判 gRPC 16/7) - change-password spec §12 修复后,后端 Login 流程被冻结/封禁时返 `codes.PermissionDenied`,前端按 7 处理(清 token + 跳登录 + 显示封禁原因) **状态码重构本身只是把这些改造"标准化"到全仓,不引入新的功能修复**。 ### 5.4 Call-site 审计 ```bash # 1. 找现有前端读 res.data.code === HTTP 数字的位置(必须全量迁移) grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --include="*.js" --include="*.vue" frontend/ # 2. 找其他 HTTP 数字判断位置(res.code !== 200 等) grep -rn "res\.code\s*!==\s*200\|res\.data\.code\s*!==\s*200" --include="*.js" --include="*.vue" frontend/ # 3. 找 change-password spec §5.3.3 中类似的数字判断位置 grep -rn "code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --include="*.js" --include="*.vue" frontend/ # 4. 找 HTTP 数字字面量(可能误用 401/403) grep -rn "\b\(200\|400\|401\|403\|404\|429\|500\)\b" --include="*.js" --include="*.vue" frontend/ # 需人工过滤,业务数字(如价格)不在此列 ``` 涉及文件(初估):`TBD pending audit`(实施前跑 §5.4 几条 grep 确认准确数量)。**所有命中位置必须同步改为 gRPC code 数字**,前后端发版要保持原子性(后端上线后所有老客户端会立刻失效)。 --- ## 6. 一次性全量更新计划 ### 6.1 阶段划分 | 阶段 | 后端 | 前端 | 时长 | |---|---|---|---| | **Phase 0: 准备** | 删除 `StatusCode` enum,`code` 改 `uint32`;`errors.go` 删除 `ToStatusCode` 只留 `ToGRPCCode`;provider/service 全部改为 `appErrors.FormatErrorResponse(status.Error(codes.X, msg))`;实现 `GRPCStatusInterceptor` | 无变化(老客户端继续读老 code,会全部读 `uint32` 但语义不对,**不能发版**) | 1 sprint | | **Phase 1: 原子发布** | 后端上线到生产 | **前端必须同步上线**(拦截器 + 业务代码全量迁移) | 0 sprint(同一天) | | **Phase 2: 收尾** | 监控 + 收尾,无残留 enum/老字段 | 监控 + 收尾,无残留 HTTP 数字判断 | 0.5 sprint | **关键点**: - Phase 0 和 Phase 1 **不能拆开发布**:后端一旦切换 `code` 字段类型,所有老前端会读到 `uint32` 但语义是 HTTP 数字,完全错乱 - 前端 PR 必须在后端 PR 合并前 **先合入并打包**,等后端上线时**同一天推前端发版** - 没有"先上后端观察几天"的灰度——这是协议层变更,无法灰度 ### 6.2 风险控制 | 风险 | 缓解 | |---|---| | 前后端发版不同步,后端先上 | CI/CD 加门禁:前端 PR 必须先合入并打 release;后端发版前检查前端 release 是否已就绪 | | Dubbo 框架自动转换 HTTP 状态码未拦截 | gateway `responseRecorder` 拦截器必上,加 e2e 测试 | | `google.rpc.Code` 数字与业务 error 多对多关系不清 | `ToGRPCCode` 完整 switch,单测覆盖每个 case | | 旧 HTTP 数字(401/403)与新 gRPC code(16/7)记错 | §3.4 数字映射表必查;code review 重点关注 `=== 401/403` 是否全部清除 | | 漏改的业务判断位置未被发现 | §5.4 几条 grep 必跑,作为 PR 合并前 checklist | | AuthMiddleware 与 GRPCStatusInterceptor 顺序冲突 | 实施时调整中间件链:AuthMiddleware 走 HTTP 401 时不被 GRPCStatusInterceptor 拦截(AuthMiddleware 挂在 GRPCStatusInterceptor **之前**) | ### 6.3 发布流程 - **T-1 周**: 跑完 §5.4 grep 审计,列出所有需要修改的前端位置,全部 PR 化 - **T-3 天**: 前端 PR 全部合入,前端 release 包就绪 - **T-1 天**: 后端 PR 合入 main,准备发布 - **T-0 天(发布日)**: - 早 8:00: 后端全量上线(强制 HTTP 200 + `code` 改 gRPC) - 早 8:30: 前端全量发版(拦截器 + 业务代码全量切换) - 9:00-18:00: 监控 + 人工巡检,重点关注 `code === 0/3/5/7/8/13/16` 分布是否正常 - **T+1 天**: 若无异常,关闭发布窗口,完成 **重要**: **禁止**后端先上线,前端分批发版——一旦后端上线,**所有未升级客户端立刻全部失效**(返回 gRPC 数字但客户端按 HTTP 数字判,逻辑全乱)。 --- ## 7. 测试策略 ### 7.1 后端单测 **新增** `backend/pkg/errors/errors_test.go` | # | 测试用例 | 期望 | |---|---|---| | 1 | `nil` error | `ToGRPCCode = OK (0)` | | 2 | `ErrUserNotFound` | `ToGRPCCode = NotFound (5)` | | 3 | `ErrInvalidPassword` | `ToGRPCCode = Unauthenticated (16)` | | 4 | `ErrAccountFrozen` (wrapped) | `ToGRPCCode = PermissionDenied (7)` | | 5 | `ErrAccountBanned` (wrapped) | `ToGRPCCode = PermissionDenied (7)` | | 6 | `ErrPasswordTooShort` | `ToGRPCCode = InvalidArgument (3)` | | 7 | `ErrRequestInCooldown` | `ToGRPCCode = ResourceExhausted (8)` | | 8 | `errors.New("unknown")` | `ToGRPCCode = Internal (13)` | | 9 | wrapped error `fmt.Errorf("%w: extra", ErrXxx)` | `errors.Is` 仍能识别,`ToGRPCCode` 正确 | | 10 | `FormatErrorResponse(ErrXxx)` | `BaseResponse.Code = gRPC 数字`,`Message`/`Timestamp` 正确 | **新增** 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 | 正常请求 | code=0,正常返回数据 | | 2 | 输错旧密码 | code=3,toast "旧密码错误",不登出 | | 3 | 账号被冻结 | code=7,弹封禁原因,2s 后跳登录 | | 4 | token 过期 | code=16,立即跳登录 | | 5 | 资源不存在 | code=5,toast "资源不存在" | | 6 | 操作太频繁 | code=8,toast "请稍后再试" | | 7 | 网络 500 / 内部错 | code=13,toast "服务异常",不登出 | ### 7.4 端到端兼容性验证 **发版当天必跑**(可手工或自动化): - [ ] 老前端 + 新后端:**严禁出现**(发版顺序保证) - [ ] 新前端 + 新后端:全场景测试(上面 7 个场景 + 正常流程) - [ ] 移动端原生壳(若有) + 新后端:全场景测试 - [ ] 第三方客户端(若有):提前通知,要求同步升级或下线 --- ## 8. 文件变更总览 ### 后端 | 变更 | 文件 | |---|---| | 修改 | `backend/proto/common.proto`(删除 `StatusCode` enum,`BaseResponse.code` 改为 `uint32`) | | regen | `backend/pkg/proto/common/common.pb.go` | | regen | `backend/pkg/proto/common/common.triple.go` | | 重写 | `backend/pkg/errors/errors.go`(删除 `ToStatusCode`,只留 `ToGRPCCode`;`FormatErrorResponse` 填 `uint32(codes.X)`) | | 新增 | `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`(拦截器 `=== 401/403/...` 改为 `=== 16/7/...`;新增 `GrpcCode` 常量 helper) | | 新增 | `frontend/utils/grpc_code.js`(gRPC code 数字常量集中管理,推荐) | | 修改 | `frontend/pages/**/*.vue`(TBD pending audit;所有 `res.data.code === 401/403/...` 改为 gRPC code 数字) | ### 总计 - 后端: ~35 文件改动,4 个新文件 - 前端: TBD pending audit(预计 30-50 处 HTTP 数字判断需迁移) --- ## 9. 与现有"修改密码"spec 的协同 [change-password spec](../specs/2026-06-12-change-password-design.md) 中的: | 项 | 关系 | |---|---| | §4.5 错误码映射(`ErrInvalidOldPassword` → gRPC 3) | 本次重构后,`BaseResponse.code = 3`(`InvalidArgument`),前端按 gRPC code 判断 | | §5.1 拦截器修复 | 本次重构后,前端拦截器统一判 gRPC code 数字(16/7/...),不再判 HTTP 数字 | | §12 Login BUG 修复(`errors.Is` 改造) | **本次重构的前置依赖**;`ToGRPCCode` 内部依赖 `errors.Is` | | §11 部署清单 | 加上"前后端同步发版"硬约束 | 执行顺序:**先实现 change-password spec(含 §12 Login BUG 修复)**,**再启动本次状态码重构**。 --- ## 10. 部署/上线检查清单 - [ ] proto regen 后所有 service 编译通过 - [ ] gateway `GRPCStatusInterceptor` 单元测试通过(responseRecorder 正确捕获 + 重写) - [ ] **E2E 测试覆盖**: (a) 所有 gRPC code 路径(0/3/5/7/8/13/16);(b) **AuthMiddleware 真鉴权失败仍返 HTTP 401**(不被 GRPCStatusInterceptor 拦截);(c) 路由不存在仍返 HTTP 404(transport 错误) - [ ] 前端拦截器 gRPC code 数字判断全量迁移测试 - [ ] 前端业务代码 `=== 401/403/...` 全部清除(跑 §5.4 grep 应为 0 命中) - [ ] Dubbo 框架未自动把 gRPC code 转 HTTP 状态码(已被 gateway 拦截器剥除) - [ ] AuthMiddleware 顺序正确(挂在 GRPCStatusInterceptor 之前,真鉴权失败仍返 HTTP 401) - [ ] **前后端发版同步就绪**(前端 release 包先于后端 release) - [ ] 监控埋点:HTTP 4xx/5xx 比例应降至接近 0(transport 错误除外) - [ ] 第三方客户端(若有)已通知并完成升级 - [ ] **回滚预案**:后端回滚 + 前端回滚 必须同步执行(单独回滚任意一端都会导致协议不一致) --- ## 11. 不在本次范围内 | 项 | 处理 | |---|---| | HTTP 状态码完全废弃(连 401/404 都用 200) | 暂不动,transport 错误仍用 HTTP 状态码 | | 业务子码 `biz_code`(在 google.rpc.Code 之外再做细粒度区分) | 留待未来扩展,本次只做 gRPC code 标准化 | | dubbo-go 内部错误机制调整 | 维持原状,只在外层包装 | | 历史日志/审计中的老 code 数字(200/400/401/...)含义保留 | 文档说明,不改字段 |