"applicant" 契约:退回发起人的闭环设计

系列定位 :jeeflow 系列第 6 篇(第二季「核心设计」第 3 篇) 平台 :掘金(代码密度高,原理讲透) 素材版本 :引擎 v1.8.15,源码取自 jeeflow-java 前置阅读第 3 篇 · 状态机与 submitType · 第 5 篇 · DDD 聚合根与充血模型


一、一个约定,三条链路

在 jeeflow 的 10 个共享流程 JSON 里,有一个规律:每个流程的第一个任务节点,assignee 永远是 "applicant"

json 复制代码
{
  "id": "apply",
  "type": "snaker:task",
  "properties": {
    "assignee": "applicant",
    "taskType": 0,
    "performType": 0
  },
  "text": { "value": "发起申请" }
}

applicant 不是一个用户名,而是一个契约标记 ------引擎解析时把它替换成流程发起人(instance.operator)。

这个约定看似简单,但它串起了三条关键链路:

  1. 发起链路:谁发起流程,申请节点就分配给谁,自动完成
  2. 退回链路:任何审批人点"退回发起人",流程回到第一个任务节点,发起人收到新待办
  3. 重提链路:发起人改单后重新提交(submitType=5),流程继续往下走

没有这个约定,"退回发起人"就无从谈起------引擎不知道"发起人"是谁。

本文把这三条链路一次讲透。


二、发起链路:startAndExecute 契约

2.1 引擎不自动执行节点

jeeflow 引擎有一个重要设计原则:引擎只负责"路由",不负责"自动完成"。

startProcessInstanceById 启动流程后,引擎会执行开始节点(snaker:start),开始节点的输出边指向第一个任务节点(apply)。引擎为 apply 节点创建任务 ,但不会自动完成它

java 复制代码
// JeeflowEngineImpl.startProcessInstanceById 核心流程
public ProcessInstance startProcessInstanceById(Long defineId, String operator, FlowData args) {
    // 1. 查流程定义
    ProcessDefine define = repository.findDefineById(defineId);
    // 2. 解析流程模型
    ProcessModel model = ModelParser.parse(define.getContent());
    // 3. 追加用户信息(u_userId / u_realName 等)
    FlowUtil.addUserInfoToArgs(operator, args, userProvider);
    // 4. 创建聚合根(operator 写入 instance.operator)
    ProcessInstance instance = ProcessInstance.create(define, operator, args, null, null);
    // 5. 持久化
    repository.createInstance(instance);
    // 6. 构建 Execution 并执行开始节点
    Execution exec = buildExecution(model, instance, args, operator);
    model.getStart().execute(exec);  // → 触发 apply 节点,创建任务
    return instance;
}

启动后,apply 节点的任务状态是 10(进行中) ,参与者是发起人。但它没有被完成 ------需要调用方显式调用 executeProcessTask

2.2 调用方负责完成申请节点

这就是 startAndExecute 契约:启动 + 自动完成申请节点

java 复制代码
// JeeflowFacade.startAndExecute
private Map<String, Object> startAndExecute(Map<String, Object> args) {
    Long defineId = toLong(args.get("processDefineId"));
    String operator = toStr(args.get("operator"));
    FlowData flowArgs = buildFlowArgs(args);

    // 1. 启动流程(引擎创建 apply 任务,但不完成)
    ProcessInstance inst = engine.startProcessInstanceById(defineId, operator, flowArgs);

    // 2. 查找进行中任务(此时只有 apply 节点)
    List<ProcessTask> doingTasks = repository.findDoingTasks(inst.getInstanceId(), null);

    // 3. 自动完成申请节点
    for (ProcessTask task : doingTasks) {
        repository.addTaskActor(task.getTaskId(), Collections.singletonList(operator));
        flowArgs.put(FlowConst.SUBMIT_TYPE, ProcessSubmitTypeEnum.APPLY.getCode());
        // 对齐 boot3:f_nextNodeOperator → tf_nextNodeOperator
        Object startNextOp = flowArgs.get("f_nextNodeOperator");
        if (startNextOp != null && !startNextOp.toString().isEmpty()) {
            flowArgs.put("tf_nextNodeOperator", startNextOp);
        }
        engine.executeProcessTask(task.getTaskId(), operator, flowArgs);
    }

    return ok(Collections.singletonMap("processInstanceId", inst.getInstanceId()));
}

为什么引擎不自己做这件事?

因为"申请节点"的语义是业务层的------引擎不知道哪个节点是"申请节点",它只知道"第一个任务节点"。由调用方(Facade / Controller)决定"启动后自动完成第一个任务",引擎保持纯粹的路由职责。

2.3 "applicant" 的解析过程

apply 节点的 assignee: "applicant" 是怎么变成发起人的?

