三人会签只批了一个就往下走:Forge Admin 把 all / any / ratio 写成 completionCondition

请假单要三人会签:组长、部门经理、分管领导都批了才能进下一节点。结果组长一点「通过」,待办就跳到财务------另外两人的任务直接没了。值班群第一反应通常是:「会签画成或签了?」「是不是 Flowable multiInstance 配错了?」或者「completionCondition 写了个啥?」

Forge Admin(开源后台,仓库见文末)在审批设计器里把这件事拆成三层:会签模式(并行 / 串行) 、完成条件(all / any / ratio) 、运行态动态加签(仅并行) 。前端关键文件在 forge-admin-ui/.../converter/:completion-condition.js 构造表达式,user-task-writer.js 写出 multiInstanceLoopCharacteristics,user-task-parser.js 与单测对偶回读;后端动态加签落在 FlowTaskDynamicSignCoordinator(提交 ead75720,2026-09-27 Asia/Shanghai)。下面按仓库真实源码写;未起完整审批实例处标明「按源码推演」,发布前请作者自行点一遍。

查重:和已发的驳回直送、条件分支公共节点合并、跨租户短信串发、在线 DDL、门户入口权限等都不是同一题。本篇只谈会签 multiInstance + completionCondition + 动态加签。

TL;DR

  1. 现象:设计器勾了「会签 / 多人审批」,运行时一人批完节点就结束;或比例通过写成 0 / 100 被兜成全票;串行加签报不支持。
  2. 根因 :completionCondition=any 会生成 ${nrOfCompletedInstances >= 1};ratio 的 passRate 非法时 buildCompletionExpression 兜底成 all;动态加签入口强制 PARALLEL。
  3. 改法:面板选 all / any / ratio 并核对导出 BPMN;依赖 writer/parser 对偶与单测;运行态加人走协调器的幂等键与 50 人上限。

全景:设计器 JSON 怎么变成 Flowable 多实例

会签不是「多拖几个审批节点」,而是同一个 userTask 带上多实例循环特征。Forge Admin 的链路可以压成一张对照:

设计器字段 落到 BPMN 运行时含义
multiInstanceType=parallel isSequential="false" 多人同时出待办
multiInstanceType=sequential isSequential="true" 按集合顺序一人批完再下一人
completionCondition=all ${nrOfCompletedInstances/nrOfInstances == 1} 全票完成才过节点
completionCondition=any ${nrOfCompletedInstances >= 1} 一人完成即过节点(或签)
completionCondition=ratio + passRate ${nrOfCompletedInstances/nrOfInstances >= 0.N} 达到比例即过
multiInstanceCollection flowable:collection=... 实例从哪份名单展开
text 复制代码
NodePropertiesPanel
  → multiInstanceType / completionCondition / passRate / collection
    → user-task-writer.js
      → <bpmn:multiInstanceLoopCharacteristics isSequential="...">
           <bpmn:completionCondition>...</bpmn:completionCondition>
         </bpmn:multiInstanceLoopCharacteristics>
    ← user-task-parser.js(回读面板)
Runtime(可选)
  → FlowTaskDynamicSignCoordinator 动态加/减签(仅并行)

坑一:三人会签,一个人批完就往下走

现象

面板写着「会签」,候选人挂了三人。A 批完后,B、C 的待办消失,流程进下一节点。业务同学以为引擎坏了;按源码推演,更常见的是完成条件其实是 any(或签)。

根因(代码)

completion-condition.js 把三种条件写成 Flowable 表达式,与 parser 注释对偶:

js 复制代码
// forge-admin-ui/.../converter/completion-condition.js
export function buildCompletionExpression(condition, passRate) {
  const c = String(condition || 'all').toLowerCase()
  if (c === 'any')
    return `${DOLLAR}{nrOfCompletedInstances >= 1}`

  if (c === 'ratio') {
    const n = Number(passRate)
    if (!Number.isFinite(n) || n <= 0 || n >= 100)
      return `${DOLLAR}{nrOfCompletedInstances/nrOfInstances == 1}` // 兜底 all
    const decimal = (n / 100).toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
    return `${DOLLAR}{nrOfCompletedInstances/nrOfInstances >= ${decimal}}`
  }

  // all / 默认
  return `${DOLLAR}{nrOfCompletedInstances/nrOfInstances == 1}`
}

