
你在 Forge Admin 审批设计器里给某个节点开了「超时自动驳回」:截止时间一过,扫描任务该把这单关掉、网关走驳回分支。结果值班群里两件事同时出现------待办中心里这条任务的 status 变成了 5(已取消) ;会签 / 排他网关的 completionCondition 也读不到 approved=false,整条线表现得像「人没批、系统也没驳,只是任务消失了」。
第一反应通常是:「超时 Job 没跑?」或「Flowable 的 dueDate 没写上?」按源码推演,这两处往往都跑了------真正把状态写歪的,是 超时动作只调了引擎 complete,既没写入审批变量,也没回写业务任务终态;随后 Flowable 发出的 ENTITY_DELETED 又被监听器无脑盖成「已取消」。
Forge Admin 在 2026-10-16 的提交 fa3043318(完整 SHA:fa304331856e2504feea036d8b2ed28984c78ffe)里把这两刀一起补上。下面只贴故障与修复相关代码;推断处标「按源码推演」,发布前建议在自己环境用一条带超时的请假/审批流复验。
查重:与已发的门户入口权限解耦、Boot4、flw_*、Sa-Token Session、COUNT 分页、JAR Flyway,以及 10-23 未发草稿「审批公共节点合并」都不是同一题。本篇只谈流程超时动作的业务终态与 Flowable 删除事件覆盖。
TL;DR
- 现象 :节点配置
auto_reject/auto_pass后,超时扫描执行了,但sys_flow_task.status变成 5(已取消);auto_reject与auto_pass在业务表上几乎同相。 - 误判:以为扫描 Job 没触发、dueDate 没回填,或只怪 Flowable 边界事件。
- 根因 A :
handleTimeoutTask旧实现只taskService.complete,未写approved/approvalResult,也未回写APPROVED/REJECTED。 - 根因 B :
complete后引擎发ENTITY_DELETED;handleTaskDeleted旧逻辑无条件写成CANCELED,把刚写好的终态盖掉。 - 修复 :超时分支写入与人工审批对齐的变量 +
markTimeoutTaskResult;监听器用FlowTaskStatus.isActionable保护终态。 - 自查 :先查业务表 status 与流程变量
approved,再查监听器是否在 DELETED 时无脑取消。
现象:超时动作「跑了」,终态却像取消
超时扫描链路(按源码推演,方便对照值班日志)
FlowTimeoutServiceImpl 里有一条 @Scheduled 扫描(默认 forge.flow.timeout.scan-interval-ms=300000,约五分钟)。一轮大致是:
- 租户忽略上下文拿分布式扫描锁,避免多实例重复处理;
backfillMissingDueDates:对还没有due_date的业务任务,按节点配置的超时毫秒数从创建时间推截止时间,回写引擎setDueDate与业务表;selectTimeoutCandidates按截止时间游标批取到期任务;- 对每条候选再查
ACT_RU_TASK是否仍 active,存在才checkTaskTimeout→handleTimeoutTask(action)。
所以「扫描日志有开始检查超时任务」只说明 Job 活着;终态对不对,要看 handleTimeoutTask 有没有写变量 + 业务 status,以及 DELETED 监听有没有把它盖掉。 状态码约定在 FlowTaskStatus:PENDING=0、CLAIMED=1、APPROVED=2、REJECTED=3、CANCELED=5;isActionable 仅 0/1。
值班时建议按这个顺序看日志关键字(按源码推演):
开始检查超时任务/已有流程超时扫描实例执行中:扫描是否互斥跳过;超时任务自动通过/超时任务自动拒绝:动作分支是否进入;任务删除事件忽略:已是终态:保护是否生效;- 若只有
更新任务状态为已取消而没有「忽略终态」,就是覆盖仍在发生。
低代码 + 流程交付里,超时场景常同时满足三点:
- 节点配置里选了
auto_pass或auto_reject,并配了超时毫秒数; FlowTimeoutServiceImpl的定时扫描(默认约 5 分钟一轮,配置项forge.flow.timeout.scan-interval-ms)能扫到due_date到期的候选任务;- 引擎侧任务确实被
complete掉了------运行时表里任务没了,但业务表sys_flow_task的 status 不是 2/3,而是 5。
code-copilot/memory/pitfalls/flow.md 把现场写得很直:
超时扫描触发后
sys_flow_task.status变成 5(已取消),auto_reject与auto_pass行为几乎相同,会签/网关读不到approved=false。
CHANGELOG(同提交)对应 Fixed 条目:
修复流程超时
auto_pass/auto_reject仅调用引擎complete、未写入approved与业务任务终态的问题;同步避免任务完成后的ENTITY_DELETED事件把已通过/已驳回覆盖为已取消。
也就是说:引擎完成 ≠ 业务终态正确。低代码工作台、已办列表、会签条件读的是 Forge 自己的任务表和流程变量,不是「ACT_RU_TASK 空了就算驳回成功」。
根因一:超时只 complete,不写 approved,不落业务终态
修复前(fa3043318 父提交,真实 diff):
java
case "auto_pass":
Map<String, Object> variables = new HashMap<>();
variables.put("timeout_auto_pass", true);
variables.put("timeout_action", "auto_pass");
taskService.complete(taskId, variables);
break;
case "auto_reject":
Map<String, Object> rejectVariables = new HashMap<>();
rejectVariables.put("timeout_auto_reject", true);
rejectVariables.put("timeout_action", "auto_reject");
taskService.complete(taskId, rejectVariables);
break;
按源码推演:人工审批路径会写入 approved / approvalResult,网关和会签的 completionCondition 也按这个约定分流。超时路径只打了 timeout_* 标记------下游条件表达式若只认 approved,驳回分支永远进不去;业务表也没有人把它从待办改成通过/驳回。
修复后(当前 FlowTimeoutServiceImpl.handleTimeoutTask):
java
case "auto_pass":
Map<String, Object> variables = new HashMap<>();
variables.put("approved", true);
variables.put("approvalResult", "approve");
variables.put("timeout_auto_pass", true);
variables.put("timeout_action", "auto_pass");
taskService.complete(taskId, variables);
markTimeoutTaskResult(taskId, FlowTaskStatus.APPROVED, "超时自动通过");
break;
case "auto_reject":
Map<String, Object> rejectVariables = new HashMap<>();
rejectVariables.put("approved", false);
rejectVariables.put("approvalResult", "reject");
rejectVariables.put("timeout_auto_reject", true);
rejectVariables.put("timeout_action", "auto_reject");
taskService.complete(taskId, rejectVariables);
markTimeoutTaskResult(taskId, FlowTaskStatus.REJECTED, "超时自动拒绝");
break;
markTimeoutTaskResult 显式回写 status / comment / completeTime。注释写明意图:complete 会触发 ENTITY_DELETED,监听器已对终态做保护,这里仍显式落库避免竞态丢状态。
契约单测 FlowTimeoutServiceQueryContractTest.timeoutActionsMustWriteApprovedFlagAndBusinessTaskStatus 直接断言源码里必须出现 approved true/false 与 markTimeoutTaskResult(...APPROVED/REJECTED)------防止以后又退化成「只 complete」。
根因二:ENTITY_DELETED 把终态盖成取消
Flowable 在任务 complete 之后会发任务实体删除事件。旧监听器逻辑是「看到删除 → 写成已取消」:
java
// 修复前
FlowTask flowTask = flowTaskMapper.selectByTaskId(task.getId());
if (flowTask != null) {
flowTask.setStatus(FlowTaskStatus.CANCELED.getCode());
flowTask.setCompleteTime(LocalDateTime.now());
flowTaskMapper.updateById(flowTask);
}
按源码推演:即便超时路径先把 status 写成了 2/3,只要 DELETED 监听器后跑,最终仍会落到 5。于是值班同学看到的永远是「已取消」,auto_pass 与 auto_reject 在业务表上无法区分。
修复后(FlowTaskEventListener.handleTaskDeleted):
java
// Flowable 在任务 complete 后也会发 ENTITY_DELETED;已终态禁止覆盖成取消
FlowTask flowTask = flowTaskMapper.selectByTaskId(task.getId());
if (flowTask != null) {
if (!FlowTaskStatus.isActionable(flowTask.getStatus())) {
log.debug("任务删除事件忽略:已是终态 status={} taskId={}",
flowTask.getStatus(), task.getId());
} else {
flowTask.setStatus(FlowTaskStatus.CANCELED.getCode());
flowTask.setCompleteTime(LocalDateTime.now());
flowTaskMapper.updateById(flowTask);
}
}
FlowTaskStatus.isActionable 只认 PENDING(0) / CLAIMED(1)。已经是 APPROVED(2)、REJECTED(3)、CANCELED(5) 等终态时,删除事件只记 debug,不再覆盖。契约单测 taskDeletedListenerMustNotOverwriteTerminalStatus 卡住这段分支,避免回归。

