你在 Forge Admin 审批设计器里画了一条「金额 ≤ 1 万 / > 1 万」的互斥条件分支,两条支路末端都是「部门经理审批」,人员、表单权限、会签方式「看起来一模一样」。保存、发布、发起一单------待办中心里却冒出两张 经理待办,或 BPMN 里出现两个 userTask。值班群第一反应往往是:「是不是 Flowable 多实例配错了?」
Forge Admin 在 2026-10-20 的提交 f86a50f4b(完整 SHA:f86a50f4b9e2fd1910422dd4785bffc6caa91036)里,把这件事钉成一条设计器契约------画布上「看起来一样」不等于运行时只办一次;公共审批必须是显式、可撤销的拓扑合并,并且要过全量配置校验与 XML 往返。 下面按仓库真实源码与 Vitest 写;推断处标「按源码推演」。
查重:和已发的 Boot4 / flw_* 纯净库 / 门户入口权限 / JAR Flyway、以及今日作废的 snake/camel 字段编码都不是同一题。本篇只谈互斥条件支路上的审批节点合并,不讲 H5 钉钉风视觉(那是另一条头条草稿线)。
TL;DR
- 现象 :条件分支两边各画一个审批节点,配置「看着一样」,发布后变成两个
userTask/ 两份待办。 - 误判:以为审批人摘要相同就会自动汇合;或把「画布合成一张卡」理解成运行时语义已折叠。
- 根因:每张审批卡默认对应独立节点 ID;互斥支路末端若不合并,导出 BPMN 就会各自生成任务定义。
- 修复 :审批节点「更多 → 合并审批节点」;纯函数
inspectApprovalMerge/mergeApprovalNodes校验互斥网关、配置全量一致、共同后继、无回流;通过后保留目标 ID/配置,重定向入边。 - 边界:并行/包容网关拒绝直接合并;只改草稿,不自动发布;已有运行实例不变;外部若按节点 ID 绑规则须发布前核对。
- 自查 :先看 BPMN 是否只剩一个目标
userTask,再谈待办为什么多一张。
现象:两条「一样」的审批,为什么会变成两份待办?
低代码 + 流程的交付现场,常是下面三件事同时成立:
- 设计器画布上两个审批卡名称/审批人摘要几乎相同;
- 业务同学认为「反正都是经理批,画两张或一张无所谓」;
- 发布后监控/待办出现重复任务,或 XML 里能搜到两个
userTask id。
人脑直觉会把「视觉相似」当成「运行时同一节点」。BPMN / Flowable 不这么想:节点 ID 才是任务定义身份 。条件网关把令牌分到不同支路,每条支路末端如果是不同的 userTask,引擎就会按各自定义创建任务------即使办理人碰巧相同。
CHANGELOG(同提交)写得很直白:
审批设计器支持手动合并条件支路的公共审批节点:完整审批配置一致且后续相同时可合并,保留分支条件和默认路径......并行/包容或配置不一致时明确阻止,不影响已运行实例。
也就是说:Forge 故意不做「按审批人自动去重」。自动去重最容易在表单权限、会签、监听器仍不一致时,把两条本该分流的审批默默合成一条,属于流程状态/权限敏感变更。
根因一:重复节点 = 重复 userTask(按源码推演)
设计器内部图是 nodes + edges。合并前,fixture 形态(见 approval-merge-fixture.js / 单测)大致是:
- 互斥条件网关
G分出支路头H1/H2; - 支路末端审批
A1、A2; - 二者后继指向同一后续节点。
未合并时,convertJsonToBpmn(按源码推演)会对每个 approver 节点各写一个 bpmn:userTask。单测在合并后断言:
js
const xml = convertJsonToBpmn(merged)
expect(xml).not.toContain('<bpmn:userTask id="A1"')
expect(xml.match(/<bpmn:userTask id="A2"/g)).toHaveLength(1)
反过来:合并前 若保存发布,XML 里就会同时留下 A1 与 A2------这就是「两份待办」的定义层根源,不是候选人策略偶然撞车。
文件路径:
forge-admin-ui/src/components/flow-designer/utils/approval-node-merge.jsforge-admin-ui/src/components/flow-designer/utils/__tests__/approval-node-merge.spec.js
根因二:什么才叫「可以合并」------全量配置,不只看审批人
核心门禁在 inspectApprovalMerge(flow, sourceId, targetId)。源码顶部注释先立规矩:
js
/** 只折叠互斥支路上的等价审批;不能把"画布只有一个卡片"误当成运行时只办一次。 */
资格条件(压缩转述,细节以源码为准):
| 检查项 | 不通过时的典型 reason |
|---|---|
两端都必须是 approver,且各只有一条出边 |
只能合并审批节点 / 只有一个后续节点 |
必须追溯到同一个 condition(互斥)网关的不同支路 |
仅支持互斥条件分支;并行、包容拒绝 |
| 两个节点后继必须相同,且不存在回流 | 后续节点不同 / 合并位置存在回流 |
| 出边执行语义一致(条件、默认分支、扩展字段) | 后续连线条件或扩展配置不一致 |
config 全量等价(忽略仅布局用的 mergeNode) |
审批配置不一致,并返回 differenceKeys |
| 节点级扩展(除 id/name/config)也要一致 | 节点级扩展信息不一致 |
单测用 it.each 把「审批人看似相同但某某字段不一致」钉死,包括:candidateUsers、requireComment、multiInstanceType、formKey、表单/子表/数组权限、任务/执行监听器、审批要点、打印策略,甚至 unknownExtension------未知扩展也参与比较,防止静默丢权限。
这和产品文案一致(ApprovalNodeMergeModal.vue):
候选来源于当前真实流程图,不能仅凭审批人摘要判断可合并。
怎么改:合并算法只做「重定向」,不做配置并集
通过校验后,mergeApprovalNodes 的行为刻意克制:
js
export function mergeApprovalNodes(flow, sourceId, targetId) {
const inspection = inspectApprovalMerge(flow, sourceId, targetId)
if (!inspection.eligible)
throw new Error(inspection.reason)
const next = JSON.parse(JSON.stringify(flow))
next.nodes = next.nodes.filter(node => node.id !== sourceId)
const target = next.nodes.find(node => node.id === targetId)
target.config = { ...target.config, mergeNode: true }
next.edges = next.edges.filter(edge => edge.source !== sourceId)
.map(edge => edge.target === sourceId ? { ...edge, target: targetId } : edge)
return next
}
要点:
- 原图不变(纯函数,先深拷贝);失败抛错,不产生半截图。
- 删来源、留目标 :目标 ID / 名称 / 完整
config不做并集或覆盖。 - 入边只改 target :边的
id、条件、默认分支属性保留------互斥网关上「金额 ≤1万 / >1万」两条边仍然各自存在,只是都指向公共审批卡。 mergeNode: true只是画布布局标记,比较配置时会被剥掉,不能拿来掩盖差异。
交互层(useApprovalNodeMerge.js + Modal):
- 属性抽屉有未确认草稿时先挡住,避免丢配置;
flowJson重载 / 只读 / 卸载时关闭合并态,防止套到另一张图;- 确认框展示保留/删除的节点 ID,并写明:只改草稿,可撤销;保存并发布新版本后供新流程用;已有运行实例不会随之合并;外部按节点 ID 绑定请发布前核对。
同提交还顺带把设计器 Soft UI(节点卡、添加菜单、窄屏浮层)收齐,但本文焦点仍是合并语义,不展开视觉细节。

