diff --git a/flow-engine-example/src/test/java/com/codingapi/example/FlowIssue235AnyRejectTerminateTest.java b/flow-engine-example/src/test/java/com/codingapi/example/FlowIssue235AnyRejectTerminateTest.java
new file mode 100644
index 00000000..7dcb74d1
--- /dev/null
+++ b/flow-engine-example/src/test/java/com/codingapi/example/FlowIssue235AnyRejectTerminateTest.java
@@ -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 节点任意审批,拒绝配置为终止流程。
+ *
+ *
验证 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 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 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 processRecords = flowRecordRepository.findProcessRecords(processId);
+ List 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 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);
+ }
+}
\ No newline at end of file
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/BaseAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/BaseAction.java
index 755ddb00..7218ee7b 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/BaseAction.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/BaseAction.java
@@ -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;
@@ -110,6 +112,14 @@ protected static T fromMap(Map data, Clas
public void run(FlowSession flowSession) {
}
+ /**
+ * 是否为结束节点虚拟记录的待办事件(非真实业务待办,推送时需要拦截)
+ */
+ protected boolean isEndNodeTodoEvent(IFlowEvent event) {
+ return event instanceof FlowRecordTodoEvent todoEvent
+ && !todoEvent.getFlowRecord().isNotEndNode();
+ }
+
/**
* 触发并执行后续节点
*
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java
index 3b776776..8d328c07 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java
@@ -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();
- }
-
/**
* 收集需要审批人设定的下游节点。
*
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/RejectAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/RejectAction.java
index a6caa401..108907f6 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/RejectAction.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/RejectAction.java
@@ -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 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);
}
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/cache/FlowOperatorLocalThreadCache.java b/flow-engine-framework/src/main/java/com/codingapi/flow/cache/FlowOperatorLocalThreadCache.java
index 3ca35f62..57a994ca 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/cache/FlowOperatorLocalThreadCache.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/cache/FlowOperatorLocalThreadCache.java
@@ -48,7 +48,7 @@ public IFlowOperator get(long id, Supplier operatorLoader) {
operator = operatorLoader.get();
if (operator != null) {
- operatorCaches.put(operator.getUserId(), operator);
+ operatorCaches.put(id, operator);
this.cache.set(operatorCaches);
}
return operator;
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java
index 378025e9..5892fac8 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowStateException.java
@@ -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));
+ }
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContext.java b/flow-engine-framework/src/main/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContext.java
index 38aa838e..e15fdb9d 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContext.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContext.java
@@ -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;
@@ -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;
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowGroovyScriptUtils.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowGroovyScriptUtils.java
index fb9d9960..abd33ed6 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowGroovyScriptUtils.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowGroovyScriptUtils.java
@@ -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;
@@ -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();
});
}
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowService.java
index 39242f75..9774b4ab 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowService.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/WorkflowService.java
@@ -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;
@@ -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);
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowGroovyScriptTransferService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowGroovyScriptTransferService.java
index b0aa97b4..8f3737eb 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowGroovyScriptTransferService.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowGroovyScriptTransferService.java
@@ -57,6 +57,34 @@ public Map exportScripts(Collection> targets) {
return scripts;
}
+ /**
+ * 将字段中直接保存的脚本正文归一化为脚本key。
+ *
+ * 旧版设计器可能把脚本正文直接写入动作/节点的脚本字段,而引擎的保存、运行时与导入导出
+ * 都以脚本key为准。保存流程前先做一次归一化:把正文按字段所属脚本类型注册为临时脚本
+ * 并回填脚本key,随后由 {@code WorkflowGroovyScriptUtils.resetScripts} 统一生成持久化脚本。
+ *
+ * @param target 目标对象
+ */
+ public void normalizeInlineScripts(Object target) {
+ if (target == null) {
+ return;
+ }
+ AnnotationTargetFieldResult fields = findFields(target);
+ Map keyMapping = new LinkedHashMap<>();
+ fields.fetch(field -> {
+ String value = field.getValue();
+ if (keyMapping.containsKey(value) || !looksLikeInlineScript(value)) {
+ return;
+ }
+ GroovyScript groovyScript = ScriptType.from(field.getTarget()).create(value);
+ keyMapping.put(value, groovyScript.getKey());
+ });
+ if (!keyMapping.isEmpty()) {
+ fields.update(value -> keyMapping.getOrDefault(value, value));
+ }
+ }
+
/**
* 按脚本引用所属类型重建脚本,并将所有旧key替换为新key。
*
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContextTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContextTest.java
index 75847086..091897d9 100644
--- a/flow-engine-framework/src/test/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContextTest.java
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/script/runtime/FlowScriptRuntimeContextTest.java
@@ -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;
import org.junit.jupiter.api.AfterEach;
@@ -11,6 +12,8 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 流程运行时脚本代理测试。
@@ -101,6 +104,30 @@ void should_return_global_script_when_no_runtime_cache() {
assertEquals(NEW_CONTENT, script.getScript());
}
+ @Test
+ void should_throw_clear_exception_when_script_missing_everywhere() {
+ // given 脚本在任何缓存与仓储中都不存在(模拟脚本未持久化或数据丢失)
+ String missingKey = "missing-script-" + System.nanoTime();
+ FlowRuntimeScriptLocalCache.getInstance().clear();
+
+ // when / then 不再返回 null 导致调用方空指针,而是抛出明确的流程状态异常
+ FlowStateException exception = assertThrows(FlowStateException.class,
+ () -> FlowScriptRuntimeContext.getInstance().getGroovyScript(missingKey));
+ assertTrue(exception.getMessage().contains(missingKey),
+ "异常信息应包含缺失的脚本key,便于定位");
+ }
+
+ @Test
+ void should_throw_clear_exception_when_runtime_snapshot_missing_key() {
+ // given 存在运行时快照,但快照中不包含目标脚本key,且全局缓存也没有
+ String missingKey = "missing-runtime-script-" + System.nanoTime();
+ FlowRuntimeScriptLocalCache.getInstance().set(Map.of(KEY, OLD_CONTENT));
+
+ // when / then
+ assertThrows(FlowStateException.class,
+ () -> FlowScriptRuntimeContext.getInstance().getGroovyScript(missingKey));
+ }
+
@Test
void should_return_null_from_local_cache_when_not_set() {
// given
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue235AnyAuditRejectTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue235AnyAuditRejectTest.java
new file mode 100644
index 00000000..f1c3e58b
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue235AnyAuditRejectTest.java
@@ -0,0 +1,421 @@
+package com.codingapi.flow.service;
+
+import com.codingapi.flow.action.ActionType;
+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.context.GatewayContext;
+import com.codingapi.flow.event.FlowRecordFinishEvent;
+import com.codingapi.flow.event.FlowRecordTodoEvent;
+import com.codingapi.flow.exception.FlowStateException;
+import com.codingapi.flow.factory.MyFlowServiceFactory;
+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.IFlowNode;
+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.pojo.response.ProcessNode;
+import com.codingapi.flow.record.FlowRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+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.user.User;
+import com.codingapi.flow.workflow.Workflow;
+import com.codingapi.flow.workflow.WorkflowBuilder;
+import com.codingapi.flow.workflow.runtime.WorkflowRuntime;
+import com.codingapi.springboot.framework.event.DomainEvent;
+import com.codingapi.springboot.framework.event.SpringEventInitializer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationEvent;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * issue #235 任意审批(或签)节点拒绝场景。
+ *
+ * 流程设计:A(开始) -> B(审批,审批人 b1) -> C(审批,审批人 c1/c2,任意审核) -> D(结束)。
+ * C 节点拒绝动作退回开始节点。
+ *
+ *
当 c1 拒绝时,C 节点即告结束:
+ *
+ * - c2 在同节点的待办应自动办结(视为已审核),不能继续审批;
+ * - 流程退回开始节点,发起人收到新的待办;
+ * - 流程节点视图中 C 节点不再处于审批中,也不会从遗留的 c2 待办错误预览后续的 D 节点。
+ *
+ */
+class FlowIssue235AnyAuditRejectTest {
+
+ private final MyFlowServiceFactory factory = new MyFlowServiceFactory();
+ private final List capturedEvents = new ArrayList<>();
+
+ @AfterEach
+ void resetEventContext() throws Exception {
+ // 还原 DomainEventContext 的 Spring 上下文,避免影响其他测试(无上下文时事件再次被丢弃)
+ Class> clazz = Class.forName("com.codingapi.springboot.framework.event.DomainEventContext");
+ java.lang.reflect.Method getInstance = clazz.getDeclaredMethod("getInstance");
+ getInstance.setAccessible(true);
+ Object instance = getInstance.invoke(null);
+ Field field = clazz.getDeclaredField("context");
+ field.setAccessible(true);
+ field.set(instance, null);
+ }
+
+ private void initEventCapture() throws Exception {
+ ApplicationContext mockContext = Mockito.mock(ApplicationContext.class);
+ Mockito.doAnswer(invocation -> {
+ DomainEvent domainEvent = invocation.getArgument(0);
+ capturedEvents.add(domainEvent.getEvent());
+ return null;
+ }).when(mockContext).publishEvent(Mockito.any(ApplicationEvent.class));
+ new SpringEventInitializer(mockContext).afterPropertiesSet();
+ }
+
+ @Test
+ void anyAuditRejectShouldAutoDoneSiblingTodoAndReturnToStart() {
+ // given:A-B-C-D,B 的审批人为 b1,C 的审批人为 c1/c2,C 为任意审核,拒绝退回开始节点
+ User a = new User(1, "a");
+ User b1 = new User(2, "b1");
+ User c1 = new User(3, "c1");
+ User c2 = new User(4, "c2");
+ registerUsers(a, b1, c1, c2);
+
+ StartNode startNode = startNode();
+ ApprovalNode bNode = approvalNode("B审批", "[2]", null);
+ RejectAction rejectAction = RejectAction.defaultAction();
+ rejectAction.setScript(FlowGroovyScriptFactory
+ .createActionRejectScript("def run(request){return request.getStartNode().getId()}")
+ .getKey());
+ ApprovalNode cNode = approvalNode("C审批", "[3,4]",
+ new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.ANY, 0), rejectAction);
+ EndNode endNode = EndNode.builder().build();
+ Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode);
+ Map data = data();
+
+ // 1. 发起人提交,b1 审批通过,流程到达 C 节点
+ startAndSubmit(workflow, startNode, a, data);
+ pass(todoOf(b1), bNode, b1, data);
+ FlowRecord c1Todo = todoOf(c1);
+ FlowRecord c2Todo = todoOf(c2);
+ assertEquals(cNode.getId(), c1Todo.getNodeId());
+ assertEquals(cNode.getId(), c2Todo.getNodeId());
+
+ // when:c1 拒绝
+ reject(c1Todo, cNode, c1, data);
+
+ // then 1:c2 的同节点待办自动办结,c1/c2 均无待办
+ assertNoTodo(c1);
+ assertNoTodo(c2);
+ assertTrue(factory.flowRecordRepository.get(c2Todo.getId()).isDone(),
+ "c1 拒绝后,c2 的同节点待办应自动置为已办");
+
+ // then 2:流程退回开始节点,仅发起人存在一条新的待办
+ List todoRecords = factory.flowRecordRepository.findTodoByOperator(a.getUserId());
+ assertEquals(1, todoRecords.size(), "流程退回后发起人应收到一条待办");
+ assertEquals(startNode.getId(), todoRecords.get(0).getNodeId());
+
+ // then 3:C 节点两条记录均为已办,流程中不存在遗留的 C 节点待办
+ List records = processRecords(c1Todo);
+ List cRecords = recordsOfNode(records, cNode);
+ assertEquals(2, cRecords.size(), "C 节点应保留 c1、c2 两条记录");
+ assertEquals(2, cRecords.stream().filter(FlowRecord::isDone).count(),
+ "C 节点两条记录都应为已办(c1 拒绝,c2 自动办结)");
+ assertEquals(1, records.stream().filter(FlowRecord::isTodo).count(),
+ "流程中仅应存在发起人的一条待办");
+
+ // then 4:c2 不能再对该节点执行审批
+ assertThrows(FlowStateException.class, () -> pass(c2Todo, cNode, c2, data),
+ "c2 的记录已自动办结,不应再允许审批");
+
+ // then 5:流程节点视图不再停留在 C 节点审批中
+ List nodeList = processNodes(todoRecords.get(0), a, data);
+
+ ProcessNode cNodeView = nodeList.stream()
+ .filter(node -> cNode.getId().equals(node.getNodeId()))
+ .filter(node -> !node.isHistory())
+ .findFirst()
+ .orElse(null);
+ assertTrue(cNodeView == null || cNodeView.getApproveState() != ProcessNode.ApproveState.PROCESSING,
+ "C 节点不应仍展示为审批中");
+
+ ProcessNode cHistoryView = nodeList.stream()
+ .filter(node -> cNode.getId().equals(node.getNodeId()))
+ .filter(ProcessNode::isHistory)
+ .findFirst()
+ .orElseThrow();
+ assertTrue(cHistoryView.getOperators().stream()
+ .noneMatch(operator -> ActionType.PASS.name().equals(operator.getActionType())),
+ "C 节点历史中不应把拒绝误记为通过");
+ }
+
+ /**
+ * 拒绝配置为「终止流程」时,或签节点的其他待办同样应自动办结,流程正常终止。
+ */
+ @Test
+ void anyAuditRejectTerminateShouldCloseSiblingAndTerminateFlow() throws Exception {
+ // given:A-B-C-D,C 为任意审核(c1/c2),拒绝动作终止流程
+ User a = new User(1, "a");
+ User b1 = new User(2, "b1");
+ User c1 = new User(3, "c1");
+ User c2 = new User(4, "c2");
+ registerUsers(a, b1, c1, c2);
+
+ StartNode startNode = startNode();
+ ApprovalNode bNode = approvalNode("B审批", "[2]", null);
+ RejectAction rejectAction = RejectAction.defaultAction();
+ rejectAction.setScript(FlowGroovyScriptFactory
+ .createActionRejectScript("def run(request){return 'TERMINATE'}")
+ .getKey());
+ ApprovalNode cNode = approvalNode("C审批", "[3,4]",
+ new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.ANY, 0), rejectAction);
+ EndNode endNode = EndNode.builder().build();
+ Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode);
+ Map data = data();
+
+ // 1. 流程到达 C 节点,c1、c2 同时收到待办
+ startAndSubmit(workflow, startNode, a, data);
+ pass(todoOf(b1), bNode, b1, data);
+ FlowRecord c1Todo = todoOf(c1);
+ FlowRecord c2Todo = todoOf(c2);
+
+ // 捕获拒绝环节推送的事件,用于校验结束节点虚拟待办事件被拦截
+ initEventCapture();
+ capturedEvents.clear();
+
+ // when:c1 拒绝并终止流程
+ reject(c1Todo, cNode, c1, data);
+
+ // then:c1、c2 均无待办,流程终止
+ assertNoTodo(c1);
+ assertNoTodo(c2);
+ List records = processRecords(c1Todo);
+ assertEquals(2, recordsOfNode(records, cNode).stream().filter(FlowRecord::isDone).count(),
+ "C 节点两条记录都应为已办");
+ assertTrue(records.stream().filter(FlowRecord::isTodo).findFirst().isEmpty(),
+ "流程终止后不应遗留待办");
+ assertTrue(records.stream()
+ .filter(record -> cNode.getId().equals(record.getNodeId()))
+ .allMatch(FlowRecord::isFinish),
+ "流程终止后 C 节点记录应处于结束状态");
+
+ // 结束节点的虚拟记录(operatorId=-1)不是真实业务待办,不应推送 Todo 事件,
+ // 否则订阅方按操作人加载时会拿到空对象而出现空指针
+ assertTrue(capturedEvents.stream()
+ .filter(FlowRecordTodoEvent.class::isInstance)
+ .map(FlowRecordTodoEvent.class::cast)
+ .allMatch(event -> event.getFlowRecord().isNotEndNode()),
+ "结束节点虚拟记录不应推送待办事件");
+ assertTrue(capturedEvents.stream().anyMatch(FlowRecordFinishEvent.class::isInstance),
+ "流程终止后应推送流程结束事件");
+
+ // 流程终止后查询流程视图应正常返回
+ List nodeList = processNodes(records.get(records.size() - 1), a, data);
+ assertTrue(nodeList.stream().anyMatch(node -> cNode.getId().equals(node.getNodeId())),
+ "流程视图应包含 C 节点");
+
+ // 以自动办结的 c2 记录为入口查询流程视图与详情,均不应报错
+ processNodes(c2Todo, c2, data);
+ factory.flowService.detail(new FlowDetailRequest(c2Todo.getId(), c2.getUserId()));
+ }
+
+ /**
+ * 运行时脚本快照必须包含拒绝动作脚本,否则应用重启后(全局脚本缓存为空时)拒绝操作会因脚本缺失而失败。
+ */
+ @Test
+ void workflowRuntimeShouldSnapshotRejectScript() {
+ // given:C 节点配置了自定义的拒绝脚本
+ User a = new User(1, "a");
+ User b1 = new User(2, "b1");
+ User c1 = new User(3, "c1");
+ User c2 = new User(4, "c2");
+ registerUsers(a, b1, c1, c2);
+
+ RejectAction rejectAction = RejectAction.defaultAction();
+ rejectAction.setScript(FlowGroovyScriptFactory
+ .createActionRejectScript("def run(request){return 'TERMINATE'}")
+ .getKey());
+
+ ApprovalNode bNode = approvalNode("B审批", "[2]", null);
+ ApprovalNode cNode = approvalNode("C审批", "[3,4]",
+ new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.ANY, 0), rejectAction);
+ Workflow workflow = saveWorkflow(a, startNode(), bNode, cNode, EndNode.builder().build());
+
+ // 保存版本时会重建脚本key,因此以保存后的流程定义中的key为准
+ String savedRejectScriptKey = workflow.getFlowNode(cNode.getId())
+ .actionManager().getActions().stream()
+ .filter(action -> ActionType.REJECT.name().equals(action.type()))
+ .map(RejectAction.class::cast)
+ .findFirst().orElseThrow()
+ .getScript().getScript();
+
+ // when:创建运行时快照
+ WorkflowRuntime workflowRuntime = new WorkflowRuntime(workflow);
+
+ // then:快照中包含拒绝脚本正文
+ assertTrue(workflowRuntime.getScripts().containsKey(savedRejectScriptKey),
+ "运行时快照应包含拒绝动作脚本");
+ }
+
+ // ==================== 流程装配 ====================
+
+ private void registerUsers(User... users) {
+ for (User user : users) {
+ factory.userGateway.save(user);
+ }
+ GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway);
+ }
+
+ private FlowForm form() {
+ return FlowFormBuilder.builder()
+ .name("issue235")
+ .code("issue235")
+ .addField("name", "name", DataType.STRING)
+ .addField("days", "days", DataType.INTEGER)
+ .build();
+ }
+
+ private Map data() {
+ return Map.of("name", "lorne", "days", 1);
+ }
+
+ private FormFieldPermissionStrategy perm() {
+ return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission("issue235", "name", PermissionType.WRITE)
+ .addPermission("issue235", "days", PermissionType.WRITE)
+ .build());
+ }
+
+ private StartNode startNode() {
+ return StartNode.builder()
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(perm())
+ .build())
+ .actions(ActionBuilder.builder()
+ .addAction(CustomAction.defaultAction())
+ .build())
+ .build();
+ }
+
+ private ApprovalNode approvalNode(String name, String operatorIds, MultiOperatorAuditStrategy auditStrategy) {
+ return approvalNode(name, operatorIds, auditStrategy, null);
+ }
+
+ private ApprovalNode approvalNode(String name, String operatorIds,
+ MultiOperatorAuditStrategy auditStrategy,
+ RejectAction rejectAction) {
+ NodeStrategyBuilder strategyBuilder = NodeStrategyBuilder.builder()
+ .addStrategy(perm())
+ .addStrategy(new OperatorLoadStrategy(
+ FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return " + operatorIds + "}")
+ .getKey()));
+ if (auditStrategy != null) {
+ strategyBuilder.addStrategy(auditStrategy);
+ }
+ ActionBuilder actionBuilder = ActionBuilder.builder().addAction(PassAction.defaultAction());
+ if (rejectAction != null) {
+ actionBuilder.addAction(rejectAction);
+ }
+ return ApprovalNode.builder()
+ .name(name)
+ .strategies(strategyBuilder.build())
+ .actions(actionBuilder.build())
+ .build();
+ }
+
+ private Workflow saveWorkflow(User creator, IFlowNode... nodes) {
+ WorkflowBuilder builder = WorkflowBuilder.builder()
+ .title("issue235")
+ .code("issue235")
+ .createdOperator(creator)
+ .form(form());
+ for (IFlowNode node : nodes) {
+ builder.addNode(node);
+ }
+ Workflow workflow = builder.build();
+ factory.workflowService.saveWorkflow(workflow);
+ return workflow;
+ }
+
+ // ==================== 操作与断言 ====================
+
+ private void startAndSubmit(Workflow workflow, StartNode startNode, User user, Map data) {
+ List startActions = startNode.actionManager().getActions();
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(data);
+ createRequest.setActionId(startActions.get(0).id());
+ createRequest.setOperatorId(user.getUserId());
+ factory.flowService.create(createRequest);
+
+ pass(todoOf(user), startNode, user, data);
+ }
+
+ private void pass(FlowRecord record, IFlowNode node, User operator, Map data) {
+ IFlowAction action = node.actionManager().getActions().stream()
+ .filter(item -> ActionType.PASS.name().equals(item.type()))
+ .findFirst().orElseThrow();
+ flowAction(record, operator, action, data, "同意");
+ }
+
+ private void reject(FlowRecord record, IFlowNode node, User operator, Map data) {
+ IFlowAction action = node.actionManager().getActions().stream()
+ .filter(item -> ActionType.REJECT.name().equals(item.type()))
+ .findFirst().orElseThrow();
+ flowAction(record, operator, action, data, "拒绝");
+ }
+
+ private void flowAction(FlowRecord record, User operator, IFlowAction action,
+ Map data, String advice) {
+ FlowActionRequest request = new FlowActionRequest();
+ request.setFormData(data);
+ request.setRecordId(record.getId());
+ request.setAdvice(new FlowAdviceBody(action.id(), advice, operator.getUserId()));
+ factory.flowService.action(request);
+ }
+
+ private FlowRecord todoOf(User user) {
+ List list = factory.flowRecordRepository.findTodoByOperator(user.getUserId());
+ assertEquals(1, list.size(), user.getName() + " 应有且仅有一条待办");
+ return list.get(0);
+ }
+
+ private void assertNoTodo(User user) {
+ assertEquals(0, factory.flowRecordRepository.findTodoByOperator(user.getUserId()).size(),
+ user.getName() + " 不应有待办");
+ }
+
+ private List processRecords(FlowRecord anyRecord) {
+ return factory.flowRecordRepository.findProcessRecords(anyRecord.getProcessId());
+ }
+
+ private List recordsOfNode(List records, IFlowNode node) {
+ return records.stream().filter(record -> record.getNodeId().equals(node.getId())).toList();
+ }
+
+ private List processNodes(FlowRecord record, User viewer, Map data) {
+ return factory.flowService.processNodes(
+ new FlowProcessNodeRequest(record.getId(), viewer.getUserId(), data));
+ }
+}
\ No newline at end of file
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/WorkflowServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/WorkflowServiceTest.java
index 6ea54c20..2489fb32 100644
--- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/WorkflowServiceTest.java
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/WorkflowServiceTest.java
@@ -1,5 +1,9 @@
package com.codingapi.flow.service;
+import com.codingapi.flow.action.actions.PassAction;
+import com.codingapi.flow.action.actions.RejectAction;
+import com.codingapi.flow.builder.ActionBuilder;
+import com.codingapi.flow.exception.FlowStateException;
import com.codingapi.flow.form.DataType;
import com.codingapi.flow.form.FlowForm;
import com.codingapi.flow.form.FormField;
@@ -16,13 +20,17 @@
import com.codingapi.flow.workflow.WorkflowBuilder;
import com.codingapi.flow.workflow.WorkflowVersion;
import com.codingapi.flow.workflow.runtime.WorkflowRuntime;
+import com.codingapi.springboot.script.cache.GroovyScriptCacheContext;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class WorkflowServiceTest {
@@ -100,6 +108,86 @@ void should_align_historical_field_permissions_before_saving_version() {
assertEquals(PermissionType.WRITE, permission.getType());
}
+ @Test
+ void should_fail_fast_when_saving_workflow_with_missing_script() {
+ // given:流程引用的拒绝脚本在任何缓存/仓储中都不存在(模拟设计器临时脚本已过期)
+ SnapshotWorkflowVersionRepository versionRepository = new SnapshotWorkflowVersionRepository();
+ WorkflowRepository workflowRepository = new WorkflowRepositoryImpl();
+ WorkflowRuntimeRepository runtimeRepository = new WorkflowRuntimeRepositoryImpl();
+ WorkflowService service = new WorkflowService(versionRepository, workflowRepository, runtimeRepository);
+
+ String missingScriptKey = "missing-script-" + System.nanoTime();
+ RejectAction rejectAction = RejectAction.defaultAction();
+ rejectAction.setScript(missingScriptKey);
+
+ ApprovalNode approvalNode = ApprovalNode.builder()
+ .name("审批节点")
+ .actions(ActionBuilder.builder()
+ .addAction(PassAction.defaultAction())
+ .addAction(rejectAction)
+ .build())
+ .build();
+
+ Workflow workflow = WorkflowBuilder.builder()
+ .id("workflow-missing-script")
+ .code("leave-workflow")
+ .title("请假流程")
+ .form(formWithDescriptionField())
+ .addNode(approvalNode)
+ .build(false);
+
+ // when / then:保存阶段直接失败,避免悬空脚本key在运行时才以空指针暴露
+ FlowStateException exception = assertThrows(FlowStateException.class,
+ () -> service.saveWorkflow(workflow));
+ assertTrue(exception.getMessage().contains(missingScriptKey));
+ }
+
+ /**
+ * 历史设计数据中,动作的 script 字段可能直接保存脚本正文(旧版设计器的问题)。
+ * 保存流程时应把正文归一化为脚本key,避免运行时按key查找脚本失败(issue #235)。
+ */
+ @Test
+ void should_normalize_inline_reject_script_to_script_key_when_saving_workflow() {
+ // given
+ String inlineScript = """
+ // @SCRIPT_TITLE 终止流程
+ // @SCRIPT_META {"type":"TERMINATE"}
+ def run(request){ return 'TERMINATE' }
+ """;
+ SnapshotWorkflowVersionRepository versionRepository = new SnapshotWorkflowVersionRepository();
+ WorkflowService service = new WorkflowService(versionRepository,
+ new WorkflowRepositoryImpl(), new WorkflowRuntimeRepositoryImpl());
+
+ RejectAction rejectAction = RejectAction.defaultAction();
+ rejectAction.setScript(inlineScript);
+
+ ApprovalNode approvalNode = ApprovalNode.builder()
+ .name("审批节点")
+ .actions(ActionBuilder.builder()
+ .addAction(PassAction.defaultAction())
+ .addAction(rejectAction)
+ .build())
+ .build();
+
+ Workflow workflow = WorkflowBuilder.builder()
+ .id("workflow-inline-script")
+ .code("leave-inline-workflow")
+ .title("请假流程")
+ .form(formWithDescriptionField())
+ .addNode(approvalNode)
+ .build(false);
+
+ // when
+ service.saveWorkflow(workflow);
+
+ // then:脚本字段被替换为脚本key,且key可以取回原始脚本正文
+ String savedScriptKey = rejectAction.getScript().getScript();
+ assertNotEquals(inlineScript, savedScriptKey);
+ assertTrue(!savedScriptKey.contains("def run"), "保存后的脚本字段应为脚本key而不是脚本正文");
+ assertEquals(inlineScript,
+ GroovyScriptCacheContext.getInstance().getGroovyScript(savedScriptKey).getScript());
+ }
+
private FlowForm formWithDescriptionField() {
FormField field = new FormField();
field.setCode("description");
diff --git a/flow-frontend b/flow-frontend
index cdc357d2..ac7a7c50 160000
--- a/flow-frontend
+++ b/flow-frontend
@@ -1 +1 @@
-Subproject commit cdc357d23628a37111de483ce0a0084d88ba6516
+Subproject commit ac7a7c5011d47a94fc12b2a7ce74aa05c90d439a