会签三兄弟:并行/串行/按比例的实现与取舍

系列定位 :jeeflow 系列第 8 篇(第二季「核心设计」第 5 篇) 平台 :掘金(代码密度高,原理讲透) 素材版本 :引擎 v1.8.18,源码取自 jeeflow-java 前置阅读第 7 篇 · 零依赖的秘密:8 大 SPI 设计


一、为什么需要会签?

上一篇我们讲了引擎靠 8+1 个 SPI 做到零依赖。这一篇回到流程模型本身------多个人审批同一个节点

普通审批是一对一的:assignee: "leader",任务创建给一个人,他同意就流转。

但现实中经常是"一个人说了不算":

  • 财务报销:金额超过一定额度,需要财务 + 部门经理 + 分管领导一起点头
  • 合同会签:法务、财务、业务三个部门都要审
  • 民主表决:5 个人投票,超过 3 人同意才通过

这时候就引出第一个问题:多个人审批同一个节点,到底是谁的待办?

这就是 performType 要回答的。


二、performType:先分清"是谁的活"

ProcessTaskPerformTypeEnum 只有两个取值:

code 枚举 语义
0 NORMAL 普通参与:多人参与同一任务,任一人完成即可驱动下一步
1 COUNTERSIGN 会签参与:为每人创建独立任务,满足条件后才驱动下一步

看代码(CreateTaskHandler):

java 复制代码
List<ProcessTask> tasks;
if (ProcessTaskPerformTypeEnum.COUNTERSIGN.equals(taskModel.getPerformType())) {
    // 会签:为每个办理人建独立任务
    tasks = instance.createCountersignTasks(taskModel, actors, operator);
} else {
    // 普通:只建一个任务,多个 actor 挂在同一任务上
    ProcessTask task = instance.createTask(taskModel, taskModel.getDisplayName(), actors, operator);
    tasks = new ArrayList<>();
    tasks.add(task);
}

关键区别在待办列表:

  • performType=0assignee: "userA,userB"1 个任务,userA/userB 都看得到,谁先点谁办理
  • performType=1assignee: "userA,userB"2 个独立任务,userA 有一个、userB 有一个,互不干扰

那会签什么时候才"算通过"?这就要 countersignType 来定了。


三、会签三兄弟:一个节点,三种放行逻辑

countersignType 定义会签节点"谁全做完才推进"。CountersignTypeEnum 表面上只有两个值:

java 复制代码
public enum CountersignTypeEnum implements IDictEnum {
    /** 并行会签 */
    PARALLEL(0, "并行会签"),
    /** 串行会签 */
    SEQUENTIAL(1, "串行会签");
    ...
}

但实际有三种 放行模式。第三种「按比例」不留在一个独立枚举里------它是 PARALLEL + 一个完成条件表达式(下详)。

3.1 并行会签:全员同时开工,全做完才过

流程定义(05-countersign-parallel.json):

json5 复制代码
{
  "id": "task1",
  "type": "snaker:task",
  "properties": {
    "form": "countersign-form",
    "assignee": "userA,userB,userC",
    "taskType": 0,
    "performType": 1,
    "countersignType": "PARALLEL",
    "field": { "candidateUsers": "userA,userB,userC" }
  },
  "text": { "value": "会签审批" }
}

并行会签在创建任务时一次性把所有人任务全建出来createCountersignTasks 的末尾循环):

java 复制代码
for (int i = 0; i < actorIds.size(); i++) {
    ProcessTask task = ProcessTask.create(...);
    list.add(task);
    this.tasks.add(task);
}

userA、userB、userC 三人的待办同时出现,三个都做完 节点才放行。判据在 CountersignHandler

java 复制代码
// 无特殊条件 → 全部完成
merged = finishedCount >= allTasks.size();

3.2 串行会签:排队一个一个来

流程定义(06-countersign-sequential.json):

json5 复制代码
{
  "id": "task1",
  "type": "snaker:task",
  "properties": {
    "form": "seq-form",
    "assignee": "userA,userB",
    "taskType": 0,
    "performType": 1,
    "countersignType": "SEQUENTIAL",
    "field": { "candidateUsers": "userA,userB" }
  },
  "text": { "value": "串行会签" }
}

串行会签最特殊:任意时刻只有一个 DOING 任务。创建时只建第一位:

java 复制代码
if (CountersignTypeEnum.SEQUENTIAL.equals(taskModel.getCountersignType())) {
    ProcessTask first = ProcessTask.create(..., singletonList(actorIds.get(0)), operator);
    // 把全量成员 + 进度计数器写进首个任务变量
    first.getVariables().put(COUNTERSIGN_OPERATOR_LIST + "_" + node, new ArrayList<>(actorIds));
    first.getVariables().put(LOOP_COUNTER + "_" + node, 0);
    first.getVariables().put(NR_OF_INSTANCES + "_" + node, actorIds.size());
    list.add(first);
    return list;
}

userA 先办;办完了,CountersignHandler 才创建下一位的待办:

