$readonly对审计日志字段无实质保护作用,因其仅过滤模型save/update输入,无法拦截原生SQL、强制赋值、大小写不匹配及非模型入口写入;真正可靠的是数据库自动生成+应用层校验+中间件注入。

ThinkPHP 的 $readonly 对审计日志字段(如 created_at、operator_id、ip、action)几乎起不到实质保护作用——它只过滤模型 save() 和 update() 的输入数据,而审计日志恰恰是多入口高频写入、且常被原生 SQL 或定时任务批量修正的场景。
审计日志字段为什么不能靠 $readonly 拦
审计日志表(如 sys_log)的核心价值在于“不可抵赖”,但 $readonly 既不校验值来源,也不阻止非法写入路径:
-
Db::name('sys_log')->insert(['ip' => '127.0.0.1', 'action' => 'delete_user'])—— 原生插入完全绕过模型,$readonly不触发 -
$log->data($data, true)->save()—— 第二个参数true强制赋值,所有字段(包括operator_id)照单全收 - 字段名大小写不一致:数据库列是
operator_id,但$readonly = ['operatorId'],匹配失败,过滤失效 - 日志常由中间件、命令行脚本、队列任务写入,这些入口根本不会实例化模型,
$readonly形同虚设
真正该设为只读的审计字段有哪些
不是“能不能设”,而是“设了是否符合审计逻辑”。关键看字段语义是否在写入后必须锁定:
-
必须设只读:
created_at(记录事件发生时间,改了就失真)、ip(客户端真实出口 IP,业务层不应覆盖)、user_agent(设备指纹,改了等于伪造终端) -
谨慎设只读:
operator_id—— 若支持代操作(如管理员替用户执行),需允许写入;设只读反而导致日志归属错误 -
别碰
id和log_id—— 主键自增或 UUID 生成由框架/数据库控制,加$readonly可能干扰写入逻辑
比 $readonly 更早、更硬的三道防线
审计字段要防的是“写入即生效”,不是“写入时提醒”。必须组合数据库约束 + 应用层拦截 + 写入前校验:
立即学习“PHP免费学习笔记(深入)”;
-
数据库层加 GENERATED ALWAYS AS(MySQL 5.7+):
ip字段定义为ip VARCHAR(45) GENERATED ALWAYS AS (INET6_NTOA(@@remote_addr)) STORED,任何INSERT/UPDATE尝试显式写入都会报错 -
验证器里用
only()显式放行:只允许['action', 'target_id', 'remark']进入数据流,ip、created_at等字段压根不会被解析,比$readonly过滤更早、更彻底 -
中间件统一注入审计字段:在日志写入前的中间件里,用
$this->request->ip()和$this->auth->id()覆盖数据,而不是依赖控制器传参——避免前端伪造operator_id
最容易被忽略的静默失效点
把 created_at 加进 $readonly 后,它确实不会被 POST 数据覆盖,但你也失去了服务端主动补全的机会。更危险的是:如果后续用 Db::name('sys_log')->where(...)->update(['created_at' => '2020-01-01']) 批量修正旧日志,$readonly 完全不拦,而你日志里的时间戳就悄悄变成假的了,审计时根本看不出异常。真正可靠的方案,是让数据库自己生成时间戳,而不是靠 PHP 层“礼貌性忽略”。



















