Skip to content
Merged

Dev #236

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package com.codingapi.example;

import com.codingapi.example.entity.User;
import com.codingapi.example.repository.UserRepository;
import com.codingapi.flow.action.IFlowAction;
import com.codingapi.flow.action.actions.CustomAction;
import com.codingapi.flow.action.actions.PassAction;
import com.codingapi.flow.action.actions.RejectAction;
import com.codingapi.flow.builder.ActionBuilder;
import com.codingapi.flow.builder.FormFieldPermissionsBuilder;
import com.codingapi.flow.builder.NodeStrategyBuilder;
import com.codingapi.flow.form.DataType;
import com.codingapi.flow.form.FlowForm;
import com.codingapi.flow.form.FlowFormBuilder;
import com.codingapi.flow.form.permission.PermissionType;
import com.codingapi.flow.node.nodes.ApprovalNode;
import com.codingapi.flow.node.nodes.EndNode;
import com.codingapi.flow.node.nodes.StartNode;
import com.codingapi.flow.pojo.body.FlowAdviceBody;
import com.codingapi.flow.pojo.request.FlowActionRequest;
import com.codingapi.flow.pojo.request.FlowCreateRequest;
import com.codingapi.flow.pojo.request.FlowDetailRequest;
import com.codingapi.flow.pojo.request.FlowProcessNodeRequest;
import com.codingapi.flow.record.FlowRecord;
import com.codingapi.flow.repository.FlowRecordRepository;
import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
import com.codingapi.flow.service.FlowService;
import com.codingapi.flow.service.WorkflowService;
import com.codingapi.flow.strategy.node.FormFieldPermissionStrategy;
import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
import com.codingapi.flow.strategy.node.OperatorLoadStrategy;
import com.codingapi.flow.workflow.Workflow;
import com.codingapi.flow.workflow.WorkflowBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* issue #235 补充场景(生产路径):C 节点任意审批,拒绝配置为终止流程。
*
* <p>验证 c1 拒绝终止后,c2 的同节点待办自动办结,流程终止,且流程视图/详情查询正常。
*/
@SpringBootTest
@TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:flow-issue235-terminate-test;DB_CLOSE_DELAY=-1")
class FlowIssue235AnyRejectTerminateTest {

@Autowired
private FlowService flowService;

@Autowired
private WorkflowService workflowService;

@Autowired
private UserRepository userRepository;

@Autowired
private FlowRecordRepository flowRecordRepository;

private User createUser(String account) {
User user = new User();
user.setName(account);
user.setAccount(account);
user.setPassword("pwd-" + System.nanoTime());
user.setFlowManager(false);
return userRepository.save(user);
}

private FlowRecord todoOf(String processId, long operatorId) {
List<FlowRecord> todos = flowRecordRepository.findTodoRecords(processId).stream()
.filter(record -> record.getCurrentOperatorId() == operatorId)
.toList();
assertEquals(1, todos.size(), "审批人[" + operatorId + "]应有一条待办");
return todos.get(0);
}

@Test
void anyRejectTerminateShouldAutoDoneSiblingAndTerminateFlow() {
String code = "issue235-terminate-" + System.nanoTime();

User creator = createUser("issue235-creator-" + System.nanoTime());
User b1 = createUser("issue235-b1-" + System.nanoTime());
User c1 = createUser("issue235-c1-" + System.nanoTime());
User c2 = createUser("issue235-c2-" + System.nanoTime());

FlowForm form = FlowFormBuilder.builder()
.name("issue235")
.code(code)
.addField("name", "name", DataType.STRING)
.build();
FormFieldPermissionsBuilder permissions = FormFieldPermissionsBuilder.builder()
.addPermission(code, "name", PermissionType.WRITE);

StartNode startNode = StartNode.builder()
.strategies(NodeStrategyBuilder.builder()
.addStrategy(new FormFieldPermissionStrategy(permissions.build()))
.build())
.actions(ActionBuilder.builder().addAction(CustomAction.defaultAction()).build())
.build();

ApprovalNode bNode = ApprovalNode.builder()
.name("B审批")
.strategies(NodeStrategyBuilder.builder()
.addStrategy(new FormFieldPermissionStrategy(permissions.build()))
.addStrategy(new OperatorLoadStrategy(FlowGroovyScriptFactory
.createOperatorLoadScript("def run(request){return [" + b1.getId() + "]}")
.getKey()))
.build())
.build();

RejectAction rejectAction = RejectAction.defaultAction();
rejectAction.setScript(FlowGroovyScriptFactory
.createActionRejectScript("def run(request){return 'TERMINATE'}")
.getKey());
ApprovalNode cNode = ApprovalNode.builder()
.name("C审批")
.strategies(NodeStrategyBuilder.builder()
.addStrategy(new FormFieldPermissionStrategy(permissions.build()))
.addStrategy(new OperatorLoadStrategy(FlowGroovyScriptFactory
.createOperatorLoadScript(
"def run(request){return [" + c1.getId() + "," + c2.getId() + "]}")
.getKey()))
.addStrategy(new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.ANY, 0))
.build())
.actions(ActionBuilder.builder()
.addAction(PassAction.defaultAction())
.addAction(rejectAction)
.build())
.build();

Workflow workflow = WorkflowBuilder.builder()
.title("issue235")
.code(code)
.createdOperator(creator)
.form(form)
.addNode(startNode)
.addNode(bNode)
.addNode(cNode)
.addNode(EndNode.builder().build())
.build();
workflowService.saveWorkflow(workflow);

Map<String, Object> data = Map.of("name", "lorne");

// 发起 -> B
IFlowAction startAction = startNode.actionManager().getActions().get(0);
FlowCreateRequest createRequest = new FlowCreateRequest();
createRequest.setWorkCode(code);
createRequest.setFormData(data);
createRequest.setActionId(startAction.id());
createRequest.setOperatorId(creator.getId());
long startRecordId = flowService.create(createRequest);
String processId = flowRecordRepository.get(startRecordId).getProcessId();

FlowActionRequest startPass = new FlowActionRequest();
startPass.setFormData(data);
startPass.setRecordId(startRecordId);
startPass.setAdvice(new FlowAdviceBody(startAction.id(), "同意", creator.getId()));
flowService.action(startPass);

// B 通过 -> C
FlowRecord bTodo = todoOf(processId, b1.getId());
pass(bTodo, workflow.getFlowNode(bTodo.getNodeId()), b1.getId(), data);

// C 节点 c1、c2 同时收到待办
FlowRecord c1Record = todoOf(processId, c1.getId());
FlowRecord c2Record = todoOf(processId, c2.getId());

// c1 拒绝并终止流程
IFlowAction rejectActionOfNode = workflow.getFlowNode(c1Record.getNodeId())
.actionManager().getActions().stream()
.filter(action -> "REJECT".equals(action.type()))
.findFirst().orElseThrow();
FlowActionRequest rejectRequest = new FlowActionRequest();
rejectRequest.setFormData(data);
rejectRequest.setRecordId(c1Record.getId());
rejectRequest.setAdvice(new FlowAdviceBody(rejectActionOfNode.id(), "拒绝", c1.getId()));
flowService.action(rejectRequest);

// 终止后:c2 无待办,c2 记录已办
assertEquals(0, flowRecordRepository.findTodoRecords(processId).stream()
.filter(record -> record.getCurrentOperatorId() == c2.getId()).count(),
"c2 不应遗留待办");

// 以自动办结的 c2 记录为入口查询流程视图与详情,均不应报错
assertNotNull(flowService.processNodes(
new FlowProcessNodeRequest(c2Record.getId(), c2.getId(), data)));
assertNotNull(flowService.detail(new FlowDetailRequest(c2Record.getId(), c2.getId())));

List<FlowRecord> processRecords = flowRecordRepository.findProcessRecords(processId);
List<FlowRecord> cRecords = processRecords.stream()
.filter(record -> c1Record.getNodeId().equals(record.getNodeId()))
.toList();
assertEquals(2, cRecords.stream().filter(FlowRecord::isDone).count(),
"C 节点两条记录都应为已办");
assertTrue(processRecords.stream().allMatch(FlowRecord::isFinish),
"流程终止后所有记录应进入终态");
assertTrue(processRecords.stream()
.filter(record -> c2.getId() == record.getCurrentOperatorId())
.allMatch(FlowRecord::isFinish),
"c2 自动办结的记录也应进入终态");
}