any 的语义就是「完成实例数 ≥ 1」。并行三人时,A 一完成,表达式为真,多实例节点收束------看起来像「会签只批了一个」。若面板默认被改成 any、或从旧 BPMN 回读成 any,就会稳定复现。

怎么改

  1. 打开节点属性,确认 完成条件 = all(全票);真要或签就显式选 any,别口头叫会签。
  2. 保存后打开导出的 BPMN,核对 completionCondition 是否为 == 1(all)而不是 >= 1(any)。
  3. 单测 user-task-parser-multi-instance.spec.js 已覆盖三种表达式解析,改映射时先跑它。

坑二:勾了会签,BPMN 里却没有 multiInstanceLoopCharacteristics

现象

设计器 JSON 里 multiInstanceType 看着不是 none,发布后 Flowable 仍按单人任务跑;或者取消会签后再打开,面板又变回会签。

根因(代码)

writer 只在 multiInstanceType !== 'none' 时写循环特征,并显式带上 flowable:multiInstanceType;注释写明:不会签不写 loop,避免回读后又变成会签。

js 复制代码
// user-task-writer.js(节选)
const multiInstanceType = cfg.multiInstanceType && cfg.multiInstanceType !== 'none'
  ? cfg.multiInstanceType
  : 'none'
if (multiInstanceType !== 'none')
  attrs.push(`flowable:multiInstanceType="${escapeXmlAttr(multiInstanceType)}"`)

if (multiInstanceType !== 'none') {
  const seq = multiInstanceType === 'sequential' ? 'true' : 'false'
  const expr = buildCompletionExpression(cfg.completionCondition, cfg.passRate)
  const collection = initiatorSelect
    ? `${DOLLAR}{PROCESS_START_USER['${nodeKey}']}`
    : normalizeOptionalText(cfg.multiInstanceCollection)
  const elementVariable = normalizeOptionalText(cfg.multiInstanceElementVariable) || 'assignee'
  const loopAttrs = [`isSequential="${seq}"`]
  // ... collection → flowable:collection / elementVariable
  // ... 否则写 loopCardinality 兜底
  children.push(
    `<bpmn:multiInstanceLoopCharacteristics ${loopAttrs.join(' ')}>${
      loopCardinalityXml
    }<bpmn:completionCondition xsi:type="bpmn:tFormalExpression">${escapeXmlText(expr)}</bpmn:completionCondition>`
    + `</bpmn:multiInstanceLoopCharacteristics>`,
  )
}

要点:

  • 并行 / 串行 只靠 isSequential(sequential → true,否则 false)。
  • 名单 :发起人自选走 PROCESS_START_USER[nodeKey];否则用配置的 multiInstanceCollection;都没有时退回 loopCardinality(默认 ${nrOfInstances})。
  • 元素变量 默认 assignee,与任务办理人表达式对齐。

按源码推演:若转换链路没走到这段 writer、或配置仍是 none,引擎就看不到多实例。

怎么改

保存后检查 BPMN 是否出现 multiInstanceLoopCharacteristics;并行会签应看到 isSequential="false" 以及期望的 completionCondition。回读时依赖下一节的 parser,不要手改一半 XML。

坑三:回读 / 往返后,all 变成 any,或比例丢了

现象

导出再导入,或再次打开设计器:完成条件漂移;passRate=60 变成 100;旧式 ${nrOfCompletedInstances == nrOfInstances} 面板显示异常。

根因(代码)

parser 与 builder 对偶。parseCompletionExpression 按表达式形态还原:

js 复制代码
// user-task-parser.js(节选)
export function parseCompletionExpression(expr) {
  const text = String(expr || '').trim()
  if (!text)
    return { condition: 'all', passRate: 100 }

  if (/nrOfCompletedInstances\s*>=\s*1\b/.test(text) && !text.includes('/'))
    return { condition: 'any', passRate: null }

  if (/==\s*1\b/.test(text) || /==\s*nrOfInstances\b/.test(text))
    return { condition: 'all', passRate: 100 }

  const ratio = text.match(/>=\s*([\d.]+)/)
  if (ratio) {
    const v = Number.parseFloat(ratio[1])
    if (Number.isFinite(v))
      return { condition: 'ratio', passRate: Math.round(v * 100) }
  }

  return { condition: 'all', passRate: 100 }
}

