diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a130838a..730d5dfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,13 @@ jobs: - name: Test run: ./mvnw test -P travis + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: flow-engine-framework/target/site/jacoco/jacoco.xml + fail_ci_if_error: true + verbose: true + - name: Install run: ./mvnw install -P travis -DskipTests \ No newline at end of file diff --git a/README.md b/README.md index 2d1a269a..bda47c10 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ [](https://github.com/codingapi/flow-engine/blob/main/LICENSE) [](https://search.maven.org/search?q=g:%22com.codingapi.flow%22%20AND%20a:%22flow-engine-starter%22) +[](https://github.com/codingapi/flow-engine/actions) +[](https://codecov.io/gh/codingapi/flow-engine) # Flow Engine > 企业级流程引擎 - 可视化流程设计、动态表单配置、多节点类型流转 diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowRejectTerminateTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowRejectTerminateTest.java new file mode 100644 index 00000000..7d575ea0 --- /dev/null +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowRejectTerminateTest.java @@ -0,0 +1,172 @@ +package com.codingapi.flow.service; + +import com.codingapi.flow.action.IFlowAction; +import com.codingapi.flow.action.actions.PassAction; +import com.codingapi.flow.action.actions.RejectAction; +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.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.record.FlowRecord; +import com.codingapi.flow.script.factory.FlowGroovyScriptFactory; +import com.codingapi.flow.strategy.node.FormFieldPermissionStrategy; +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.BeforeEach; +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; + +/** + * 拒绝(退回)动作场景测试 + *
+ * 覆盖 RejectAction 的 TERMINATE(终止流程)与其返回不存在的节点时抛出异常的路径: + *
+ * 覆盖 FlowRevokeService 的守卫分支与 REVOKE_NEXT(退回撤回下级) 逻辑: + *
+ * HandleNode(办理节点) 默认策略不含 RevokeStrategy,故节点不支持撤回。 + * Handle 节点后追加审批节点以保证办理完成后流程仍处于运行中(RUNNING), + * 避免 isFinish 驱逐在 nodeNotSupportRevoke 之前抛出。 + */ + @Test + void revoke_nodeNotSupportRevoke_shouldThrow() { + Workflow workflow = handleWorkflow("revoke-node"); + factory.workflowService.saveWorkflow(workflow); + + FlowRecord startDone = submitStartToFirstApproval(workflow, user.getUserId()); + + // 用户办理 HandleNode,产生已办记录(所在节点无撤回策略);后继审批节点仍在等待,流程保持运行 + FlowRecord handleTodo = factory.flowRecordRepository.findTodoByOperator(user.getUserId()).stream() + .filter(record -> record.getId() != startDone.getId()) + .findFirst().orElseThrow(); + approve(workflow, user, handleTodo.getId()); + assertEquals(1, factory.flowRecordRepository.findTodoByOperator(boss.getUserId()).size()); + + assertThrows(FlowStateException.class, + () -> factory.flowService.revoke(new FlowRevokeRequest(handleTodo.getId(), user.getUserId()))); + } + + /** + * REVOKE_NEXT:下级仍为待办时,撤回会连同下级待办一并撤销 + */ + @Test + void revoke_nextType_whenNextTodo_shouldRevokeNextRecords() { + Workflow workflow = workflowChain("revoke-next-todo", RevokeStrategy.Type.REVOKE_NEXT, boss.getUserId(), cfo.getUserId()); + factory.workflowService.saveWorkflow(workflow); + + FlowRecord startDone = submitStartToFirstApproval(workflow, user.getUserId()); + + // 下级(boss)待办存在 + assertEquals(1, factory.flowRecordRepository.findTodoByOperator(boss.getUserId()).size()); + + // 撤回不应抛异常,且下级待办被撤销,发起人回到待办 + factory.flowService.revoke(new FlowRevokeRequest(startDone.getId(), user.getUserId())); + + assertEquals(1, factory.flowRecordRepository.findTodoByOperator(user.getUserId()).size()); + assertEquals(0, factory.flowRecordRepository.findTodoByOperator(boss.getUserId()).size()); + } + + /** + * REVOKE_NEXT:下级直接后继记录已全部完成时,不允许撤回 + */ + @Test + void revoke_nextType_whenNextDone_shouldThrow() { + Workflow workflow = workflowChain("revoke-next-done", RevokeStrategy.Type.REVOKE_NEXT, boss.getUserId(), cfo.getUserId()); + factory.workflowService.saveWorkflow(workflow); + + FlowRecord startDone = submitStartToFirstApproval(workflow, user.getUserId()); + + // boss 完成 B 节点,仍有更下级的 cfo 待办,流程仍运行中 + approve(workflow, boss, startDone.getProcessId()); + assertEquals(1, factory.flowRecordRepository.findTodoByOperator(cfo.getUserId()).size()); + + // 发起节点的直接后继(boss 的 B 记录)已完成 -> 禁止撤回 + assertThrows(FlowStateException.class, + () -> factory.flowService.revoke(new FlowRevokeRequest(startDone.getId(), user.getUserId()))); + } + + /** + * 撤回参数校验:recordId/operatorId 非法时抛参数校验异常 + */ + @Test + void revoke_invalidRequest_shouldThrow() { + assertThrows(com.codingapi.flow.exception.FlowValidationException.class, + () -> factory.flowService.revoke(new FlowRevokeRequest(0, user.getUserId()))); + assertThrows(com.codingapi.flow.exception.FlowValidationException.class, + () -> factory.flowService.revoke(new FlowRevokeRequest(1, 0))); + } + + // ------------------ 私有助手 ------------------ + + /** + * 链式审批流程:Start -> [审批节点...] -> End + * + * @param code 流程编码 + * @param revokeType Start 节点撤回类型(REVOKE_CURRENT / REVOKE_NEXT) + * @param approvalOperatorIds 各审批节点操作人 + */ + private Workflow workflowChain(String code, RevokeStrategy.Type revokeType, long... approvalOperatorIds) { + StartNode startNode = StartNode.builder() + .strategies(NodeStrategyBuilder.builder() + .addStrategy(fieldPermission(code)) + .addStrategy(new RevokeStrategy(true, revokeType)) + .build()) + .build(); + + WorkflowBuilder builder = WorkflowBuilder.builder() + .title("请假流程").code(code).createdOperator(user).form(leaveForm(code)) + .addNode(startNode); + for (long operatorId : approvalOperatorIds) { + builder.addNode(approvalNode(code, operatorId)); + } + return builder.addNode(EndNode.builder().build()).build(); + } + + /** + * 办理节点流程:Start -> Handle(操作人=user) -> 审批(boss) -> End,用于验证"节点不支持撤回"。 + * 使用办理节点定位用户已办记录所在节点(无 RevokeStrategy);追加 boss 审批保持流程运行。 + */ + private Workflow handleWorkflow(String code) { + StartNode startNode = StartNode.builder() + .strategies(NodeStrategyBuilder.builder() + .addStrategy(fieldPermission(code)) + .build()) + .build(); + HandleNode handleNode = HandleNode.builder() + .name("办理") + .strategies(NodeStrategyBuilder.builder() + .addStrategy(fieldPermission(code)) + .addStrategy(new OperatorLoadStrategy( + FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return [" + user.getUserId() + "]}").getKey())) + .build()) + .build(); + return WorkflowBuilder.builder() + .title("办理流程").code(code).createdOperator(user).form(leaveForm(code)) + .addNode(startNode).addNode(handleNode).addNode(approvalNode(code, boss.getUserId())).addNode(EndNode.builder().build()) + .build(); + } + + private FormFieldPermissionStrategy fieldPermission(String code) { + return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder() + .addPermission(code, "name", PermissionType.WRITE) + .addPermission(code, "days", PermissionType.WRITE) + .addPermission(code, "reason", PermissionType.WRITE) + .build()); + } + + private ApprovalNode approvalNode(String code, long operatorId) { + return ApprovalNode.builder() + .strategies(NodeStrategyBuilder.builder() + .addStrategy(fieldPermission(code)) + .addStrategy(new OperatorLoadStrategy( + FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return [" + operatorId + "]}").getKey())) + .build()) + .build(); + } + + private FlowForm leaveForm(String code) { + return FlowFormBuilder.builder() + .name("请假流程").code(code) + .addField("请假人", "name", DataType.STRING) + .addField("请假天数", "days", DataType.INTEGER) + .addField("请假事由", "reason", DataType.STRING) + .build(); + } + + /** + * 发起流程,返回发起的 StartNode 记录(待办) + */ + private FlowRecord createFlow(Workflow workflow, long operatorId) { + StartNode startNode = (StartNode) workflow.getStartNode(); + IFlowAction startAction = startNode.actionManager().getActions().get(0); + FlowCreateRequest createRequest = new FlowCreateRequest(); + createRequest.setWorkCode(workflow.getCode()); + createRequest.setFormData(DATA); + createRequest.setActionId(startAction.id()); + createRequest.setOperatorId(operatorId); + long recordId = factory.flowService.create(createRequest); + return factory.flowRecordRepository.get(recordId); + } + + /** + * 发起并流转到第一个审批节点,返回发起人的已办记录(StartNode 记录) + */ + private FlowRecord submitStartToFirstApproval(Workflow workflow, long operatorId) { + FlowRecord startTodo = createFlow(workflow, operatorId); + StartNode startNode = (StartNode) workflow.getStartNode(); + IFlowAction startAction = startNode.actionManager().getActions().get(0); + FlowActionRequest actionRequest = new FlowActionRequest(); + actionRequest.setFormData(DATA); + actionRequest.setRecordId(startTodo.getId()); + actionRequest.setAdvice(new FlowAdviceBody(startAction.id(), "同意", operatorId)); + factory.flowService.action(actionRequest); + return startTodo; + } + + /** + * 审批某条待办记录(取节点第一个动作) + */ + private void approve(Workflow workflow, User approver, long recordId) { + FlowRecord record = factory.flowRecordRepository.get(recordId); + IFlowAction action = workflow.getFlowNode(record.getNodeId()).actionManager().getActions().get(0); + FlowActionRequest actionRequest = new FlowActionRequest(); + actionRequest.setFormData(DATA); + actionRequest.setRecordId(recordId); + actionRequest.setAdvice(new FlowAdviceBody(action.id(), "同意", approver.getUserId())); + factory.flowService.action(actionRequest); + } + + /** + * 审批由指定操作人待办中第一条属于该流程的记录,返回该已办记录 + */ + private FlowRecord approve(Workflow workflow, User approver, String processId) { + FlowRecord todo = factory.flowRecordRepository.findTodoByOperator(approver.getUserId()).stream() + .filter(record -> record.getProcessId().equals(processId)) + .findFirst().orElseThrow(); + approve(workflow, approver, todo.getId()); + return todo; + } +} \ No newline at end of file diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowUrgeServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowUrgeServiceTest.java new file mode 100644 index 00000000..1ebfde42 --- /dev/null +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowUrgeServiceTest.java @@ -0,0 +1,233 @@ +package com.codingapi.flow.service; + +import com.codingapi.flow.action.IFlowAction; +import com.codingapi.flow.builder.FormFieldPermissionsBuilder; +import com.codingapi.flow.builder.NodeStrategyBuilder; +import com.codingapi.flow.context.GatewayContext; +import com.codingapi.flow.exception.FlowNotFoundException; +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.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.FlowUrgeRequest; +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.OperatorLoadStrategy; +import com.codingapi.flow.user.User; +import com.codingapi.flow.workflow.Workflow; +import com.codingapi.flow.workflow.WorkflowBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 催办服务场景测试 + *
+ * 覆盖 FlowUrgeService 的守卫分支与催办频率限制逻辑: + *
+ * 首次催办保存催办间隔,间隔内再次催办应被拒绝。
+ * 工作流默认装配 InterfereStrategy(enable=true) 与 UrgeStrategy(interval=60s),
+ * isEnableUrge() 语义上基于默认策略生效,故第二次催办会进入频率校验。
+ */
+ @Test
+ void urge_within_interval_shouldThrowLimit() {
+ Workflow workflow = workflowChain("urge-limit", boss.getUserId());
+ factory.workflowService.saveWorkflow(workflow);
+
+ FlowRecord startDone = submitStartToFirstApproval(workflow, user.getUserId());
+
+ FlowUrgeRequest request = new FlowUrgeRequest(startDone.getId(), user.getUserId());
+ factory.flowService.urge(request); // 首次催办,保存催办间隔
+
+ assertThrows(FlowStateException.class, () -> factory.flowService.urge(request));
+ }
+
+ // ------------------ 私有助手 ------------------
+
+ private Workflow workflowChain(String code, long... approvalOperatorIds) {
+ StartNode startNode = StartNode.builder()
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(fieldPermission(code))
+ .build())
+ .build();
+ WorkflowBuilder builder = WorkflowBuilder.builder()
+ .title("请假流程").code(code).createdOperator(user).form(leaveForm(code))
+ .addNode(startNode);
+ for (long operatorId : approvalOperatorIds) {
+ builder.addNode(approvalNode(code, operatorId));
+ }
+ return builder.addNode(EndNode.builder().build()).build();
+ }
+
+ private FormFieldPermissionStrategy fieldPermission(String code) {
+ return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission(code, "name", PermissionType.WRITE)
+ .addPermission(code, "days", PermissionType.WRITE)
+ .addPermission(code, "reason", PermissionType.WRITE)
+ .build());
+ }
+
+ private ApprovalNode approvalNode(String code, long operatorId) {
+ return ApprovalNode.builder()
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(fieldPermission(code))
+ .addStrategy(new OperatorLoadStrategy(
+ FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return [" + operatorId + "]}").getKey()))
+ .build())
+ .build();
+ }
+
+ private FlowForm leaveForm(String code) {
+ return FlowFormBuilder.builder()
+ .name("请假流程").code(code)
+ .addField("请假人", "name", DataType.STRING)
+ .addField("请假天数", "days", DataType.INTEGER)
+ .addField("请假事由", "reason", DataType.STRING)
+ .build();
+ }
+
+ private FlowRecord createFlow(Workflow workflow, long operatorId) {
+ StartNode startNode = (StartNode) workflow.getStartNode();
+ IFlowAction startAction = startNode.actionManager().getActions().get(0);
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(DATA);
+ createRequest.setActionId(startAction.id());
+ createRequest.setOperatorId(operatorId);
+ long recordId = factory.flowService.create(createRequest);
+ return factory.flowRecordRepository.get(recordId);
+ }
+
+ private FlowRecord submitStartToFirstApproval(Workflow workflow, long operatorId) {
+ FlowRecord startTodo = createFlow(workflow, operatorId);
+ StartNode startNode = (StartNode) workflow.getStartNode();
+ IFlowAction startAction = startNode.actionManager().getActions().get(0);
+ FlowActionRequest actionRequest = new FlowActionRequest();
+ actionRequest.setFormData(DATA);
+ actionRequest.setRecordId(startTodo.getId());
+ actionRequest.setAdvice(new FlowAdviceBody(startAction.id(), "同意", operatorId));
+ factory.flowService.action(actionRequest);
+ return startTodo;
+ }
+
+ private void approve(Workflow workflow, User approver, long recordId) {
+ FlowRecord record = factory.flowRecordRepository.get(recordId);
+ IFlowAction action = workflow.getFlowNode(record.getNodeId()).actionManager().getActions().get(0);
+ FlowActionRequest actionRequest = new FlowActionRequest();
+ actionRequest.setFormData(DATA);
+ actionRequest.setRecordId(recordId);
+ actionRequest.setAdvice(new FlowAdviceBody(action.id(), "同意", approver.getUserId()));
+ factory.flowService.action(actionRequest);
+ }
+
+ private FlowRecord approve(Workflow workflow, User approver, String processId) {
+ List
+ * 覆盖撤回类型判定(isRemoveNext)、fromMap 反序列化与 copy 复制
+ */
+class RevokeStrategyTest {
+
+ @Test
+ void isRemoveNext_whenTypeIsRevokeNext_shouldReturnTrue() {
+ RevokeStrategy strategy = new RevokeStrategy(true, RevokeStrategy.Type.REVOKE_NEXT);
+ assertTrue(strategy.isRemoveNext());
+ }
+
+ @Test
+ void isRemoveNext_whenTypeIsRevokeCurrent_shouldReturnFalse() {
+ RevokeStrategy strategy = new RevokeStrategy(true, RevokeStrategy.Type.REVOKE_CURRENT);
+ assertFalse(strategy.isRemoveNext());
+ }
+
+ @Test
+ void fromMap_emptyMap_shouldReturnNull() {
+ assertNull(RevokeStrategy.fromMap(Map.of()));
+ }
+
+ @Test
+ void fromMap_shouldRestoreEnableAndType() {
+ Map
+ * 覆盖催办频率判断(hasUrge 毫秒换算)、fromMap 反序列化与 copy 复制
+ */
+class UrgeStrategyTest {
+
+ @Test
+ void hasUrge_intervalElapsed_shouldReturnTrue() {
+ // 间隔已超过默认 60 秒(createTime 在 61 秒前) -> 可再次催办
+ UrgeStrategy strategy = UrgeStrategy.defaultStrategy();
+ UrgeInterval past = new UrgeInterval(1, "process", 100L, System.currentTimeMillis() - 61000);
+ assertTrue(strategy.hasUrge(past));
+ }
+
+ @Test
+ void hasUrge_withinInterval_shouldReturnFalse() {
+ // 刚催办过(createTime 为当前时刻) -> 仍在间隔内,不可催办
+ UrgeStrategy strategy = UrgeStrategy.defaultStrategy();
+ UrgeInterval recent = new UrgeInterval(1, "process", 100L, System.currentTimeMillis());
+ assertFalse(strategy.hasUrge(recent));
+ }
+
+ @Test
+ void fromMap_emptyMap_shouldReturnNull() {
+ assertNull(UrgeStrategy.fromMap(Map.of()));
+ }
+
+ @Test
+ void fromMap_shouldRestoreEnableAndInterval() {
+ Map