Files

108 lines
5.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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?