请假单要三人会签:组长、部门经理、分管领导都批了才能进下一节点。结果组长一点「通过」,待办就跳到财务------另外两人的任务直接没了。值班群第一反应通常是:「会签画成或签了?」「是不是 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
- 现象:设计器勾了「会签 / 多人审批」,运行时一人批完节点就结束;或比例通过写成 0 / 100 被兜成全票;串行加签报不支持。
- 根因 :
completionCondition=any会生成${nrOfCompletedInstances >= 1};ratio 的passRate非法时buildCompletionExpression兜底成 all;动态加签入口强制PARALLEL。 - 改法:面板选 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,就会稳定复现。
怎么改
- 打开节点属性,确认 完成条件 = all(全票);真要或签就显式选 any,别口头叫会签。
- 保存后打开导出的 BPMN,核对
completionCondition是否为== 1(all)而不是>= 1(any)。 - 单测
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 单纯没按钮。
怎么改
- 需要运行中加人的节点,建模阶段就选 并行会签。
- 前端/网关带上稳定的幂等键,避免双击重复加签。
- 减签只对「本次动态加出来」且仍有
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 绿 |
跟我对照:从面板到表达式
假设三人并行、全票通过:
- 面板:
multiInstanceType=parallel,completionCondition=all。 - writer 产出:
isSequential="false"+${nrOfCompletedInstances/nrOfInstances == 1}。 - 运行:三人各有待办;仅当
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 拒绝,避免「加了人但看不见待办」。
排障五分钟:从待办反查表达式
按源码推演的最短路径:
- 看待办数量 :同一节点是否同时出现多人待办?没有 → 先查
isSequential/collection是否展开失败。 - 看一人通过后是否收束 :收束了 → 把 BPMN 里的
completionCondition贴出来,对照上表;>= 1基本就是 any。 - 看面板回读:打开设计器,完成条件是否仍是你保存的值?漂移就跑 parser 单测,确认不是手工改坏了 XML。
- 看加签错误码 :
FLOW_TASK_SIGN_MODE_UNSUPPORTED→ 模式不是并行;FLOW_TASK_IDEMPOTENCY_CONFLICT→ 同幂等键不同 digest;超 50 人 → 拆节点或改建模。 - 看 ratio 兜底 :面板写了比例却导出
== 1,检查passRate是否落在 (0,100)。
把这五步做成值班手册,比事后翻 Flowable 论坛更快。会签问题里,十有八九停在「条件选错」或「XML 没写出 loop」,而不是引擎内核缺陷。
系列说明与仓库
本文是 Forge Admin 审批设计器踩坑系列的一篇,只覆盖 会签 multiInstance / completionCondition / 动态加签。前序可读:条件分支公共节点合并、驳回给发起人后的直送跳回等。
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
源码路径速查:
forge-admin-ui/src/components/flow-designer/converter/completion-condition.jsforge-admin-ui/src/components/flow-designer/converter/user-task-writer.jsforge-admin-ui/src/components/flow-designer/converter/user-task-parser.jsforge-admin-ui/src/components/flow-designer/converter/__tests__/user-task-parser-multi-instance.spec.jsforge-server/.../FlowTaskDynamicSignCoordinator.java
若这篇帮你对上了「假会签」现场,麻烦点个赞和收藏。你在会签/或签/加签上还卡过哪一题?评论区丢给我,下一篇优先写读者票最高的坑。