applyMultiInstance 还会读 isSequential、flowable:collection、elementVariable、loopCardinality;没有 loop 时强制 multiInstanceType=none(即便属性上残留 parallel/sequential 字样也会收敛)。单测用同一套 SAMPLE XML 断言:

样例 期望
.../nrOfInstances == 1 all / 100
nrOfCompletedInstances >= 1 any / null
... >= 0.6 ratio / 60
legacy == nrOfInstances all / 100
空 / garbage all / 100

怎么改

改表达式映射时同时改 buildCompletionExpression 与 parseCompletionExpression,并补 user-task-parser-multi-instance.spec.js。ratio 的小数用 (n/100).toFixed(2) 去尾零,回读再 *100 取整------手写 0.600 之类也能进 ratio 分支,但建议统一走面板。

另:passRate 配成 0、100、NaN 时,builder 故意兜底成 all ,避免生成 >= 0 / >= 1.0 这种歧义式。比例要通过就填 (0, 100) 开区间,例如 60。

坑四:会签进行中想加一个人------串行直接拒,并行也要守幂等

现象

节点已是多人会签,审批中途还想再拉一个会签人。点加签后:串行模式报不支持;并行模式重复点两次出现重复候选人;或者一口气加几十人拖垮任务列表。

根因(代码)

FlowTaskDynamicSignCoordinator(ead75720 抽出)统一租户、审计、幂等和关系镜像。入口先卡死模式:

java 复制代码
// FlowTaskDynamicSignCoordinator.mutate(节选)
String normalizedSignMode = FlowTaskSignMode.fromCode(signMode).getCode();
if (!FlowTaskSignMode.PARALLEL.getCode().equals(normalizedSignMode)) {
    throw new IllegalStateException("FLOW_TASK_SIGN_MODE_UNSUPPORTED");
}

也就是说:动态加签目前只支持并行 。多实例分支里调用 Flowable 的 runtimeService.addMultiInstanceExecution,按 elementVariable(默认 assignee)注入目标用户;减签则按关系表里的 childExecutionId 调 deleteMultiInstanceExecution。

硬约束还有:

  • 上限 :MAX_DYNAMIC_SIGNERS = 50,候选人集合超限直接抛「单个任务最多允许加签 50 人」。
  • 幂等 :idempotencyKey 与 requestDigest 必须同有或同无;重复请求命中同 digest 则直接 return;digest / 目标用户 / 加减方向不一致则 FLOW_TASK_IDEMPOTENCY_CONFLICT。
  • 自加签禁止:不能把当前办理人再加进名单;已在名单里的目标用户也不能再加。

按源码推演:串行多实例上点「运行态加签」会在协调器门口被拒,不是 UI 单纯没按钮。

怎么改

  1. 需要运行中加人的节点,建模阶段就选 并行会签。
  2. 前端/网关带上稳定的幂等键,避免双击重复加签。
  3. 减签只对「本次动态加出来」且仍有 childExecutionId 的关系生效;原始 collection 里的人不是同一条撤销路径。

自查清单(建议收藏)

# 检查项 通过标准
1 业务要的是会签还是或签? 会签 → all 或 ratio;或签 → 显式 any
2 导出 BPMN 的 completionCondition all=== 1;any=>= 1;ratio=>= 0.N
3 passRate 仅 ratio 使用,且 ∈ (0,100)
4 isSequential 并行 false / 串行 true,与面板一致
5 flowable:collection / elementVariable 名单能展开;变量名与 assignee 表达式对齐
6 取消会签后 XML 不应再残留 loop,避免回读变会签
7 运行态加签 仅并行;幂等键成对;≤50 人
8 单测 user-task-parser-multi-instance.spec.js 绿

跟我对照:从面板到表达式

假设三人并行、全票通过:

  1. 面板:multiInstanceType=parallel,completionCondition=all。
  2. writer 产出:isSequential="false" + ${nrOfCompletedInstances/nrOfInstances == 1}。
  3. 运行:三人各有待办;仅当 nrOfCompletedInstances/nrOfInstances == 1 时节点完成。

若误选 any,同为三人并行,表达式变成 ${nrOfCompletedInstances >= 1}------这就是标题里的现场。

比例 60% 时,builder 生成 >= 0.6,parser 回读 passRate=60。把 passRate 填 0 或 100,会静默落到 all,这是保护而不是神秘 bug。

并行 vs 串行:别把「依次批」和「全票过」混为一谈

