Files

5.4 KiB
Raw Permalink Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
id title type domain stack tags level prerequisites est_hours related week
backend.gin.scenario.unified-api-response 企业后台接口规范:统一响应、错误码、请求 ID 和参数校验 scenario backend
gin
go
接口规范
错误码
请求ID
可观测性
参数校验
intermediate
backend.gin.middleware-auth
2
backend.gin.interview
2
3

企业后台接口规范:统一响应、错误码、请求 ID 和参数校验

业务背景与问题

一个内部管理系统由 4 个后端同事开发,半年后接口风格五花八门:有的返回 {"error": "..."},有的返回 {"msg": "...", "status": -1};参数错误有时 400 有时 200;数据库报错原文直接返回给前端(暴露了表名);用户反馈“提交失败”时,开发只能根据时间点大海捞针地翻日志。前端为每个接口单独写错误处理,维护成本高。

约束

  • 不能一次性重写所有接口,要能逐步迁移。
  • 前端需要稳定的结构:成功、业务失败、系统错误能用同一套代码处理。
  • 安全要求:内部错误信息(SQL、堆栈、连接串)不能返回给用户。
  • 运维要求:任何一次失败都能通过一个 ID 在日志里找到完整链路。

方案设计

响应格式(HTTP 状态码表达大类,code 表达具体业务原因):

{"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 系统错误。错误码在一个文件里集中定义,并生成文档给前端。

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。核心是错误中间件:

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?