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

657 lines
29 KiB
Markdown

# 状态码体系重构设计文档
- **创建日期**: 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)
- **过渡**(自审修订后):**`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 响应体形态(目标态 — 双协议期结束后)
```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 数字(目标态)
- `grpc_code` 字段: 与 `code` 同值(双协议期前端若想用语义化字段可读这个)
### 3.2 响应体形态(双协议过渡期)
```json
{
"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](backend/proto/common.proto)
**自审修订**:原设计把 `code` 改为 gRPC 数字会破坏所有老前端(它们期望 HTTP 码)。改为**保留 `code` 语义不变,新增 `grpc_code` 字段**:
```protobuf
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](backend/pkg/errors/errors.go)
```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](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 引用
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`
```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](frontend/utils/api.js#L73-L133)
**自审修订**:原设计用 `getBizCode` helper 同时读 `code`/`legacy_code`,但实际"老前端"代码读的是 `res.data.code`(HTTP 码)。过渡期更稳的做法:
- **保留现有拦截器逻辑**(读 `res.data.code` HTTP 码,**完全不动**)—— 老前端照常工作
- **新增一个读 grpc_code 的 helper**,供新前端业务代码使用
```js
// 新增 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 审计
```bash
# 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`;`BaseResponse` 加 `grpc_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 多对多关系不清 | `ToStatusCode``ToGRPCCode` 平行 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` 仍能识别,`ToStatusCode` 和 `ToGRPCCode` 都正确 |
| 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` 同时填 `code``grpc_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](../specs/2026-06-12-change-password-design.md) 中的:
| 项 | 关系 |
|---|---|
| §4.5 错误码映射(`ErrInvalidOldPassword` → 400) | 本次重构后,`BaseResponse.code = 400`(HTTP 镜像,老前端继续判),`BaseResponse.grpc_code = 3`(gRPC 数字,新前端读) |
| §5.1 拦截器修复 | 本次重构后,新前端业务代码可用 `getGrpcCode()` 替代原 `res.data.code` 判断;老前端拦截器逻辑完全不动 |
| §12 Login BUG 修复(`errors.Is` 改造) | **本次重构的前置依赖**;`ToStatusCode` 和 `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 错误)
- [ ] 前端拦截器向后兼容测试(老前端读 `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 内部错误机制调整 | 维持原状,只在外层包装 |