28 KiB
状态码体系重构设计文档
- 创建日期: 2026-06-12
- 状态: 待评审
- 目标: 将后端业务状态码从"HTTP 镜像自定义 enum"重构为"google.rpc.Code 标准 gRPC 语义",一次性全量更新(无平滑过渡期)
1. 背景与需求
1.1 现状
当前后端 backend/proto/common.proto:7-16 定义的 StatusCode 是个镜像 HTTP 状态码的自定义 enum:
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 的 ToStatusCode() 把 typed error 映射到这个 enum 数字。
1.2 问题
- 不是真正的 gRPC 语义:
google.rpc.Code才是 gRPC 生态事实标准,本项目用 HTTP 数字是"穿马甲" - 与 Dubbo-Triples 内部语义不一致:Dubbo-Triples 内部走
google.golang.org/grpc/status,前端却要把响应里的数字当 HTTP 码理解,语义错位 - 混用引发 BUG:
Login流程被冻结/封禁时用 genericerrors.New(),落到 default 分支返回 500(应为 403);前端拦截器把 400/401/403 一律当 token 过期踢出登录(见 change-password spec §1.1) - 跨语言/跨服务不通用:未来若拆服务给非 Go 客户端,对方看
STATUS_BAD_REQUEST = 400这种命名会觉得奇怪
1.3 目标
- 核心:业务状态码与
google.rpc.Code数字(0/3/5/7/8/13/16 等)对齐 - 传输:HTTP 状态码固定 200(只有 transport 错误才用 4xx/5xx)
- 迁移策略:一次性全量更新,无平滑过渡期。后端
BaseResponse.code字段类型从StatusCodeenum 改为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 响应体形态(唯一目标态)
{
"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 调整
关键变更:BaseResponse.code 字段类型从 StatusCode enum 改为 uint32,StatusCode enum 整个删除(不再需要 HTTP 镜像命名):
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
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 中 30+ 处硬编码 status code 的地方:
// 改前
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 审计(实施前必跑):
# 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
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 重写
改造策略:拦截器内部所有 === 401/403/... 数字判断改为 gRPC code 数字(7/16/...)。一次性全量更新,无兼容期:
// 改前(基于 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):
0OK成功3INVALID_ARGUMENT参数/数据错 → toast "请检查输入"5NOT_FOUND资源不存在 → toast "资源不存在"7PERMISSION_DENIED权限不够/封禁 → 弹封禁原因或 toast8RESOURCE_EXHAUSTED限流 → toast "操作太频繁,请稍后再试"13INTERNAL内部错 → toast "服务异常,请稍后再试"16UNAUTHENTICATED鉴权失败 → 跳登录
新业务代码:用 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 集中管理(可选,推荐):
// 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 审计
# 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分布是否正常
- 早 8:00: 后端全量上线(强制 HTTP 200 +
- 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 的协同
| 项 | 关系 |
|---|---|
§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/...)含义保留 | 文档说明,不改字段 |