从一段很常见的 Controller 说起
刚开始写 Gin 项目的时候,Controller 基本都长这样:
go
func (c *UserController) Update(ctx *gin.Context) {
var req UpdateReq
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(400, gin.H{"code": 400, "message": "参数错误"})
return
}
user, err := userService.Get(req.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.JSON(400, gin.H{"code": 400, "message": "用户不存在"})
return
}
ctx.JSON(500, gin.H{"code": 500, "message": "服务器错误"})
return
}
// ...
}
问题不在于它能不能跑,而在于:Controller 被迫知道了错误是从哪一层、因为什么原因产生的 。gorm.ErrRecordNotFound 是数据访问层的概念,它不该泄漏到 HTTP 层。等哪天你把 GORM 换掉,或者给这个查询加一层缓存,所有 Controller 都得跟着改。
而且这段判断会在每个 Controller 里复制一遍。二十个接口就是二十份。
只区分两类错误
我们最后收敛到一个很简单的模型:所有错误只分两类。
一类是「能直接说给用户听的」------参数不合法、余额不足、名字重复。这类错误的 Message 本身就是要展示到界面上的文案。
另一类是「不能说给用户听的」------数据库连不上、下游超时、空指针。这类一律返回一句通用提示,真实原因只进日志。
于是错误类型就只需要一个:
go
type BusinessError struct {
Message string
}
func (e *BusinessError) Error() string {
return e.Message
}
func NewBusinessError(message string) *BusinessError {
return &BusinessError{Message: message}
}
func IsBusinessError(err error) bool {
var bizErr *BusinessError
return errors.As(err, &bizErr)
}
注意 IsBusinessError 用的是 errors.As 而不是类型断言。Service 层很可能会用 fmt.Errorf("查询用户失败: %w", err) 把错误包一层再往上抛,直接断言会漏掉被包装的情况。这个坑我们踩过。
Controller 只剩一行
统一的收口函数:
go
func HandleError(c *gin.Context, err error, messages ...string) {
requestID := getRequestID(c)
if IsBusinessError(err) {
ErrorLogger.Warn("business error",
zap.String("request_id", requestID), zap.Error(err))
BadRequest(c, err.Error()) // 400,直接把 Message 给用户
} else {
message := "服务器错误,请稍后重试"
if len(messages) > 0 {
message = strings.Join(messages, "")
}
ErrorLogger.Error("server error",
zap.String("request_id", requestID), zap.Error(err))
InternalServerError(c, message) // 500,通用提示
}
}
Controller 就退化成这样:
go
user, err := userService.Get(req.UserID)
if err != nil {
util.HandleError(ctx, err)
return
}
Service 层该做的是把「用户不存在」这个判断收进去:
go
func (s *UserService) Get(id int64) (*model.User, error) {
user, err := s.repo.FindByID(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, util.NewBusinessError("用户不存在")
}
if err != nil {
return nil, fmt.Errorf("查询用户失败: %w", err) // 原样上抛,走 500
}
return user, nil
}
GORM 的错误类型现在只出现在 Service 层,再没往上跑。
两条我们定死的纪律
第一,NewBusinessError 里绝不拼接 err.Error()。
见过太多这种写法:
go
return util.NewBusinessError("保存失败: " + err.Error())
这会把 Error 1062: Duplicate entry 'xxx' for key 'uk_email' 原样怼到用户脸上,既看不懂又泄漏表结构。原始错误记日志就够了,用户看到的必须是人话。
第二,日志里带 request_id。
响应体里也返回同一个 request_id。用户来报障时只要给这串 ID,直接就能捞出当时那条日志,不用问"你大概几点操作的"。这条的收益远超实现成本。
这套方案的局限,说清楚
它没有错误码。
前端拿到的只有 400/500 和一句文案,没法按 code 做分支处理。如果你的产品需要「余额不足时弹充值弹窗、未实名时跳实名页」这种差异化逻辑,或者要做多语言,这套就不够用了------得在 BusinessError 上再加一个 Code 字段,并且维护一张码表。
我们目前故意没做,因为现阶段绝大多数场景就是「把一句话展示给用户」,加了码表反而是净成本:每加一个错误就要想一个码、登记、同步给前端。等真出现第三类需求了再加不迟。
它也不区分错误的严重级别。 所有非业务错误都是 500 + 一条 Error 日志。下游超时和空指针在告警系统里长得一样,真要做分级告警还得另外加东西。
这两个局限我们是知情的,不是没想到。选型这事没有普适最优解,只有"当前这个项目值不值得为它付复杂度"。
这套东西跑在 forxi.cn 的后端上,一年多没出过"用户看到一串 SQL 报错"这类事故。如果你的 Controller 现在还在到处 if errors.Is(...),可以试试这个方向。