From a31f94cb6546e0903f6250162822d1abe8652f4c Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Fri, 30 Oct 2026 00:11:39 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E4=BB=BB=E6=84=8F=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=8B=92=E7=BB=9D=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8A=9E=E7=BB=93=E5=90=8C=E8=8A=82=E7=82=B9=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=20issue=20#235?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RejectAction 拒绝时,将同节点其他未办理的待办自动置为已办(autoDone) - 修复或签节点一人拒绝后,其他审批人遗留待办仍可继续办理的问题 - 修复流程记录/流程图因遗留待办停留在当前节点、错误预览结束节点的问题 - 新增 FlowIssue235AnyAuditRejectTest 覆盖 A-B-C(c1/c2 或签)-D 拒绝退回开始节点场景 Co-Authored-By: Claude Code --- .../flow/action/actions/RejectAction.java | 10 + .../FlowIssue235AnyAuditRejectTest.java | 278 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue235AnyAuditRejectTest.java 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..003e7d15 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,6 +105,16 @@ 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())); + } + } + List records = this.generateRecords(flowSession); if (!records.isEmpty()) { recordList.addAll(records); 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..5f102d25 --- /dev/null +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue235AnyAuditRejectTest.java @@ -0,0 +1,278 @@ +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.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.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 org.junit.jupiter.api.Test; + +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 节点即告结束: + *

+ */ +class FlowIssue235AnyAuditRejectTest { + + private final MyFlowServiceFactory factory = new MyFlowServiceFactory(); + + @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 节点历史中不应把拒绝误记为通过"); + } + + // ==================== 流程装配 ==================== + + 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 From a994aac10f42846eedca3b4fc25b1040bb0c110c Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Fri, 30 Oct 2026 00:36:49 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E6=88=96=E7=AD=BE=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E7=BB=88=E6=AD=A2=E6=B5=81=E7=A8=8B=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E8=AE=B0=E5=BD=95=E7=BB=88=E6=80=81=E4=B8=8E=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E4=BA=8B=E4=BB=B6=E4=BF=AE=E5=A4=8D=20#235?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RejectAction 先保存当前记录及同节点自动办结记录,再触发退回/终止节点, 避免结束节点写入的终态被会话中陈旧的“运行中”状态覆盖 - 结束节点虚拟记录的 Todo 事件在 RejectAction 中同样拦截, 避免下游按操作人加载时出现空指针 - isEndNodeTodoEvent 上移至 BaseAction,供 PassAction/RejectAction 复用 - 修正操作人线程缓存写入时使用请求的 id 作为缓存 key - 新增或签拒绝终止流程场景的 mock 与生产路径测试 issue #235 Co-Authored-By: Claude Code --- .../FlowIssue235AnyRejectTerminateTest.java | 222 ++++++++++++++++++ .../com/codingapi/flow/action/BaseAction.java | 10 + .../flow/action/actions/PassAction.java | 8 - .../flow/action/actions/RejectAction.java | 14 +- .../cache/FlowOperatorLocalThreadCache.java | 2 +- .../FlowIssue235AnyAuditRejectTest.java | 104 ++++++++ 6 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 flow-engine-example/src/test/java/com/codingapi/example/FlowIssue235AnyRejectTerminateTest.java 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 003e7d15..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 @@ -115,17 +115,27 @@ public void run(FlowSession flowSession) { } } + // 先保存当前记录及同节点待办的自动办结状态,再触发退回/终止节点。 + // 终止流程时 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/test/java/com/codingapi/flow/service/FlowIssue235AnyAuditRejectTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue235AnyAuditRejectTest.java index 5f102d25..7b2201e9 100644 --- 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 @@ -9,6 +9,8 @@ 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; @@ -22,6 +24,7 @@ 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; @@ -32,8 +35,16 @@ import com.codingapi.flow.user.User; import com.codingapi.flow.workflow.Workflow; import com.codingapi.flow.workflow.WorkflowBuilder; +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; @@ -57,6 +68,29 @@ 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() { @@ -135,6 +169,76 @@ void anyAuditRejectShouldAutoDoneSiblingTodoAndReturnToStart() { "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())); + } + // ==================== 流程装配 ==================== private void registerUsers(User... users) { From 795033a78615fce3066317b25cd377f02e5dd906 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Fri, 30 Oct 2026 01:31:51 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E6=8B=92=E7=BB=9D=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E6=8C=89=20key=20=E5=AD=98=E5=82=A8=E5=B9=B6?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=8E=86=E5=8F=B2=E5=86=85=E8=81=94=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=20#235?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存新版本前将脚本字段中的正文归一化为脚本 key,避免悬空脚本在运行时才暴露 - 脚本缺失时抛出明确的 FlowStateException.scriptNotFound,替代隐式空指针 - 补充脚本归一化、运行时快照与缺失脚本的测试 Co-Authored-By: Claude Code --- .../flow/exception/FlowStateException.java | 13 +++ .../runtime/FlowScriptRuntimeContext.java | 6 ++ .../service/WorkflowGroovyScriptUtils.java | 13 +-- .../flow/service/WorkflowService.java | 4 + .../WorkflowGroovyScriptTransferService.java | 28 ++++++ .../runtime/FlowScriptRuntimeContextTest.java | 27 ++++++ .../FlowIssue235AnyAuditRejectTest.java | 39 ++++++++ .../flow/service/WorkflowServiceTest.java | 88 +++++++++++++++++++ 8 files changed, 213 insertions(+), 5 deletions(-) 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 index 7b2201e9..f1c3e58b 100644 --- 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 @@ -35,6 +35,7 @@ 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; @@ -239,6 +240,44 @@ void anyAuditRejectTerminateShouldCloseSiblingAndTerminateFlow() throws Exceptio 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) { 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"); From e8daed398671ae00872d0a19c43fe45c2ee2cd0e Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Fri, 30 Oct 2026 01:34:01 +0800 Subject: [PATCH 4/4] Update flow-frontend to latest main --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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