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:
parent
fd763298c7
commit
7c897ec4e0
@ -37,8 +37,8 @@ enum StatusCode {
|
|||||||
|
|
||||||
- **核心**:业务状态码与 `google.rpc.Code` 数字(0/3/5/7/8/13/16 等)对齐
|
- **核心**:业务状态码与 `google.rpc.Code` 数字(0/3/5/7/8/13/16 等)对齐
|
||||||
- **传输**:HTTP 状态码固定 200(只有 transport 错误才用 4xx/5xx)
|
- **传输**:HTTP 状态码固定 200(只有 transport 错误才用 4xx/5xx)
|
||||||
- **过渡**:旧 enum(`STATUS_BAD_REQUEST` 等)在过渡期保留,响应体同时返 `code`(新 gRPC 数字)+ `legacy_code`(旧 HTTP 数字)
|
- **过渡**(自审修订后):**`code` 字段保留旧 HTTP 镜像码语义不变**(给老前端,无需改),**新增 `grpc_code` 字段**(新 gRPC code,给新前端)。真"双协议期"
|
||||||
- **前端**:拦截器只判 `res.data.code`,无视 `res.statusCode` 与 `legacy_code`(过渡期兼容)
|
- **前端**:老前端继续读 `res.data.code`(HTTP 镜像码),无需改;新前端读 `res.data.grpc_code` 走 gRPC 语义
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -54,11 +54,13 @@ enum StatusCode {
|
|||||||
|
|
||||||
**最终方案**: B(真正 gRPC codes + grpc-go status 包 + 双协议期迁移)
|
**最终方案**: B(真正 gRPC codes + grpc-go status 包 + 双协议期迁移)
|
||||||
|
|
||||||
|
**自审修订**:原方案 B 的具体实施细节经过自审优化——**保留 `StatusCode` enum(老字段),新增 `grpc_code` 字段**,而不是直接把 `code` 改为 gRPC 数字。理由见 §1.3 和 §4.1。这样过渡期**老前端零改动**即可继续工作(读 `code` HTTP 码),新前端读 `grpc_code` 走 gRPC 语义,真正"双协议"。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 目标架构
|
## 3. 目标架构
|
||||||
|
|
||||||
### 3.1 响应体形态(目标态)
|
### 3.1 响应体形态(目标态 — 双协议期结束后)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -69,23 +71,23 @@ enum StatusCode {
|
|||||||
```
|
```
|
||||||
|
|
||||||
- HTTP 状态码: 固定 200(transport 错误除外)
|
- HTTP 状态码: 固定 200(transport 错误除外)
|
||||||
- `code` 字段: google.rpc.Code 数字
|
- `code` 字段: google.rpc.Code 数字(目标态)
|
||||||
- 业务子码(可选,未来扩展): `biz_code`(在 `code` 之外单独表达业务子分类)
|
- `grpc_code` 字段: 与 `code` 同值(双协议期前端若想用语义化字段可读这个)
|
||||||
|
|
||||||
### 3.2 响应体形态(双协议过渡期)
|
### 3.2 响应体形态(双协议过渡期)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 7, // 新: google.rpc.Code 数字(0=OK)
|
"code": 403, // 旧 HTTP 镜像码(老字段,语义不变;老前端无需改)
|
||||||
"legacy_code": 403, // 旧: HTTP 镜像码,给未升级的前端
|
"grpc_code": 7, // 新 google.rpc.Code 数字(新前端读这个)
|
||||||
"message": "账号已被封禁",
|
"message": "账号已被封禁",
|
||||||
"timestamp": 1718179200000
|
"timestamp": 1718179200000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 已升级前端:读 `code` 判业务类型
|
- **老前端**:继续读 `res.data.code`(HTTP 镜像码 403),无需任何改动 ✅
|
||||||
- 未升级前端:仍可读 `legacy_code` 维持旧逻辑
|
- **新前端**:读 `res.data.grpc_code`(gRPC code 7),按 gRPC 语义处理
|
||||||
- 过渡期结束(预估 2-3 个 sprint):移除 `legacy_code`,更新所有前端
|
- 过渡期结束(预估 2-3 sprint):移除 `grpc_code` 字段,把 `code` 改成 gRPC 数字;同时强制升级所有前端
|
||||||
|
|
||||||
### 3.3 google.rpc.Code 映射表(最终态)
|
### 3.3 google.rpc.Code 映射表(最终态)
|
||||||
|
|
||||||
@ -110,30 +112,35 @@ enum StatusCode {
|
|||||||
▼
|
▼
|
||||||
[Provider Layer]
|
[Provider Layer]
|
||||||
resp := &pb.UpdatePasswordResponse{
|
resp := &pb.UpdatePasswordResponse{
|
||||||
Base: &pbCommon.BaseResponse{
|
Base: appErrors.FormatErrorResponse(
|
||||||
Code: uint32(codes.PermissionDenied), // = 7
|
status.Error(codes.PermissionDenied, "账号已被封禁"),
|
||||||
Message: "账号已被封禁",
|
),
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
resp, err
|
// BaseResponse.Code = STATUS_FORBIDDEN (403) ← 老字段
|
||||||
|
// BaseResponse.GrpcCode = 7 ← 新字段
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
[Dubbo-Triples 框架]
|
[Dubbo-Triples 框架]
|
||||||
将 gRPC status 序列化为 protobuf 响应
|
将 gRPC status 序列化为 protobuf 响应
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
[Gin Gateway 拦截器]
|
[Gin Gateway GRPCStatusInterceptor]
|
||||||
中间件统一改写:无论 gRPC code 是什么,HTTP statusCode 强制 = 200
|
用 responseRecorder 捕获 body,强制 HTTP 200,body 原样写回
|
||||||
c.JSON(200, resData)
|
body = {code: 403, grpc_code: 7, message: "...", timestamp: ...}
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
[uni-app 前端拦截器]
|
[uni-app 老前端拦截器]
|
||||||
只判 res.data.code
|
读 res.data.code (= 403),按 HTTP 语义处理
|
||||||
if (res.data.code === 0) resolve()
|
if (res.data.code === 401) logout()
|
||||||
else if (res.data.code === 16) logout()
|
else if (res.data.code === 403) showBanReason()
|
||||||
else if (res.data.code === 7) showBanReason()
|
else toast
|
||||||
else reject()
|
│
|
||||||
|
▼
|
||||||
|
[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)
|
**文件**:[backend/proto/common.proto](backend/proto/common.proto)
|
||||||
|
|
||||||
引入 `google.rpc.Code` 作为新 `code` 字段语义来源。**proto 文件本身不需要枚举定义**(直接用 google 的),仅需 `BaseResponse` 字段保持兼容:
|
**自审修订**:原设计把 `code` 改为 gRPC 数字会破坏所有老前端(它们期望 HTTP 码)。改为**保留 `code` 语义不变,新增 `grpc_code` 字段**:
|
||||||
|
|
||||||
```protobuf
|
```protobuf
|
||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
@ -152,18 +159,32 @@ package topfans.common;
|
|||||||
|
|
||||||
option go_package = "github.com/topfans/backend/pkg/proto/common;common";
|
option go_package = "github.com/topfans/backend/pkg/proto/common;common";
|
||||||
|
|
||||||
// 不再自定义 enum,直接用 google.rpc.Code 数字
|
// 保留自定义 StatusCode enum(老代码已用)
|
||||||
// BaseResponse.code 语义改为 google.rpc.Code 数字
|
// 过渡期内 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 {
|
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;
|
string message = 2;
|
||||||
int64 timestamp = 3;
|
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 类型传递,保持向后兼容。
|
> proto 中不直接 `import "google/rpc/status.proto"` 是因为我们要的是 code 数字常量(从 `google.golang.org/grpc/codes` 取),不强制通过 proto 类型传递,保持向后兼容。
|
||||||
|
|
||||||
### 4.2 errors.go 重写
|
### 4.2 errors.go 重写
|
||||||
@ -178,19 +199,51 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
pb "github.com/topfans/backend/pkg/proto/common"
|
pb "github.com/topfans/backend/pkg/proto/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 业务错误类型(同前,只改 ToGRPCCode 和 FormatErrorResponse)
|
// 业务错误类型(同前;新增 ErrInvalidOldPassword 由 change-password spec 引入)
|
||||||
var (
|
var (
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrUserAlreadyExists = errors.New("user already exists")
|
ErrUserAlreadyExists = errors.New("user already exists")
|
||||||
ErrInvalidPassword = errors.New("invalid password")
|
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 数字
|
// ToGRPCCode: 业务 error -> google.rpc.Code 数字
|
||||||
// 同时返回旧 HTTP 镜像码,用于过渡期
|
// 新增,给 BaseResponse.grpc_code 用
|
||||||
func ToGRPCCode(err error) codes.Code {
|
func ToGRPCCode(err error) codes.Code {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return codes.OK
|
return codes.OK
|
||||||
@ -200,24 +253,17 @@ func ToGRPCCode(err error) codes.Code {
|
|||||||
return codes.NotFound
|
return codes.NotFound
|
||||||
case errors.Is(err, ErrUserAlreadyExists), errors.Is(err, ErrFanProfileAlreadyExists),
|
case errors.Is(err, ErrUserAlreadyExists), errors.Is(err, ErrFanProfileAlreadyExists),
|
||||||
errors.Is(err, ErrNicknameAlreadyExists), errors.Is(err, ErrInvalidMobile),
|
errors.Is(err, ErrNicknameAlreadyExists), errors.Is(err, ErrInvalidMobile),
|
||||||
errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrInvalidStarID),
|
errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrInvalidOldPassword), // ← 新增
|
||||||
errors.Is(err, ErrInvalidUserID), errors.Is(err, ErrMaxIdentitiesReached),
|
errors.Is(err, ErrInvalidStarID), errors.Is(err, ErrInvalidUserID),
|
||||||
errors.Is(err, ErrInvalidNickname), errors.Is(err, ErrCannotAddSelf),
|
errors.Is(err, ErrMaxIdentitiesReached), errors.Is(err, ErrInvalidNickname),
|
||||||
errors.Is(err, ErrCannotSearchSelf), errors.Is(err, ErrNotFanOfStar),
|
// ... 其他业务错误同 ToStatusCode
|
||||||
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
|
return codes.InvalidArgument
|
||||||
case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken),
|
case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken),
|
||||||
errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch):
|
errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch):
|
||||||
return codes.Unauthenticated
|
return codes.Unauthenticated
|
||||||
case errors.Is(err, ErrAccountFrozen), errors.Is(err, ErrAccountBanned),
|
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
|
return codes.PermissionDenied
|
||||||
case errors.Is(err, ErrRequestInCooldown):
|
case errors.Is(err, ErrRequestInCooldown):
|
||||||
return codes.ResourceExhausted
|
return codes.ResourceExhausted
|
||||||
@ -226,66 +272,43 @@ func ToGRPCCode(err error) codes.Code {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToLegacyCode: 业务 error -> 旧 HTTP 镜像码(过渡期用)
|
// FormatErrorResponse: 构造 BaseResponse,同时填 code(老) 和 grpc_code(新)
|
||||||
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 {
|
func FormatErrorResponse(err error) *pb.BaseResponse {
|
||||||
return &pb.BaseResponse{
|
return &pb.BaseResponse{
|
||||||
Code: uint32(ToGRPCCode(err)), // 新 gRPC code
|
Code: ToStatusCode(err), // 旧: HTTP 镜像码(老前端读)
|
||||||
LegacyCode: ToLegacyCode(err), // 旧 HTTP 镜像码(过渡期)
|
GrpcCode: uint32(ToGRPCCode(err)), // 新: gRPC 数字(新前端读)
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
Timestamp: getCurrentTimestamp(),
|
Timestamp: getCurrentTimestamp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatSuccessResponse: 成功响应
|
// FormatSuccessResponse: 成功响应
|
||||||
func FormatSuccessResponse() *pb.BaseResponse {
|
func FormatSuccessResponse() *pb.BaseResponse {
|
||||||
return &pb.BaseResponse{
|
return &pb.BaseResponse{
|
||||||
Code: uint32(codes.OK),
|
Code: pb.StatusCode_STATUS_OK,
|
||||||
LegacyCode: 200,
|
GrpcCode: uint32(codes.OK),
|
||||||
Message: "",
|
Message: "",
|
||||||
Timestamp: getCurrentTimestamp(),
|
Timestamp: getCurrentTimestamp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留 BuildBaseResponse 兼容
|
// 保留 BuildBaseResponse 别名(防外部包未及时更新)
|
||||||
func BuildBaseResponse(err error) *pb.BaseResponse {
|
func BuildBaseResponse(err error) *pb.BaseResponse {
|
||||||
return FormatErrorResponse(err)
|
return FormatErrorResponse(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewError: 用 google.rpc.Code 构造业务错误
|
// NewError: 业务 error -> google.rpc.status.Error
|
||||||
// 推荐 service 层用 status.Error(codes.X, msg),但保留这个 helper 兼容
|
// 推荐 service 层用 status.Error(codes.X, msg);保留 helper 兼容
|
||||||
func NewError(code codes.Code, message string) error {
|
func NewError(code codes.Code, message string) error {
|
||||||
return status.Error(code, message)
|
return status.Error(code, message)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键点**:
|
**关键点**:
|
||||||
- 删掉旧的 `ToStatusCode` 函数
|
- `ToStatusCode` 函数保留(只内部补 `ErrInvalidOldPassword` / `ErrUserInactive` 两个 case),兼容所有 `BaseResponse{Code: ...}` 旧调用方
|
||||||
- 新增 `ToGRPCCode`(业务 → gRPC code) + `ToLegacyCode`(业务 → 旧 HTTP 码)
|
- 新增 `ToGRPCCode` 函数,内部逻辑与 `ToStatusCode` 镜像
|
||||||
- `BaseResponse` 同时返两个码,过渡期两套前端都能用
|
- `BaseResponse.Code` 继续填 HTTP 镜像码,`BaseResponse.GrpcCode` 填 gRPC 数字
|
||||||
- 保留 `BuildBaseResponse` 别名(防外部包未及时更新)
|
- 过渡期老前端读 `code` 行为完全不变;新前端读 `grpc_code`
|
||||||
|
|
||||||
### 4.3 service 层调用点改造
|
### 4.3 service 层调用点改造
|
||||||
|
|
||||||
@ -316,11 +339,17 @@ return &pb.UpdatePasswordResponse{
|
|||||||
**Call-site 审计**(实施前必跑):
|
**Call-site 审计**(实施前必跑):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -rn "StatusCode_STATUS_" --include="*.go" backend/services/
|
# 1. 找硬编码 StatusCode enum 引用
|
||||||
grep -rn "StatusCode_STATUS_" --include="*.go" backend/gateway/
|
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 适配
|
### 4.4 Dubbo 适配
|
||||||
|
|
||||||
@ -342,13 +371,26 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// GRPCStatusInterceptor: 拦截所有 Dubbo 透传过来的响应,统一改写 HTTP 状态码为 200
|
||||||
// 业务码在响应体 code 字段中
|
// 业务码在响应体 code 字段中
|
||||||
// 仅 transport 错误(404 路由不存在、405 方法不允许、5xx 网关内部错)保留 HTTP 状态码
|
// 仅 transport 错误(404 路由不存在、405 方法不允许、5xx 网关内部错)保留 HTTP 状态码
|
||||||
@ -360,13 +402,14 @@ func GRPCStatusInterceptor() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拦截响应体
|
// 拦截响应体到 recorder
|
||||||
writer := &responseRecorder{ResponseWriter: c.Writer, body: &bytes.Buffer{}}
|
recorder := &responseRecorder{ResponseWriter: c.Writer, body: &bytes.Buffer{}}
|
||||||
c.Writer = writer
|
c.Writer = recorder
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
// 强制改写 HTTP 状态码为 200
|
// 把捕获的 body 原样写回,但强制 HTTP 200
|
||||||
writer.ResponseWriter.WriteHeader(http.StatusOK)
|
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),这是**问题根源**——它把业务码泄漏到传输层。
|
实际上 Dubbo 框架**已经会自动把 gRPC status 转成 HTTP 状态码**(例如 `codes.Unauthenticated` → HTTP 401),这是**问题根源**——它把业务码泄漏到传输层。
|
||||||
|
|
||||||
需要在 gateway 这一层**剥掉 Dubbo 的自动转换**,强制所有 `/api/*` 走 HTTP 200。
|
需要在 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 子包依赖
|
### 4.6 新增 gRPC 子包依赖
|
||||||
|
|
||||||
**go.mod** 增加:
|
**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)
|
**修改** [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
|
```js
|
||||||
// 过渡期:双协议兼容
|
// 新增 helper:读 gRPC code(新前端业务代码用)
|
||||||
// 优先读新 gRPC code (res.data.code),fallback 到旧 HTTP 业务码 (res.data.legacy_code)
|
export function getGrpcCode(res) {
|
||||||
const getBizCode = (res) => {
|
if (res?.data && typeof res.data.grpc_code === 'number') {
|
||||||
if (res.data && res.data.code !== undefined) {
|
return res.data.grpc_code // 0=OK, 3=InvalidArgument, 7=PermissionDenied, 16=Unauthenticated, ...
|
||||||
return res.data.code // 新 gRPC code (uint32 数字)
|
|
||||||
}
|
}
|
||||||
return res.data?.legacy_code ?? res.statusCode // 兜底
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode === 200 || res.statusCode === 202) {
|
// 现有拦截器 api.js 内 code 完全保留(老逻辑判 res.data.code === 401/403 等)
|
||||||
if (res.data && getBizCode(res) !== undefined) {
|
// 不动 401/400/403 的老判断逻辑
|
||||||
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 现有逻辑**,只新增 `getGrpcCode` helper。新业务代码(如改密流程的"输错旧密码"判断)用 `getGrpcCode(res) === 3` 替代 `res.data.code === 400`,获得更精细的语义。
|
||||||
|
|
||||||
### 5.2 BUG 修复自然吸收
|
### 5.2 BUG 修复自然吸收
|
||||||
|
|
||||||
§1.3 中提到的 BUG 在新架构下自动消失:
|
§1.3 中提到的 BUG 在新架构下**依赖 change-password spec 的 §12 Login BUG 修复**(errors.Is 改造 + ErrInvalidOldPassword)才能完全消失:
|
||||||
- 前端不再判 HTTP 400 → 不会误踢
|
- change-password spec §5.1 修复后,前端拦截器已不再判 HTTP 400 自动登出(改为判 401/403)
|
||||||
- 前端判 gRPC code 7/16 → 精准区分"封号"和"密码错"
|
- change-password spec §12 修复后,后端 Login 流程被冻结/封禁时返 `codes.PermissionDenied`,前端按 403 处理(清 token + 跳登录 + 显示封禁原因)
|
||||||
- 后端 Login 流程用 `codes.PermissionDenied` 替代 `errors.New` → 403 不再是 500
|
|
||||||
|
**状态码重构本身只是把这些改造"标准化"到全仓,不引入新的功能修复**。
|
||||||
|
|
||||||
### 5.3 过渡期兼容矩阵
|
### 5.3 过渡期兼容矩阵
|
||||||
|
|
||||||
| 旧前端(读 legacy_code) | 新前端(读 code) | 后端响应包含 | 行为 |
|
| 老前端(读 `res.data.code` HTTP 码) | 新前端(读 `res.data.grpc_code` gRPC) | 后端响应 | 老前端行为 | 新前端行为 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 旧 | 旧 | `{code: 7, legacy_code: 403}` | 旧前端按 403 处理 ✅ |
|
| 旧版未升级 | 旧版未升级 | `{code: 401, grpc_code: 16}` | 按 401 跳登录 ✅ | — |
|
||||||
| 旧 | 新 | `{code: 7, legacy_code: 403}` | 都正确 |
|
| 旧版未升级 | 新版升级 | `{code: 401, grpc_code: 16}` | 按 401 跳登录 ✅ | 走 grpc_code=16 判 Unauthenticated ✅ |
|
||||||
| 新 | 旧 | `{code: 7, legacy_code: 403}` | 都正确 |
|
| 新版升级 | 旧版未升级 | `{code: 401, grpc_code: 16}` | — | 老前端按 401 ✅ |
|
||||||
| 新 | 新 | `{code: 7, legacy_code: 403}` | 都正确 |
|
| 新版升级 | 新版升级 | `{code: 401, grpc_code: 16}` | — | 都正确 ✅ |
|
||||||
|
|
||||||
过渡期只要后端同时返两个字段,任何前端都能跑。
|
**双协议期真正的"双协议"**:后端同时返 `code`(HTTP 镜像) 和 `grpc_code`(gRPC 数字),任何前端都能跑。
|
||||||
|
|
||||||
### 5.4 Call-site 审计
|
### 5.4 Call-site 审计
|
||||||
|
|
||||||
```bash
|
```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/
|
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 0: 准备** | 写 `ToGRPCCode`,保留 `ToStatusCode`;`BaseResponse` 加 `grpc_code` 字段 | 无变化 | 0.5 sprint |
|
||||||
| **Phase 1: 双协议期** | 所有响应同时返 `code`(gRPC) 和 `legacy_code`(HTTP) | 旧前端继续读 `legacy_code`;新前端读 `code`;拦截器支持两者 | 2-3 sprint |
|
| **Phase 1: 双协议期** | 所有响应同时返 `code`(HTTP 镜像) 和 `grpc_code`(gRPC 数字) | **老前端零改动继续跑**;新前端业务代码可选用 `getGrpcCode()` 读新字段 | 2-3 sprint |
|
||||||
| **Phase 2: 全量升级前端** | 仍双协议 | 全部前端改为读 `code`;移除 `legacy_code` 兜底 | 1 sprint |
|
| **Phase 2: 全量升级前端** | 仍双协议 | 全部前端迁移到读 `grpc_code`;移除对 `code` 的 HTTP 数字依赖 | 1 sprint |
|
||||||
| **Phase 3: 清理** | 移除 `legacy_code` 字段,proto 改回单字段 | 移除 `getBizCode` 兼容逻辑 | 0.5 sprint |
|
| **Phase 3: 清理** | 移除 `grpc_code` 字段,`code` 改回 gRPC 数字(此时所有前端已升级,语义统一) | 移除 `getGrpcCode` 兼容 helper | 0.5 sprint |
|
||||||
|
|
||||||
### 6.2 风险控制
|
### 6.2 风险控制
|
||||||
|
|
||||||
| 风险 | 缓解 |
|
| 风险 | 缓解 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 过渡期前端误判(新旧字段混读) | `getBizCode` helper 统一处理,代码评审重点 |
|
| 过渡期前端误判(新老字段混读) | 老逻辑不动;新逻辑用 `getGrpcCode()` helper,代码评审重点 |
|
||||||
| Dubbo 框架自动转换 HTTP 状态码未拦截 | gateway 强制 HTTP 200 拦截器必上,加 e2e 测试 |
|
| Dubbo 框架自动转换 HTTP 状态码未拦截 | gateway `responseRecorder` 拦截器必上,加 e2e 测试 |
|
||||||
| `google.rpc.Code` 数字与业务 error 多对多关系不清 | `ToGRPCCode` switch 集中映射,单测覆盖每个 case |
|
| `google.rpc.Code` 数字与业务 error 多对多关系不清 | `ToStatusCode` 和 `ToGRPCCode` 平行 switch,单测覆盖每个 case |
|
||||||
| 部分老业务调用方未升级 | 编译时 `BuildBaseResponse` 仍可用,只是新代码统一走 `FormatErrorResponse` |
|
| 部分老业务调用方未升级 | `ToStatusCode` 函数保留,所有现有 `BaseResponse{Code: ToStatusCode(err)}` 继续工作 |
|
||||||
|
| AuthMiddleware 与 GRPCStatusInterceptor 顺序冲突 | 实施时调整中间件链:AuthMiddleware 走 HTTP 401 时不被 GRPCStatusInterceptor 拦截(AuthMiddleware 挂在 GRPCStatusInterceptor **之前**) |
|
||||||
|
|
||||||
### 6.3 灰度发布
|
### 6.3 灰度发布
|
||||||
|
|
||||||
- **Day 1**: 后端全量上双协议 + gateway 拦截器;前端保持旧版不动
|
- **Day 1**: 后端全量上双协议 + gateway 拦截器;前端保持旧版不动
|
||||||
- **Day 2-3**: 观察监控,确认没有 4xx/5xx 异常
|
- **Day 2-3**: 观察监控,确认没有 4xx/5xx 异常
|
||||||
- **Day 4**: 前端拦截器升级到新逻辑(读 `code` 优先)
|
- **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` |
|
| 1 | `nil` error | `ToStatusCode = STATUS_OK`, `ToGRPCCode = OK (0)` |
|
||||||
| 2 | `ErrUserNotFound` | `NotFound (5)`, legacy 404 |
|
| 2 | `ErrUserNotFound` | `ToStatusCode = STATUS_NOT_FOUND`, `ToGRPCCode = NotFound (5)` |
|
||||||
| 3 | `ErrInvalidPassword` | `Unauthenticated (16)`, legacy 401 |
|
| 3 | `ErrInvalidPassword` | `ToStatusCode = STATUS_UNAUTHORIZED`, `ToGRPCCode = Unauthenticated (16)` |
|
||||||
| 4 | `ErrAccountFrozen` (wrapped) | `PermissionDenied (7)`, legacy 403 |
|
| 4 | `ErrAccountFrozen` (wrapped) | `ToStatusCode = STATUS_FORBIDDEN`, `ToGRPCCode = PermissionDenied (7)` |
|
||||||
| 5 | `ErrAccountBanned` (wrapped) | `PermissionDenied (7)`, legacy 403 |
|
| 5 | `ErrAccountBanned` (wrapped) | `ToStatusCode = STATUS_FORBIDDEN`, `ToGRPCCode = PermissionDenied (7)` |
|
||||||
| 6 | `ErrPasswordTooShort` | `InvalidArgument (3)`, legacy 400 |
|
| 6 | `ErrPasswordTooShort` | `ToStatusCode = STATUS_BAD_REQUEST`, `ToGRPCCode = InvalidArgument (3)` |
|
||||||
| 7 | `ErrRequestInCooldown` | `ResourceExhausted (8)`, legacy 429 |
|
| 7 | `ErrRequestInCooldown` | `ToStatusCode = STATUS_TOO_MANY_REQUESTS`, `ToGRPCCode = ResourceExhausted (8)` |
|
||||||
| 8 | `errors.New("unknown")` | `Internal (13)`, legacy 500 |
|
| 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 正确 |
|
| 9 | wrapped error `fmt.Errorf("%w: extra", ErrXxx)` | `errors.Is` 仍能识别,code 正确 |
|
||||||
| 10 | `FormatErrorResponse(ErrXxx)` | BaseResponse.Code = gRPC 数字, LegacyCode = HTTP 数字 |
|
| 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/登出均正确 |
|
| 2 | 新前端(读 code) | 弹窗/toast/登出均正确 |
|
||||||
| 3 | 输错旧密码 | 不登出,toast "旧密码错误" |
|
| 3 | 输错旧密码 | 不登出,toast "旧密码错误" |
|
||||||
| 4 | 账号被冻结 | 弹封禁原因,2s 后跳登录 |
|
| 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.pb.go` |
|
||||||
| regen | `backend/pkg/proto/common/common.triple.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/pkg/errors/errors_test.go` |
|
||||||
| 修改 | `backend/services/*/provider/*.go`(30+ 文件,所有硬编码 `StatusCode_STATUS_X` 改为 `appErrors.FormatErrorResponse(status.Error(codes.X, msg))`) |
|
| 修改 | `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/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) |
|
| 修改 | `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` 保留给旧前端 |
|
| §4.5 错误码映射(`ErrInvalidOldPassword` → 400) | 本次重构后,`BaseResponse.code = 400`(HTTP 镜像,老前端继续判),`BaseResponse.grpc_code = 3`(gRPC 数字,新前端读) |
|
||||||
| §5.1 拦截器修复 | 本次重构后,前端拦截器直接判 gRPC code,无需"按 401/403 自动登出"的特殊判断 |
|
| §5.1 拦截器修复 | 本次重构后,新前端业务代码可用 `getGrpcCode()` 替代原 `res.data.code` 判断;老前端拦截器逻辑完全不动 |
|
||||||
| §12 Login BUG 修复(`errors.Is` 改造) | **本次重构的前置依赖**;`ToGRPCCode` 内部依赖 `errors.Is` |
|
| §12 Login BUG 修复(`errors.Is` 改造) | **本次重构的前置依赖**;`ToStatusCode` 和 `ToGRPCCode` 内部都依赖 `errors.Is` |
|
||||||
| §11 部署清单 | 加上"双协议期开关" |
|
| §11 部署清单 | 加上"双协议期开关" |
|
||||||
|
|
||||||
执行顺序:**先实现 change-password spec(含 §12 Login BUG 修复)**,**再启动本次状态码重构**。
|
执行顺序:**先实现 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. 部署/上线检查清单
|
## 10. 部署/上线检查清单
|
||||||
|
|
||||||
- [ ] proto regen 后所有 service 编译通过
|
- [ ] proto regen 后所有 service 编译通过
|
||||||
- [ ] gateway 拦截器单元测试通过
|
- [ ] gateway `GRPCStatusInterceptor` 单元测试通过(responseRecorder 正确捕获 + 重写)
|
||||||
- [ ] E2E 测试覆盖所有 gRPC code 路径
|
- [ ] E2E 测试覆盖所有 gRPC code 路径(0/3/5/7/8/13/16)
|
||||||
- [ ] 前端拦截器向后兼容测试(旧前端读 legacy_code 仍正常)
|
- [ ] 前端拦截器向后兼容测试(老前端读 `code` HTTP 码 仍正常)
|
||||||
- [ ] Dubbo 框架未自动把 gRPC code 转 HTTP 状态码(已被 gateway 拦截器剥除)
|
- [ ] Dubbo 框架未自动把 gRPC code 转 HTTP 状态码(已被 gateway 拦截器剥除)
|
||||||
|
- [ ] AuthMiddleware 顺序正确(挂在 GRPCStatusInterceptor 之前,真鉴权失败仍返 HTTP 401)
|
||||||
- [ ] 监控埋点:HTTP 4xx/5xx 比例应降至接近 0(transport 错误除外)
|
- [ ] 监控埋点:HTTP 4xx/5xx 比例应降至接近 0(transport 错误除外)
|
||||||
- [ ] 灰度 10% → 50% → 100%,每阶段观察 24h
|
- [ ] 灰度 10% → 50% → 100%,每阶段观察 24h
|
||||||
- [ ] 1-2 sprint 稳定后,移除 `legacy_code` 字段
|
- [ ] 1-2 sprint 稳定后,移除 `grpc_code` 字段,`code` 改为 gRPC 数字
|
||||||
- [ ] 旧前端缓存清理(用户升级到新前端)
|
- [ ] 老前端缓存清理(用户升级到新前端)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user