topfans/docs/superpowers/specs/2026-06-12-status-code-refactor-design.md
zheng020 e52f46e50f docs(status-code): 合并 5 个 review advisory 修复
- §7.1 测试表 #9/#10 重复行清理
- §6.1 增加 Phase 0a Dubbo 探针(验证 dubbo-go-pixiu/triples
  对 gRPC code->HTTP status 的实际行为,影响 gateway 拦截器策略)
- §6.3 Day 4 与 §5.1 冲突修复(老前端拦截器保持不变,只新业务代码引入 getGrpcCode)
- §5.4 / §8 文件数 '10+/15+/12+' 统一为 'TBD pending audit'
- §10 E2E 测试细化(3 类断言);增加'第三方客户端风险通知'清单项
2026-06-12 13:37:39 +08:00

29 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-109ToStatusCode() 把 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)
  4. 跨语言/跨服务不通用:未来若拆服务给非 Go 客户端,对方看 STATUS_BAD_REQUEST = 400 这种命名会觉得奇怪

1.3 目标

  • 核心:业务状态码与 google.rpc.Code 数字(0/3/5/7/8/13/16 等)对齐
  • 传输:HTTP 状态码固定 200(只有 transport 错误才用 4xx/5xx)
  • 过渡(自审修订后):code 字段保留旧 HTTP 镜像码语义不变(给老前端,无需改),新增 grpc_code 字段(新 gRPC code,给新前端)。真"双协议期"
  • 前端:老前端继续读 res.data.code(HTTP 镜像码),无需改;新前端读 res.data.grpc_code 走 gRPC 语义

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 包 + 双协议期迁移)

自审修订:原方案 B 的具体实施细节经过自审优化——保留 StatusCode enum(老字段),新增 grpc_code 字段,而不是直接把 code 改为 gRPC 数字。理由见 §1.3 和 §4.1。这样过渡期老前端零改动即可继续工作(读 code HTTP 码),新前端读 grpc_code 走 gRPC 语义,真正"双协议"。


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 数字(目标态)
  • grpc_code 字段: 与 code 同值(双协议期前端若想用语义化字段可读这个)

3.2 响应体形态(双协议过渡期)

{
    "code": 403,                       // 旧 HTTP 镜像码(老字段,语义不变;老前端无需改)
    "grpc_code": 7,                    // 新 google.rpc.Code 数字(新前端读这个)
    "message": "账号已被封禁",
    "timestamp": 1718179200000
}
  • 老前端:继续读 res.data.code(HTTP 镜像码 403),无需任何改动
  • 新前端:读 res.data.grpc_code(gRPC code 7),按 gRPC 语义处理
  • 过渡期结束(预估 2-3 sprint):移除 grpc_code 字段,把 code 改成 gRPC 数字;同时强制升级所有前端

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: appErrors.FormatErrorResponse(
          status.Error(codes.PermissionDenied, "账号已被封禁"),
      ),
  }
  // BaseResponse.Code = STATUS_FORBIDDEN (403)  ← 老字段
  // BaseResponse.GrpcCode = 7                   ← 新字段
       │
       ▼
[Dubbo-Triples 框架]
  将 gRPC status 序列化为 protobuf 响应
       │
       ▼
[Gin Gateway GRPCStatusInterceptor]
  用 responseRecorder 捕获 body,强制 HTTP 200,body 原样写回
  body = {code: 403, grpc_code: 7, message: "...", timestamp: ...}
       │
       ▼
[uni-app 老前端拦截器]
  读 res.data.code (= 403),按 HTTP 语义处理
  if (res.data.code === 401) logout()
  else if (res.data.code === 403) showBanReason()
  else toast
       │
       ▼
[uni-app 新前端业务代码]
  调 getGrpcCode(res) === 7,按 gRPC 语义处理
  if (getGrpcCode(res) === 16) logout()
  else if (getGrpcCode(res) === 7) showBanReason()
  else toast

4. 后端改动

4.1 proto 调整

文件:backend/proto/common.proto

自审修订:原设计把 code 改为 gRPC 数字会破坏所有老前端(它们期望 HTTP 码)。改为保留 code 语义不变,新增 grpc_code 字段:

syntax = "proto3";
package topfans.common;

option go_package = "github.com/topfans/backend/pkg/proto/common;common";

// 保留自定义 StatusCode enum(老代码已用)
// 过渡期内 code 字段保持 HTTP 镜像码(老前端无需改)
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;
}

message BaseResponse {
    StatusCode code = 1;            // HTTP 镜像码(过渡期不变,老前端继续读)
    string message = 2;
    int64 timestamp = 3;

    // 双协议期新增字段,稳定后删除
    uint32 grpc_code = 4;           // google.rpc.Code 数字: 0=OK, 3=INVALID_ARGUMENT, 7=PERMISSION_DENIED, 16=UNAUTHENTICATED, ...
}