java 复制代码
// CreateTaskHandler.resolveActors --- 参与者解析核心逻辑
private List<String> resolveActors(TaskModel taskModel, ProcessModel model, Execution execution) {
    List<String> actors = new ArrayList<>();
    String assignee = taskModel.getAssignee();

    for (String raw : assignee.split(",")) {
        String token = raw.trim();
        // ★★★ 契约核心:applicant → instance.operator ★★★
        if (token.contains("applicant")) {
            token = token.replace("applicant",
                execution.getProcessInstance().getOperator());
        }
        // 尝试从流程变量中取值
        Object v = execution.getArgs().get(token);
        if (v != null) {
            actors.add(v.toString());
        } else {
            // 变量未命中 → token 本身作为字面量
            actors.add(token);
        }
    }
    return actors;
}

解析过程:

  1. 读到 assignee: "applicant"
  2. "applicant" 包含关键字 "applicant" → 替换为 instance.getOperator()(即发起人,如 "admin"
  3. "admin" 作为 token 在流程变量中查找 → 未命中
  4. "admin" 作为字面量加入参与者列表

五语言同构 :Go/Python/Node/PHP 引擎都有完全相同的解析逻辑------p == "applicant" → inst.Operator


三、退回链路:submitType=6

3.1 完整的退回发起人流程

用户在审批页面点"退回发起人"按钮 → 前端调用 processTask/execute,传 submitType=6

java 复制代码
// JeeflowFacade.execute --- submitType=6 分支
if (ProcessSubmitTypeEnum.ROLLBACK_TO_OPERATOR.getCode().equals(submitType)) {
    engine.executeAndJumpToFirstTaskNode(taskId, operator, flowArgs);
}

3.2 引擎实现:强制覆盖 assignee

java 复制代码
// JeeflowEngineImpl.executeAndJumpToFirstTaskNode
@Override
public List<ProcessTask> executeAndJumpToFirstTaskNode(
        Long taskId, String operator, FlowData args) {
    return runInTx(() -> {
        Execution exec = prepareExecution(taskId, operator, args);
        if (exec == null) return Collections.emptyList();

        ProcessModel model = exec.getProcessModel();

        // 关键:从 start 节点沿输出边找到第一个任务节点
        for (TransitionModel tm : model.getStart().getOutputs()) {
            tm.setEnabled(true);
            if (tm.getTarget() instanceof TaskModel) {
                // ★★★ 强制 assignee = 发起人 ★★★
                ((TaskModel) tm.getTarget())
                    .setAssignee(exec.getProcessInstance().getOperator());
            }
            tm.execute(exec);  // 创建新任务
        }

        persistTasks(exec);
        return exec.getProcessTaskList();
    });
}

核心动作只有两步:

  1. 找到第一个任务节点(apply)
  2. 强制把 assignee 设为发起人instance.getOperator()

不管 JSON 里原来写的是什么,退回发起人时一律覆盖为发起人。

3.3 状态变化

阶段 实例状态 任务状态
退回前 10(进行中) 当前审批任务 10→99(废弃)
退回后 10(进行中,不变 新 apply 任务 10(发起人收到待办)

注意:实例状态不变,还是 10。只有当前审批人的任务被废弃(99),同时为发起人创建一个新任务。


四、重提链路:submitType=5

发起人收到退回的待办后,修改表单数据,点"重新提交":

java 复制代码
// submitType=5 → 走普通 executeProcessTask 路径
// 但有一个关键区别:f_nextNodeOperator 变量

重提时,前端可以传 f_nextNodeOperator(流程启动时下一节点处理人),引擎会把它转成 tf_nextNodeOperator跳过正常的 assignee 解析,直接指定下一节点处理人

java 复制代码
// startAndExecute 中的变量转换
Object startNextOp = flowArgs.get("f_nextNodeOperator");
if (startNextOp != null && !startNextOp.toString().isEmpty()) {
    flowArgs.put("tf_nextNodeOperator", startNextOp);
}

这意味着发起人重新提交时,可以指定审批人(比如换个领导审批),而不必走原来的 assignee 解析链路。


五、系统身份:flow.auto 与 flow.admin

在参与者解析和权限校验中,有两个特殊身份:

身份 常量 含义
flow.auto FlowConst.AUTO_ID 系统自动执行(如定时任务触发)
flow.admin FlowConst.ADMIN_ID 超级管理员代执行

它们在两个地方生效:

1. 权限放行ProcessTask.isAllowed):

java 复制代码
public boolean isAllowed(String operator) {
    if (FlowConst.AUTO_ID.equalsIgnoreCase(operator)
        || FlowConst.ADMIN_ID.equalsIgnoreCase(operator)) {
        return true;  // 系统身份直接放行
    }
    return isDoing() && this.actorIds != null
           && this.actorIds.contains(operator);
}

2. 跳过用户信息注入FlowUtil.addUserInfoToArgs):