java 复制代码
// 串行会签逐个创建:每次仅 1 个 DOING 成员任务
ProcessTask completed = execution.getProcessTask();
List<String> operatorList = toStringList(completed.getVariables().get(COUNTERSIGN_OPERATOR_LIST + "_" + node));
int loopCounter = completed.getVariables().getInt(LOOP_COUNTER + "_" + node, 0);

if (operatorList != null && !operatorList.isEmpty() && loopCounter + 1 < operatorList.size()) {
    // 还有下一位 → 创建下一位成员任务并停留
    createNextCountersignTask(instance, taskModel, operatorList.get(loopCounter + 1),
            loopCounter + 1, operatorList.size(), execution);
} else {
    // 最后一位完成 → 流转
    merged = true;
}

3.3 按比例(RATIO):X 人同意即通过

第三种模式最灵活,所以它不是一个枚举值,而是一个表达式/schemas/07-countersign-ratio.json

json5 复制代码
{
  "id": "task1",
  "type": "snaker:task",
  "properties": {
    "form": "ratio-form",
    "assignee": "userA,userB,userC,userD",
    "taskType": 0,
    "performType": 1,
    "countersignType": "PARALLEL",
    "field": {
      "candidateUsers": "userA,userB,userC,userD",
      "countersignCompletionCondition": "#nrOfCompletedInstances==2"
    }
  },
  "text": { "value": "2人同意即通过" }
}

注意:countersignType 仍然是 PARALLEL(任务全员同时建),但 countersignCompletionCondition#nrOfCompletedInstances==2------4 个人里只要 2 个同意就放行,其余 2 人的剩余待办被废弃。

判据在 CountersignHandler 的并行分支:

java 复制代码
String cond = taskModel.getCountersignCompletionCondition();
if (StringUtils.isEmpty(cond)) {
    merged = finishedCount >= allTasks.size();
} else {
    IExpressionEvaluator evaluator = ServiceContext.find(IExpressionEvaluator.class);
    if (evaluator != null) {
        FlowData vars = buildCountersignVars(instance, taskModel, allTasks);
        vars.putAll(execution.getArgs());
        Object result = evaluator.eval(cond, vars);
        merged = Boolean.TRUE.equals(result);
    }
}

支撑表达式的变量在 buildCountersignVars 里组装,统一带 csv_{node}_ 前缀:

变量(csv_{node}_ 前缀) 语义
csv_{node}_nrOfInstances 该节点总人数
csv_{node}_nrOfActivateInstances 进行中的任务数
csv_{node}_nrOfCompletedInstances 已完成的任务数

所以「按比例」本质是并行会签 + 阈值表达式 ,跟 RATIO 这个字面枚举没有绑定关系。文档站把它和 PARALLEL/SEQUENTIAL 并列,是为了读者好理解。


四、特殊成员:一票否决(submitType=20)

会签还有一种"一人否决全盘"的场景。mldong 契约里对应 submitType=20COUNTERSIGN_DISAGREE):

java 复制代码
public enum ProcessSubmitTypeEnum {
    ...
    /** 拒绝申请(会签) */
    COUNTERSIGN_DISAGREE(20, "拒绝申请");
    ...
}

但一票否决有个取舍:否决到底是"软拒绝"(只记一笔,不阻断)还是"硬否决"(一个人不同意就废弃全单)?

jeeflow 用了定义级开关 来兼顾------节点配了 countersignCompletionCondition: "ONE_VOTE_VETO" 才是硬否决(13-countersign-one-vote-veto.json):

json5 复制代码
{
  "id": "task1",
  "type": "snaker:task",
  "properties": {
    "assignee": "userA,userB,userC",
    "performType": 1,
    "countersignType": "PARALLEL",
    "countersignCompletionCondition": "ONE_VOTE_VETO",
    "field": { "candidateUsers": "userA,userB,userC" }
  },
  "text": { "value": "会签审批(一票否决)" }
}

处理逻辑在 CountersignHandler 开头:

java 复制代码
Integer submitType = execution.getArgs().getInt(SUBMIT_TYPE);
if (COUNTERSIGN_DISAGREE.getCode().equals(submitType)
        && isOneVoteVeto(taskModel.getCountersignCompletionCondition())) {
    // 硬否决:节点立即推进 + 废弃剩余 DOING 会签任务
    execution.setMerged(true);
    abandonRemainingCountersignTasks(instance, execution.getOperator());
    return;
}

isOneVoteVeto 判断开关是否开启:

java 复制代码
private boolean isOneVoteVeto(String condition) {
    return condition != null && FlowConst.ONE_VOTE_VETO.equalsIgnoreCase(condition.trim());
}

设计取舍

  • 未配置 ONE_VOTE_VETO (默认):submitType=20软拒绝 ------该成员的会签任务正常完成,countersignDisagreeFlag=1 写入流程变量供下游节点参考,审批流程不阻断,继续按常规会签条件(全部完成 / 表达式)等待。
  • 配置了 ONE_VOTE_VETO :任一成员 submitType=20,会签节点立即推进,并废弃本节点剩余所有 DOING 任务。