字段命名说明:

  • code(旧):HTTP 镜像码,语义不变;老前端判 res.data.code === 401/403 仍正常工作
  • grpc_code(新):gRPC 数字;新前端读这个

proto 中不直接 import "google/rpc/status.proto" 是因为我们要的是 code 数字常量(从 google.golang.org/grpc/codes 取),不强制通过 proto 类型传递,保持向后兼容。

4.2 errors.go 重写

文件:backend/pkg/errors/errors.go

package errors

import (
    "errors"
    "fmt"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    pb "github.com/topfans/backend/pkg/proto/common"
)

// 业务错误类型(同前;新增 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
    // ... 其他不变
)

// ToStatusCode: 旧 API(已存在),保持不变,继续返 HTTP 镜像码,给 BaseResponse.code 用
// 过渡期所有老代码继续走这个
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),  // ← 新增
        errors.Is(err, ErrInvalidStarID), errors.Is(err, ErrInvalidUserID),
        errors.Is(err, ErrMaxIdentitiesReached), errors.Is(err, ErrInvalidNickname),
        // ... 其他业务错误同前
        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),  // ← 此前 fall into default 500,现修复为 403
        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
    }
}

// ToGRPCCode: 业务 error -> google.rpc.Code 数字
// 新增,给 BaseResponse.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),
        // ... 其他业务错误同 ToStatusCode
        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),  // ← 同样修复
        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_code(新)
func FormatErrorResponse(err error) *pb.BaseResponse {
    return &pb.BaseResponse{
        Code:     ToStatusCode(err),            // 旧: HTTP 镜像码(老前端读)
        GrpcCode: uint32(ToGRPCCode(err)),      // 新: gRPC 数字(新前端读)
        Message:  err.Error(),
        Timestamp: getCurrentTimestamp(),
    }
}