java 复制代码
public static void addUserInfoToArgs(String operator, FlowData args, IUserProvider userProvider) {
    if (FlowConst.AUTO_ID.equalsIgnoreCase(operator)
        || FlowConst.ADMIN_ID.equalsIgnoreCase(operator)) {
        return;  // 非真实用户,跳过 u_userId 等变量注入
    }
    // 正常用户:注入 u_userId / u_realName / u_deptId 等
    ...
}

六、内置参与者处理器

除了 assignee 字段,jeeflow 还提供一套内置参与者处理器AssignmentHandler SPI),按优先级排序:

处理器 说明 优先级
OperatorAssignmentHandler 流程发起人 -9999(最高)
ApplicantDeptLeaderAssignmentHandler 发起人所属部门经理 10
ApplicantDeptMainLeaderAssignmentHandler 发起人所属部门分管领导 20
DeptLeaderAssignmentHandler 当前用户所属部门经理 30
DeptMainLeaderAssignmentHandler 当前用户所属部门分管领导 40
FormFieldAssigneeHandler 根据表单字段值分配 50
TaskRoleAssigneeHandler 根据任务节点关联角色分配 60

这些处理器通过 HandlerRegistry 注册,引擎在 assignee 为空时按优先级依次尝试。

关键设计OperatorAssignmentHandler(流程发起人)优先级最高(-9999),确保"发起人"这个身份在任何场景下都能被正确解析。


七、闭环全景

把三条链路串起来,看一个完整的"退回-重提"闭环:

ini 复制代码
admin 发起请假申请
  → startAndExecute(operator="admin")
    → instance.operator = "admin"
    → apply 节点 assignee="applicant" → 解析为 "admin"
    → 自动完成 apply(submitType=0)
    → 流程推进到 task1(部门经理审批,assignee="deptLeader")

lina(部门经理)审批中...
  → 点"退回发起人"(submitType=6)
    → executeAndJumpToFirstTaskNode
    → apply 节点 assignee 强制 = instance.operator = "admin"
    → lina 的任务废弃(99)
    → admin 收到新待办(apply 节点,状态 10)

admin 修改请假天数,重新提交(submitType=5)
  → executeProcessTask(applyTaskId, "admin", args)
  → 流程重新推进到 task1(部门经理审批)

这就是"applicant 契约"的完整闭环 ------从发起到退回到重提,发起人的身份始终由 instance.operator 承载,不依赖外部配置。


结语

一个 assignee: "applicant",串起了发起、退回、重提三条链路。

这不是语法糖,而是引擎与调用方之间的契约

  • 引擎承诺:instance.operator 全局可访问,applicant 标记统一解析
  • 调用方承诺:每个流程第一个任务节点是申请节点,启动后自动完成

有了这个契约,"退回发起人"不再是一个特殊的 hack,而是引擎能力的自然延伸。

下一篇预告:[第 7 篇 · 零依赖的秘密:6 大 SPI 设计](#第 7 篇 · 零依赖的秘密:6 大 SPI 设计 "#") ------ 引擎核心 98KB,不碰 JSON 库和 ORM,靠什么扩展?


参考资料


下一篇预告 :[第 7 篇 · 零依赖的秘密:6 大 SPI 设计](#第 7 篇 · 零依赖的秘密:6 大 SPI 设计 "#") ------ 引擎核心 98KB,不碰 JSON 库和 ORM,靠什么扩展?IProcessRepository / IUserProvider / IJsonProvider / IExpressionEvaluator / IIdGenerator / ITransactionTemplate 逐一拆解。

相关推荐
月华路1 小时前
G1 垃圾回收:脏卡队列、记忆集与并发精化线程机制
java·开发语言
gugucoding2 小时前
47. 【Java】Java内存模型(JMM)与可见性
java·开发语言
zww89491112 小时前
校园跑腿系统开发实战指南:从需求分析到上线部署全流程解析
java
Fluxart.ai3 小时前
电商商品图审核怎么自动化?规则引擎、人工复核与发布门禁
java·前端·自动化
名字还没想好☜4 小时前
Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏
java·开发语言·jvm·内存泄漏
这个DBA有点耶5 小时前
数据库迁移怎么做到“零翻车“:金仓 KDMS/KDTS/KFS 工具链实测
数据库·架构·dba
IT爱学堂5 小时前
尚硅谷 - 2025年3月Java+AI大模型应用开发
java·开发语言·人工智能
lucky_syq6 小时前
第5篇 · S1·下:前沿架构:MoE、Reasoning 模型、长上下文、多模态、SSM/Mamba 与模型谱系
人工智能·学习·架构
小贤plus6 小时前
SpringBoot 三大核心注解精讲:@ControllerAdvice、@RestControllerAdvice、@Validated 分组校验(实战)
java