怎么改(落地顺序)
若你在自研审批中心复现同类坑,不必先大改设计器,按依赖顺序改三处即可:
- 超时动作与人工审批对齐变量键名 (本仓库是
approved+approvalResult); - complete 之后立刻回写业务任务终态与意见 (本仓库
markTimeoutTaskResult); - 所有「任务删除 → 取消」监听加终态门闩 (本仓库
isActionable)。
Forge Admin 侧这三步已经落在 fa3043318;升级到该提交之后的 flow 插件即可继承行为。若你暂时不能升级,至少先把第 3 步门闩打上------它能阻止「已经正确的 2/3 被盖回 5」,是止血最快的一刀。
对照表:超时动作到底该留下什么
| 维度 | 修复前 | 修复后 |
|---|---|---|
| 引擎动作 | taskService.complete |
同样 complete |
| 流程变量 | 仅 timeout_auto_* |
额外 approved + approvalResult(与人工对齐) |
sys_flow_task.status |
常被 DELETED 盖成 5(已取消) | auto_pass→2 / auto_reject→3 |
| 会签/网关 | 读不到驳回语义 | 可按 approved=false 走驳回分支(按源码推演,需节点条件约定一致) |
| 意见字段 | 无超时说明 | comment=超时自动通过/拒绝 |
| 监听器 | DELETED 无条件 → CANCELED | 仅 actionable 才取消 |
自查清单(可收藏)
- 先查配置,再查 Job :节点
timeoutAction是否真是auto_pass/auto_reject;扫描间隔与due_date是否回填(backfillMissingDueDates存在于同服务)。 - 查流程变量 :超时 complete 后历史变量里有没有
approved;只有timeout_auto_reject不够。 - 查业务表 status:期望 2/3,若稳定为 5,优先怀疑 DELETED 监听器覆盖,而不是「超时没跑」。
- 查监听器顺序与幂等 :任何「任务删除 → 写取消」的监听,都必须先判断是否已终态(
isActionable或等价白名单)。 - 查会签条件 :
completionCondition/ 网关表达式是否依赖approved;超时路径必须与人工审批写同一套键。 - 用契约单测锁住:至少锁「超时写入 approved + 终态」和「DELETED 不覆盖终态」两条,避免下次重构打回原形。
- 已运行实例:本修复改的是扫描与监听逻辑,不自动改写历史错误 status;线上脏数据需单独订正脚本(按源码推演,仓库该提交未附批量修复 SQL)。

可带走的 5 条诀窍
- 超时是审批的一种,不是「删任务」:变量与终态要和人工通过/驳回对齐。
- Flowable
complete会连带ENTITY_DELETED:业务监听器不能把删除等价于取消。 - 双写防竞态 :超时服务显式
markTimeoutTaskResult,监听器再加终态保护,两道闸。 - 用源码契约测试锁语义:比只测「complete 没抛异常」更接近生产故障。
- 低代码工作台读的是业务表:引擎运行时清空了,不等于待办中心状态正确。
结尾
如果你也在自研 / 二开审批中心,被「超时了但状态像取消」折磨过,希望这篇少让你排一次夜班。觉得有用请 点赞 + 收藏(收藏通常比点赞多,清单就是为这个写的);下一篇想看设计器、待办还是超时通知渠道,评论区直接出题。
系列相关:低代码 + Flowable 整合、审批公共节点合并(草稿)、门户入口权限解耦等,都在同一套 Forge Admin 仓库里。
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
- 文档:www.dlforgelab.com:8084/forge-docs/
- 演示:www.dlforgelab.com:8084/forge/login... / 123456)