超时配置了自动驳回,任务状态却全变「已取消」:Forge Admin 流程超时终态与 Flowable ENTITY_DELETED

你在 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

  1. 现象 :节点配置 auto_reject / auto_pass 后,超时扫描执行了,但 sys_flow_task.status 变成 5(已取消);auto_reject 与 auto_pass 在业务表上几乎同相。
  2. 误判:以为扫描 Job 没触发、dueDate 没回填,或只怪 Flowable 边界事件。
  3. 根因 A :handleTimeoutTask 旧实现只 taskService.complete,未写 approved / approvalResult,也未回写 APPROVED / REJECTED。
  4. 根因 B :complete 后引擎发 ENTITY_DELETED;handleTaskDeleted 旧逻辑无条件写成 CANCELED,把刚写好的终态盖掉。
  5. 修复 :超时分支写入与人工审批对齐的变量 + markTimeoutTaskResult;监听器用 FlowTaskStatus.isActionable 保护终态。
  6. 自查 :先查业务表 status 与流程变量 approved,再查监听器是否在 DELETED 时无脑取消。

现象:超时动作「跑了」,终态却像取消

超时扫描链路(按源码推演,方便对照值班日志)

FlowTimeoutServiceImpl 里有一条 @Scheduled 扫描(默认 forge.flow.timeout.scan-interval-ms=300000,约五分钟)。一轮大致是:

  1. 租户忽略上下文拿分布式扫描锁,避免多实例重复处理;
  2. backfillMissingDueDates:对还没有 due_date 的业务任务,按节点配置的超时毫秒数从创建时间推截止时间,回写引擎 setDueDate 与业务表;
  3. selectTimeoutCandidates 按截止时间游标批取到期任务;
  4. 对每条候选再查 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。

值班时建议按这个顺序看日志关键字(按源码推演):

  • 开始检查超时任务 / 已有流程超时扫描实例执行中:扫描是否互斥跳过;
  • 超时任务自动通过 / 超时任务自动拒绝:动作分支是否进入;
  • 任务删除事件忽略:已是终态:保护是否生效;
  • 若只有 更新任务状态为已取消 而没有「忽略终态」,就是覆盖仍在发生。

低代码 + 流程交付里,超时场景常同时满足三点:

  1. 节点配置里选了 auto_pass 或 auto_reject,并配了超时毫秒数;
  2. FlowTimeoutServiceImpl 的定时扫描(默认约 5 分钟一轮,配置项 forge.flow.timeout.scan-interval-ms)能扫到 due_date 到期的候选任务;
  3. 引擎侧任务确实被 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 卡住这段分支,避免回归。

怎么改(落地顺序)

若你在自研审批中心复现同类坑,不必先大改设计器,按依赖顺序改三处即可:

  1. 超时动作与人工审批对齐变量键名 (本仓库是 approved + approvalResult);
  2. complete 之后立刻回写业务任务终态与意见 (本仓库 markTimeoutTaskResult);
  3. 所有「任务删除 → 取消」监听加终态门闩 (本仓库 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 才取消

自查清单(可收藏)

  1. 先查配置,再查 Job :节点 timeoutAction 是否真是 auto_pass / auto_reject;扫描间隔与 due_date 是否回填(backfillMissingDueDates 存在于同服务)。
  2. 查流程变量 :超时 complete 后历史变量里有没有 approved;只有 timeout_auto_reject 不够。
  3. 查业务表 status:期望 2/3,若稳定为 5,优先怀疑 DELETED 监听器覆盖,而不是「超时没跑」。
  4. 查监听器顺序与幂等 :任何「任务删除 → 写取消」的监听,都必须先判断是否已终态(isActionable 或等价白名单)。
  5. 查会签条件 :completionCondition / 网关表达式是否依赖 approved;超时路径必须与人工审批写同一套键。
  6. 用契约单测锁住:至少锁「超时写入 approved + 终态」和「DELETED 不覆盖终态」两条,避免下次重构打回原形。
  7. 已运行实例:本修复改的是扫描与监听逻辑,不自动改写历史错误 status;线上脏数据需单独订正脚本(按源码推演,仓库该提交未附批量修复 SQL)。

可带走的 5 条诀窍

  1. 超时是审批的一种,不是「删任务」:变量与终态要和人工通过/驳回对齐。
  2. Flowable complete 会连带 ENTITY_DELETED:业务监听器不能把删除等价于取消。
  3. 双写防竞态 :超时服务显式 markTimeoutTaskResult,监听器再加终态保护,两道闸。
  4. 用源码契约测试锁语义:比只测「complete 没抛异常」更接近生产故障。
  5. 低代码工作台读的是业务表:引擎运行时清空了,不等于待办中心状态正确。

结尾

如果你也在自研 / 二开审批中心,被「超时了但状态像取消」折磨过,希望这篇少让你排一次夜班。觉得有用请 点赞 + 收藏(收藏通常比点赞多,清单就是为这个写的);下一篇想看设计器、待办还是超时通知渠道,评论区直接出题。

系列相关:低代码 + Flowable 整合、审批公共节点合并(草稿)、门户入口权限解耦等,都在同一套 Forge Admin 仓库里。

相关推荐
二宝哥2 小时前
02.springboot项目对接国内模型实现对话聊天
数据库·人工智能·spring boot
limit00753 小时前
三维模型悬空与切片偏移的排查顺序
低代码·3d·arcgis·web3·安卓·webgl
学长毕设3 小时前
基于springboot的农资管理系统-附源码
java·spring boot·后端
Magicdata大数据5 小时前
AI辅助教学:调用大模型从教材中生成习题,方便学员练习
java·spring boot·大模型·知识图谱·neo4j·ai辅助教学
不识君5 小时前
esl-client 2.0.0 发布:Java 项目接入 FreeSWITCH,从引入到使用
spring boot·后端·开源
二宝哥5 小时前
03.Spring AI Embedding 实战:从向量化原理到项目落地
人工智能·spring boot·向量·spring ai
驰骋工作流6 小时前
流程引擎功能量化描述:配置项、规则与对外接口清点驰骋BPM驰骋低代码低代码工作流引擎
低代码
captain3766 小时前
Spring Web MVC(2)
java·前端·spring boot·后端·spring·mvc