不报错 ≠ 生效:三类"静默失效"缺陷与它们的共同指纹
主题:请求校验与静默失效
先说结论
有一类 bug 特别阴险:代码/配置"看着改了",但在运行时根本没起作用 ------ 而且不抛异常、不打日志。
它们能潜伏很久,因为平时看不出来。我遇到的是三类形态:
| # | 形态 | 典型 |
|---|---|---|
| ① | 触发机制缺失 | 校验规则写得齐齐整整,但漏了触发注解 → 规则从不生效 |
| ② | 异常处理器缺失 | 校验失败没有对应 handler → 落到兜底分支,返回 500 而不是 400 |
| ③ | 隐式依赖运行环境 | LocalDate.now() 取 JVM 默认时区(容器是 UTC)→ 业务时间整体偏移 |
案例一:时区 bug ------ 日预算在早上 8 点被重置
现象
一个"每日预算"功能,本该零点重置 ,实际在北京时间早上 8:00 重置。
根因
java
// 修复前
private String buildKey() { return KEY_PREFIX + LocalDate.now(); }
LocalDate.now() 用的是 JVM 默认时区 ------ 而容器默认是 UTC:
vbnet
UTC 的"一天"从 UTC 0 点开始 = 北京时间早上 8:00
→ 每日预算 key 在北京时间 8:00 切换
修复(~10 行)
java
// 修复后 ------ 显式指定业务时区,与运行环境解耦
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private String buildKey() { return KEY_PREFIX + LocalDate.now(ZONE); }
private long getSecondsUntilMidnight() {
ZonedDateTime now = ZonedDateTime.now(ZONE);
ZonedDateTime midnight = now.toLocalDate().plusDays(1).atStartOfDay(ZONE);
return Duration.between(now, midnight).getSeconds();
}
为什么长期没被发现
这个模块 0 调用、预算从未打满 → 重置时间错了也无人感知。
⭐ "没触发"不等于"没 bug"。
企业规范 :所有"按天/按小时"的业务 key,日期计算必须显式传 ZoneId ;更规范的是服务统一 spring.jackson.time-zone + 数据库存 UTC/时间戳,展示层转本地。
案例二:校验静默失效 ------ 而且"审计结论本身也是错的"
第一层:分清"漏触发"和"没规则"
原审计结论是"5 个接口漏触发校验注解"。我逐个回代码核查,发现真相分三类:
| 类 | 情况 | 真相 | 正确处理 |
|---|---|---|---|
| A | DTO 里规则齐全,但 @RequestBody 前漏了 @Valid |
真 bug:规则在但不触发 | 补 @Valid |
| B | DTO 里压根没有任何规则(import 被注释掉) | 不是"漏触发",是"根本没规则" | 先设计规则,再触发 |
| C | 同上 | 同上 | 同上 |
⭐ 核心认知:审计"校验失效"必须先分清「规则存在但没触发」与「规则本身不存在」------ 对后者补触发注解是空转。 审计报告是"线索"不是"结论",必须逐项回到代码核实。
第二层(更关键):补了 @Valid,校验仍然"假生效"
连带发现 :全局异常处理器只处理了 BindException (旧的表单场景),而 MethodArgumentNotValidException 和 ConstraintViolationException 没有 handler → 落到兜底分支 → 校验失败返回 500 而不是 400!
这意味着 :如果只补 @Valid 而不补异常处理器,校验"看起来修好了",实际上用户看到的是"服务器错误",前端也无法区分"参数错"还是"服务挂了"。
补全(两个 handler):
java
@ExceptionHandler(MethodArgumentNotValidException.class) // @RequestBody + @Valid 失败
// 取 bindingResult.getFieldError().getDefaultMessage() → 返回 400 + 友好提示
@ExceptionHandler(ConstraintViolationException.class) // 参数级/类级校验失败
// 取 constraintViolations.stream().findFirst().getMessage() → 返回 400
三类校验异常都要有 handler:
| 触发方式 | 异常类型 |
|---|---|
@RequestBody + @Valid |
MethodArgumentNotValidException |
参数级/类级 @Validated |
ConstraintViolationException |
| 表单绑定 | BindException |
第三层:校验的表达边界(设计层面)
| 边界 | 说明 |
|---|---|
| bean validation 表达不了"或"关系 | 如"手机/固话二选一" → ① 两者都放宽为"有则验格式" ② Service 层兜底"至少一个非空"(本项目采用) |
| 编辑接口不能照抄新增的全必填 | 编辑走 MyBatis-Plus 动态 SQL(只更新非 null)→ 强制 @NotBlank 会误伤合法的部分更新 |
@Valid vs @Validated |
包不同;@Valid 支持嵌套/级联校验,@Validated 支持分组;GET 绑定的 DTO 对象参数 需参数级/类级 @Validated(对 @RequestParam/@PathVariable 生效) |
附带产出:全项目覆盖审计
把"点"的问题变成"面"的治理清单 ------ 39 个 @RequestBody 接口全覆盖分类:
| 类 | 数量 | 处理 |
|---|---|---|
| 真 bug(有规则无触发) | 1 | ✅ 补 @Valid |
| 无触发 + DTO 无规则 | 3 | ✅ 补规则 + 触发 |
| 有触发 + DTO 无规则(注解空转) | 2 | ✅ 补规则 |
| 正常(触发+规则都有) | 31 | ✅ 复核 |
| String body 豁免 | 1 | ✅ |
这一类问题的共同指纹
| 指纹 | 说明 |
|---|---|
| ⭐ 不报错、不打日志 | 所以能潜伏很久 ------ "平时看不出来"是它们的共同特征 |
| ⭐ "配置/代码存在"给人虚假的安全感 | 规则写在 DTO 里、机制写在配置里 ------ review 时很难注意"少了触发/少了开关" |
| ⭐ 500 而不是 400(或 429)是"handler 没接住"的典型信号 | 本项目在两个地方遇到同一个信号:校验异常没 handler、限流 blockHandler 签名不匹配 |
| ⚠️ 审计结论本身也会错 | 必须回到代码逐项核实 |
| ⚠️ 时间计算不能依赖运行环境时区 | "时间"必须带时区语义 |
| ⚠️ 环境变量优先级会埋雷 | IDE Run Configuration > 系统/用户变量 > .env > yml 占位符默认值 ------ 排查"配置不生效"要先查 IDE(本项目真踩过:IDE 里残留的旧 key 覆盖了一切,重启 IDE 也不变) |
排查顺序建议 :先直连验证 (排除配置/网络)→ 再加临时脱敏日志 看运行时实际值 → 别猜。
一句总结
补一半比不补更危险 ------ 只补
@Valid不补异常 handler,校验依然是"假生效"。"不报错 ≠ 生效"应该成为一条自查清单。
本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。