条件分支两边审批一样,却发成两份待办:Forge Admin 审批设计器的公共节点合并

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

  1. 现象 :条件分支两边各画一个审批节点,配置「看着一样」,发布后变成两个 userTask / 两份待办。
  2. 误判:以为审批人摘要相同就会自动汇合;或把「画布合成一张卡」理解成运行时语义已折叠。
  3. 根因:每张审批卡默认对应独立节点 ID;互斥支路末端若不合并,导出 BPMN 就会各自生成任务定义。
  4. 修复 :审批节点「更多 → 合并审批节点」;纯函数 inspectApprovalMerge / mergeApprovalNodes 校验互斥网关、配置全量一致、共同后继、无回流;通过后保留目标 ID/配置,重定向入边。
  5. 边界:并行/包容网关拒绝直接合并;只改草稿,不自动发布;已有运行实例不变;外部若按节点 ID 绑规则须发布前核对。
  6. 自查 :先看 BPMN 是否只剩一个目标 userTask,再谈待办为什么多一张。

现象:两条「一样」的审批,为什么会变成两份待办?

低代码 + 流程的交付现场,常是下面三件事同时成立:

  1. 设计器画布上两个审批卡名称/审批人摘要几乎相同;
  2. 业务同学认为「反正都是经理批,画两张或一张无所谓」;
  3. 发布后监控/待办出现重复任务,或 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.js
  • forge-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
}

要点:

  1. 原图不变(纯函数,先深拷贝);失败抛错,不产生半截图。
  2. 删来源、留目标 :目标 ID / 名称 / 完整 config 不做并集或覆盖。
  3. 入边只改 target :边的 id、条件、默认分支属性保留------互斥网关上「金额 ≤1万 / >1万」两条边仍然各自存在,只是都指向公共审批卡。
  4. 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 仍存在。本篇不展开,避免和设计器合并混题。

小结

  1. 重复审批卡 = 重复任务定义;视觉相似救不了两份待办。
  2. Forge Admin 用手动合并 + 全量配置校验,拒绝并行/包容静默折叠,拒绝「只看审批人」。
  3. 合并只重定向入边、保留目标配置与边元数据,并用 XML 往返单测锁契约。
  4. 交付自查:画布 → Modal → XML → 发布版本 → 外部节点 ID。

仓库:Forge Admin。欢迎在评论区甩你现场的「双待办」截图或 BPMN 片段,我按节点 ID 帮你看该不该合并。

相关推荐
captain3761 小时前
Spring Boot的配置文件
java·spring boot·后端
captain3761 小时前
Spring Boot日志
java·数据库·spring boot
RuoyiOffice2 小时前
SpringBoot+Vue3 单据校验设计:保存草稿、提交审批、退回重填,各该检查什么?
spring boot·vue3·flowable·表单设计·ruoyi office·草稿保存·单据校验
木易 士心2 小时前
Spring Boot 微信公众号后端服务开发实战指南
spring boot·后端·微信
ss2733 小时前
基于springboot+vue+mysql「校园二手教材循环交易」小程序,三端全跑通
vue.js·spring boot·mysql
码士集团小青11 小时前
SpringBoot 集成缓存性能之王 Caffeine
spring boot·后端·缓存
专业程序开发源15 小时前
springboot欧缇蔓月子中心管理系统82697-计算机课程设计、毕业设计
java·spring boot·后端·python·spring·django·课程设计
莫逸风15 小时前
【毕设源码二期01】校园失物招领系统:从发布线索到核验交接
spring boot·毕业设计·课程设计·react
vx_Biye_Design16 小时前
django房屋租赁推荐系统78455-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·课程设计·express