一列数据穿过六道半开的门,唯独标着时间戳那一格空着没被点亮

一、一条数据从请求到落库要过六道关

最让人抓狂的一类 bug 不是报错,而是“它没报错,但值没进去”。给模型挂一个自动填时间戳的行为类,库里字段也有,列类型看着也正常,创建时间和更新时间却一直是空的。你去查行为类,配置没写错;去查库,列在;去查类型,也吃得住。差的是第三处、也是最少有人会去翻的那处:模型校验规则里那两行白名单声明。框架的“自动”从来不是无条件的,而这些条件往往分散在好几处代码里,没有一处会集中提醒你。这一篇就把这条链路摊开,讲怎么定位、怎么把它变成会响的故障。以下行为以实测的 Yii 2 场景为例,不能据此断言其它框架或版本的行为一致。

值没写进去,本质是没通过某一关。与其到处猜,不如把这条链路一次性排出来:入口先决定哪些字段能被批量加载进来,这就是批量赋值白名单;接着校验规则逐条判定字段合不合法;再有一层场景或角色,会改变“这次到底哪些字段可用”;然后是行为类挂在哪个生命周期钩子上;钩子跑起来后,属性名要和库列名对上;最后序列化输出走的是另一套字段列表。任何一关没通,落到用户面前的现象都是同一句“值没变”。

在 Yii 2 里,REST 场景下 $model->load($data) 默认只加载被标记为 safe 的属性。时间戳这两个字段如果没在 rules() 里声明成 safe,属性根本不会被赋值;那么当行为类在 beforeInsert、beforeUpdate 上触发时,它接手的是一条空值路径,于是静默失效——它没坏,只是没东西给它填。这就是“三个隐式前提”里最关键的一个:声明位置在校验规则里,而症状表现在数据上,两者隔着好几层代码。行为类本身在基类的 behaviors() 里配好、时间取值用整型也没问题,字段名要和库里列名完全一致,可这三处都对了,唯独漏了 rules() 里那两行 safe,链路照样断在白名单这一关。

关卡 决定什么 没通时的表现 检查点
入口白名单 哪些字段允许被批量加载 属性压根没赋值 该字段是否被声明为可安全赋值
校验规则 字段值合不合法 校验拦截或放行空值 是否被误写成必填导致前端不传就失败
场景/角色 本接口下可用字段集 字段在此场景下视同不存在 自定义场景是否显式含该字段
行为钩子 何时触发自动填充 触发时机没到或拿到空值 行为类挂载的钩子是否正确
名称映射 属性名与库列名对应 填了但没落到列上 属性名与列名是否完全一致
序列化输出 出参走哪套字段列表 库里存了但接口不吐 输出字段列表是否含该字段

二、白名单取向:更安全,但故障更隐蔽

字段能不能被批量赋值,业界有两种安全取向:一种是默认拒绝、要用的字段逐个显式声明为可赋值;另一种是默认允许、再用黑名单把危险字段挡在外面。默认允许的那套写起来顺手,出问题时报错也直接;白名单这套更安全——它挡住了批量赋值越权改字段的老问题,代价就是故障更隐蔽。你没声明的字段不会报错,它只是安静地不被加载,于是行为类也就安静地填不上。这就是为什么时间戳失效通常不伴随任何异常:整个链路上没有任何一环觉得这是错误,只有最终数据少了一格。这里顺带点一个常见误配:给这类自动填充字段写必填规则是错的,前端本来就不传、由行为代填,一旦标成必填,校验反而先把请求拦下。安全取向本身我们不替你做决定,但选了白名单,就必须接受“漏声明等于静默没填”这个后果,并配套下面的检测手段。

三、场景这一关:按接口看,而不是按模型看

