没用过“撤回到任意节点”这个功能的,肯定觉得挺简单的——不就是把流程往回拽一下吗?等你真正在Activiti里落过一次地,就知道里面藏着多少暗坑了。我这个需求当初是业务方在评审会上提的:“审批链走到后面,发现某个前置节点选错了,需要能撤回那个节点,重新审批,而不是每次都退回到发起人再重跑一遍。”听起来很合理,但Activiti原生API并不直接支持“跳回任意已审批节点”这种操作。折腾了一圈,把官方跳转API、自定义Command、流程变量快照、历史任务处理这些全摸了一遍,今天把这套完整方案整理出来。
1. 为什么需要“撤回任意节点”
1.1 业务场景:审批链路上的人也会犯错
工作流最常见的形态是“发起人提交 -> 部门经理审批 -> 财务复核 -> 总经理批准”。正常流程没问题,但现实业务里经常出现这类情况:财务复核时发现,部门经理选的费用类型不对,或者部门经理审批时漏看了一个附件,导致后面的环节基于错误信息做出了判断。这时候业务方要求的不是“驳回到发起人”,而是“退回到部门经理那一步,让他修正并重新审批”。
更麻烦的是,这个回退目标不是固定的。有时候要退到第一级审批,有时候要退到中间的某个会签节点,完全取决于业务判断。如果只做“退回上一步”或者“退回发起人”,业务方的操作灵活度远远不够。
所以,“撤回到已审批的任意节点”本质上是给流程增加一个“时空回溯”能力,让管理员或指定角色可以把进行中的流程实例,从当前节点重新拉回到历史流程定义中的任意一个已执行节点,并恢复当时的执行状态。
1.2 Activiti原生能力的边界
Activiti原生对“驳回”的支持其实很弱。标准API里,通过taskService.complete(taskId)只能把任务往下一步推进,runtimeService里也没有直接的goBackToPreviousNode这样的方法。虽然可以通过runtimeService.createProcessInstanceModificationBuilder()来修改流程实例的当前状态,但它的语义更多是“跳过某些节点”或“终止某些分支”,面向的是前向跳转,而不是回退。
真正被社区常用的是createChangeActivityStateBuilder(),这是Activiti封装好的一组状态迁移API,理论上可以做到把流程实例从当前活动节点迁移到任意其他活动节点,包括历史节点。但在真实项目中只依赖它是不够的,后面我会详细说。
还有一个隐性难点:回退之后,原来节点上已经执行完的逻辑怎么处理?历史任务记录、流程变量、会签变量、任务监听器触发记录,这些都需要联动处理,否则退回去以后,流程状态虽然变了,但历史数据和报表统计全乱了。
2. 方案选型:官方跳转API与自定义指令
2.1 官方API:createChangeActivityStateBuilder
Activiti 5.16+ 提供了一个比较完整的活动状态迁移能力,入口是:
runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(currentActivityId, targetActivityId) .changeState();它做的事情可以简单理解为:把流程实例从currentActivityId所在的活动状态,迁移到targetActivityId,同时会为该目标活动生成新的待办任务。如果你要向目标节点传递额外数据,可以链式设置流程变量:
runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo("financeReview", "managerApprove") .processVariable("overrideReason", "财务费用类型有误,退回重审") .changeState();使用过程中有个要点:流程实例当前可能不只有一个活动节点。比如并行网关之后,两个分支同时走到了“部门审批A”和“部门审批B”,这时候迁移必须指定“从哪个活动节点移动”,否则引擎不知道你要移动哪一条执行分支。这也是后续踩坑最多的地方。
2.2 自定义Command:可控性与纵深
官方API虽然写起来爽,但一旦牵扯到多实例(会签)、子流程、并行网关,或者需要更精细地控制“回退时清理哪些变量、保留哪些变量”,原生API就有点力不从心了。
这时候可以走自定义Command的路子。Activiti的命令模式允许你通过managementService.executeCommand()直接操作底层引擎数据,绕开高层API的限制。
自定义Command的核心思路是:
- 找到当前任务对应的执行实例(Execution)。
- 把执行实例的当前活动节点改成目标节点。
- 删除当前节点的待办任务。
- 为目标节点创建新的待办任务,指定新的审批人或候选人。
- 按需清理或覆盖流程变量。
这个方案能够精确控制“到底怎么退”,但代价是要对Activiti的内部数据结构有足够理解,否则很容易把流程实例玩坏。
2.3 选型建议
我自己在项目里的选择是“官方API为主,自定义Command为辅”。
如果回退目标只是普通用户任务节点,且流程结构比较简单(没有并行网关、没有会签),直接用createChangeActivityStateBuilder()就够了。代码少,不容易出问题。
如果回退涉及会签节点、并行分支,或者业务上要求回退后清空某些流程变量、重置多实例计数器,我会主动选择自定义Command,因为官方迁移API在多实例场景下经常出现“任务创建了,但会签变量没重置,导致后续审批人数量异常”这种奇怪问题。
3. 核心实现步骤:代码落地全过程
3.1 准备流程模型,标记关键节点
动手写代码前,先把流程模型梳理清楚。最稳妥的做法是在BPMN模型文件里,给所有“允许作为回退目标”的节点增加一个自定义扩展属性,比如:
<bpmn:userTask id="managerApprove" name="经理审批" activiti:assignee="${manager}"> <bpmn:extensionElements> <activiti:executionListener event="start" class="..."/> </bpmn:extensionElements> </bpmn:userTask>建议用activiti:extensionElements里加一个自定义属性,例如:
if (targetNode.getAttributeValue("custom:rollbackTarget") == null) { throw new BpmnError("ROLLBACK_NOT_ALLOWED", "该节点不允许被作为回退目标"); }这么做的原因是,在真实项目中“任意节点”不等于“所有节点”。有些节点(比如定时器事件节点、子流程节点、网关节点)不适合作为回退目标,或者业务上不允许退回到某些敏感节点。提前在模型里做管控,比在代码里硬编码节点ID要灵活很多。
3.2 基于官方API实现任意跳转
在流程比较简单的前提下,最快实现“撤回任意节点”的代码,其实只要一个Service方法:
public void rollbackToNode(String taskId, String targetNodeKey) { Task task = taskService.createTaskQuery().taskId(taskId).singleResult(); String processInstanceId = task.getProcessInstanceId(); // 获取当前任务所在节点的Key String currentTaskKey = task.getTaskDefinitionKey(); // 校验目标节点是否允许回退 ProcessDefinition pd = repositoryService.createProcessDefinitionQuery() .processDefinitionId(task.getProcessDefinitionId()) .singleResult(); BpmnModel model = repositoryService.getBpmnModel(pd.getId()); if (!isRollbackTargetAllowed(model, targetNodeKey)) { throw new BusinessException("该节点不允许作为回退目标"); } runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(currentTaskKey, targetNodeKey) .changeState(); }这里有个细节:moveActivityIdTo的两个参数,第一个是“当前活动的Key”,第二个是“目标活动的Key”。如果你不确定当前任务的taskDefinitionKey是什么,可以通过taskService.createTaskQuery().taskId(taskId).singleResult().getTaskDefinitionKey()拿到。
执行完changeState()之后,当前任务会被删除,目标节点会生成一个新的待办任务。如果目标节点绑定了流程变量(比如assignee表达式),引擎会根据当前流程变量重新计算审批人。
3.3 自定义Command实现回退
官方方案满足不了复杂场景的时候,我写了一套自定义Command。先看核心指令:
public class JumpTaskCommand implements Command<Void> { private final String taskId; private final String targetNodeId; private final Map<String, Object> extraVariables; private final Map<String, Object> removeVariables; public JumpTaskCommand(String taskId, String targetNodeId, Map<String, Object> extraVariables, Map<String, Object> removeVariables) { this.taskId = taskId; this.targetNodeId = targetNodeId; this.extraVariables = extraVariables; this.removeVariables = removeVariables; } @Override public Void execute(CommandContext commandContext) { // 1. 找到当前任务 TaskEntityManager taskEntityManager = commandContext.getTaskEntityManager(); TaskEntity taskEntity = taskEntityManager.findById(taskId); if (taskEntity == null) { throw new ActivitiException("要撤回的任务不存在: " + taskId); } ExecutionEntity execution = (ExecutionEntity) taskEntity.getExecution(); if (execution == null) { throw new ActivitiException("任务没有关联的执行实例,无法撤回"); } // 2. 找到流程定义中的目标节点 ProcessDefinitionEntity processDefinition = ProcessDefinitionUtil .getProcessDefinition(execution.getProcessDefinitionId()); ActivityImpl targetActivity = processDefinition.findActivity(targetNodeId); if (targetActivity == null) { throw new ActivitiException("流程定义中找不到目标节点: " + targetNodeId); } // 3. 删除当前任务 taskEntityManager.delete(taskEntity); // 4. 把执行实例当前活动设置到目标节点 execution.setActivity(targetActivity); // 5. 为目标节点创建新的任务 TaskEntity newTask = taskEntityManager.create(); newTask.setExecution(execution); newTask.setProcessInstanceId(execution.getProcessInstanceId()); newTask.setProcessDefinitionId(execution.getProcessDefinitionId()); newTask.setTaskDefinitionKey(targetActivity.getId()); newTask.setName(targetActivity.getProperty("name").toString()); newTask.setAssignee(computeAssignee(targetActivity, execution)); // 6. 处理额外变量与要删除的变量 if (extraVariables != null && !extraVariables.isEmpty()) { execution.getProcessInstance().setVariables(extraVariables); } if (removeVariables != null && !removeVariables.isEmpty()) { execution.getProcessInstance().removeVariables(removeVariables); } // 7. 触发任务创建事件 commandContext.getHistoryManager().recordTaskCreated(newTask); return null; } private String computeAssignee(ActivityImpl activity, ExecutionEntity execution) { // 这里按实际需求解析 activiti:assignee 或 activiti:candidateUsers // 简单示例:取目标节点的 assignee 表达式,然后代入流程变量 String assigneeExpr = (String) activity.getProperty("assignee"); if (assigneeExpr == null) { return null; } // 兼容 ${assignee} 这类表达式 String varName = assigneeExpr.replace("${", "").replace("}", ""); Object value = execution.getVariable(varName); return value == null ? null : value.toString(); } }调用方式:
managementService.executeCommand( new JumpTaskCommand(taskId, targetNodeId, Map.of("rejectReason", "审批信息有误,退回重审"), Map.of("financeResult", null)) );这段代码以“可控性”为目标,每一步都拆开做了。最核心的一步是execution.setActivity(targetActivity),它决定了流程实例的下一步从哪个节点“继续长出任务”。之后taskEntityManager.create()创建任务,挂到同一个执行实例上。
实际使用时,这套Command还需要处理“目标节点之前的历史执行实例是否要隐藏”这类问题。我习惯借助CommandContext.getHistoryManager()把新建的任务轨迹写入历史,同时把目标节点历史任务的状态保留原样,这样流程记录看起来是完整的。
3.4 处理审批人重新计算与变量快照
回退到底要不要把流程变量也“回滚”,是业务上最容易扯皮的地方。
比如流程走到财务复核,过程中财务已经修改了amount变量,现在退回部门经理重审,问题是amount要不要恢复成部门经理当时审批的旧值?
我这里的做法是:在每次任务提交时,对关键流程变量做一份快照,存成一张独立的业务表,记录“节点Key、任务ID、变量名、变量值、提交时间”。回退时调用方决定“恢复变量快照”还是“保留当前值”。
public class ProcessVariableSnapshot { // 流程实例ID、节点Key、变量名、变量值 }实际操作时,我很少把回滚做成“全量覆盖”,因为业务方很难接受“之前填的数据全部没了”。更稳妥的设计是:回退时把当前变量快照以额外变量形式挂到目标任务上,比如previousFinanceAmount=5000,审批人打开表单时能看到旧值和新值,或者主动选择“沿用旧值”。这是业务层面的取舍,技术上千万不要自以为是地覆盖数据。
4. 踩坑记录与排查思路
4.1 历史任务记录重复
第一次用官方API回退时,我发现目标节点的历史任务表里出现了两条managerApprove任务。原本那一条是已完成的,回退后又生成了一条待办的。从流程记录来看,这个节点被审批了两次,这其实是符合业务逻辑的:“第一次审批 + 回退后的第二次审批”。但报表系统一般不做这种去重,导致后续统计出现“审批节点数量异常”。
解决办法是在业务侧约定:查询“有效历史记录”时,过滤掉“回退标记”之前的记录。我通常会通过自定义历史数据写入,给回退生成的待办任务附加一个rollbackSourceTaskId变量,记录它是由哪个历史任务回退产生的。这样报表和审计依赖这个标识就可以精确还原真实链路。
4.2 并行网关下的执行实例错乱
并行网关后面的两个分支,各有一个待办任务。如果只指定processInstanceId和targetActivityId,官方API在迁移时会犯迷糊,因为它搞不清要移动的是哪条分支上的执行。
实际排查时发现,createChangeActivityStateBuilder支持更细粒度的方法:
runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveSingleExecutionToActivityId(executionId, targetActivityId) .changeState();moveSingleExecutionToActivityId可以明确指定“从哪条执行流移动”。所以在并行场景里,必须先通过任务查到对应的executionId,再执行迁移,否则新的待办任务会挂到错误的执行实例下,流程流程互相缠绕。
4.3 多实例任务残留
回退到会签节点是最头疼的场景。会签节点的特点是存在一个多实例活动,下面有多个子执行实例和多个待办任务。比如“部门经理会签”,三个人同时审批,现在要撤回这个节点重新会签。
直接moveActivityIdTo往往只能把主执行迁过去,但多实例的循环计数器、已完成任务数、待办任务列表并不会自动重置。结果就是新任务建出来了,但会签一直处于“等待其他子任务完成”的假死状态。
这种情况建议回退到“多实例节点的父级”或者“网关之前的节点”,让多实例重新发起。如果业务方一定要回退到会签节点本身,那就需要自己手动删除当前活动下所有关联的子任务与子执行,并重置nrOfInstances、nrOfActiveInstances、nrOfCompletedInstances这几个多实例变量。
这是一个比较脏的活,我的经验是:
多实例回退不要试图跟Activiti内部计数器搏斗,最好的策略是“整体退出多实例区域,退回到进入多实例之前的节点”,让流程自然重新进入多实例区段。
4.4 监听器重复触发
回退后目标节点重建了待办任务,那节点的create监听器自然也重新执行了。如果监听器里有发邮件、发短信这类副作用逻辑,审批人就会收到两条通知,一次是第一次正常审批产生的,一次是回退后产生的。
改造建议是:在监听器里判断当前任务是否带有rollbackSourceTaskId变量,如果有,说明是回退产生的任务,通知内容要改成“任务被退回,请重新审批”,而不是普通的新任务通知。这样用户体验才不会糊涂。
5. 回退策略的取舍与最终心得
功能做出来以后,我回头看这个需求,觉得它不只是“技术能不能实现”的问题,更是“流程可控性”的博弈。任意节点回退听起来很自由,但如果完全放开,流程管理会变得混乱。比如已经走过网关的分支,强行拉回到网关之前的节点,后续分支条件一旦发生改变,流程可能走向完全不同的路径,业务状态与历史记录对不上。
所以代码层面我给这个功能加了三个“闸门”:
- 第一闸门:只有流程定义中明确标记了“允许作为回退目标”的节点才能被选择。
- 第二闸门:只有当前任务的办理人或流程管理员才有权限执行回退,普通审批人看不到这个入口。
- 第三闸门:回退操作必须记录原因,写入流程变量,留痕给所有相关方。
至于实现方式,我的最终建议是:能用官方createChangeActivityStateBuilder解决的问题,绝不轻易上自定义Command。因为自定义Command直接操作底层引擎数据,出错以后问题往往隐蔽难查。如果业务模型已经复杂到官方API兜不住,再考虑自定义Command,并且一定要把“执行实例定位”“变量处理”“多实例重置”“历史留痕”这四件事单独抽出来写清楚,每个都对应一套单元测试。
这个功能我前前后后改了三版才稳定。第一版简单跳转,第二版支持并行分支,第三版加入了会签重置。回退的坑真的只有在真实数据和真实流程上跑过才会懂,纸上谈兵永远感受不到“流程卡死”“任务幽灵”有多磨人。希望这份过程记录能帮已经在踩坑或者正准备开工的你少走一段弯路。