自查清单(交付前 2 分钟)
| # | 检查项 | 通过标准 | 不通过时先看 |
|---|---|---|---|
| 1 | 画布是否仍有「同后继、同配置」的双审批卡 | 互斥支路末端应合并为 1 张公共审批(或业务上明确要办两次) | 审批节点更多 → 合并审批节点 |
| 2 | Modal 是否报配置不一致 | differenceKeys 为空才可确认 |
人员/表单权限/会签/监听器逐项对齐 |
| 3 | 网关类型 | 仅互斥 condition;并行/包容不要硬合并 |
改拓扑或分别配置 |
| 4 | 导出 XML | 目标 userTask 仅 1 个;支路条件边仍在 |
convertJsonToBpmn 往返 + 重新导入 |
| 5 | 发布边界 | 新版本生效;旧实例不变 | 不要指望「画布合并」热修在途单 |
| 6 | 外部绑定 | 若回调/规则绑了被删节点 ID,已迁移到保留 ID | 发布前人工核对 |
| 7 | 待办仍重复? | 先确认是否未发布新版本,或发起的是旧定义 | 模型列表发布状态 / 流程定义版本 |
和「低代码页面挂流程」怎么接得上
Forge 的低代码应用把业务对象、表单权限和审批节点配在同一条交付链上。条件分支常来自表单字段(金额、类型、组织),支路末端又容易拖成「同角色审批」重复卡。公共节点合并解决的是设计器拓扑卫生 :让「表单条件分流 + 同一审批权汇合」在 BPMN 层也诚实------分流用边,汇合用共享 userTask,而不是靠候选人碰巧相同去赌。
(按源码推演)若你还在排「待办幽灵 / 超时自动通过变取消」类问题,那是另一条线:fa3043318 里 FlowTimeoutServiceImpl 补 approved 并回写终态、FlowTaskEventListener 对已终态忽略 ENTITY_DELETED、待办 SQL 要求 ACT_RU_TASK 仍存在。本篇不展开,避免和设计器合并混题。
小结
- 重复审批卡 = 重复任务定义;视觉相似救不了两份待办。
- Forge Admin 用手动合并 + 全量配置校验,拒绝并行/包容静默折叠,拒绝「只看审批人」。
- 合并只重定向入边、保留目标配置与边元数据,并用 XML 往返单测锁契约。
- 交付自查:画布 → Modal → XML → 发布版本 → 外部节点 ID。
仓库:Forge Admin。欢迎在评论区甩你现场的「双待办」截图或 BPMN 片段,我按节点 ID 帮你看该不该合并。