很多静默失效栽在场景上。同一份模型,在不同的接口、不同的校验场景下,可用字段集合是不一样的——一个自定义的创建场景如果没把时间戳这两个字段显式包含进去,那在这个场景里它们就等于不存在,白名单再对也白搭。排查这类问题时一个反直觉但很管用的原则:不要只盯着模型看,要按“具体是哪个接口、走的是哪个场景”去看。因为模型是对的,坏的是这次请求选用的场景没把字段带进来。列类型这一层也有个容易误判的点:自动填充并不强制数据库列必须是对应的时间类型,日期时间类型的列同样能填,所以“我列类型是不是不对”往往是无效怀疑方向,别在这里绕圈。

四、把静默失效变成会响的故障

静默的反面是显式。我们要做的是让“没填上”这件事要么当场失败、要么在测试里必响。可落地的检测手段有四类,按性价比排:

  • 结果型断言:在测试里直接断言“新建记录的创建时间非空”。这是最容易加、也最不该省的一条——它不看过程,只看结果,链路任何一关断了它都会红。
  • 行为触发打点:用框架自带的调试输出确认行为到底触发了没有,把“我猜没触发”变成“日志证明没触发”。
  • 数据库层兜底:给这类字段加非空约束或合理默认值,让写入直接失败。这等于把静默升级成报错,宁可上线前炸,也别线上悄悄少数据。
  • 端到端用例:覆盖“用一个新接口创建一条数据”的完整路径,而不是只测模型本身。只测模型会漏掉入口白名单和场景这两关。

这四条合起来,就是本篇交付的第二样东西——“自动行为必须结果型断言”的用例清单:每一个依赖框架自动填充的字段,都要有一条非空断言加一条端到端创建用例兜着。

五、同类前提清单:那些“看着对但少了点什么”的字段

自动填时间戳只是一个样本。凡是“框架替你干、且它的生效条件散在别处”的字段,失效表现长得都一样:数据看着正常,就是少了点东西,也不报错。排查时把它们当成同一类逐项过一遍声明位置。

  • 审计字段(创建人、修改人、创建时间、更新时间):声明在校验白名单与行为钩子里。
  • 软删除标记:删除动作改的是标记位,声明漏了就会变成物理删或没删。
  • 租户隔离字段:漏填会让数据串到别的租户,比少个时间戳严重得多。
  • 乐观锁版本列:不自动递增就会让并发覆盖检测失效。
  • 自动生成的标识(如 slug):没触发就空着或重复。
  • 精度相关的字段(如金额小数位):格式化发生在哪一层会直接改变存的值。

每一个都要问同一句:它的“自动”依赖哪几处声明,这几处我有没有各配一条结果型断言。这就是本篇交付的第一样东西——字段声明六关卡排查表加上这份同类字段清单。

六、手写赋值与输出类型:两条最容易走歪的路

最后一类问题是人自己制造出来的。在控制器里手写一句“直接把时间戳赋成当前值”,看着是在补救,实则绕过了行为类,破坏的是一致性:一旦行为填充和手工赋值并存,一部分代码路径走行为、另一部分走手工,历史数据里就会出现两种时间语义。之后任何“按时间做增量同步”的逻辑都会偏,因为它分不清哪条时间可信。规则写清楚:这类字段只允许由行为统一填,控制器禁止手写覆盖。

输出侧同样有隐性不一致。同一个时间字段,有的接口直接吐字符串、有的吐整型;前端拿字符串显示没问题,一旦按整型去比较就出错。修法不是在每个接口里各转一遍,而是把格式收敛到序列化层统一处理——需要整型就在输出字段定义里重写,已有序列化器的项目应在自定义序列化器里一次性处理,别散落到控制器。洹拓内部把这条定为硬规矩:这类字段的读写格式只在序列化层定一次,控制器不许各自为政。至于框架升级或替换时的复查项:声明式配置的写法可能变、默认白名单策略可能变,任何一处变了,第四节立下的那几条端到端结果型断言必须重跑一遍。断言在,升级才不会把静默失效重新引进来。