Activiti 如何通过自定义 Command 实现任意节点跳转?

企业审批系统里的“退回修改”“驳回到指定节点”“管理员纠偏”,最终都会落到一个技术问题:怎样让正在某个活动上的流程实例,离开当前节点并从目标节点重新执行?

先给结论:在 Activiti 9.0.0 中,可以通过 ManagementService.executeCommand(...) 进入引擎命令链,在同一 CommandContext 和事务中完成五件事:校验运行时结构、关闭当前任务、清理边界执行、把 ExecutionEntity.currentFlowElement 指向目标、再通过 Agenda.planContinueProcessOperation(...) 驱动目标节点。不能只改 ACT_RU_EXECUTION.ACT_ID_,也不能把“任意节点”理解为所有 BPMN 结构都能用同一段代码跳。

本文以 Activiti 官方仓库 9.0.0 标签为源码基线。该版本于 2026 年 3 月 5 日发布;截至 2026 年 8 月 2 日,它仍是官方 Releases 中的最新正式版本。示例优先覆盖“同一流程定义、同一作用域、普通单实例用户任务”的生产实现;多实例、并行网关、子流程、事件子流程和调用活动另行说明边界。

一、核心结论与问题边界

先明确答案:一次正确跳转包含什么

一个最小但完整的节点跳转,不是改一个活动 ID,而是一组有顺序的状态迁移:

  1. 锁定并校验当前 TaskEntity、ExecutionEntity 与目标 FlowElement。
  2. 按“取消/跳转”语义删除当前运行时任务,并写入可识别的 deleteReason。
  3. 结束当前历史活动,清理当前节点挂载的边界定时器、消息订阅等子执行。
  4. 将当前执行令牌的 currentFlowElement 切换到目标 FlowNode。
  5. 将 ContinueProcessOperation 放入引擎 Agenda,让目标节点按 Activiti 自己的行为逻辑启动。

在这里插入图片描述

图 1:自定义 Command 必须进入 Activiti 的命令链、事务和 Agenda,而不是绕过引擎直接改库。

1. 一句话边界

普通同作用域用户任务可以用一个受控 Command 实现;跨作用域、并行、多实例和事件结构需要先重构执行树,不能直接套用。

最新源码基线:Activiti 9.0.0 里有哪些关键入口

Activiti 9.0.0 的 Command<T> 位于 org.activiti.engine.impl.interceptor,源码明确标记为 @Internal。这意味着它能做公共 API 做不到的事,但属于内部扩展点,升级时必须重新核对。

源码对象 9.0.0 中的职责 跳转时的用途
ManagementService.executeCommand 对外接受自定义命令 进入标准命令执行器
CommandContextInterceptor 创建或复用上下文,关闭时 flush/rollback 保证引擎状态在同一事务提交
CommandContext 暴露实体管理器、历史管理器和 Agenda 跳转操作的统一上下文
TaskEntityManager 管理运行时任务及其历史、变量、身份链路 删除当前任务并记录取消原因
ExecutionEntityManager 管理 Execution 树及相关运行时数据 清理边界子执行、维护令牌
HistoryManager 记录活动和任务历史 结束源活动、保持轨迹闭合
DefaultActivitiEngineAgenda 安排引擎原子操作 安排目标节点继续执行
ContinueProcessOperation 读取当前 FlowElement 并执行 ActivityBehavior 创建目标任务、作业或订阅

源码调用链可以概括为:

ManagementService.executeCommand(command)
  -> CommandExecutorImpl.execute(command)
  -> CommandContextInterceptor.execute(...)
  -> CommandInvoker.execute(...)
  -> command.execute(commandContext)
  -> commandContext.getAgenda().planContinueProcessOperation(execution)
  -> ContinueProcessOperation.run()
  -> target ActivityBehavior.execute(execution)

CommandInvoker 不只是调用 Command.execute。它会把命令作为 Agenda 操作执行,并持续消费 Agenda,直到没有待执行操作。因此,Command 中安排的 ContinueProcessOperation 会在同一引擎执行周期继续运行。

二、关键概念与能力差异

为什么不能只修改 ACT_ID_

