不能用 recover 捕获 GORM 事务 panic,因 GORM 不自动 panic 而是返回 error;手动 panic 会绕过中间件、丢失上下文,且 recover 仅限当前 goroutine。

事务异常不能靠 panic 统一抛,必须显式捕获并转成结构化错误;错误码封装的关键是让 Code 和 HttpCode 可分离、可透传、不丢失上下文。
为什么不能用 recover 捕获 GORM 事务 panic?
GORM 本身不会在事务出错时自动 panic —— 它返回 error。你手动 panic 的话,会绕过所有中间件和日志链路,recover 只能在当前 goroutine 生效,且无法携带业务码、请求 ID、调用栈等关键诊断信息。
常见错误现象:tx.Commit().Error 是 nil,但实际数据库没写入(比如外键冲突被静默吞掉);或 tx.Rollback() 失败却没检查返回值,导致你以为回滚成功。
- 事务提交/回滚后,**必须检查
tx.Commit().Error或tx.Rollback().Error** - 不要在事务函数里
return err后还继续执行后续逻辑(比如已 rollback 却又去调用tx.Create()) - 避免把
db.Transaction()的回调函数写成闭包嵌套过深,否则错误堆栈难定位
如何把 GORM error 映射成带业务码的 ApiException?
直接用 errors.Is() 或 errors.As() 判断底层错误类型,再包装成 ApiException,而不是字符串匹配错误信息。
例如 MySQL 唯一键冲突:
if errors.Is(err, gorm.ErrRecordNotFound) {
return exception.NewNotFound("用户 %s 不存在", userID)
}
if mysqlErr, ok := err.(*mysql.MySQLError); ok && mysqlErr.Number == 1062 {
return exception.NewConflict("邮箱 %s 已被注册", email)
}
-
gorm.ErrRecordNotFound是哨兵错误,可用errors.Is安全比对 - MySQL 错误需类型断言 + 错误号判断,
mysql.MySQLError来自github.com/go-sql-driver/mysql - 不要用
strings.Contains(err.Error(), "Duplicate entry"),易误判、难维护
事务函数怎么设计才能让错误码可追踪、可测试?
把事务逻辑抽成纯函数,接收 *gorm.DB(或 *gorm.Session)作为参数,返回 error,不在函数内做 tx.Commit() 或 tx.Rollback() —— 这些由调用方统一控制。
示例:
func CreateUserWithRoles(tx *gorm.DB, user *User, roleIDs []uint) error {
if err := tx.Create(user).Error; err != nil {
return exception.NewInternalError(50001, "创建用户失败").WithCause(err)
}
// 关联中间表
if err := tx.Model(user).Association("Roles").Append(roleIDs); err != nil {
return exception.NewInternalError(50002, "绑定角色失败").WithCause(err)
}
return nil
}
// 调用处
err := db.Transaction(func(tx *gorm.DB) error {
return CreateUserWithRoles(tx, &u, roles)
})
if err != nil {
// 此处 err 已是封装好的 ApiException
return err
}
- 事务函数只管“做什么”,不管“提交还是回滚”——GORM
Transaction方法会自动处理 - 每个子操作都用
exception.NewXXX()包装,保留原始err作为Cause - 测试时可直接传 mock
*gorm.DB,无需启动真实数据库
最常被忽略的一点:事务中调用外部服务(如发短信、调第三方 API)失败时,不能只 rollback 数据库 —— 必须用 Saga 模式或本地消息表补偿,否则一致性就断了。错误码封装再漂亮,也救不了跨系统状态不一致。


















