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:
parent
8c90de5b08
commit
fd763298c7
621
docs/superpowers/specs/2026-06-12-status-code-refactor-design.md
Normal file
621
docs/superpowers/specs/2026-06-12-status-code-refactor-design.md
Normal 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 内部错误机制调整 | 维持原状,只在外层包装 |
|
||||
Loading…
Reference in New Issue
Block a user