很多早期二开代码会直接更新 ACT_RU_EXECUTION.ACT_ID_,或者只调用 execution.setCurrentFlowElement(target)。这两种做法都只改变了“令牌看起来在哪里”,没有完成“节点生命周期已经切换”的事实。

在这里插入图片描述

图 2:节点跳转的重点是重建一致的运行时状态,而不是更新一个字段。

只改活动位置通常会留下四类问题:

  • ACT_RU_TASK 里的旧任务仍然可查、可领、可办。
  • ACT_HI_ACTINST 的源活动没有结束时间,流程图高亮与审计轨迹失真。
  • 边界定时器、消息订阅或补偿结构仍挂在旧节点,稍后可能再次触发。
  • 目标 UserTaskActivityBehavior.execute 没有运行,因此不会创建新的 TaskEntity,也不会执行分配人、任务监听器和事件派发。

Activiti 9 的正常离开路径由 TakeOutgoingSequenceFlowsOperation 负责结束活动、清理边界执行、触发结束监听器、记录历史并沿连线创建后续执行。自定义跳转绕过了正常 Sequence Flow,所以必须明确补齐自己需要的语义。

先设计业务接口,不要让前端直接传任意 activityId

对业务层公开的接口不应等同于引擎内部 Command。推荐先定义稳定的应用服务契约:

public record JumpRequest(
    String taskId,
    String targetActivityId,
    String operatorId,
    String reason,
    String idempotencyKey
) {}

public record JumpResult(
    String processInstanceId,
    String sourceActivityId,
    String targetActivityId,
    String operatorId
) {}

public interface ProcessJumpService {
    JumpResult jump(JumpRequest request);
}

应用服务负责权限、幂等、候选目标、业务单据状态和操作日志;Activiti 适配层只负责运行时迁移。前端最好只提交“选择的目标”,服务器必须根据已发布流程模型、历史轨迹和操作策略重新计算允许集合。

典型权限规则包括:发起人只能撤回到配置允许的节点;处理人只能退回到历史经过且标记为可退回的节点;管理员纠偏必须经过专门授权和二次确认。任何人都不应通过修改请求参数跳到任意服务任务或结束事件。

三、模型架构与运行机制

自定义 Command 的核心实现

下面代码面向 Activiti 9.0.0,演示同一作用域内“普通用户任务 → 普通用户任务”的核心迁移。为了让代码能被安全复用,复杂结构由 JumpPolicy 在进入 Command 后再次校验,而不是只相信接口层。

package com.example.workflow.activiti9;

import org.activiti.bpmn.model.FlowElement;
import org.activiti.bpmn.model.FlowNode;
import org.activiti.bpmn.model.Process;
import org.activiti.engine.ActivitiException;
import org.activiti.engine.impl.interceptor.Command;
import org.activiti.engine.impl.interceptor.CommandContext;
import org.activiti.engine.impl.persistence.entity.ExecutionEntity;
import org.activiti.engine.impl.persistence.entity.ExecutionEntityManager;
import org.activiti.engine.impl.persistence.entity.TaskEntity;
import org.activiti.engine.impl.util.ProcessDefinitionUtil;