// FormatSuccessResponse: 成功响应
func FormatSuccessResponse() *pb.BaseResponse {
    return &pb.BaseResponse{
        Code:     pb.StatusCode_STATUS_OK,
        GrpcCode: 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 函数保留(只内部补 ErrInvalidOldPassword / ErrUserInactive 两个 case),兼容所有 BaseResponse{Code: ...} 旧调用方
  • 新增 ToGRPCCode 函数,内部逻辑与 ToStatusCode 镜像
  • BaseResponse.Code 继续填 HTTP 镜像码,BaseResponse.GrpcCode 填 gRPC 数字
  • 过渡期老前端读 code 行为完全不变;新前端读 grpc_code

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 引用
grep -rn "StatusCode_STATUS_" --include="*.go" backend/services/ backend/gateway/

# 2. 找 raw 数字赋值的 Code: 字段(可能漏掉)
grep -rn "Code:\s*uint32(\?\(200\|400\|401\|403\|404\|429\|500\)" --include="*.go" backend/services/ backend/gateway/

# 3. 找 ToStatusCode 调用点(看 ToGRPCCode 是否需要补同样的 case)
grep -rn "ToStatusCode" --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/")
}

自审修订说明:原设计拦截器只改 header,但 Dubbo 框架已经把 gRPC code 转成 HTTP status 写进 body 字段(body 中 code 是 HTTP 数字,如 401)。完整方案是用 responseRecorder 捕获整个 body,重写 header 200 后再回写。body 本身不需要修改——它已经包含 code(HTTP 镜像)和 grpc_code(gRPC 数字)两个字段。

实际上 Dubbo 框架已经会自动把 gRPC status 转成 HTTP 状态码(例如 codes.Unauthenticated → HTTP 401),这是问题根源——它把业务码泄漏到传输层。

需要在 gateway 这一层剥掉 Dubbo 的自动转换,强制所有 /api/* 走 HTTP 200。

与 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

自审修订:原设计用 getBizCode helper 同时读 code/legacy_code,但实际"老前端"代码读的是 res.data.code(HTTP 码)。过渡期更稳的做法:

  • 保留现有拦截器逻辑(读 res.data.code HTTP 码,完全不动)—— 老前端照常工作
  • 新增一个读 grpc_code 的 helper,供新前端业务代码使用
// 新增 helper:读 gRPC code(新前端业务代码用)
export function getGrpcCode(res) {
    if (res?.data && typeof res.data.grpc_code === 'number') {
        return res.data.grpc_code  // 0=OK, 3=InvalidArgument, 7=PermissionDenied, 16=Unauthenticated, ...
    }
    return null
}

// 现有拦截器 api.js 内 code 完全保留(老逻辑判 res.data.code === 401/403 等)
// 不动 401/400/403 的老判断逻辑
// 老前端零改动继续跑

关键决策:过渡期不改 api.js 现有逻辑,只新增 getGrpcCode helper。新业务代码(如改密流程的"输错旧密码"判断)用 getGrpcCode(res) === 3 替代 res.data.code === 400,获得更精细的语义。

5.2 BUG 修复自然吸收

§1.3 中提到的 BUG 在新架构下依赖 change-password spec 的 §12 Login BUG 修复(errors.Is 改造 + ErrInvalidOldPassword)才能完全消失:

  • change-password spec §5.1 修复后,前端拦截器已不再判 HTTP 400 自动登出(改为判 401/403)
  • change-password spec §12 修复后,后端 Login 流程被冻结/封禁时返 codes.PermissionDenied,前端按 403 处理(清 token + 跳登录 + 显示封禁原因)

状态码重构本身只是把这些改造"标准化"到全仓,不引入新的功能修复

5.3 过渡期兼容矩阵

老前端(读 res.data.code HTTP 码) 新前端(读 res.data.grpc_code gRPC) 后端响应 老前端行为 新前端行为
旧版未升级 旧版未升级 {code: 401, grpc_code: 16} 按 401 跳登录
旧版未升级 新版升级 {code: 401, grpc_code: 16} 按 401 跳登录 走 grpc_code=16 判 Unauthenticated
新版升级 旧版未升级 {code: 401, grpc_code: 16} 老前端按 401
新版升级 新版升级 {code: 401, grpc_code: 16} 都正确

双协议期真正的"双协议":后端同时返 code(HTTP 镜像) 和 grpc_code(gRPC 数字),任何前端都能跑。

5.4 Call-site 审计

# 1. 找现有前端读 res.data.code === 401/403/... 的位置(老逻辑,过渡期继续工作,无需改)
grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --include="*.js" --include="*.vue" frontend/

# 2. 找新前端业务代码将引入的 grpc_code 判断位置
grep -rn "grpc_code" --include="*.js" --include="*.vue" frontend/

# 3. 找 change-password spec §5.3.3 中类似 "if (res.code !== 200)" 的位置
grep -rn "res\.code\s*!==\s*200\|res\.data\.code\s*!==\s*200" --include="*.js" --include="*.vue" frontend/

涉及文件(初估):TBD pending audit(实施前跑 §5.4 三条 grep 确认准确数量)。其中老逻辑(=== 401/403)过渡期不动;新逻辑(用 grpc_code)按需引入。


6. 双协议期迁移计划

6.1 阶段划分

阶段 后端 前端 时长
Phase 0a: 探针 用最小 demo 验证当前 dubbo-go-pixiu/triples 版本下,Dubbo 框架对 gRPC code → HTTP status 的实际行为(响应 header、body 字段、状态码) 无变化 0.5 sprint
Phase 0b: 准备 基于 0a 探针结果写 ToGRPCCode,保留 ToStatusCode;BaseResponsegrpc_code 字段;实现 GRPCStatusInterceptor 无变化 0.5 sprint
Phase 1: 双协议期 所有响应同时返 code(HTTP 镜像) 和 grpc_code(gRPC 数字) 老前端零改动继续跑;新前端业务代码可选用 getGrpcCode() 读新字段 2-3 sprint
Phase 2: 全量升级前端 仍双协议 全部前端迁移到读 grpc_code;移除对 code 的 HTTP 数字依赖 1 sprint
Phase 3: 清理 移除 grpc_code 字段,code 改回 gRPC 数字(此时所有前端已升级,语义统一) 移除 getGrpcCode 兼容 helper 0.5 sprint

6.2 风险控制

风险 缓解
过渡期前端误判(新老字段混读) 老逻辑不动;新逻辑用 getGrpcCode() helper,代码评审重点
Dubbo 框架自动转换 HTTP 状态码未拦截 gateway responseRecorder 拦截器必上,加 e2e 测试
google.rpc.Code 数字与业务 error 多对多关系不清 ToStatusCodeToGRPCCode 平行 switch,单测覆盖每个 case
部分老业务调用方未升级 ToStatusCode 函数保留,所有现有 BaseResponse{Code: ToStatusCode(err)} 继续工作
AuthMiddleware 与 GRPCStatusInterceptor 顺序冲突 实施时调整中间件链:AuthMiddleware 走 HTTP 401 时不被 GRPCStatusInterceptor 拦截(AuthMiddleware 挂在 GRPCStatusInterceptor 之前)

6.3 灰度发布

  • Day 1: 后端全量上双协议 + gateway 拦截器;前端保持不动(老前端继续读 code HTTP 码,无需改)
  • Day 2-3: 观察监控,确认没有 4xx/5xx 异常
  • Day 4+: 新业务代码按需引入 getGrpcCode()grpc_code 字段;老前端拦截器继续不改动,与 §5.1 / §6.1 Phase 1 保持一致
  • Day 5+: 全量验证 1 周后,移除 grpc_code 字段(此时所有前端已升级,code 直接改为 gRPC 数字)

7. 测试策略

7.1 后端单测

新增 backend/pkg/errors/errors_test.go

# 测试用例 期望
1 nil error ToStatusCode = STATUS_OK, ToGRPCCode = OK (0)
2 ErrUserNotFound ToStatusCode = STATUS_NOT_FOUND, ToGRPCCode = NotFound (5)
3 ErrInvalidPassword ToStatusCode = STATUS_UNAUTHORIZED, ToGRPCCode = Unauthenticated (16)
4 ErrAccountFrozen (wrapped) ToStatusCode = STATUS_FORBIDDEN, ToGRPCCode = PermissionDenied (7)
5 ErrAccountBanned (wrapped) ToStatusCode = STATUS_FORBIDDEN, ToGRPCCode = PermissionDenied (7)
6 ErrPasswordTooShort ToStatusCode = STATUS_BAD_REQUEST, ToGRPCCode = InvalidArgument (3)
7 ErrRequestInCooldown ToStatusCode = STATUS_TOO_MANY_REQUESTS, ToGRPCCode = ResourceExhausted (8)
8 errors.New("unknown") ToStatusCode = STATUS_INTERNAL_ERROR, ToGRPCCode = Internal (13)
9 wrapped error fmt.Errorf("%w: extra", ErrXxx) errors.Is 仍能识别,ToStatusCodeToGRPCCode 都正确
10 FormatErrorResponse(ErrXxx) BaseResponse.Code = HTTP 镜像码, BaseResponse.GrpcCode = 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 HTTP 码) 行为不变
2 新前端(读 code) 弹窗/toast/登出均正确
3 输错旧密码 不登出,toast "旧密码错误"
4 账号被冻结 弹封禁原因,2s 后跳登录
5 token 过期 立即跳登录
6 网络 500 toast "网络异常",不登出

8. 文件变更总览

后端

变更 文件
修改 backend/proto/common.proto(BaseResponse 加 grpc_code 字段)
regen backend/pkg/proto/common/common.pb.go
regen backend/pkg/proto/common/common.triple.go
重写 backend/pkg/errors/errors.go(保留 ToStatusCode,新增 ToGRPCCode;FormatErrorResponse 同时填 codegrpc_code)
新增 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(保留现有拦截器逻辑不动;新增 getGrpcCode() helper 给新业务代码)
修改 frontend/pages/**/*.vue(TBD pending audit;过渡期老逻辑不动,新业务代码按需引入 getGrpcCode())

总计

  • 后端: ~35 文件改动,4 个新文件(加 Phase 0a 探针 demo)
  • 前端: TBD pending audit

9. 与现有"修改密码"spec 的协同

change-password spec 中的:

关系
§4.5 错误码映射(ErrInvalidOldPassword → 400) 本次重构后,BaseResponse.code = 400(HTTP 镜像,老前端继续判),BaseResponse.grpc_code = 3(gRPC 数字,新前端读)
§5.1 拦截器修复 本次重构后,新前端业务代码可用 getGrpcCode() 替代原 res.data.code 判断;老前端拦截器逻辑完全不动
§12 Login BUG 修复(errors.Is 改造) 本次重构的前置依赖;ToStatusCodeToGRPCCode 内部都依赖 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 错误)
  • 前端拦截器向后兼容测试(老前端读 code HTTP 码 仍正常)
  • Dubbo 框架未自动把 gRPC code 转 HTTP 状态码(已被 gateway 拦截器剥除)
  • AuthMiddleware 顺序正确(挂在 GRPCStatusInterceptor 之前,真鉴权失败仍返 HTTP 401)
  • 监控埋点:HTTP 4xx/5xx 比例应降至接近 0(transport 错误除外)
  • 灰度 10% → 50% → 100%,每阶段观察 24h
  • 1-2 sprint 稳定后,移除 grpc_code 字段,code 改为 gRPC 数字
  • 老前端缓存清理(用户升级到新前端)
  • 第三方客户端风险通知:若有项目外客户端(合作伙伴、小程序插件)解析 code 字段为 HTTP 状态码,Phase 3 收尾时需提前通知(可选保留 code 字段为 HTTP 数字作为兼容期,但 spec 不强制)

11. 不在本次范围内

处理
HTTP 状态码完全废弃(连 401/404 都用 200) 暂不动,transport 错误仍用 HTTP 状态码
业务子码 biz_code(在 google.rpc.Code 之外再做细粒度区分) 留待未来扩展,本次只做 gRPC code 标准化
老旧前端硬编码 === 401/403/... 的判断全部迁移 跟随双协议期渐进迁移,不在本次单点爆破
dubbo-go 内部错误机制调整 维持原状,只在外层包装