同一天两个 500:校验的对象,和提交的对象不是同一个

园区平台的信息发布模块,同一天在同一个接口上连续爆出两个不同的 500:下午五点半一个 NullPointerException,二十分钟后一个 DateTimeParseException。表面是两个不相干的异常,根因却是同一个模式——前端把非法值带进了请求体,后端在关键路径上既没有校验也没有空值保护@Transactional 回滚,数据未落库。这篇记录两个 bug 的完整触发链,以及那条最值得记住的结论:前端校验的对象,和最终提交的对象,根本不是同一个。

校验链与提交链的错位
图 1:UI 状态、校验逻辑、提交对象三条链没有对齐时,非法值总能找到绕过去的路。

Bug 1:空串厂商字段引发的 NPE

请求体里 factory: ""。后端把请求体的厂商字段塞进 DTO,走进一个按厂商分派的 switch:

1
2
3
4
5
6
switch (factory) {
case "2": return ...; // 大华逻辑
case "1":
case "3": return null; // 海康、明景:未实现,直接 return null
default: return null;
}

空串不匹配任何 case,落到 default 返回 null。上层不加保护地调用 baseResult.isSuccess()——NPE。

前端的触发链更有意思。节目列表的行内有一个”发布计划”按钮,它往路由 query 里塞的是新增/编辑弹窗的表单值(初始为空串),而不是 row.factory(该行节目自己的厂商):

1
2
3
4
this.$router.push({
path: '/informationIssueManage/planMng',
query: { factory: this.addEditForm.factory, ... } // 应为 row.factory
})

用户不打开弹窗、直接点行内按钮,空串就被带到了计划页,再被 created() 写进计划表单。

而真正暴露设计问题的是:同一份 Service 里,另一个方法取的是节目的厂商字段,这个方法取的是请求的厂商字段。同一个业务动作、两条数据口径——只要请求体被污染,后端就会用错值。如果统一从节目取,空串根本走不到设备下发。

Bug 2:必须先”看过一条”才能触发的 500

请求体里 startTime: ""endTime: "",后端直接 LocalTime.parse(""),抛 DateTimeParseException

前端的触发链是教科书级的”校验被绕过”。时间选择器绑定的是 timelist,只有 @change 事件才会把值同步进表单字段;保存时的校验查的是 timelist,提交的却是 addEditForm.startTime/endTime——校验一个变量,提交另一个变量。雪上加霜的是,add() 重置表单时清空了一堆状态,唯独漏了 this.timelist

1
2
3
4
5
6
add() {
this.addEditForm = { /* 默认值 */ };
this.datetime = [];
this.timelists = [];
// 漏了 this.timelist = ['', '']
}

于是触发序列是:

  1. 用户先查看或编辑过某条计划 → timelist 被填上旧时间;
  2. 点”新增” → 弹窗里时间选择器仍显示旧时间;
  3. 用户以为时间已填,不碰选择器 → @change 不触发,表单字段一直是空串;
  4. 校验查的是旧 timelist(非空)→ 放行;
  5. 空时间提交 → 后端 500。

这个 bug 最反直觉的地方:全新页面首次新增反而提交不了(初始空 timelist 会被校验拦住),必须先看过或编辑过一条计划、再新增,才能触发。这类”依赖前序操作状态残留”的 bug,在功能测试里几乎必然漏网——测试习惯从干净页面开始。

两个 bug 的共性

维度 Bug 1 Bug 2
直接异常 NullPointerException DateTimeParseException
非法值来源 行内按钮传错了字段来源 新增时状态残留、漏清空
后端问题 对 null 返回值无保护 对空串直接 parse
前端问题 弹窗值与行数据混用 校验对象与提交对象不一致
结果 事务回滚,计划未落库 同左

还有一个被空串场景盖住的隐患:计划页的表单默认值是海康厂商,但后端对海康 case 直接 return null——即使前端不传空串,从计划页直接新增海康计划也会 500。默认值也是攻击面。

五条带得走的结论

  1. 不要被堆栈顶行骗到。 两个 bug 的堆栈都停在同一个方法,但一个是”下游返回 null 上层没防”,另一个是”入口直接 parse 空串”。只看行号会以为都是同一类空值问题,实际根因位置不同。
  2. 前端校验不等于提交安全。 只校验 UI 状态、不校验最终提交对象的校验,都是脆弱校验。
  3. 同一动作的数据口径要一致。 一个地方取请求值、另一个地方取实体值,前端的小错会被放大成后端的崩溃。
  4. 不要假设调用链一定返回非 null。 下游”对未实现的厂商返回 null”是事实,上层必须按”可能返回 null”来设计。
  5. 前后端要各防一手。 后端入口校验非空、空则记日志并降级(计划照常落库但不下发设备),比让异常穿透到事务层体面得多。

修复方案

后端:厂商字段改从节目实体取、对分派结果做空值判断、入口校验时间字段非空并降级。前端:add() 补上 timelist 的重置、行内按钮改传 row.factory。方案不在多,在于每一刀都对应一条被实证过的触发链。

参考资料