公司后台有个需求:所有对订单表的增删改,都要自动记录一条操作日志,写清楚谁在什么时间改了什么字段、旧值是什么、新值是什么。以前的做法是在每个控制器里手动调日志方法,结果一个订单模块十几个接口,漏记了不说,代码还到处重复。后来看到ThinkPHP8的模型事件,发现直接用模型事件去做这个事,一次定义,自动生效,总算把这个烂摊子收拾干净了。
模型事件好在哪里
ThinkPHP的模型在写入、更新、删除后都会触发一些事件,比如after_insert、after_update、after_delete。你只需要在模型类里定义好对应的方法,这些操作发生时就会自动调用,不需要动控制器代码。如果项目里有多处入口都能改订单,比如后台、接口、定时任务,模型事件就能无一遗漏地记录日志。这比在控制器里手动记录靠谱得多。
我用的ThinkPHP版本
项目用的是ThinkPHP 8.0,PHP 8.2。模型事件在6.0、7.0里也有,但写法上略有不同。这篇文章以TP8为准,如果你用的老版本,大概也能参考。
第一步:创建订单模型和日志模型
订单模型放在app/model/Order.php,日志模型放在app/model/OperateLog.php。
Order模型
<?php
namespace appmodel;
use thinkModel;
class Order extends Model
{
protected $name = 'orders';
// 这里暂时不写事件方法,回头用观察者来绑定
}
OperateLog模型
<?php
namespace appmodel;
use thinkModel;
class OperateLog extends Model
{
protected $name = 'operate_logs';
protected $autoWriteTimestamp = true;
// 定义日志类型常量
const TYPE_INSERT = 'insert';
const TYPE_UPDATE = 'update';
const TYPE_DELETE = 'delete';
}
第二步:用观察者类集中管理事件
TP8支持把一个类注册为某个模型的观察者。在观察者类里,你可以定义afterInsert、afterUpdate、afterDelete等方法,这些方法名对应模型事件。我建了个app/observer/OrderObserver.php。
<?php
namespace appobserver;
use appmodelOrder;
use appmodelOperateLog;
use thinkfacadeSession;
class OrderObserver
{
// 新增后
public function afterInsert(Order $order)
{
$this->log($order, OperateLog::TYPE_INSERT);
}
// 更新后
public function afterUpdate(Order $order)
{
$this->log($order, OperateLog::TYPE_UPDATE);
}
// 删除后(注意删除分为软删除和真实删除,这里只处理真实删除)
public function afterDelete(Order $order)
{
$this->log($order, OperateLog::TYPE_DELETE);
}
private function log(Order $order, string $type)
{
// 获取当前操作用户,假设在登录时写入了session
$userId = Session::get('admin_id') ?? 0;
$username = Session::get('admin_name') ?? 'system';
// 记录原始数据和修改后的数据
$originalData = $order->getData();
// 对于update,可以通过getOrigin获取更新前的数据
$updateData = $order->getData();
// 组装差异字段
$changes = $this->diffFields($order, $type);
OperateLog::create([
'order_id' => $order->id ?? 0,
'type' => $type,
'operator_id' => $userId,
'operator_name' => $username,
'original_json' => $type === OperateLog::TYPE_UPDATE ? json_encode($order->getOrigin(), JSON_UNESCAPED_UNICODE) : json_encode($originalData, JSON_UNESCAPED_UNICODE),
'changed_json' => json_encode($updateData, JSON_UNESCAPED_UNICODE),
'changes' => json_encode($changes, JSON_UNESCAPED_UNICODE),
'log_time' => time(),
]);
}
private function diffFields(Order $order, string $type)
{
if ($type === OperateLog::TYPE_UPDATE) {
$original = $order->getOrigin();
$current = $order->getData();
$diff = [];
foreach ($current as $field => $value) {
if (array_key_exists($field, $original) && $original[$field] !== $value) {
$diff[$field] = ['old' => $original[$field], 'new' => $value];
}
}
return $diff;
}
if ($type === OperateLog::TYPE_INSERT) {
return $order->getData();
}
return [];
}
}
这里用到了getOrigin(),它返回更新前原始的字段值,用于对比差异。比如用户把订单金额从100改成200,那么差异数组里会记录旧值和新值。
第三步:把观察者注册到模型
在Order模型里增加一行代码,指定观察者类。
<?php
namespace appmodel;
use thinkModel;
use appobserverOrderObserver;
class Order extends Model
{
protected $name = 'orders';
// 注册观察者
protected static function onAfterInit()
{
static::observe(OrderObserver::class);
}
}
也可以把注册放在服务提供者里,但对于单模块项目,直接写在模型里最方便。TP8里模型初始化事件onAfterInit会在模型实例化后触发,在里面调用observe(),观察者就生效了。
第四步:处理软删除的情况
订单表通常使用软删除,也就是数据库里留一个delete_time字段。TP8默认的软删除会走update事件而不是delete事件,所以afterDelete并不会触发。这时候需要监听软删除的onBeforeDelete?其实TP8中软删除是实例调用了delete()方法然后更新数据。因此会触发更新事件,并且where条件里带上delete_time为空。这样上述观察者会把软删除记录成一次字段更新(update),而不是删除日志。如果业务上需要区分软删除,可以改成监听onBeforeDelete,但此时模型里的delete_time还没写入,你无法获取最终修改后的值。另一种做法是在更新事件里检测delete_time字段是否存在并且非空,且原值为null,那就可以判断为软删除。
我把代码调整一下,让它在update事件里判断一下是不是软删除:
public function afterUpdate(Order $order)
{
$original = $order->getOrigin();
$current = $order->getData();
// 如果delete_time从null变成了时间戳,说明是软删除
if (isset($current['delete_time']) && $current['delete_time'] !== null
&& (array_key_exists('delete_time', $original) && $original['delete_time'] === null)) {
$this->log($order, OperateLog::TYPE_DELETE);
} else {
$this->log($order, OperateLog::TYPE_UPDATE);
}
}
这样日志里软删除也会被记成删除类型,比较符合直觉。
第五步:测试效果
我在控制器里写了一个测试方法,模拟管理员修改订单价格:
<?php
namespace appcontroller;
use appmodelOrder;
class Admin
{
public function test()
{
// 模拟登录 - 实际项目中由登录中间件写入
session([
'admin_id' => 1,
'admin_name' => '管理员老王'
]);
$order = Order::find(12);
$order->price = 200.00;
$order->save();
}
}
执行后查看operate_logs表,插入了一条记录,字段大致如下:
order_id: 12
type: update
operator_id: 1
operator_name: 管理员老王
original_json: {"id":12,"order_no":"202412010001","price":100,"status":1,"delete_time":null}
changed_json: {"id":12,"order_no":"202412010001","price":200,"status":1,"delete_time":null}
changes: {"price":{"old":100,"new":200}}
确实能精确到哪个字段改变了,清清楚楚。
在项目里可能遇到的小坑
- 模型事件里不能再用模型做查询:在观察者方法里,如果又用Order::where()查询,可能导致事件循环或死锁。因此最好只用当前传入的模型实例。
- 批量更新不会触发模型事件:比如
Order::where('status', 1)->update(['status' => 2]),这种查询构造器的方式不走模型事件,只有用模型对象调用save和delete才会触发。如果需要记录批量操作,得自己在业务代码里处理。 - 保存日志时注意外键约束:如果orders表有物理外键,而删除日志时订单还没被删(afterDelete是在数据库删除之后触发),对于软删除没影响,硬删除的话订单就不存在了,日志里存一个order_id用于溯源即可。
- 修改user_id和ip之类的元信息:可以在log方法里加上request的信息,记录操作IP。这就不多写了。
把日志表建一下
如果你需要直接跑这段代码,可以复制下面的SQL建表:
CREATE TABLE `operate_logs` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`order_id` int(11) NOT NULL DEFAULT '0',
`type` varchar(20) NOT NULL DEFAULT '',
`operator_id` int(11) NOT NULL DEFAULT '0',
`operator_name` varchar(50) NOT NULL DEFAULT '',
`original_json` text,
`changed_json` text,
`changes` text,
`log_time` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
再扩展一点:全局操作日志
除了订单,其他表如果也要记录日志,可以写一个BaseObserver,让不同模型复用。但不同模型的主要字段不同,模板可能需要做成配置方式。这里的订单日志是写死的,对于快速开发足够。
也可以用事件监听方式绑定模型,不需要观察者,直接在事件定义里写闭包。但观察者类更清晰,方便维护。
总结
模型事件最佳的应用场景就是这种“无侵入的横切逻辑”。如果你还在被一堆打日志的代码纠缠,可以试试这个方案。它不需要修改任何控制器,只需要在模型定义好观察者,后面新增接口也好、定时任务也好,所有走模型的操作全部自动入日志。这个功能给我的最大感受是:ThinkPHP 8学起来还是值。

