用gorm钩子实现审计日志最稳方式是beforecreate/beforeupdate/beforedelete中调用tx.statement.setcolumn写审计字段;记录完整变更需主动查旧数据并从context.context提取用户标识,因钩子无自动上下文且结构体副本修改不生效。

直接说结论:用 GORM 的 BeforeCreate/BeforeUpdate/BeforeDelete 钩子 + tx.Statement.SetColumn 写审计字段最稳;但想记录完整变更(旧值/新值/操作人/IP),必须在钩子里主动查旧数据 + 从 context.Context 提取用户标识,不能依赖反射自动填。
为什么 BeforeUpdate 里直接赋值 UpdatedAt 没用
常见错误是写 u.UpdatedAt = time.Now(),结果数据库字段仍是零值或旧值。原因有二:
- 字段类型是
time.Time(非指针)时,钩子里的u是副本,改了不生效 - 即使字段是
*time.Time,GORM 在执行Update("status", "done")这类单字段更新时,默认只生成含status的 SQL,UpdatedAt不会被包含进去
正确做法是统一用 tx.Statement.SetColumn("updated_at", time.Now().UTC())——它强制把字段加进 SQL 的 SET 子句,不依赖 struct 字段是否被修改。
context.Context 怎么安全传操作人信息进钩子
GORM 钩子函数签名里没有 context.Context,必须靠 db.WithContext(ctx) 显式透传,再在钩子里用 tx.Statement.Context 取值。关键点:
- 定义私有 key 类型防冲突:
type auditKey struct{},别用string直接当 key - HTTP 中间件里注入:
ctx = context.WithValue(r.Context(), auditKey{}, userID) - 钩子里提取:
if userID, ok := tx.Statement.Context.Value(auditKey{}).(string); ok { tx.Statement.SetColumn("created_by", userID) } - 绝不能把
ctx存到 model struct 里,也不要用map[string]interface{}塞值——会污染序列化、破坏可测试性
怎么在 BeforeUpdate 里拿到旧数据
AfterUpdate 能看到新值,但旧值已覆盖。要记录变更对比,必须在 BeforeUpdate 主动查库:
- 用
tx.Model(&Model{}).Where("id = ?", u.ID).First(&old)查当前行快照 - 查完立刻用
json.Marshal序列化old,存进tx.Statement.Context或临时字段(如u._oldValues)供后续日志用 - 注意事务隔离级别:若用
ReadUncommitted,可能读到未提交脏数据;默认ReadCommitted是安全的 - 批量更新(
Updates([]Model{}))时,不能对每个元素都查一次——应改用原生 SQL 或分批处理,避免 N+1 查询
审计日志表要不要和业务表放一起
不要。审计日志有三个硬性需求:高写入吞吐、长期保留、故障隔离。和业务库共用会互相拖累:
- 高频审计写入可能打满连接池或锁表,影响主业务查询
- 审计日志按月分区、冷热分离,业务库通常不做这种设计
- 一旦业务库宕机,操作记录和审计日志全丢,失去追溯能力
实际做法是:审计日志单独建库(甚至用时序数据库如 TimescaleDB),通过异步 channel + worker 写入,避免阻塞主事务。钩子里只做轻量准备(序列化、取上下文),重活交给后台 goroutine。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











