ThinkPHP 8模型事件与队列异步记录:一条数据改动自动带出完整操作轨迹

2026-07-03 0 561

年初给一个后台管理系统做安全审查,对方提了一条要求:所有配置项、用户信息、订单状态的修改,必须有完整的操作记录,包括谁在什么时间改了哪个字段、改成什么值。一开始想着在每个控制器方法里手动加日志写入,写完两个发现不但代码乱成一团,还经常漏记。后来翻文档看到ThinkPHP 8的模型事件配合队列能几乎零侵入地完成这件事,花了一天把整套东西理顺了,现在任何数据变更都能自动带上操作轨迹。

最初踩过的坑:到处埋点的尴尬

开始的做法非常直白:修改配置的接口里,拿到旧数据和新数据比对,然后把变化拼成一段文字,调用Log::record。写完配置模块还行,轮到用户模块、订单模块,重复代码翻了三倍。更要命的是,有的更新是通过模型直接save(),有的用了Db::table()->update(),有的在命令行下批量处理,接口里的埋点完全覆盖不了这些路径。

后来想写一个公共函数统一调用,可还是绕不开在每个业务方法结束前“记得”调用一下。一旦换了同事接手,忘掉的概率不比忘写漏掉低。这时候才意识到,靠人肉维护的日志必然不完整,必须把触发点挂到数据层的生命周期里去。

用模型事件自动捕获变更

ThinkPHP 8的模型事件比前几版更顺手,不仅支持常规的afterSaveafterUpdate,还能拿到原始数据和变更后的数据。我的做法是创建一个基类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挂了或队列消费异常,日志不能丢失。我额外加了一条:在LoggableModelonAfterUpdate里同时写一条本地文件日志作为备份,队列写入失败时至少文件里有。
  • 避免循环触发。如果日志表自己也用了审计模型,那么记录日志本身的操作又会触发新的日志写入,造成死循环。解决方法很简单:在LoggableModel里用一个静态属性标记是否正在处理日志,如果是日志表自身操作就跳过。
  • 操作人获取的健壮性。命令行下没有HTTP Request,request()->ip()会报错。我加了一层判断,如果是在CLI环境下,操作人ID设为0,IP留空。

这套模式的适用场景和边界

模型事件加队列这套组合非常适合需要审计追踪的数据实体,比如配置表、用户资金账户、权限角色等。但如果你的更新操作绝大多数都是批量UPDATE,且不经过模型,那么模型事件就鞭长莫及了,需要额外用数据库触发器或者在业务层手动埋点。

就我们目前的使用经验来看,90%以上的数据修改都走模型save()update()方法,所以模型事件基本全覆盖。剩下的少数复杂关联操作,我们单独写了一个AuditLogger工具类手动记录,风格上跟自动日志保持一致,后续再逐渐收拢。

总结一句话

用ThinkPHP 8内置的模型事件和队列,把操作日志从业务代码里剥离出来,不但省掉了大把重复代码,还让日志记录变成了一件不用刻意记着做的事。以后再有安全审查,打开审计日志页面,什么时间谁改了哪个字段一清二楚,底气也足了不少。

ThinkPHP 8模型事件与队列异步记录:一条数据改动自动带出完整操作轨迹
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8模型事件与队列异步记录:一条数据改动自动带出完整操作轨迹 https://www.taomawang.com/server/thinkphp/2313.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务