快照需在beforeUpdate中查库获取真实旧值并记录时间戳与操作人,禁用模型事件防递归,快照表字段类型索引须兼容原表,JSON和中文需UTF-8标准化处理。

快照字段怎么自动记录时间戳和操作人
ThinkPHP 本身不内置“数据快照”功能,得靠手动触发或模型事件实现。关键不是加个钩子就完事,而是得在 save() 或 update() 前把旧值捞出来、打上标记再存——否则一提交就覆盖了,根本没得比。
推荐在模型的 beforeUpdate 钩子里做:先用 find() 查当前数据库里的原始数据,再和待更新数据做差异对比。注意别用 getOriginal(),它只返回上次 select() 后缓存的值,中间如果被其他逻辑改过,就失真了。
- 必须显式查一次数据库获取真实旧值:
Db::name('user')->where('id', $this->id)->find() - 操作人不能依赖 session 或 request —— CLI 或队列场景下会为空,建议由业务层传入并透传到模型
- 时间戳别用
date('Y-m-d H:i:s'),统一走date('c')或Carbon::now()->toDateTimeString(),避免时区错位
历史表结构设计要注意哪些兼容性问题
快照表不是原表复制粘贴就行。字段类型、长度、是否允许 NULL,都得对齐原表,否则后期 join 或 diff 时会隐式转换甚至报错。尤其注意 json 字段在 MySQL 5.7+ 才原生支持,老版本得用 text 存序列化字符串。
更麻烦的是索引。快照表一般按 record_id + version 查询,但如果你还常按 updated_by 或 created_at 查变更记录,就得单独建联合索引,不然翻几万条历史就卡住。
立即学习“PHP免费学习笔记(深入)”;
- 主键建议设为自增
id,同时加唯一索引(record_id, version) - 原表的
enum字段在快照里要转成varchar,否则迁移或导出时容易丢值 - 别给快照表加外键约束——历史记录要永久保留,原表删了关联数据也不能级联删快照
如何避免 save() 时无限递归写快照
这是最常踩的坑:在 beforeUpdate 里调 Db::insert() 写快照,结果这个 insert 又触发了同个模型的事件,再写一遍快照……最后爆栈或写入几百条重复快照。
根本解法是「隔离事件」:写快照时临时关闭模型事件监听。ThinkPHP 6.1+ 可用 Model::event(false),6.0 则得用 Model::$event = false(注意是静态属性)。写完快照再恢复。
- 不要在快照写入逻辑里调用任何可能触发模型事件的方法,包括
save()、update()、create() - 快照表用
Db::table('user_snapshot')直接操作,绕过模型层 - 如果用了软删除,快照表千万别加
delete_time字段——历史就是历史,不该被“软删”
diff 对比时中文字段乱码或 JSON 格式错乱
MySQL 的 utf8mb4 和 PHP 的 json_encode() 默认行为不一致,会导致快照里存的 JSON 字符串出现 \uXXXX 转义、中文变问号,或者字段值看着一样但字符串 hash 不同。
根源在于:数据库连接没设好 charset,或者 PHP 侧没强制 UTF-8 编码。只要有一环掉链子,diff 就不可靠。
- 数据库连接配置里必须明确指定
'charset' => 'utf8mb4' - 写快照前对字段值做标准化:
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) - 对比时别直接 strcmp,先用
json_decode($old, true)和json_decode($new, true)转数组再递归比较,避开字符串编码干扰
快照真正的难点不在写入,而在「什么时候该写」和「写多少」——比如批量更新 1000 条用户状态,你是每条都记快照,还是只记聚合摘要?这得看审计要求,而不是技术能不能做到。


















