108 lines
5.4 KiB
Markdown
108 lines
5.4 KiB
Markdown
---
|
||
id: backend.gin.scenario.unified-api-response
|
||
title: 企业后台接口规范:统一响应、错误码、请求 ID 和参数校验
|
||
type: scenario
|
||
domain: backend
|
||
stack: [gin, go]
|
||
tags: [接口规范, 错误码, 请求ID, 可观测性, 参数校验]
|
||
level: intermediate
|
||
prerequisites: [backend.gin.middleware-auth]
|
||
est_hours: 2
|
||
related: [backend.gin.interview]
|
||
week: [2, 3]
|
||
---
|
||
|
||
# 企业后台接口规范:统一响应、错误码、请求 ID 和参数校验
|
||
|
||
## 业务背景与问题
|
||
|
||
一个内部管理系统由 4 个后端同事开发,半年后接口风格五花八门:有的返回 `{"error": "..."}`,有的返回 `{"msg": "...", "status": -1}`;参数错误有时 400 有时 200;数据库报错原文直接返回给前端(暴露了表名);用户反馈“提交失败”时,开发只能根据时间点大海捞针地翻日志。前端为每个接口单独写错误处理,维护成本高。
|
||
|
||
## 约束
|
||
|
||
- 不能一次性重写所有接口,要能逐步迁移。
|
||
- 前端需要稳定的结构:成功、业务失败、系统错误能用同一套代码处理。
|
||
- 安全要求:内部错误信息(SQL、堆栈、连接串)不能返回给用户。
|
||
- 运维要求:任何一次失败都能通过一个 ID 在日志里找到完整链路。
|
||
|
||
## 方案设计
|
||
|
||
**响应格式**(HTTP 状态码表达大类,`code` 表达具体业务原因):
|
||
|
||
```json
|
||
{"code": 0, "message": "ok", "data": {"id": 1}, "request_id": "a1b2c3d4e5f60718"}
|
||
{"code": 40000, "message": "title 不能为空", "data": null, "request_id": "..."}
|
||
```
|
||
|
||
**错误码规则**:5 位数字,前 3 位对应 HTTP 状态码,后 2 位是业务细分。例如 40000 参数错误、40100 未登录、40101 登录过期、40300 无权限、40400 资源不存在、40900 状态冲突、50000 系统错误。错误码在一个文件里集中定义,并生成文档给前端。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant FE as 前端
|
||
participant RID as RequestID 中间件
|
||
participant ERR as Errors 中间件
|
||
participant H as 处理函数
|
||
FE->>RID: 请求(可带 X-Request-ID)
|
||
RID->>ERR: 设置 request_id,写回响应头
|
||
ERR->>H: c.Next()
|
||
H-->>ERR: c.Error(AppError) 或 panic 或 成功响应
|
||
ERR-->>FE: 统一格式;内部错误只返回“服务器开小差”,详细信息写日志(带 request_id)
|
||
```
|
||
|
||
**职责划分**:
|
||
|
||
1. `RequestID` 中间件:沿用网关或前端传入的 ID(便于跨服务追踪),没有就生成。
|
||
2. 处理函数:只做“绑定参数 → 调用 service → 成功响应 / `c.Error(err)`”,不自己拼错误 JSON。
|
||
3. `Errors` 中间件:把 `AppError` 转成对应响应;未知错误和 panic 统一转成 50000,详细信息只写日志。
|
||
4. 校验错误翻译成中文、用 JSON 字段名,前端可以直接展示。
|
||
5. **逐步迁移**:新接口直接用新规范;旧接口在网关层或前端适配层兼容,按模块逐步改造。
|
||
|
||
## 关键代码
|
||
|
||
完整实现和 8 个测试用例见 [examples/unified-api](../examples/unified-api/)。核心是错误中间件:
|
||
|
||
```go
|
||
func Errors(log *slog.Logger) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Error("panic", "request_id", c.GetString("requestID"), "panic", r)
|
||
writeErr(c, ErrInternal)
|
||
}
|
||
}()
|
||
c.Next()
|
||
if len(c.Errors) == 0 || c.Writer.Written() {
|
||
return
|
||
}
|
||
err := c.Errors.Last().Err
|
||
var ae *AppError
|
||
if errors.As(err, &ae) {
|
||
writeErr(c, ae) // 业务错误:提示语是写给用户看的
|
||
return
|
||
}
|
||
log.Error("internal error", "request_id", c.GetString("requestID"), "err", err)
|
||
writeErr(c, ErrInternal) // 未知错误:不暴露细节
|
||
}
|
||
}
|
||
```
|
||
|
||
## 取舍
|
||
|
||
- **HTTP 状态码 + 业务码 vs 全部返回 200**:全部 200 的做法前端处理简单,但网关监控、浏览器缓存、重试策略都依赖 HTTP 状态码。本方案两者都给:状态码表达大类,业务码表达细节。
|
||
- **错误码集中定义**:增加一点流程成本(新增错误码要登记),换来前后端一致和可生成文档。
|
||
- **请求 ID 自己生成 vs 接入链路追踪(OpenTelemetry)**:先用请求 ID 解决 80% 的排查问题,后续接入追踪时把 trace ID 作为请求 ID 即可,接口格式不变。
|
||
|
||
## 易错点
|
||
|
||
- 处理函数里既 `c.JSON` 又 `c.Error`,中间件里要判断 `c.Writer.Written()`,否则会写两次响应。
|
||
- `panic` 恢复后要记录堆栈(`debug.Stack()`),否则很难定位。
|
||
- 请求 ID 来自客户端时要限制长度和字符,防止日志注入。
|
||
- 错误提示里拼接用户输入,要注意不要把敏感数据回显。
|
||
- 不同服务的错误码冲突:多服务时按服务分段(例如前两位是服务编号)。
|
||
|
||
## 面试怎么讲
|
||
|
||
> “我们后台接口曾经格式不统一、错误信息直接暴露 SQL、排查问题要翻很久日志。我定了统一响应格式和 5 位业务错误码,用两个中间件实现:一个生成或透传请求 ID,一个统一把业务错误、未知错误和 panic 转成标准响应,内部错误只给用户友好提示、细节带着请求 ID 写日志。校验错误翻译成中文直接给前端展示。新接口直接用,旧接口逐步迁移。之后用户报错只要提供请求 ID,就能在日志里一次定位。”
|
||
|
||
可能的追问:错误码怎么管理和生成文档?多个微服务之间请求 ID 怎么传递(HTTP 头透传、gRPC metadata)?为什么不全部返回 200?
|