feat(flow): 优化拒绝处理逻辑,支持多实例结束后重定向到发起人

- 更新 ApproveServiceTask 中拒绝处理逻辑,支持多实例结束后当前执行流尚未落库的情况
- 增加对 CommandContextUtil 的使用,确保执行流迁移的正确性
- 添加 ModelUtilMultiMatchTests 中的单元测试,验证拒绝到发起人的逻辑

(cherry picked from commit 9e37864b02b14a2c980b74e01e126b5d38d10b09)
This commit is contained in:
PIGCLOUD committed 2026-11-03 14:56:21 +08:00
1 parent 2f089f029f
commit 0974a56294
2 files changed
+105 -15

No files matched your search

@@ -1,17 +1,25 @@
package com.pig4cloud.pigx.flow.support.tasks;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.dto.Refuse;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser;
import com.pig4cloud.pigx.flow.service.IProcessNodeDataService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.delegate.DelegateExecution;
import org.flowable.engine.delegate.JavaDelegate;
import org.flowable.engine.impl.persistence.entity.ExecutionEntityImpl;
import org.flowable.engine.impl.util.CommandContextUtil;
import org.flowable.engine.runtime.Execution;
import java.util.Date;
import java.util.List;
/**
@@ -68,7 +76,8 @@ public class ApproveServiceTask implements JavaDelegate {
/**
* 处理任务被拒绝时的流程重定向逻辑
* <p>
* 根据节点配置的拒绝策略,将流程重定向到指定节点或结束节点。 支持的拒绝策略: 1. TO_NODE:跳转到指定节点 2. 其他:直接结束流程
* 根据节点配置跳转到指定节点、发起人或结束节点。TO_START 会收回实例全部执行流,并结束被收回节点的本轮审批记录;
* 与引擎状态迁移共用当前审批事务,不覆盖已经完成的审批历史。
* @param processDefinitionId 流程定义 ID
* @param nodeId 当前节点标识符
* @param currentActivityId 当前活动标识符
@@ -97,17 +106,36 @@ public class ApproveServiceTask implements JavaDelegate {
// 根据拒绝设置重定向到特定节点
stateChangeBuilder.moveActivityIdTo(currentActivityId, refuseConfig.getNodeId()).changeState();
}
else if (StrUtil.equals(rejectionHandler, "TO_START")) {
// 驳回到发起人:设置驳回标识变量,跳转到 root 节点
// 并行、多选分支内驳回时,兄弟分支执行流需一并收回,否则重新提交后汇聚网关会多出令牌
runtimeService.setVariable(processInstanceId, "rejectToStarter", true);
List<String> executionIds = runtimeService.createExecutionQuery()
.parentId(processInstanceId)
.list()
else if (StrUtil.equals(rejectionHandler, ProcessInstanceConstant.USER_TASK_REFUSE_TYPE_TO_START)) {
// 多实例结束后当前执行流尚未落库,必须使用包含命令缓存的执行树,并排除已删除的旧 scope。
// 按活动迁移会合并为一个 root;按执行流迁移会为每条分支各建一个 root 待办。
List<String> activityIds = CommandContextUtil.getExecutionEntityManager()
.findChildExecutionsByProcessInstanceId(processInstanceId)
.stream()
.map(Execution::getId)
.filter(child -> !child.isDeleted() && StrUtil.equals(child.getParentId(), processInstanceId))
.map(Execution::getActivityId)
.distinct()
.toList();
stateChangeBuilder.moveExecutionsToSingleActivityId(executionIds, "root").changeState();
runtimeService.setVariable(processInstanceId, ProcessInstanceConstant.REJECT_TO_STARTER_VAR, true);
// 先结束本轮活动记录,避免重提复用被收回的节点及参与人;迁移新建的 root 不应被关闭。
Date now = new Date();
SpringUtil.getBean(IProcessNodeRecordService.class)
.lambdaUpdate()
.set(ProcessNodeRecord::getStatus, NodeStatusEnum.YJS.getCode())
.set(ProcessNodeRecord::getEndTime, now)
.eq(ProcessNodeRecord::getProcessInstanceId, processInstanceId)
.eq(ProcessNodeRecord::getStatus, NodeStatusEnum.JXZ.getCode())
.update(new ProcessNodeRecord());
SpringUtil.getBean(IProcessNodeRecordAssignUserService.class)
.lambdaUpdate()
.set(ProcessNodeRecordAssignUser::getStatus, ProcessInstanceConstant.ASSIGN_USER_STATUS_WITHDRAW)
.set(ProcessNodeRecordAssignUser::getEndTime, Convert.toLocalDateTime(now))
.set(ProcessNodeRecordAssignUser::getTaskType, "WITHDRAW")
.eq(ProcessNodeRecordAssignUser::getProcessInstanceId, processInstanceId)
.eq(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode())
.update(new ProcessNodeRecordAssignUser());
stateChangeBuilder.moveActivityIdsToSingleActivityId(activityIds, "root").changeState();
}
else {
// 默认行为:重定向到结束节点
@@ -1,10 +1,15 @@
package com.pig4cloud.pigx.flow.support.utils;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.dto.Refuse;
import com.pig4cloud.pigx.flow.service.IProcessNodeDataService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService;
import org.flowable.bpmn.model.BpmnModel;
import org.flowable.bpmn.model.ExclusiveGateway;
import org.flowable.bpmn.model.InclusiveGateway;
@@ -17,6 +22,7 @@ import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.stubbing.Answer;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
@@ -25,6 +31,7 @@ import org.flowable.spring.SpringProcessEngineConfiguration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -65,6 +72,18 @@ class ModelUtilMultiMatchTests {
context = new GenericApplicationContext();
context.getBeanFactory().registerSingleton("runtimeService", engine.getRuntimeService());
context.getBeanFactory().registerSingleton("processNodeDataService", nodes);
// set/eq 泛型返回值擦除为 Object,RETURNS_SELF 不会返回自身;链式调用返回 mock,终结的 update 返回 true
Answer<Object> chain = invocation -> "update".equals(invocation.getMethod().getName()) ? true
: invocation.getMock();
IProcessNodeRecordService records = mock(IProcessNodeRecordService.class);
LambdaUpdateChainWrapper<ProcessNodeRecord> recordUpdate = mock(LambdaUpdateChainWrapper.class, chain);
when(records.lambdaUpdate()).thenReturn(recordUpdate);
IProcessNodeRecordAssignUserService assignments = mock(IProcessNodeRecordAssignUserService.class);
LambdaUpdateChainWrapper<ProcessNodeRecordAssignUser> assignmentUpdate = mock(LambdaUpdateChainWrapper.class,
chain);
when(assignments.lambdaUpdate()).thenReturn(assignmentUpdate);
context.getBeanFactory().registerSingleton("processNodeRecordService", records);
context.getBeanFactory().registerSingleton("processNodeRecordAssignUserService", assignments);
context.refresh();
new SpringUtil().setApplicationContext(context);
}
@@ -83,8 +102,8 @@ class ModelUtilMultiMatchTests {
assertThat(model.getMainProcess().getFlowElement("gw")).isInstanceOf(InclusiveGateway.class);
assertThat(model.getMainProcess().getFlowElement("gw_merge_gateway")).isInstanceOf(InclusiveGateway.class);
BpmnModel single = ModelUtil.buildBpmnModel(tree(false, approval("a"), approval("b"), approval("other")),
"f", "f");
BpmnModel single = ModelUtil.buildBpmnModel(tree(false, approval("a"), approval("b"), approval("other")), "f",
"f");
assertThat(single.getMainProcess().getFlowElement("gw")).isInstanceOf(ExclusiveGateway.class);
assertThat(single.getMainProcess().getFlowElement("gw_merge_gateway")).isInstanceOf(ExclusiveGateway.class);
}
@@ -156,6 +175,45 @@ class ModelUtilMultiMatchTests {
engine.getTaskService().complete(task(instance, "root").getId(), Map.of("a_approve_condition", true));
assertThat(activeTaskKeys(instance)).containsExactlyInAnyOrder("a", "b");
complete(instance, "a");
assertThat(activeTaskKeys(instance)).containsExactly("b");
complete(instance, "b");
assertThat(activeTaskKeys(instance)).containsExactly("after");
}
@Test
void rejectToStarterOnSinglePathStopsAtRoot() {
Refuse refuse = new Refuse();
refuse.setHandler("TO_START");
Node rejectConfig = new Node();
rejectConfig.setRefuse(refuse);
when(nodes.getPublishedNode(anyString(), eq("a"))).thenReturn(R.ok(rejectConfig));
Node root = tree(false, approval("x"), approval("y"), approval("z"));
Node a = approval("a");
a.setChildren(approval("after"));
root.setChildren(a);
ProcessInstance instance = submit(root, 200, "it");
engine.getTaskService().complete(task(instance, "a").getId(), Map.of("a_approve_condition", false));
assertThat(activeTaskKeys(instance)).containsExactly("root");
}
@Test
void rejectToStarterAfterSiblingWaitsAtJoin() {
Refuse refuse = new Refuse();
refuse.setHandler("TO_START");
Node rejectConfig = new Node();
rejectConfig.setRefuse(refuse);
when(nodes.getPublishedNode(anyString(), eq("a"))).thenReturn(R.ok(rejectConfig));
ProcessInstance instance = submit(tree(true, approval("a"), approval("b"), approval("other")), 200, "it");
complete(instance, "b");
engine.getTaskService().complete(task(instance, "a").getId(), Map.of("a_approve_condition", false));
assertThat(activeTaskKeys(instance)).containsExactly("root");
engine.getTaskService().complete(task(instance, "root").getId(), Map.of("a_approve_condition", true));
assertThat(activeTaskKeys(instance)).containsExactlyInAnyOrder("a", "b");
complete(instance, "a");
assertThat(activeTaskKeys(instance)).containsExactly("b");
complete(instance, "b");
assertThat(activeTaskKeys(instance)).containsExactly("after");
}
@@ -178,6 +236,7 @@ class ModelUtilMultiMatchTests {
engine.getTaskService().complete(task(instance, "root").getId(), Map.of("a_approve_condition", true));
complete(instance, "a");
assertThat(activeTaskKeys(instance)).containsExactly("b");
complete(instance, "b");
assertThat(activeTaskKeys(instance)).containsExactly("after");
}
@@ -223,7 +282,7 @@ class ModelUtilMultiMatchTests {
}
/**
* 部署后发起并提交发起人节点;剥离监听器和多实例,只保留真实网关、连线和审批服务任务。
* 部署后发起并提交发起人节点;保留多实例执行树,隔离人员解析和业务监听器。
*/
private ProcessInstance submit(Node root, int amount, String dept) {
BpmnModel model = ModelUtil.buildBpmnModel(root, "f", "f");
@@ -231,13 +290,16 @@ class ModelUtilMultiMatchTests {
for (var element : model.getMainProcess().getFlowElements()) {
if (element instanceof UserTask task) {
task.setTaskListeners(new ArrayList<>());
task.setLoopCharacteristics(null);
if (Objects.nonNull(task.getLoopCharacteristics())) {
task.getLoopCharacteristics().setInputDataItem("${assignees}");
task.getLoopCharacteristics().setCompletionCondition("${nrOfCompletedInstances == nrOfInstances}");
}
task.setAssignee("9000");
}
}
engine.getRepositoryService().createDeployment().addBpmnModel("f.bpmn20.xml", model).deploy();
ProcessInstance instance = engine.getRuntimeService()
.startProcessInstanceByKey("f", Map.of("amount", amount, "dept", dept));
.startProcessInstanceByKey("f", Map.of("amount", amount, "dept", dept, "assignees", List.of("9000")));
complete(instance, "root");
return instance;
}