四轮外部评审,十二条意见全部属实——afterCommit 不是异步任务
人脸审批钉钉卡片功能做完后,交给外部评审挑刺。四轮下来共十二条意见,我们逐条核实——全部属实,然后逐条修复。评审意见全对这种事并不常见,更难得的是其中几条属于”教科书不会讲、不炸一次不会信”的坑:afterCommit 根本不异步、vant Toast 是单例、surefire 版本太老会让测试”假装通过”。逐条记录。
四轮修复清单
| 轮次 | 问题 | 修法 |
|---|---|---|
| 一审 P1 | H5 图片质量检测失败仍提交(拿旧照片建无效审批单,原有 bug 被揪出) | 删提交逻辑,重置列表 + 提示重拍 |
| 一审 P1 | 钉钉调用在 complete() 事务内同步执行(Feign×2 + 钉钉 HTTP 拖事务) | 套 TransactionSynchronization.afterCommit 壳 |
| 一审 P2 | applyReason 的 @Length 不生效(controller 无 @Valid) |
service 层三值白名单 + 空值兼容旧客户端 |
| 一审 P2 | 取消事由选择后页面显示未生效的新照片 | 暂存 pendingFaceUrl,确认后才写入 |
| 一审 | iot 钉钉单次 100 人上限,超限整卡拒发 | 按 100 分批,单批失败不影响其余 |
| 复审 P1 | afterCommit 仍在原请求线程同步执行 | 有界线程池,afterCommit 里只负责 execute |
| 复审 P2 | H5 提交失败仍显示新照片(只有 .then 无 .catch) | 成功回调才写入新照片 |
| 三审 P2 | catch 里 Toast.clear() 清掉了 axios 拦截器刚弹的错误提示 |
去 loading,catch 空处理 |
| 四审 P2 | makePush 无条件注册异步任务,无关类型空任务挤满有界队列 | 发送前同步 guard |
| 四审 P2 | security 回放审批通过通知同步 afterCommit(既有逻辑) | 两处调用点改池内异步 |
其中最贵的是复审那条——一审引入的修法本身是个半成品,复审才把它真正修对。下面展开讲四个最有复用价值的坑。
坑一:afterCommit 不是异步任务
TransactionSynchronization.afterCommit() 在触发提交的那个请求线程上同步执行。把钉钉调用从事务里挪进 afterCommit,只解决了”不占数据库事务”,**没有解决”不拖 HTTP 响应”**。
更危险的是嵌套 Feign 场景:admin 的 @Transactional 方法通过 Feign 调 bpm 的 complete(),bpm 的 afterCommit 同步跑钉钉 HTTP 时,外层 admin 的事务还在等。钉钉一慢,外层 Feign 超时 → admin 事务回滚 → 但 bpm 的事务已经提交了——一笔孤立工单就此产生,两边数据永久不一致。
旁路通知的正确姿势是:afterCommit 里只做一件事——把任务交给有界线程池。
1 | flowchart LR |
线程池的参数语义每条都有出处:有界(无界队列起不到保护作用,warning 侧已有教训);daemon 线程 + @PreDestroy shutdown(不挡 JVM 退出);拒绝策略必须是丢弃 + 记日志,不能 CallerRunsPolicy——队列满时退回调用线程执行,等于把活又拖回请求线程,池子白建了(admin 的 AsyncConfig 用 CallerRuns 是”日志不能丢”的场景,不能照抄)。通知是旁路逻辑,丢一张卡片记条 warn,完全可接受;拖死审批,不可接受。

图 1:afterCommit 只释放事务,不释放请求线程;真正的异步要再过一个有界线程池。
坑二:vant Toast 是单例
H5 页面的 Toast.loading 和 axios 拦截器的错误 Toast(respMsg) 共用同一个 Toast 实例。失败时的时序是:拦截器先弹错误 Toast(替换掉 loading)→ reject → 页面 catch 里的 Toast.clear() 执行——清掉的正是刚弹出来的错误提示。用户看到错误一闪而过,然后盯着没有变化的页面发懵。
修法:失败路径依赖拦截器统一提示的页面,不要在 catch 里 clear/Toast,防连点也别用 loading 而改用状态占位。任何”loading + 统一错误 Toast”的页面都是同款坑。

图 2:拦截器弹出的错误提示,被页面 catch 里的 clear 当场擦掉——用户只看到错误一闪而过。
坑三:surefire 2.12.4 不认 JUnit5——“全过”可能是”没跑”
bpm 模块的 pom 一直依赖 spring-boot-starter-test,但 surefire 没显式配版本,用的是 Maven 老默认 2.12.4——它不认 JUnit5,测试静默执行 0 个,而且 BUILD SUCCESS。也就是说,bpm 历史上所有”测试通过”的构建,可能一个用例都没跑过。security 模块能跑是因为它显式配了 2.22.2。
发现这个坑之后顺手补上了 bpm 的第一批测试(钉钉通知分派 4 个用例)。教训一句话:新模块加测试,先确认 surefire ≥ 2.22.2,再看测试报告里的执行数是不是 0——“BUILD SUCCESS”和”测试真的跑了”之间,隔着一个版本号。

图 3:构建全绿、用例为零——最危险的测试状态不是红,是”假装绿”。
坑四:Bean 校验注解会静默失效
@RequestBody 不加 @Valid/@Validated,实体上的 @Length/@NotBlank 一次都不会执行,请求照样进 service。人脸申请事由的边界校验因此从注解挪到 service 层显式做:三值白名单(与 H5 选项一致、trim 归一),空值放行兼容旧客户端,任意文本、markdown 链接一律拒绝。校验逻辑提成包级静态纯函数 isAllowedApplyReason,直测两个用例覆盖——admin 模块的测试风格就是不起 Spring、不 mock,可测逻辑尽量提静态纯函数。
同一批挖出来的小坑(也都有复用价值)
- 同名枚举:bpm 内部
enums.BusinessTypeEnum(4/5 = ACTUAL_TIME/PLAYBACK)和 service-model 的model.enums.BusinessTypeEnum(REALTIME_VIDEO/PLAYBACK_VIDEO)同名不同物,import 错就是编译期”找不到符号”,运行时看就是行为诡异。 -pl <svc> compile不带-am会加载本地仓库里的旧 model jar,报”找不到符号”——文档里警告过,又踩一次。- 实体字段不要给初始化值:通用 mapper 的 OGNL 会把初始值卷进动态 SQL 条件(项目里栽过
Date != ''的坑),新增可空字段保持null初始。 - 默认文案变长会同时影响短信:无模板规则的兜底文案变长会让短信拆条(费用),这是产品权衡不是 bug;配了模板的规则走模板不受影响。
收尾
修复完成时五个模块的测试全绿:warning 29/29、iot 2/2(含 101 人分批)、admin 2/2(白名单)、bpm 4/4(史上首批)、security 64/64。四轮评审最有价值的产出不是十二个修复,而是确认了一件事:评审意见逐条核实这个流程本身是对的——“全部属实”不是因为评审客气,而是因为每一条都附了可复现的推理。尤其复审那条:一审的修法(挪进 afterCommit)当时看起来已经”修了”,是第二轮追问”那 HTTP 响应阻塞解决了吗”才戳穿半成品。对修复方案本身再做一轮评审,这个钱花得值。