public final class JumpToActivityCmd
        implements Command<JumpResult> {

    private final String taskId;
    private final String targetActivityId;
    private final String operatorId;
    private final String reason;
    private final JumpPolicy jumpPolicy;

    public JumpToActivityCmd(String taskId, String targetActivityId,
                             String operatorId, String reason,
                             JumpPolicy jumpPolicy) {
        this.taskId = taskId;
        this.targetActivityId = targetActivityId;
        this.operatorId = operatorId;
        this.reason = reason;
        this.jumpPolicy = jumpPolicy;
    }

    @Override
    public JumpResult execute(CommandContext context) {
        TaskEntity task = context.getTaskEntityManager().findById(taskId);
        if (task == null || task.getExecutionId() == null) {
            throw new ActivitiException("Active process task not found: " + taskId);
        }

        ExecutionEntityManager executions = context.getExecutionEntityManager();
        ExecutionEntity execution = executions.findById(task.getExecutionId());
        if (execution == null || !execution.isActive()) {
            throw new ActivitiException("Active execution not found: " + taskId);
        }

        Process process = ProcessDefinitionUtil.getProcess(
            execution.getProcessDefinitionId());
        FlowElement current = execution.getCurrentFlowElement();
        FlowElement target = process.getFlowElement(targetActivityId, true);
        if (!(target instanceof FlowNode)) {
            throw new ActivitiException(
                "Target is not an executable FlowNode: " + targetActivityId);
        }

        jumpPolicy.assertSupported(context, task, execution, current, target);
        String deleteReason = "jump:" + targetActivityId + ":" + reason;

        // 1. 删除运行时任务;cancel=true 会按取消语义派发事件
        context.getTaskEntityManager()
            .deleteTask(task, deleteReason, false, true);

        // 2. 关闭源活动历史;任务历史已由 deleteTask 处理
        context.getHistoryManager()
            .recordActivityEnd(execution, deleteReason);

        // 3. 按取消语义递归清理边界事件等子执行及其关联数据
        executions.cancelChildExecutions(execution, deleteReason);

        // 4. 切换令牌位置,并让 Activiti 自己启动目标行为
        execution.setCurrentFlowElement(target);
        execution.setActive(true);
        context.getAgenda().planContinueProcessOperation(execution);

        return new JumpResult(execution.getProcessInstanceId(),
                              current.getId(), target.getId(), operatorId);
    }
}

这段代码有三个刻意的设计:

  • 使用 TaskEntityManager.deleteTask(TaskEntity, ..., cancel=true),而不是字符串 ID 重载。9.0.0 的字符串 ID 删除方法会拒绝删除仍属于运行中流程实例的任务。
  • 先结束源节点,再计划目标节点。目标节点的任务创建、受理人解析、任务监听器和事件派发交给 UserTaskActivityBehavior.execute。
  • Command 不自行调用 ContinueProcessOperation.run(),而是放入 Agenda,使其遵守引擎的操作调度顺序。

JumpPolicy 必须挡住复杂执行树

上面的 Command 只有在 JumpPolicy 证明它面对的是“单个叶子执行、同一 FlowElementsContainer、无多实例根、无并行兄弟分支”时才成立。最小策略可以这样表达:

public void assertSupported(CommandContext context,
                            TaskEntity task,
                            ExecutionEntity execution,
                            FlowElement current,
                            FlowElement target) {
    if (current == null || current.getParentContainer() != target.getParentContainer()) {
        throw new ActivitiException("Cross-scope jump is not supported");
    }
    if (!(current instanceof org.activiti.bpmn.model.UserTask)
        || !(target instanceof org.activiti.bpmn.model.UserTask)) {
        throw new ActivitiException("Only user-task to user-task is enabled");
    }
    ExecutionTreeFacts facts = inspectExecutionTree(context, execution);
    if (facts.multiInstance() || facts.parallelSiblings()
        || facts.eventSubProcess() || facts.callActivity()) {
        throw new ActivitiException("Complex execution tree requires another strategy");
    }
}

inspectExecutionTree 不应只看 BPMN 图。它至少要检查当前 execution 的父链、兄弟 execution、isMultiInstanceRoot、活动 ID 分布,以及子执行是否只是当前用户任务的边界事件。运行时结构才决定此次跳转需要移动一个令牌还是重建一组令牌。

在这里插入图片描述

图 3:“任意节点”应被产品策略拆成不同迁移类型,而不是一个万能按钮。

场景 是否可直接套用示例 原因与处理
普通用户任务 → 同作用域用户任务 可以 单令牌、生命周期清晰
用户任务 → 服务任务 谨慎 目标可能立即执行外部副作用
用户任务 → 异步节点 专项验证 会创建 Job,必须验证失败重试和幂等
跳到排他/并行网关 不建议 网关依赖入口令牌和条件语义
多实例会签 不可以 需要处理 MI Root、实例计数和完成条件
并行分支 不可以 必须决定移动一个分支还是收拢全部分支
跨子流程、事件子流程 不可以 需要退出/创建作用域、订阅和局部变量
调用活动 不可以 涉及父子流程实例与独立生命周期

四、核心场景与处理策略

运行时状态究竟发生了什么

