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

图 1:UI 状态、校验逻辑、提交对象三条链没有对齐时,非法值总能找到绕过去的路。
Bug 1:空串厂商字段引发的 NPE
请求体里 factory: ""。后端把请求体的厂商字段塞进 DTO,走进一个按厂商分派的 switch:
1 | switch (factory) { |
空串不匹配任何 case,落到 default 返回 null。上层不加保护地调用 baseResult.isSuccess()——NPE。
前端的触发链更有意思。节目列表的行内有一个”发布计划”按钮,它往路由 query 里塞的是新增/编辑弹窗的表单值(初始为空串),而不是 row.factory(该行节目自己的厂商):
1 | this.$router.push({ |
用户不打开弹窗、直接点行内按钮,空串就被带到了计划页,再被 created() 写进计划表单。
而真正暴露设计问题的是:同一份 Service 里,另一个方法取的是节目的厂商字段,这个方法取的是请求的厂商字段。同一个业务动作、两条数据口径——只要请求体被污染,后端就会用错值。如果统一从节目取,空串根本走不到设备下发。
Bug 2:必须先”看过一条”才能触发的 500
请求体里 startTime: ""、endTime: "",后端直接 LocalTime.parse(""),抛 DateTimeParseException。
前端的触发链是教科书级的”校验被绕过”。时间选择器绑定的是 timelist,只有 @change 事件才会把值同步进表单字段;保存时的校验查的是 timelist,提交的却是 addEditForm.startTime/endTime——校验一个变量,提交另一个变量。雪上加霜的是,add() 重置表单时清空了一堆状态,唯独漏了 this.timelist:
1 | add() { |
于是触发序列是:
- 用户先查看或编辑过某条计划 →
timelist被填上旧时间; - 点”新增” → 弹窗里时间选择器仍显示旧时间;
- 用户以为时间已填,不碰选择器 →
@change不触发,表单字段一直是空串; - 校验查的是旧
timelist(非空)→ 放行; - 空时间提交 → 后端 500。
这个 bug 最反直觉的地方:全新页面首次新增反而提交不了(初始空 timelist 会被校验拦住),必须先看过或编辑过一条计划、再新增,才能触发。这类”依赖前序操作状态残留”的 bug,在功能测试里几乎必然漏网——测试习惯从干净页面开始。
两个 bug 的共性
| 维度 | Bug 1 | Bug 2 |
|---|---|---|
| 直接异常 | NullPointerException |
DateTimeParseException |
| 非法值来源 | 行内按钮传错了字段来源 | 新增时状态残留、漏清空 |
| 后端问题 | 对 null 返回值无保护 | 对空串直接 parse |
| 前端问题 | 弹窗值与行数据混用 | 校验对象与提交对象不一致 |
| 结果 | 事务回滚,计划未落库 | 同左 |
还有一个被空串场景盖住的隐患:计划页的表单默认值是海康厂商,但后端对海康 case 直接 return null——即使前端不传空串,从计划页直接新增海康计划也会 500。默认值也是攻击面。
五条带得走的结论
- 不要被堆栈顶行骗到。 两个 bug 的堆栈都停在同一个方法,但一个是”下游返回 null 上层没防”,另一个是”入口直接 parse 空串”。只看行号会以为都是同一类空值问题,实际根因位置不同。
- 前端校验不等于提交安全。 只校验 UI 状态、不校验最终提交对象的校验,都是脆弱校验。
- 同一动作的数据口径要一致。 一个地方取请求值、另一个地方取实体值,前端的小错会被放大成后端的崩溃。
- 不要假设调用链一定返回非 null。 下游”对未实现的厂商返回 null”是事实,上层必须按”可能返回 null”来设计。
- 前后端要各防一手。 后端入口校验非空、空则记日志并降级(计划照常落库但不下发设备),比让异常穿透到事务层体面得多。
修复方案
后端:厂商字段改从节目实体取、对分派结果做空值判断、入口校验时间字段非空并降级。前端:add() 补上 timelist 的重置、行内按钮改传 row.factory。方案不在多,在于每一刀都对应一条被实证过的触发链。
参考资料
- Element UI: Form 表单校验(基于 async-validator,校验的是绑定在 form 上的模型)
- Java 8 API: LocalTime.parse(空串抛
DateTimeParseException的契约) - Spring Framework: 声明式事务管理(运行时异常触发回滚的默认语义)