这个取舍很重要:会签意见默认只做参考。因为"意见相左就否决全单"在很多业务里太激进了,容易误伤。把决定性一票交给流程设计师显式配置,而不是默认开。

级联废弃------无论并行、串行、按比例,只要节点 merged(满足放行条件),剩余未办的会签任务都要弃掉,不留孤儿待办:

java 复制代码
private void abandonRemainingCountersignTasks(ProcessInstance instance, String operator) {
    for (ProcessTask task : instance.getTasks()) {
        if (taskModel.getName().equals(task.getTaskName()) && task.isDoing()) {
            task.abandon(operator);   // taskState → 99 已废弃
        }
    }
}

五、跨语言对齐:串行会签"逐个建"的坑

这一篇最后想讲一个真实的对齐故事。

在早期版本里,Java 和 PHP 的串行会签是"一次建全" ------节点入口就把所有人的任务全建出来,只是让它们排队。而 Go / Python / Node 是逐个创建,任意时刻只有一个 DOING。

这两种实现的行为差异很隐蔽:

一次建全(旧 Java/PHP) 逐个创建(Go/Python/Node)
待办列表 全员同时出现在待办 只有当前一人在待办
进度 靠任务创建顺序排队 loopCounter 变量推进
前端进度条 统计"已建任务"会漏后续成员 operatorList_{node} 全量成员

在一次集成验收(issues/93)中,前端进度条暴露了问题:一次建全下,进度条统计"已建任务处理人",串行场景永远显示不齐

最终 Java(createCountersignTasks)和 PHP 都改成了逐个创建 + 任务变量存计数,对齐 Go/Python/Node:

java 复制代码
// 串行会签(issues/93):仅创建第一位成员任务,把会签计数状态写入该任务变量
if (CountersignTypeEnum.SEQUENTIAL.equals(taskModel.getCountersignType())) {
    ProcessTask first = ProcessTask.create(..., singletonList(actorIds.get(0)), operator);
    first.getVariables().put(COUNTERSIGN_OPERATOR_LIST + "_" + node, new ArrayList<>(actorIds));
    first.getVariables().put(LOOP_COUNTER + "_" + node, 0);
    first.getVariables().put(NR_OF_INSTANCES + "_" + node, actorIds.size());
    list.add(first);
    return list;
}

这个案例说明 jeeflow 跨语言对齐的方法论:同一个语义在五语言里可以有不同实现,但对外行为必须一致。串行会签的"任意时刻只有一个待办"是行为契约,实现上可以"一次建全"(旧)或"逐个建"(新),但前者会在待办列表、进度条上露馅------所以对齐成了逐个建。

而项目里用一张 cross-language-diffs.md 台账专门记录这些"可接受差异"和"待修缺陷",避免维护时把差异当 bug 乱修、或把 bug 当本来如此。


结语

performType 和 countersignType 两把钥匙,组合出会签的三种放行逻辑:并行全过、串行排队、按比例阈值。

会签的精髓不在"多个人审批",而在谁来定义"算通过" 。jeeflow 用两个字段 + 一个可选表达式把这句话说清:performType=1 把任务拆成独立任务,countersignType 决定是全员还是排队,countersignCompletionCondition 决定阈值和一票否决开关。

下一篇预告:[第 9 篇 · 一套流程定义,四种语言跑出相同结果](#第 9 篇 · 一套流程定义,四种语言跑出相同结果 "#") ------ 多语言联邦架构下,同一份 LogicFlow JSON 怎么在 Java/Go/Python/Node 跑出一致结果?


参考资料


下一篇预告 :[第 9 篇 · 一套流程定义,四种语言跑出相同结果](#第 9 篇 · 一套流程定义,四种语言跑出相同结果 "#") ------ 多语言联邦架构下,同一份 LogicFlow JSON 怎么在 Java/Go/Python/Node 跑出一致结果?

相关推荐
Java后端的Ai之路1 小时前
01、Python - 设计模式介绍
java·人工智能·python·设计模式·通用
zzzll11111 小时前
大模型技术原理与应用实践
java·数据库·人工智能
asdfg12589631 小时前
java.util包中的ArrayList&&Collections的目的和用途
java·arraylist·collections
余额瞒着我当琳1 小时前
C++STL--list底层实现,迭代器分类,模拟list的迭代器封装、实现
java·开发语言·c++
吴声子夜歌1 小时前
Java面试——数据结构(二)
java·数据结构·面试
栀栀栀栀栀栀2 小时前
2026/8/23 maven springboot
java·spring boot·maven
SeaDhdhdhdhdh10 小时前
MCP Server 搭建与使用指南
java·ai·agent·mcp
YH552698410 小时前
GPT‑5.6 Sol 原本支持 1M 上下文,Codex 现已放开此前限制,如何看待这次调整?
java·jvm·人工智能·gpt·算法·chatgpt
ZYJCSZKJ10 小时前
AI数字人实时交互系统的工程架构与多方言适配实践
人工智能·架构·交互·ai数字人直播系统