beforedelete钩子在物理删除时失效,因gorm仅对启用软删除的模型触发该钩子;物理删除需用事务包裹“查数据+写日志+执行unscoped().delete”三步以确保审计一致性。

物理删除无法自动记录审计日志,必须显式拦截并手动写入日志表 —— GORM 的 Delete 默认绕过钩子(BeforeDelete 仅对软删除生效),直接执行 SQL DELETE FROM。
为什么 BeforeDelete 钩子在物理删除时失效
GORM 的模型钩子(如 BeforeDelete)只在调用 db.Delete() 且模型启用了软删除(即嵌入 gorm.DeletedAt)时触发。一旦你用 Unscoped().Delete() 或直接 db.Exec("DELETE FROM..."),GORM 完全跳过模型生命周期,不调用任何钩子。
-
db.Delete(&user)→ 若User含gorm.DeletedAt字段,则转为软删除,触发BeforeDelete -
db.Unscoped().Delete(&user)→ 绕过所有作用域和钩子,发原生DELETE语句 -
db.Where("id = ?", 1).Delete(&User{})→ 同样不触发钩子,除非模型定义了软删除且未加Unscoped()
手动实现物理删除的审计日志写入
核心思路:放弃依赖钩子,改用事务包裹「查原始数据 + 写日志 + 执行物理删」三步。确保原子性,避免日志与删除不一致。
- 先用
db.First()或db.Where().First()拿到待删记录完整快照(含敏感字段) - 构造审计日志结构体(如
AuditLog),填入操作人、时间、表名、主键、JSON 序列化的旧值 - 在同一个
db.Transaction()中依次执行:Create()日志 →Unscoped().Delete() - 务必检查每步返回的
err,任一失败则整个事务回滚
示例片段:
err := db.Transaction(func(tx *gorm.DB) error {
var user User
if err := tx.Where("id = ?", id).First(&user).Error; err != nil {
return err
}
log := AuditLog{
TableName: "users",
RecordID: user.ID,
Operator: "admin",
OldData: toJSON(user), // 自行实现 JSON 序列化
Action: "PHYSICAL_DELETE",
CreatedAt: time.Now(),
}
if err := tx.Create(&log).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&user).Error
})
审计日志表设计的关键约束
日志表不是随便建的,字段设计直接影响可查性和存储成本。
-
OldData字段建议用TEXT类型(MySQL)或JSONB(PostgreSQL),避免因字段长度不足截断 - 必须有
TableName和RecordID组合索引,否则按业务表查日志会慢成瓶颈 - 不要在日志表上加外键(如指向
users.id),物理删后外键失效,且降低写入性能 - 定期归档冷日志(如按月分区或导出到对象存储),防止日志表膨胀拖垮主库
真正难的不是写几行日志代码,而是保证每次物理删除都走同一套事务流程 —— 漏掉一次,审计链就断了。最好把上述事务逻辑封装成统一函数(如 SafePhysicalDelete),禁止业务代码直调 Unscoped().Delete。