假设流程是“申请 A → 部门审批 B → 财务审批 C → 归档”,当前任务位于 C,现在退回 B。迁移前,execution 指向 C,C 有一条运行时任务,也可能挂着边界定时器。迁移后,旧任务和旧边界结构必须结束,同一个叶子 execution 改为指向 B,再由 B 的行为创建一条全新任务。

在这里插入图片描述

图 4:目标任务必须是重新创建的新任务,不能把旧 TaskEntity 改名后复用。

这解释了为什么“退回后 taskId 变了”是正确现象。业务系统不应把单据与某个固定 taskId 永久绑定,应以 processInstanceId + businessKey 关联流程,用待办查询取得当前任务。

Command 怎样被业务服务调用

应用服务先做操作权限和幂等检查,再调用引擎命令。提交成功后写业务审计;如果业务库与流程库使用同一数据源,可以置于同一本地事务。如果不是同一数据库,则使用 Outbox 或可靠事件同步,不要假设两个本地事务能原子提交。

@Transactional
public JumpResult jump(JumpRequest request) {
    authorization.checkCanJump(request.operatorId(), request.taskId());
    idempotency.checkAndReserve(request.idempotencyKey());

    JumpResult result = managementService.executeCommand(
        new JumpToActivityCmd(
            request.taskId(), request.targetActivityId(),
            request.operatorId(), request.reason(), jumpPolicy));

    auditRepository.save(ProcessOperation.jump(request, result));
    documentStateService.markReturned(result.processInstanceId());
    return result;
}

如果使用 Spring,需要确认自定义服务调用与 Activiti 的事务管理器一致。Command 抛出运行时异常时,CommandContextInterceptor 会在关闭上下文时触发回滚;不要 catch 后只记日志并返回成功。

五、数据、规则与状态设计

历史、监听器和边界事件如何取舍

节点跳转本质上不是 BPMN Sequence Flow,因此没有唯一标准答案。企业必须先定义语义,再决定哪些监听器要触发。

生命周期 正常完成 跳转/驳回建议
Task complete listener 触发 不触发,避免把驳回记成审批通过
Task delete listener 通常不作为完成主路径 触发,并携带 jump:* 原因
Activity end listener 正常离开时触发 需要明确;若业务依赖,应在适配层补齐
历史任务结束 complete deleteReason=jump:target:reason
历史活动结束 正常结束 写结束时间与跳转原因
源边界定时器/订阅 正常离开时删除 跳转前必须删除
目标边界定时器/订阅 进入目标时创建 由目标行为重新创建

示例代码关闭了任务历史和活动历史,但没有假装“源节点正常完成”。如果项目的执行监听器承担业务副作用,推荐把这些副作用从监听器迁到明确的领域事件中;否则需要在跳转适配器中逐一决定哪些结束监听器应补触发,不能默认全部执行。

并发与幂等:两个请求同时操作同一任务怎么办

最常见竞争是“用户 A 点击完成,管理员同时点击跳转”。两者都可能先读到任务存在。生产实现至少要做到:

  • Command 内再次按 taskId 查询活动任务,不能依赖接口层几百毫秒前的结果。
  • 让 Activiti 实体版本字段承担乐观锁;捕获 ActivitiOptimisticLockingException 后返回“任务状态已变化”,不要自动盲重试。
  • 每次跳转使用业务幂等键,避免前端重复点击或网关重放。
  • 操作日志记录请求人、源节点、目标节点、流程定义版本、原因和最终事务结果。
  • 对外部副作用节点建立业务幂等键,尤其是跳到 Service Task 或异步 Job 时。

如果某个节点会付款、发货、调用外部系统,就不应出现在普通退回目标列表中。即便 Activiti 能从那里继续跑,业务副作用也可能重复发生。

六、工程实现与系统集成

为什么旧版自定义 Command 不能原样复制

网上大量示例基于 Activiti 5:直接操作 ExecutionEntity、调用 end() 或 createExecution()。Activiti 官方 5→6 迁移指南已说明,DelegateExecution.end() 和 createExecution() 被移除,相关能力改由 ExecutionEntityManager 处理;部分 ActivityBehavior 和 EntityManager 包结构也发生变化。

Activiti 9 的 Command 本身仍然存在,但位于 impl 包且带 @Internal。因此建议把自定义跳转代码隔离成一个窄适配模块,例如:

