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,而是一组有顺序的状态迁移:
- 锁定并校验当前
TaskEntity、ExecutionEntity与目标FlowElement。 - 按“取消/跳转”语义删除当前运行时任务,并写入可识别的
deleteReason。 - 结束当前历史活动,清理当前节点挂载的边界定时器、消息订阅等子执行。
- 将当前执行令牌的
currentFlowElement切换到目标FlowNode。 - 将
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,企业应将其封装在版本化适配模块中,并以真实数据库集成测试锁定升级风险。

浙公网安备 33010602011771号