docs(status-code): 自审修复 - 12 个问题

🔴 严重 (2):
1. "双协议期"实际无法双协议: 原设计 code=gRPC 会破坏老前端(它们读 code 期望 HTTP 码)
   - 修订: code 字段保持 HTTP 镜像码不变,新增 grpc_code 字段;老前端零改动
2. Gateway 拦截器只改 header 不改 body: Dubbo 已把 gRPC code 写进 body 字段
   - 修订: 用 responseRecorder 完整捕获 body,重写 header 200 后回写

🟡 高 (3):
3. errors.go 代码块缺 import (status, strings)
4. responseRecorder 类型引用但未定义 -> 补充完整类型定义
5. ErrInvalidOldPassword (change-password spec §4.5 新增) 未在 ToStatusCode/ToGRPCCode 映射
   - 修订: 同步加到两个函数的 InvalidArgument 分支
6. ErrUserInactive 此前 fall into default 500 -> 现在映射到 PermissionDenied (修复)

🟢 中 (4):
7. ToStatusCode 函数需要保留作为兼容(原 spec 说删) -> 保留并补 case
8. NewError 函数引用 status 但无 import -> 补 import
9. 缺 AuthMiddleware 与 GRPCStatusInterceptor 中间件链顺序说明
10. Phase 0 描述与实际实现对齐(保留 ToStatusCode)

🔵 低 (3):
11. grep 命令可能漏掉 raw 数字赋值 -> 补 grep -rn 'Code:\s*uint32...'
12. 全文清理 legacy_code / ToLegacyCode 残留
13. §3.4 完整响应流示例更新为新设计(code+grpc_code)
14. §5.1 getBizCode helper 改为新增 getGrpcCode helper,老前端拦截器不动
15. §6.1 Phase 0 描述对齐,Phase 3 清理描述对齐
16. §10 部署清单更新
This commit is contained in:
zheng020 2026-06-12 13:30:53 +08:00
parent fd763298c7
commit 7c897ec4e0

View File

