年初给一个后台管理系统做安全审查,对方提了一条要求:所有配置项、用户信息、订单状态的修改,必须有完整的操作记录,包括谁在什么时间改了哪个字段、改成什么值。一开始想着在每个控制器方法里手动加日志写入,写完两个发现不但代码乱成一团,还经常漏记。后来翻文档看到ThinkPHP 8的模型事件配合队列能几乎零侵入地完成这件事,花了一天把整套东西理顺了,现在任何数据变更都能自动带上操作轨迹。
最初踩过的坑:到处埋点的尴尬
开始的做法非常直白:修改配置的接口里,拿到旧数据和新数据比对,然后把变化拼成一段文字,调用Log::record。写完配置模块还行,轮到用户模块、订单模块,重复代码翻了三倍。更要命的是,有的更新是通过模型直接save(),有的用了Db::table()->update(),有的在命令行下批量处理,接口里的埋点完全覆盖不了这些路径。
后来想写一个公共函数统一调用,可还是绕不开在每个业务方法结束前“记得”调用一下。一旦换了同事接手,忘掉的概率不比忘写漏掉低。这时候才意识到,靠人肉维护的日志必然不完整,必须把触发点挂到数据层的生命周期里去。
用模型事件自动捕获变更
ThinkPHP 8的模型事件比前几版更顺手,不仅支持常规的afterSave、afterUpdate,还能拿到原始数据和变更后的数据。我的做法是创建一个基类LoggableModel,所有需要审计的模型继承它,在基类里绑定更新事件。
namespace appcommonmodel;
use thinkModel;
class LoggableModel extends Model
{
protected static function boot()
{
parent::boot();
// 监听更新后事件
static::onAfterUpdate(function ($model) {
$changes = $model->getChangedData();
$original = $model->getOriginal();
if (empty($changes)) return;
// 组装操作日志数据
$logData = [
'table_name' => $model->getName(),
'row_id' => $model->getKey(),
'operator_id' => request()->middleware('operator_id', 0),
'changes' => json_encode([
'old' => array_intersect_key($original, $changes),
'new' => $changes,
], JSON_UNESCAPED_UNICODE),
'ip' => request()->ip(),
'created_at' => date('Y-m-d H:i:s'),
];
// 投递到异步队列,不阻塞当前请求
thinkfacadeQueue::push(appcommonjobAuditLogJob::class, $logData);
});
}
}
这里几个关键点:getChangedData()是ThinkPHP 8模型新增的方法,只返回本次更新中实际被改动的字段,配合getOriginal()就能精确拿到新旧值。操作人ID从请求上下文里取,IP也是直接从请求对象拿。最核心的一步是把日志数据推送到队列,让后台慢慢写库,接口该返回立刻返回。
队列处理:让日志写入不影响业务响应
操作日志表有时候一天就几十万行,如果在模型事件里直接同步写数据库,每次更新都多一次INSERT,接口响应时间肯定被拖慢。推到队列后就简单了。在app/common/job/AuditLogJob.php实现具体写入:
namespace appcommonjob;
use thinkfacadeDb;
use thinkqueueJob;
class AuditLogJob
{
public function fire(Job $job, array $data)
{
try {
Db::name('audit_logs')->insert($data);
$job->delete(); // 成功则删除任务
} catch (Exception $e) {
if ($job->attempts() release(10); // 失败延迟10秒重试
} else {
$job->delete();
// 可记录到失败日志
}
}
}
}
队列驱动我用的是Redis,配置在config/queue.php里指定。启动消费者只需要在服务器上跑php think queue:work,所有日志写入就变成异步批量消化。实测下来,单次更新操作的额外耗时从原来的同步写入平均8ms降到几乎为0,队列消费端也没有积压。
怎么带上操作人信息
操作人ID是通过一个中间件注入到请求上下文里的。在app/common/middleware/AuthContext.php我写了简单的JWT解析:
namespace appcommonmiddleware;
use thinkfacadeRequest;
class AuthContext
{
public function handle($request, Closure $next)
{
// 从Header或Session中获取当前登录用户ID
$userId = 0;
$token = $request->header('Authorization');
if ($token) {
// 简化例子,实际需解析JWT
$payload = json_decode(base64_decode(explode('.', $token)[1] ?? ''), true);
$userId = $payload['uid'] ?? 0;
}
// 把用户ID挂在Request上,供全局使用
Request::macro('operator_id', fn() => $userId);
return $next($request);
}
}
模型事件里的request()->middleware('operator_id', 0)实际上我的用法是直接调request()->operator_id(),因为上面用macro注册了闭包。这样无论是HTTP请求还是通过命令行脚本调用的模型操作,只要在入口处预先设置好上下文,日志都能带上正确操作人。
注解路由和权限校验轻量接入
为了不让日志记录功能变成散落在各处的零碎配置,我用ThinkPHP 8的注解路由把审计日志查询接口直接放在一个单独的控制器里,并配上权限中间件。
namespace appadmincontroller;
use thinkannotationRoute;
use thinkmiddlewareCheckAuth;
/**
* @Route("admin/audit")
*/
class Audit extends Base
{
/**
* @Route("logs", method="GET")
*/
public function index()
{
$logs = appcommonmodelAuditLog::order('id desc')->paginate(20);
return json($logs);
}
}
配合CheckAuth中间件,只有特定角色能查看日志,这个中间件直接注册到路由组或者全局,不侵入控制器逻辑。审计日志的写入由模型事件完全承担,查询接口只管展示,职责切得很干净。
实际跑起来后的感受
整套弄好之后,最大的改变是再也不用在业务代码里写任何跟操作日志有关的语句。新增一个需要审计的表,只要让模型继承LoggableModel,日志自动就开始记了。有次产品经理突然要求把某个配置页的操作记录也纳入审计,我只花了一分钟把对应的模型类父类改了一下,发版上线搞定,没有动任何控制器和业务服务。
当然也有一些细节逐步打磨出来的:比如有些敏感字段(如密码、手机号)不希望明文记录,可以在模型里定义一个hiddenForLog属性,在LoggableModel的事件回调里自动把这些字段脱敏。再比如数据库更新用了Db::table()->update()会绕过模型事件,团队统一规范只走模型操作,或者改用模型事件没覆盖到的场景用Observer手动触发,但目前我们业务上已经完全规避了这类情况。
几个要注意的细节
- 队列失败兜底。万一Redis挂了或队列消费异常,日志不能丢失。我额外加了一条:在
LoggableModel的onAfterUpdate里同时写一条本地文件日志作为备份,队列写入失败时至少文件里有。 - 避免循环触发。如果日志表自己也用了审计模型,那么记录日志本身的操作又会触发新的日志写入,造成死循环。解决方法很简单:在
LoggableModel里用一个静态属性标记是否正在处理日志,如果是日志表自身操作就跳过。 - 操作人获取的健壮性。命令行下没有HTTP Request,
request()->ip()会报错。我加了一层判断,如果是在CLI环境下,操作人ID设为0,IP留空。
这套模式的适用场景和边界
模型事件加队列这套组合非常适合需要审计追踪的数据实体,比如配置表、用户资金账户、权限角色等。但如果你的更新操作绝大多数都是批量UPDATE,且不经过模型,那么模型事件就鞭长莫及了,需要额外用数据库触发器或者在业务层手动埋点。
就我们目前的使用经验来看,90%以上的数据修改都走模型save()、update()方法,所以模型事件基本全覆盖。剩下的少数复杂关联操作,我们单独写了一个AuditLogger工具类手动记录,风格上跟自动日志保持一致,后续再逐渐收拢。
总结一句话
用ThinkPHP 8内置的模型事件和队列,把操作日志从业务代码里剥离出来,不但省掉了大把重复代码,还让日志记录变成了一件不用刻意记着做的事。以后再有安全审查,打开审计日志页面,什么时间谁改了哪个字段一清二楚,底气也足了不少。