workflow-domain
  └─ ProcessJumpService / JumpPolicy / JumpResult
workflow-activiti9-adapter
  └─ JumpToActivityCmd / Activiti9ExecutionTreeInspector
workflow-integration-tests
  └─ Activiti 9.0.0 固定版本回归用例

升级 Activiti 时只替换适配模块,并用集成测试重新验证任务、历史、作业、订阅和监听器。不要在多个业务服务里散落 org.activiti.engine.impl.* 引用。

在低代码平台里怎样产品化

自定义 Command 只是底层执行器。真正可用的审批平台还需要在设计阶段声明“允许退回到哪里”,在运行阶段把执行树转换成可理解的操作。

以云程低代码开发平台为例,可以把能力拆为四层:设计器为节点配置 jumpable、可退回范围和角色;发布校验器拒绝高风险跨作用域配置;运行时策略计算候选目标;Activiti 9 适配器执行 Command 并回写统一操作日志。这样,底层允许迁移,不等于任何用户可以任意迁移。
在这里插入图片描述

建议设计器至少提供三种策略:仅上一人工节点、历史经过的人工节点、显式白名单节点。多实例、并行网关和子流程默认关闭“任意跳转”,改成专门的“整组退回”“分支撤回”或“退出子流程”动作。

七、安全、性能与治理要求

必须覆盖的集成测试

单元测试只能验证策略函数,跳转正确性必须用真实 Activiti 数据库和已部署 BPMN 做集成测试。

用例 关键断言
A → B 正向跳转 A 任务删除,B 新任务创建,历史闭合
C → B 反向跳转 生成新的 B 任务,旧 B 历史不被修改
带边界定时器的任务跳转 源 Timer Job 消失,目标定时器按模型重建
重复提交同一幂等键 只迁移一次,只产生一条操作日志
完成与跳转并发 只有一方提交,另一方收到状态冲突
跳到异步服务任务 Job 创建、重试、业务幂等均符合预期
多实例/并行/跨子流程 策略明确拒绝,不产生部分迁移
监听器抛异常 Command 整体回滚,旧任务仍可继续处理

数据库断言不只看 ACT_RU_TASK。还要检查 ACT_RU_EXECUTION、ACT_RU_JOB/定时器表、事件订阅、变量、身份链路,以及 ACT_HI_TASKINST 和 ACT_HI_ACTINST。流程图高亮、待办列表和业务单据状态也应一起回归。

八、案例实践与迁移路线

常见错误清单

  • 直接执行 SQL 更新 ACT_RU_EXECUTION.ACT_ID_。
  • 只设置 currentFlowElement,没有计划 Agenda 操作。
  • 删除旧任务,却没有结束活动历史或清理边界执行。
  • 把旧 TaskEntity 的 taskDefinitionKey 改成目标节点继续用。
  • 允许前端提交未经服务器验证的目标 activityId。
  • 对并行、多实例和子流程继续复用单 execution 算法。
  • 将驳回触发为正常 complete,导致审批通过监听器执行。
  • catch 所有异常后返回成功,破坏事务回滚信号。
  • 在业务模块各处直接依赖 org.activiti.engine.impl.*。

九、平台落地、测试与选型

Activiti 如何通过自定义 Command 实现任意节点跳转?

在 Activiti 9.0.0 中,通过 ManagementService.executeCommand 进入 Command 执行链;在 CommandContext 内取得任务、execution、历史与实体管理器;按跳转语义删除当前任务、结束源活动、清理边界子执行;把 ExecutionEntity.currentFlowElement 设置为目标 FlowNode;最后调用 Agenda.planContinueProcessOperation(execution),由 ContinueProcessOperation 执行目标 ActivityBehavior 并创建目标运行时数据。

这套做法只应直接用于经过策略校验的普通同作用域单实例节点。多实例、并行网关、跨子流程、事件子流程与调用活动需要独立的执行树迁移算法。由于 Command 及相关实体类属于 impl/@Internal API,企业应将其封装在版本化适配模块中,并以真实数据库集成测试锁定升级风险。

posted @ 2026-09-14 10:28  大龄码农有梦想  阅读(16)  评论(0)    收藏  举报