@ -37,8 +37,8 @@ enum StatusCode {
- **核心**:业务状态码与 `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`(过渡期兼容)
- **过渡**(自审修订后):**`code` 字段保留旧 HTTP 镜像码语义不变**(给老前端,无需改),**新增 `grpc_code` 字段**(新 gRPC code,给新前端)。真"双协议期"
- **前端**:老前端继续读 `res.data.code`(HTTP 镜像码),无需改;新前端读 `res.data.grpc_code` 走 gRPC 语义
---
@ -54,11 +54,13 @@ enum StatusCode {
**最终方案**: 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 响应体形态(目标态)
### 3.1 响应体形态(目标态 — 双协议期结束后)
```json
{
@ -69,23 +71,23 @@ enum StatusCode {
```
- HTTP 状态码: 固定 200(transport 错误除外)
- `code` 字段: google.rpc.Code 数字
- 业务子码(可选,未来扩展): `biz_code`(在 `code` 之外单独表达业务子分类)
- `code` 字段: google.rpc.Code 数字(目标态)
- `grpc_code` 字段: 与 `code` 同值(双协议期前端若想用语义化字段可读这个)
### 3.2 响应体形态(双协议过渡期)
```json
{
"code": 7, // 新: google.rpc.Code 数字(0=OK)
"legacy_code": 403, // 旧: HTTP 镜像码,给未升级的前端
"code": 403, // 旧 HTTP 镜像码(老字段,语义不变;老前端无需改)
"grpc_code": 7, // 新 google.rpc.Code 数字(新前端读这个)
"message": "账号已被封禁",
"timestamp": 1718179200000
}
```
- 已升级前端:读 `code` 判业务类型
- 未升级前端:仍可读 `legacy_code` 维持旧逻辑
- 过渡期结束(预估 2-3 个 sprint):移除 `legacy_code`,更新所有前端
- **老前端**:继续读 `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 映射表(最终态)
@ -110,30 +112,35 @@ enum StatusCode {
[Provider Layer]
resp := &pb.UpdatePasswordResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.PermissionDenied), // = 7
Message: "账号已被封禁",
Timestamp: time.Now().UnixMilli(),
},
Base: appErrors.FormatErrorResponse(
status.Error(codes.PermissionDenied, "账号已被封禁"),
),
}
resp, err
// BaseResponse.Code = STATUS_FORBIDDEN (403) ← 老字段
// BaseResponse.GrpcCode = 7 ← 新字段
[Dubbo-Triples 框架]
将 gRPC status 序列化为 protobuf 响应
[Gin Gateway 拦截器]
中间件统一改写:无论 gRPC code 是什么,HTTP statusCode 强制 = 200
c.JSON(200, resData)
[Gin Gateway GRPCStatusInterceptor]
用 responseRecorder 捕获 body,强制 HTTP 200,body 原样写回
body = {code: 403, grpc_code: 7, message: "...", timestamp: ...}
[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()
[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
```
---
@ -144,7 +151,7 @@ enum StatusCode {
**文件**:[backend/proto/common.proto](backend/proto/common.proto)
引入 `google.rpc.Code` 作为新 `code` 字段语义来源。**proto 文件本身不需要枚举定义**(直接用 google 的),仅需 `BaseResponse` 字段保持兼容:
**自审修订**:原设计把 `code` 改为 gRPC 数字会破坏所有老前端(它们期望 HTTP 码)。改为**保留 `code` 语义不变,新增 `grpc_code` 字段**:
```protobuf
syntax = "proto3";
@ -152,18 +159,32 @@ package topfans.common;
option go_package = "github.com/topfans/backend/pkg/proto/common;common";
// 不再自定义 enum,直接用 google.rpc.Code 数字
// BaseResponse.code 语义改为 google.rpc.Code 数字
// 保留自定义 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 {
uint32 code = 1; // google.rpc.Code: 0=OK, 3=INVALID_ARGUMENT, 7=PERMISSION_DENIED, 16=UNAUTHENTICATED, ...
StatusCode code = 1; // HTTP 镜像码(过渡期不变,老前端继续读)
string message = 2;
int64 timestamp = 3;
// 过渡期(可选)保留旧字段,稳定后删除
uint32 legacy_code = 4; // 旧 HTTP 镜像码 (200/400/401/403/404/429/500),仅过渡期使用
// 双协议期新增字段,稳定后删除
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 重写
@ -178,19 +199,51 @@ import (
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/topfans/backend/pkg/proto/common"
)
// 业务错误类型(同前,只改 ToGRPCCode 和 FormatErrorResponse)
// 业务错误类型(同前;新增 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 数字
// 同时返回旧 HTTP 镜像码,用于过渡期
// 新增,给 BaseResponse.grpc_code 用
func ToGRPCCode(err error) codes.Code {
if err == nil {
return codes.OK
@ -200,24 +253,17 @@ func ToGRPCCode(err error) codes.Code {
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):
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, ErrAssetAccessDenied), errors.Is(err, ErrMintOrderAccessDenied),
errors.Is(err, ErrUserInactive):
errors.Is(err, ErrUserInactive), // ← 同样修复
errors.Is(err, ErrAssetAccessDenied), errors.Is(err, ErrMintOrderAccessDenied):
return codes.PermissionDenied
case errors.Is(err, ErrRequestInCooldown):
return codes.ResourceExhausted
@ -226,34 +272,11 @@ func ToGRPCCode(err error) codes.Code {
}
}
// 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(旧)
// FormatErrorResponse: 构造 BaseResponse,同时填 code(老) 和 grpc_code(新)
func FormatErrorResponse(err error) *pb.BaseResponse {
return &pb.BaseResponse{
Code: uint32(ToGRPCCode(err)), // 新 gRPC code
LegacyCode: ToLegacyCode(err), // 旧 HTTP 镜像码(过渡期)
Code: ToStatusCode(err), // 旧: HTTP 镜像码(老前端读)
GrpcCode: uint32(ToGRPCCode(err)), // 新: gRPC 数字(新前端读)
Message: err.Error(),
Timestamp: getCurrentTimestamp(),
}
@ -262,30 +285,30 @@ func FormatErrorResponse(err error) *pb.BaseResponse {
// FormatSuccessResponse: 成功响应
func FormatSuccessResponse() *pb.BaseResponse {
return &pb.BaseResponse{
Code: uint32(codes.OK),
LegacyCode: 200,
Code: pb.StatusCode_STATUS_OK,
GrpcCode: uint32(codes.OK),
Message: "",
Timestamp: getCurrentTimestamp(),
}
}
// 保留 BuildBaseResponse 兼容
// 保留 BuildBaseResponse 别名(防外部包未及时更新)
func BuildBaseResponse(err error) *pb.BaseResponse {
return FormatErrorResponse(err)
}
// NewError: 用 google.rpc.Code 构造业务错误
// 推荐 service 层用 status.Error(codes.X, msg),但保留这个 helper 兼容
// 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` 函数
- 新增 `ToGRPCCode`(业务 → gRPC code) + `ToLegacyCode`(业务 → 旧 HTTP 码)
- `BaseResponse` 同时返两个码,过渡期两套前端都能用
- 保留 `BuildBaseResponse` 别名(防外部包未及时更新)
- `ToStatusCode` 函数保留(只内部补 `ErrInvalidOldPassword` / `ErrUserInactive` 两个 case),兼容所有 `BaseResponse{Code: ...}` 旧调用方
- 新增 `ToGRPCCode` 函数,内部逻辑与 `ToStatusCode` 镜像
- `BaseResponse.Code` 继续填 HTTP 镜像码,`BaseResponse.GrpcCode` 填 gRPC 数字
- 过渡期老前端读 `code` 行为完全不变;新前端读 `grpc_code`
### 4.3 service 层调用点改造
@ -316,11 +339,17 @@ return &pb.UpdatePasswordResponse{
**Call-site 审计**(实施前必跑):
```bash
grep -rn "StatusCode_STATUS_" --include="*.go" backend/services/
grep -rn "StatusCode_STATUS_" --include="*.go" backend/gateway/
# 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}` 的地方都要改造。
涉及文件(初估):30+ 个 provider/service 文件。所有手写 `pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_X}` 的地方都要改`appErrors.FormatErrorResponse(err)`
### 4.4 Dubbo 适配
@ -342,13 +371,26 @@ package middleware
import (
"bytes"
"encoding/json"
"io"
"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 状态码
@ -360,13 +402,14 @@ func GRPCStatusInterceptor() gin.HandlerFunc {
return
}
// 拦截响应体
writer := &responseRecorder{ResponseWriter: c.Writer, body: &bytes.Buffer{}}
c.Writer = writer
// 拦截响应体到 recorder
recorder := &responseRecorder{ResponseWriter: c.Writer, body: &bytes.Buffer{}}
c.Writer = recorder
c.Next()
// 强制改写 HTTP 状态码为 200
writer.ResponseWriter.WriteHeader(http.StatusOK)
// 把捕获的 body 原样写回,但强制 HTTP 200
c.Writer.WriteHeader(http.StatusOK)
_, _ = recorder.body.WriteTo(c.Writer)
}
}
@ -375,10 +418,14 @@ func isDubboPath(path string) bool {
}
```
**自审修订说明**:原设计拦截器只改 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** 增加:
@ -397,76 +444,60 @@ google.golang.org/genproto/googleapis/rpc v1.x.x
**修改** [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
// 过渡期:双协议兼容
// 优先读新 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 数字)
// 新增 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 res.data?.legacy_code ?? res.statusCode // 兜底
return null
}
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)
}
// 现有拦截器 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 在新架构下自动消失:
- 前端不再判 HTTP 400 → 不会误踢
- 前端判 gRPC code 7/16 → 精准区分"封号"和"密码错"
- 后端 Login 流程用 `codes.PermissionDenied` 替代 `errors.New` → 403 不再是 500
§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 过渡期兼容矩阵
| 旧前端(读 legacy_code) | 新前端(读 code) | 后端响应包含 | 行为 |
|---|---|---|---|
| 旧 | 旧 | `{code: 7, legacy_code: 403}` | 旧前端按 403 处理 ✅ |
| 旧 | 新 | `{code: 7, legacy_code: 403}` | 都正确 |
| 新 | 旧 | `{code: 7, legacy_code: 403}` | 都正确 |
| 新 | 新 | `{code: 7, legacy_code: 403}` | 都正确 |
| 老前端(读 `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/
```
涉及文件(初估):10+ 个 vue/js 文件中的业务判断。需要逐个迁移到 gRPC code 判断,或保留对 `legacy_code` 的兼容(过渡期内可接受)。
涉及文件(初估):15+ 个 vue/js 文件中的业务判断。其中老逻辑(`=== 401/403`)过渡期不动;新逻辑(用 `grpc_code`)按需引入
---
@ -476,26 +507,27 @@ grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --inclu
| 阶段 | 后端 | 前端 | 时长 |
|---|---|---|---|
| **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 |
| **Phase 0: 准备** | 写 `ToGRPCCode`,保留 `ToStatusCode`;`BaseResponse` 加 `grpc_code` 字段 | 无变化 | 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 风险控制
| 风险 | 缓解 |
|---|---|
| 过渡期前端误判(新旧字段混读) | `getBizCode` helper 统一处理,代码评审重点 |
| Dubbo 框架自动转换 HTTP 状态码未拦截 | gateway 强制 HTTP 200 拦截器必上,加 e2e 测试 |
| `google.rpc.Code` 数字与业务 error 多对多关系不清 | `ToGRPCCode` switch 集中映射,单测覆盖每个 case |
| 部分老业务调用方未升级 | 编译时 `BuildBaseResponse` 仍可用,只是新代码统一走 `FormatErrorResponse` |
| 过渡期前端误判(新老字段混读) | 老逻辑不动;新逻辑用 `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 拦截器;前端保持旧版不动
- **Day 2-3**: 观察监控,确认没有 4xx/5xx 异常
- **Day 4**: 前端拦截器升级到新逻辑(读 `code` 优先)
- **Day 5+**: 全量验证 1 周后,移除 `legacy_code` 字段
- **Day 5+**: 全量验证 1 周后,移除 `grpc_code` 字段(此时所有前端已升级,`code` 直接改为 gRPC 数字)
---
@ -507,14 +539,16 @@ grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --inclu
| # | 测试用例 | 期望 |
|---|---|---|
| 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 |
| 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` 正确 |
| 9 | wrapped error `fmt.Errorf("%w: extra", ErrXxx)` | `errors.Is` 仍能识别,code 正确 |
| 10 | `FormatErrorResponse(ErrXxx)` | BaseResponse.Code = gRPC 数字, LegacyCode = HTTP 数字 |
@ -541,7 +575,7 @@ grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --inclu
| # | 场景 | 预期 |
|---|---|---|
| 1 | 旧前端(读 legacy_code) | 行为不变 |
| 1 | 旧前端(读 `code` HTTP 码) | 行为不变 |
| 2 | 新前端(读 code) | 弹窗/toast/登出均正确 |
| 3 | 输错旧密码 | 不登出,toast "旧密码错误" |
| 4 | 账号被冻结 | 弹封禁原因,2s 后跳登录 |
@ -556,10 +590,10 @@ grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --inclu
| 变更 | 文件 |
|---|---|
| 修改 | `backend/proto/common.proto`(BaseResponse 加 `legacy_code` 字段) |
| 修改 | `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`/`ToLegacyCode`/`FormatErrorResponse`) |
| 重写 | `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, ...)`) |
@ -572,7 +606,7 @@ grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --inclu
| 变更 | 文件 |
|---|---|
| 修改 | `frontend/utils/api.js`(拦截器重写,支持 gRPC code + 兼容 legacy_code) |
| 修改 | `frontend/utils/api.js`(保留现有拦截器逻辑不动;新增 `getGrpcCode()` helper 给新业务代码) |
| 修改 | `frontend/pages/**/*.vue`(10+ 文件,业务判断从 `res.data.code === 401/403` 改为 `=== 16/7` 或读 `getBizCode` helper) |
### 总计
@ -588,9 +622,9 @@ grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --inclu
| 项 | 关系 |
|---|---|
| §4.5 错误码映射(`ErrInvalidOldPassword` → 400) | 本次重构后,自动转为 `codes.InvalidArgument (3)`;`legacy_code=400` 保留给旧前端 |
| §5.1 拦截器修复 | 本次重构后,前端拦截器直接判 gRPC code,无需"按 401/403 自动登出"的特殊判断 |
| §12 Login BUG 修复(`errors.Is` 改造) | **本次重构的前置依赖**;`ToGRPCCode` 内部依赖 `errors.Is` |
| §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 修复)**,**再启动本次状态码重构**。
@ -600,14 +634,15 @@ grep -rn "res\.data\.code\s*===\s*\(200\|400\|401\|403\|404\|429\|500\)" --inclu
## 10. 部署/上线检查清单
- [ ] proto regen 后所有 service 编译通过
- [ ] gateway 拦截器单元测试通过
- [ ] E2E 测试覆盖所有 gRPC code 路径
- [ ] 前端拦截器向后兼容测试(旧前端读 legacy_code 仍正常)
- [ ] gateway `GRPCStatusInterceptor` 单元测试通过(responseRecorder 正确捕获 + 重写)
- [ ] E2E 测试覆盖所有 gRPC code 路径(0/3/5/7/8/13/16)
- [ ] 前端拦截器向后兼容测试(老前端读 `code` HTTP 码 仍正常)
- [ ] Dubbo 框架未自动把 gRPC code 转 HTTP 状态码(已被 gateway 拦截器剥除)
- [ ] AuthMiddleware 顺序正确(挂在 GRPCStatusInterceptor 之前,真鉴权失败仍返 HTTP 401)
- [ ] 监控埋点:HTTP 4xx/5xx 比例应降至接近 0(transport 错误除外)
- [ ] 灰度 10% → 50% → 100%,每阶段观察 24h
- [ ] 1-2 sprint 稳定后,移除 `legacy_code` 字段
- [ ] 前端缓存清理(用户升级到新前端)
- [ ] 1-2 sprint 稳定后,移除 `grpc_code` 字段,`code` 改为 gRPC 数字
- [ ] 前端缓存清理(用户升级到新前端)
---