docs(status-code): 重构设计 - google.rpc.Code 标准化 + 双协议期迁移

核心:
- proto: BaseResponse.code 改用 google.rpc.Code 数字 (0/3/5/7/8/13/16)
- proto: 新增 legacy_code 字段保留旧 HTTP 镜像码(过渡期用)
- errors.go: 重写 ToStatusCode -> ToGRPCCode + ToLegacyCode,全面用 errors.Is
- service: 所有硬编码 StatusCode_STATUS_X 改为 status.Error(codes.X, msg)
- gateway: 新增拦截器强制 HTTP 200,剥除 Dubbo 自动 gRPC->HTTP 转换
- 前端: api.js 拦截器重写,优先读 code 兼容 legacy_code

迁移: 双协议期 4 阶段(准备/双协议/前端升级/清理),约 4 sprint
执行顺序: 先实现 change-password spec,再启动本重构

与 change-password spec 协同:
- §4.5 错误码映射自动升级为 gRPC code
- §5.1 拦截器修复方案被本设计自然吸收
- §12 Login BUG 修复(ToGRPCCode 用 errors.Is)作为前置依赖
This commit is contained in:
zheng020 2026-06-12 13:20:29 +08:00
parent 8c90de5b08
commit fd763298c7

View File

@ -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 内部错误机制调整 | 维持原状,只在外层包装 |