错误码与统一返回体设计
一句话结论:统一返回体解决“客户端怎么解析结果”,建议 {code, message, data, requestId}:code=0(或 200)表示成功,非 0 表示业务失败;错误码集中在一个 errcode 包内定义并文档化,按段位划分(系统/参数/认证授权/业务),只增不改、语义稳定;message 给客户端可读文案,内部细节(堆栈、SQL、第三方响应)只进日志不外泄。实现上用自定义 Error 携带 code 与 HTTP 状态,Gin 侧用响应封装 + 错误收口,避免每个 handler 手写失败分支,并用 requestId 串联日志排障。
返回体与错误码段位
| 段位 | 含义 | 示例 |
|---|---|---|
| 0 | 成功 | code=0 |
| 1xxxx | 系统错误 | 10001 内部错误、10002 超时、10003 限流 |
| 2xxxx | 参数/校验 | 20001 参数缺失、20002 格式错误 |
| 3xxxx | 认证授权 | 30001 未登录、30002 token 过期、30003 无权限 |
| 4xxxx | 业务冲突 | 40001 库存不足、40002 重复提交、40003 状态不允许 |
约定:错误码全局唯一、文档化、一旦发布只增不改;message 可 i18n,detail 可选携带可安全暴露的上下文。
实现骨架
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
HTTP int `json:"-"` // 对应 HTTP 状态
}
var ErrNotFound = &Error{Code: 30003, Message: "无权限", HTTP: 403}
func (e *Error) Error() string { return e.Message }
func OK(c *gin.Context, data any) {
c.JSON(200, gin.H{"code": 0, "message": "ok", "data": data,
"requestId": c.GetString("request_id")})
}
func Fail(c *gin.Context, err error) {
var e *Error
if errors.As(err, &e) {
c.JSON(e.HTTP, gin.H{"code": e.Code, "message": e.Message,
"requestId": c.GetString("request_id")})
return
}
log.Error("未归类错误", "err", err, "requestId", ...) // 内部细节只进日志
c.JSON(500, gin.H{"code": 10001, "message": "系统繁忙", ...})
}
handler 只做一件事:if err := svc.Do(...); err != nil { Fail(c, err); return }。
设计要点
- 不要 20 个 handler 各写一套 JSON 失败分支,成功/失败都走统一封装。
- 业务失败到底用 200+code 还是 HTTP 语义状态码?对外 API 建议 HTTP 状态码(4xx/5xx)表达大类 + body code 表达细节;内部老系统可全 200+code,全团队一致即可。
- panic 兜底:gin.Recovery 或自定义 Recovery 把 panic 转 500 并打堆栈日志,别让请求裸 500 无日志。
- requestId 由入口中间件生成并注入日志(与日志体系章节配合),出问题能按 id 拉全链路日志。
追问记忆点
- 追问:错误码为什么要文档化且只增不改?——客户端/网关按 code 分支,改语义等于线上事故;新增码要进文档与测试。
- 追问:handler 层错误和 service 层错误怎么衔接?——service 返回带 code 的错误(或 errors.Join 包装),handler 用 errors.As 还原成响应。
- 追问:10001 和 500 都表示内部错误,重复吗?——HTTP 500 面向传输层,code 面向业务分支,二者配合而非二选一。
- 记忆点:统一 {code,message,data,requestId};错误码分段、稳定、集中定义;细节进日志不进响应;收口错误与 panic。