private void pass(FlowRecord record, com.codingapi.flow.node.IFlowNode node, long operatorId,
Map<String, Object> data) {
IFlowAction action = node.actionManager().getActions().stream()
.filter(item -> "PASS".equals(item.type()))
.findFirst().orElseThrow();
FlowActionRequest request = new FlowActionRequest();
request.setFormData(data);
request.setRecordId(record.getId());
request.setAdvice(new FlowAdviceBody(action.id(), "同意", operatorId));
flowService.action(request);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.codingapi.flow.action;

import com.codingapi.flow.common.IMapConvertor;
import com.codingapi.flow.event.FlowRecordTodoEvent;
import com.codingapi.flow.event.IFlowEvent;
import com.codingapi.flow.node.IFlowNode;
import com.codingapi.flow.record.FlowRecord;
import com.codingapi.flow.session.FlowSession;
Expand Down Expand Up @@ -110,6 +112,14 @@ protected static <T extends BaseAction> T fromMap(Map<String, Object> data, Clas
public void run(FlowSession flowSession) {
}

/**
* 是否为结束节点虚拟记录的待办事件(非真实业务待办,推送时需要拦截)
*/
protected boolean isEndNodeTodoEvent(IFlowEvent event) {
return event instanceof FlowRecordTodoEvent todoEvent
&& !todoEvent.getFlowRecord().isNotEndNode();
}

/**
* 触发并执行后续节点
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,14 +234,6 @@ public void run(FlowSession flowSession) {
.forEach(EventPusher::push);
}

/**
* 是否为结束节点虚拟记录的待办事件(非真实业务待办,推送时需要拦截)
*/
private boolean isEndNodeTodoEvent(IFlowEvent event) {
return event instanceof FlowRecordTodoEvent todoEvent
&& !todoEvent.getFlowRecord().isNotEndNode();
}

/**
* 收集需要审批人设定的下游节点。
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,37 @@ public void run(FlowSession flowSession) {
// 当前审批为已办状态
flowEvents.add(new FlowRecordDoneEvent(flowRecord, flowSession.isMock()));

// 拒绝即结束当前节点:将同节点其他未办理的待办(或签/并签的其他审批人)自动置为已办。
// 否则节点被退回后仍会遗留待办,审批人可继续办理,流程视图也会停留在当前节点(issue #235)。
for (FlowRecord record : flowSession.getCurrentNodeRecords()) {
if (record.getId() != flowRecord.getId() && record.isTodo()) {
record.autoDone();
recordList.add(record);
flowEvents.add(new FlowRecordDoneEvent(record, flowSession.isMock()));
}
}

// 先保存当前记录及同节点待办的自动办结状态,再触发退回/终止节点。
// 终止流程时 EndNode.fillNewRecord 会按数据库中的最新记录统一置为结束状态,
// 若在触发后再统一保存,会用本会话中陈旧的“运行中”状态覆盖终态(issue #235 / #184)。
repositoryHolder.saveRecords(recordList);
recordList.clear();

List<FlowRecord> records = this.generateRecords(flowSession);
if (!records.isEmpty()) {
recordList.addAll(records);
for (FlowRecord record : records) {
if (record.isShow()) {
flowEvents.add(new FlowRecordTodoEvent(record, flowSession.isMock()));
}
}
recordList.addAll(records);
}
repositoryHolder.saveRecords(recordList);
flowEvents.forEach(EventPusher::push);

// 结束节点的虚拟记录不是真实业务待办,其 Todo 事件推送业务订阅方前需拦截
flowEvents.stream()
.filter(event -> !isEndNodeTodoEvent(event))
.forEach(EventPusher::push);

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public IFlowOperator get(long id, Supplier<IFlowOperator> operatorLoader) {

operator = operatorLoader.get();
if (operator != null) {
operatorCaches.put(operator.getUserId(), operator);
operatorCaches.put(id, operator);
this.cache.set(operatorCaches);
}
return operator;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,17 @@ public static FlowStateException subProcessResetMergeNodeOnly() {
return new FlowStateException("state.subProcess.resetMergeNodeOnly",
"Sub process reset only allowed on the todo record of the locked merge node");
}

/**
* Workflow script not found when executing a node or action
*
* @param key script key
* @return exception
*/
public static FlowStateException scriptNotFound(String key) {
return new FlowStateException("state.script.notFound",
String.format("Workflow script not found: %s. "
+ "Please check that the script is persisted (GroovyScriptRepository is registered) "
+ "and that the workflow version referencing it is saved correctly", key));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codingapi.flow.script.runtime;

import com.codingapi.flow.cache.FlowRuntimeScriptLocalCache;
import com.codingapi.flow.exception.FlowStateException;
import com.codingapi.springboot.script.GroovyScript;
import com.codingapi.springboot.script.cache.GroovyScriptCacheContext;

Expand Down Expand Up @@ -34,6 +35,11 @@ public static FlowScriptRuntimeContext getInstance() {
public GroovyScript getGroovyScript(String key) {
GroovyScript cached = GroovyScriptCacheContext.getInstance().getGroovyScript(key);
String snapshot = FlowRuntimeScriptLocalCache.getInstance().get(key);
if (cached == null && snapshot == null) {
// 全局缓存、临时缓存、持久化仓储与运行时快照均找不到脚本,
// 直接抛出明确异常,避免调用方在 invoke 处出现难以定位的空指针
throw FlowStateException.scriptNotFound(key);
}
if (snapshot == null) {
// 非运行态:不干预,返回全局缓存中的脚本对象
return cached;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.codingapi.flow.service;

import com.codingapi.flow.exception.FlowStateException;
import com.codingapi.flow.generator.FlowIDGeneratorGatewayContext;
import com.codingapi.flow.javscript.NodeViewJavaScript;
import com.codingapi.flow.javscript.NodeViewJavaScriptCacheContext;
Expand Down Expand Up @@ -66,12 +67,14 @@ public static void resetGroovyScripts(Object target) {
GroovyScriptFieldResult result = GroovyScriptAnnotationScannerUtils.findGroovyScriptFields(target);
result.update((key) -> {
GroovyScript groovyScript = GroovyScriptCacheContext.getInstance().getGroovyScript(key);
if (groovyScript != null) {
GroovyScript latestScript = groovyScript.copy(FlowIDGeneratorGatewayContext.getInstance().generateFlowScriptKey());
latestScript.save();
return latestScript.getKey();
if (groovyScript == null) {
// 脚本内容已丢失(例如设计器临时脚本超过有效期),若继续保存会把悬空的脚本key写入流程定义,
// 直到对应节点执行时才以空指针的形式暴露,因此保存阶段直接给出明确错误
throw FlowStateException.scriptNotFound(key);
}
return key;
GroovyScript latestScript = groovyScript.copy(FlowIDGeneratorGatewayContext.getInstance().generateFlowScriptKey());
latestScript.save();
return latestScript.getKey();
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.codingapi.flow.repository.WorkflowRepository;
import com.codingapi.flow.repository.WorkflowRuntimeRepository;
import com.codingapi.flow.repository.WorkflowVersionRepository;
import com.codingapi.flow.transfer.WorkflowGroovyScriptTransferService;
import com.codingapi.flow.transfer.WorkflowImportMode;
import com.codingapi.flow.transfer.WorkflowTransferService;
import com.codingapi.flow.utils.Base64Utils;
Expand Down Expand Up @@ -44,6 +45,9 @@ public void saveWorkflowVersion(WorkflowVersion currentVersion, boolean creatabl

currentVersion.enableVersion();
if (currentVersion.getId() == 0) {
// 兼容历史设计数据:旧版设计器可能把脚本正文直接写入脚本字段,
// 保存前先归一化为脚本key,避免悬空的脚本内容在运行时才以空指针暴露(issue #235)
new WorkflowGroovyScriptTransferService().normalizeInlineScripts(currentVersion);
// 新创建的版本,替换脚本
WorkflowGroovyScriptUtils.resetScripts(currentVersion);
}
Expand Down
Loading
Loading