现场还有一种混淆:产品说「三个领导按顺序批」,研发却配成并行 all;或者反过来,串行会签却期望三人同时看到待办。

在 Forge Admin 里这两维是正交的:

  • 谁一起出现 :multiInstanceType → isSequential
  • 何时算过 :completionCondition → all / any / ratio

串行 + all:A 批完才创建 B 的任务,B 完再 C,全部完成后节点才过。

串行 + any:按源码推演,只要当前完成实例数 ≥ 1 就可能收束------顺序名单还没走到后面的人,节点已经结束。这比并行 any 更难排查,因为待办从未发给后面的人。

并行 + ratio(60%):三人同时有待办,两人完成后表达式为真;第三人未办的任务会随多实例收束被清理(引擎行为,按源码推演请在预发环境点验)。

动态加签只开放并行,也是同一条产品边界:串行执行链上中途插入实例,和 Flowable addMultiInstanceExecution 的任务可见性时序更容易打架,协调器直接用 FLOW_TASK_SIGN_MODE_UNSUPPORTED 拒绝,避免「加了人但看不见待办」。

排障五分钟:从待办反查表达式

按源码推演的最短路径:

  1. 看待办数量 :同一节点是否同时出现多人待办?没有 → 先查 isSequential / collection 是否展开失败。
  2. 看一人通过后是否收束 :收束了 → 把 BPMN 里的 completionCondition 贴出来,对照上表;>= 1 基本就是 any。
  3. 看面板回读:打开设计器,完成条件是否仍是你保存的值?漂移就跑 parser 单测,确认不是手工改坏了 XML。
  4. 看加签错误码 :FLOW_TASK_SIGN_MODE_UNSUPPORTED → 模式不是并行;FLOW_TASK_IDEMPOTENCY_CONFLICT → 同幂等键不同 digest;超 50 人 → 拆节点或改建模。
  5. 看 ratio 兜底 :面板写了比例却导出 == 1,检查 passRate 是否落在 (0,100)。

把这五步做成值班手册,比事后翻 Flowable 论坛更快。会签问题里,十有八九停在「条件选错」或「XML 没写出 loop」,而不是引擎内核缺陷。

系列说明与仓库

本文是 Forge Admin 审批设计器踩坑系列的一篇,只覆盖 会签 multiInstance / completionCondition / 动态加签。前序可读:条件分支公共节点合并、驳回给发起人后的直送跳回等。

源码路径速查:

  • forge-admin-ui/src/components/flow-designer/converter/completion-condition.js
  • forge-admin-ui/src/components/flow-designer/converter/user-task-writer.js
  • forge-admin-ui/src/components/flow-designer/converter/user-task-parser.js
  • forge-admin-ui/src/components/flow-designer/converter/__tests__/user-task-parser-multi-instance.spec.js
  • forge-server/.../FlowTaskDynamicSignCoordinator.java

若这篇帮你对上了「假会签」现场,麻烦点个赞和收藏。你在会签/或签/加签上还卡过哪一题?评论区丢给我,下一篇优先写读者票最高的坑。

相关推荐
vipxieliang1 小时前
ValidX vs 手写验证代码:开发效率对比实测
java·spring boot·后端
vipxieliang2 小时前
虚拟线程与 GraalVM:Spring Boot 3.x 云原生性能跃迁
java·spring boot·后端
头发还在的女程序员3 小时前
基于 Spring Boot + Vue 3 + uni-app 的灵活用工系统:四端架构与业务流程设计
vue.js·spring boot·uni-app·灵活用工·灵工系统
CoderYanger9 小时前
Java EE 进阶:8.MyBatis 操作数据库(入门)(第二弹)
java·数据库·spring boot·spring·面试·java-ee·mybatis
计算机毕设定制辅导-无忧学长18 小时前
《基于微信小程序的居家医疗陪护预约系统设计与实现》
java·spring boot·微信小程序·uniapp·居家医疗陪护预约系统
小郎君6581 天前
注解式脱敏:用 Jackson 的 ContextualSerializer 做字段级策略(一)
spring boot·spring cloud
jktantan1 天前
注解式脱敏:用 Jackson 的 ContextualSerializer 做字段级策略(一)
spring boot·后端·算法·spring cloud·系统架构
露天赏雪2 天前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
Java小卷2 天前
面向 schema 编程,轻松搞定 Element 虚拟化表格复杂应用场景
vue.js·低代码·element