From d5f5463e986df804ccafc467644f1f066b5b5203 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 20 Aug 2026 10:41:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E8=83=BD=E5=8A=9B=20issue=20#217?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现版本化导入导出策略、Groovy 脚本重建和删除清理,并保持运行时脚本快照不受流程删除影响。 --- .../exception/WorkflowTransferException.java | 43 ++ .../service/WorkflowGroovyScriptUtils.java | 1 + .../flow/service/WorkflowService.java | 40 +- .../WorkflowGroovyScriptTransferService.java | 237 +++++++++ .../flow/transfer/WorkflowImportMode.java | 17 + .../flow/transfer/WorkflowTransferData.java | 21 + .../transfer/WorkflowTransferService.java | 272 ++++++++++ .../LegacyWorkflowImportStrategy.java | 39 ++ .../strategy/WorkflowExportStrategy.java | 15 + .../strategy/WorkflowImportStrategy.java | 16 + .../WorkflowSchemaStrategyRegistry.java | 55 ++ .../strategy/WorkflowV1SchemaStrategy.java | 77 +++ .../runtime/FlowScriptRuntimeContextTest.java | 15 + .../transfer/WorkflowTransferServiceTest.java | 471 ++++++++++++++++++ .../api/controller/WorkflowController.java | 15 +- .../flow/api/pojo/WorkflowImportRequest.java | 15 + flow-frontend | 2 +- 17 files changed, 1339 insertions(+), 12 deletions(-) create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/exception/WorkflowTransferException.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowGroovyScriptTransferService.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowImportMode.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferData.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferService.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/LegacyWorkflowImportStrategy.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowExportStrategy.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowImportStrategy.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowSchemaStrategyRegistry.java create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowV1SchemaStrategy.java create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/transfer/WorkflowTransferServiceTest.java create mode 100644 flow-engine-starter-api/src/main/java/com/codingapi/flow/api/pojo/WorkflowImportRequest.java diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/WorkflowTransferException.java b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/WorkflowTransferException.java new file mode 100644 index 00000000..dc8f2ab6 --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/WorkflowTransferException.java @@ -0,0 +1,43 @@ +package com.codingapi.flow.exception; + +/** + * 流程导入导出异常。 + */ +public class WorkflowTransferException extends FlowException { + + public WorkflowTransferException(String code, String message) { + super(code, message); + } + + public WorkflowTransferException(String code, String message, Throwable cause) { + super(code, message, cause); + } + + public static WorkflowTransferException invalidSchema(String message) { + return new WorkflowTransferException("workflow.transfer.schema.invalid", message); + } + + public static WorkflowTransferException unsupportedSchema(String format, int schemaVersion) { + return new WorkflowTransferException( + "workflow.transfer.schema.unsupported", + String.format("Unsupported workflow schema: format=%s, schemaVersion=%d", format, schemaVersion)); + } + + public static WorkflowTransferException scriptNotFound(String key) { + return new WorkflowTransferException( + "workflow.transfer.script.notFound", + String.format("Workflow script not found: %s", key)); + } + + public static WorkflowTransferException scriptTypeUnsupported(String type) { + return new WorkflowTransferException( + "workflow.transfer.script.typeUnsupported", + String.format("Unsupported workflow script reference type: %s", type)); + } + + public static WorkflowTransferException replaceTargetNotFound(String workCode) { + return new WorkflowTransferException( + "workflow.transfer.replace.targetNotFound", + String.format("Replace import target workflow not found: %s", workCode)); + } +} 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 c143fff5..fb9d9960 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 @@ -46,6 +46,7 @@ public static void deleteGroovyScripts(Object target) { List keys = GroovyScriptAnnotationScannerUtils.findGroovyScriptFields(target).getKeys(); for (String key : keys) { GroovyScriptRepositoryContext.getInstance().delete(key); + GroovyScriptCacheContext.getInstance().remove(key); } } } 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 176da28c..39242f75 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,8 @@ import com.codingapi.flow.repository.WorkflowRepository; import com.codingapi.flow.repository.WorkflowRuntimeRepository; import com.codingapi.flow.repository.WorkflowVersionRepository; +import com.codingapi.flow.transfer.WorkflowImportMode; +import com.codingapi.flow.transfer.WorkflowTransferService; import com.codingapi.flow.utils.Base64Utils; import com.codingapi.flow.workflow.Workflow; import com.codingapi.flow.workflow.WorkflowVersion; @@ -170,8 +172,15 @@ public void updateVersionName(long versionId, String versionName) { * * @param workId 流程编码 */ + @Transactional public void delete(String workId) { Workflow workflow = workflowRepository.getById(workId); + List versions = workflowVersionRepository.findVersion(workId); + if (versions != null) { + versions.forEach(WorkflowGroovyScriptUtils::deleteScripts); + } + // 主流程与当前版本通常引用同一组脚本;这里仍单独清理,用于兼容历史数据中 + // 主流程与版本脚本key不一致的情况。重复删除同一key是幂等的。 WorkflowGroovyScriptUtils.deleteScripts(workflow); workflowVersionRepository.delete(workId); workflowRepository.delete(workId); @@ -247,13 +256,32 @@ public WorkflowRuntime getWorkflowRuntime(String workId, long workVersion) { * @return 流程id */ public String importWorkflow(String body, IFlowOperator createOperator) { + return this.importWorkflow(body, createOperator, WorkflowImportMode.INCREMENTAL); + } + + /** + * 导入流程。 + * + * @param body base64格式的流程文件 + * @param createOperator 导入操作人 + * @param mode 导入模式 + * @return 导入后的流程id + */ + @Transactional + public String importWorkflow(String body, + IFlowOperator createOperator, + WorkflowImportMode mode) { String json = Base64Utils.toJson(body); - Workflow workflow = Workflow.formJson(json); - workflow.resetWorkflow(createOperator); - // 替换脚本 - WorkflowGroovyScriptUtils.resetScripts(workflow); - this.saveWorkflow(workflow, false); - return workflow.getId(); + return new WorkflowTransferService(workflowVersionRepository, workflowRepository) + .importWorkflow(json, createOperator, mode); + } + + /** + * 导出流程及其全部版本和Groovy脚本。 + */ + public String exportWorkflow(String workId) { + return new WorkflowTransferService(workflowVersionRepository, workflowRepository) + .exportWorkflow(workId); } 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 new file mode 100644 index 00000000..b0aa97b4 --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowGroovyScriptTransferService.java @@ -0,0 +1,237 @@ +package com.codingapi.flow.transfer; + +import com.codingapi.flow.exception.WorkflowTransferException; +import com.codingapi.flow.script.action.ActionCustomScript; +import com.codingapi.flow.script.action.ActionDisplayScript; +import com.codingapi.flow.script.action.ActionRejectScript; +import com.codingapi.flow.script.factory.FlowGroovyScriptFactory; +import com.codingapi.flow.script.node.ConditionScript; +import com.codingapi.flow.script.node.ErrorTriggerScript; +import com.codingapi.flow.script.node.NodeTitleScript; +import com.codingapi.flow.script.node.OperatorLoadScript; +import com.codingapi.flow.script.node.OperatorMatchScript; +import com.codingapi.flow.script.node.RouterNodeScript; +import com.codingapi.flow.script.node.SubProcessResultScript; +import com.codingapi.flow.script.node.SubProcessScript; +import com.codingapi.flow.script.node.TriggerScript; +import com.codingapi.springboot.framework.reflect.ObjectAnnotationFieldUtils; +import com.codingapi.springboot.framework.reflect.pojo.AnnotationTargetFieldResult; +import com.codingapi.springboot.script.GroovyScript; +import com.codingapi.springboot.script.cache.GroovyScriptCacheContext; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 与Schema版本无关的Groovy脚本收集、重建和引用替换能力。 + */ +public class WorkflowGroovyScriptTransferService { + + /** + * 收集目标对象引用的全部Groovy脚本正文。 + */ + public Map exportScripts(Collection targets) { + Map scripts = new LinkedHashMap<>(); + for (Object target : targets) { + if (target == null) { + continue; + } + AnnotationTargetFieldResult fields = findFields(target); + fields.fetch(field -> { + String key = field.getValue(); + if (scripts.containsKey(key)) { + return; + } + GroovyScript groovyScript = GroovyScriptCacheContext.getInstance().getGroovyScript(key); + if (groovyScript == null || groovyScript.getScript() == null) { + throw WorkflowTransferException.scriptNotFound(key); + } + scripts.put(key, groovyScript.getScript()); + }); + } + return scripts; + } + + /** + * 按脚本引用所属类型重建脚本,并将所有旧key替换为新key。 + * + * @return 旧key到新key的映射 + */ + public Map rebuildScripts(Collection targets, Map scriptContents) { + Map scriptTypes = new LinkedHashMap<>(); + List> targetFields = new ArrayList<>(); + for (Object target : targets) { + if (target == null) { + continue; + } + AnnotationTargetFieldResult fields = findFields(target); + targetFields.add(fields); + fields.fetch(field -> { + String key = field.getValue(); + ScriptType currentType = ScriptType.from(field.getTarget()); + ScriptType previousType = scriptTypes.putIfAbsent(key, currentType); + if (previousType != null && previousType != currentType) { + throw WorkflowTransferException.invalidSchema( + String.format("Script key %s is referenced by both %s and %s", + key, previousType, currentType)); + } + }); + } + + Set unreferencedScriptKeys = new LinkedHashSet<>(scriptContents.keySet()); + unreferencedScriptKeys.removeAll(scriptTypes.keySet()); + if (!unreferencedScriptKeys.isEmpty()) { + throw WorkflowTransferException.invalidSchema( + String.format("groovyScripts contains unreferenced keys: %s", unreferencedScriptKeys)); + } + + Map rebuiltScripts = new LinkedHashMap<>(); + Map keyMapping = new LinkedHashMap<>(); + try { + for (Map.Entry entry : scriptTypes.entrySet()) { + String oldKey = entry.getKey(); + String content = resolveScriptContent(oldKey, scriptContents); + GroovyScript rebuiltScript = entry.getValue().create(content); + rebuiltScripts.put(oldKey, rebuiltScript); + keyMapping.put(oldKey, rebuiltScript.getKey()); + } + + for (AnnotationTargetFieldResult fields : targetFields) { + fields.update(oldKey -> keyMapping.getOrDefault(oldKey, oldKey)); + } + rebuiltScripts.values().forEach(GroovyScript::save); + return keyMapping; + } catch (RuntimeException exception) { + rebuiltScripts.values().forEach(GroovyScript::remove); + throw exception; + } + } + + private String resolveScriptContent(String key, Map scriptContents) { + String content = scriptContents.get(key); + if (content != null) { + return content; + } + + // 兼容历史文件:旧文件可能将脚本正文直接放在脚本字段中。 + if (looksLikeInlineScript(key)) { + return key; + } + + // 兼容同环境导入的旧key-only文件;跨环境缺少正文时必须明确失败。 + GroovyScript existing = GroovyScriptCacheContext.getInstance().getGroovyScript(key); + if (existing != null && existing.getScript() != null) { + return existing.getScript(); + } + throw WorkflowTransferException.scriptNotFound(key); + } + + private boolean looksLikeInlineScript(String value) { + return value != null && (value.contains("def run") || value.contains("\n") || value.contains("\r")); + } + + private AnnotationTargetFieldResult findFields(Object target) { + return ObjectAnnotationFieldUtils.findFieldAnnotationValue( + target, + com.codingapi.springboot.script.annotation.GroovyScript.class, + String.class); + } + + private enum ScriptType { + ROUTER { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createRouterScript(content); + } + }, + NODE_TITLE { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createNodeTitleScript(content); + } + }, + CONDITION { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createConditionScript(content); + } + }, + TRIGGER { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createTriggerScript(content); + } + }, + SUB_PROCESS { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createSubProcessScript(content); + } + }, + SUB_PROCESS_RESULT { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createSubProcessResultScript(content); + } + }, + OPERATOR_LOAD { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createOperatorLoadScript(content); + } + }, + OPERATOR_MATCH { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createOperatorMatchScript(content); + } + }, + ERROR_TRIGGER { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createErrorTriggerScript(content); + } + }, + ACTION_DISPLAY { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createActionDisplayScript(content); + } + }, + ACTION_CUSTOM { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createActionCustomScript(content); + } + }, + ACTION_REJECT { + @Override + GroovyScript create(String content) { + return FlowGroovyScriptFactory.createActionRejectScript(content); + } + }; + + abstract GroovyScript create(String content); + + static ScriptType from(Object target) { + if (target instanceof RouterNodeScript) return ROUTER; + if (target instanceof NodeTitleScript) return NODE_TITLE; + if (target instanceof ConditionScript) return CONDITION; + if (target instanceof TriggerScript) return TRIGGER; + if (target instanceof SubProcessScript) return SUB_PROCESS; + if (target instanceof SubProcessResultScript) return SUB_PROCESS_RESULT; + if (target instanceof OperatorLoadScript) return OPERATOR_LOAD; + if (target instanceof OperatorMatchScript) return OPERATOR_MATCH; + if (target instanceof ErrorTriggerScript) return ERROR_TRIGGER; + if (target instanceof ActionDisplayScript) return ACTION_DISPLAY; + if (target instanceof ActionCustomScript) return ACTION_CUSTOM; + if (target instanceof ActionRejectScript) return ACTION_REJECT; + throw WorkflowTransferException.scriptTypeUnsupported(target.getClass().getName()); + } + } +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowImportMode.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowImportMode.java new file mode 100644 index 00000000..29fef2dc --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowImportMode.java @@ -0,0 +1,17 @@ +package com.codingapi.flow.transfer; + +/** + * 流程导入模式。 + */ +public enum WorkflowImportMode { + + /** + * 使用导入文件中的流程编码替换已存在的流程。 + */ + REPLACE, + + /** + * 生成新的流程ID并创建独立流程;源流程编码未占用时保留,冲突时生成新编码。 + */ + INCREMENTAL +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferData.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferData.java new file mode 100644 index 00000000..9325181c --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferData.java @@ -0,0 +1,21 @@ +package com.codingapi.flow.transfer; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +/** + * 与Schema版本无关的流程迁移数据。 + */ +@Getter +@AllArgsConstructor +public class WorkflowTransferData { + + private final Map workflow; + + private final List> versions; + + private final Map groovyScripts; +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferService.java new file mode 100644 index 00000000..506140b0 --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/WorkflowTransferService.java @@ -0,0 +1,272 @@ +package com.codingapi.flow.transfer; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.codingapi.flow.exception.FlowNotFoundException; +import com.codingapi.flow.exception.WorkflowTransferException; +import com.codingapi.flow.generator.FlowIDGeneratorGatewayContext; +import com.codingapi.flow.operator.IFlowOperator; +import com.codingapi.flow.repository.WorkflowRepository; +import com.codingapi.flow.repository.WorkflowVersionRepository; +import com.codingapi.flow.service.WorkflowGroovyScriptUtils; +import com.codingapi.flow.transfer.strategy.WorkflowExportStrategy; +import com.codingapi.flow.transfer.strategy.WorkflowImportStrategy; +import com.codingapi.flow.transfer.strategy.WorkflowSchemaStrategyRegistry; +import com.codingapi.flow.transfer.strategy.WorkflowV1SchemaStrategy; +import com.codingapi.flow.workflow.Workflow; +import com.codingapi.flow.workflow.WorkflowVersion; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 流程导入导出的公共业务编排,不感知具体Schema字段差异。 + */ +public class WorkflowTransferService { + + private static final List VERSION_DEFINITION_FIELDS = List.of( + "form", "operatorCreateScript", "nodes", "strategies", "maxNestDepth"); + + private final WorkflowVersionRepository workflowVersionRepository; + private final WorkflowRepository workflowRepository; + private final WorkflowSchemaStrategyRegistry strategyRegistry; + private final WorkflowGroovyScriptTransferService scriptTransferService; + + public WorkflowTransferService(WorkflowVersionRepository workflowVersionRepository, + WorkflowRepository workflowRepository) { + this.workflowVersionRepository = workflowVersionRepository; + this.workflowRepository = workflowRepository; + this.strategyRegistry = new WorkflowSchemaStrategyRegistry(); + this.scriptTransferService = new WorkflowGroovyScriptTransferService(); + } + + /** + * 按最新Schema导出流程及其全部版本。 + */ + public String exportWorkflow(String workId) { + Workflow workflow = workflowRepository.getById(workId); + if (workflow == null) { + throw FlowNotFoundException.workflow(workId); + } + + List versions = new ArrayList<>(workflowVersionRepository.findVersion(workId)); + if (versions.isEmpty()) { + versions.add(new WorkflowVersion(workflow)); + } + versions.sort(Comparator.comparingLong(WorkflowVersion::getUpdatedTime) + .thenComparingLong(WorkflowVersion::getId)); + + List currentVersions = versions.stream() + .filter(WorkflowVersion::isCurrent) + .toList(); + if (currentVersions.size() != 1) { + throw WorkflowTransferException.invalidSchema( + String.format("Workflow %s must have exactly one current version", workId)); + } + WorkflowVersion currentVersion = currentVersions.get(0); + + Map workflowData = workflowSummary(workflow, currentVersion.getVersionName()); + List> versionData = versions.stream().map(this::versionData).toList(); + + Map scripts = scriptTransferService.exportScripts(versions); + WorkflowTransferData transferData = new WorkflowTransferData(workflowData, versionData, scripts); + WorkflowExportStrategy exportStrategy = strategyRegistry.resolveExport( + WorkflowV1SchemaStrategy.FORMAT, + WorkflowV1SchemaStrategy.SCHEMA_VERSION); + return exportStrategy.serialize(transferData); + } + + /** + * 根据文件Schema与导入模式导入流程。 + */ + public String importWorkflow(String json, IFlowOperator operator, WorkflowImportMode mode) { + if (json == null || json.isBlank()) { + throw WorkflowTransferException.invalidSchema("Import content cannot be empty"); + } + JSONObject root; + try { + root = JSON.parseObject(json); + } catch (RuntimeException exception) { + throw new WorkflowTransferException( + "workflow.transfer.schema.parseError", "Invalid workflow JSON", exception); + } + if (root == null) { + throw WorkflowTransferException.invalidSchema("Import content must be a JSON object"); + } + WorkflowImportStrategy importStrategy = strategyRegistry.resolveImport(root); + WorkflowTransferData transferData = importStrategy.deserialize(root); + return persistImport(transferData, operator, + mode == null ? WorkflowImportMode.INCREMENTAL : mode); + } + + private String persistImport(WorkflowTransferData transferData, + IFlowOperator operator, + WorkflowImportMode mode) { + if (operator == null) { + throw WorkflowTransferException.invalidSchema("Import operator cannot be null"); + } + String sourceCode = stringValue(transferData.getWorkflow().get("code")); + if (sourceCode == null || sourceCode.isBlank()) { + throw WorkflowTransferException.invalidSchema("workflow.code is required"); + } + + TargetIdentity identity = resolveIdentity(sourceCode, mode); + long importTime = identity.updatedTime(); + boolean currentEnable = booleanValue(transferData.getWorkflow().get("enable"), false); + List importedVersions = new ArrayList<>(); + for (Map versionData : transferData.getVersions()) { + String versionCode = stringValue(versionData.get("code")); + if (!sourceCode.equals(versionCode)) { + throw WorkflowTransferException.invalidSchema( + "versions.code must match workflow.code"); + } + importedVersions.add(toWorkflowVersion( + versionData, + identity, + operator, + importTime, + currentEnable)); + } + + List currentVersions = importedVersions.stream() + .filter(WorkflowVersion::isCurrent) + .toList(); + if (currentVersions.size() != 1) { + throw WorkflowTransferException.invalidSchema( + "versions must contain exactly one current version"); + } + validateCurrentVersion(transferData, currentVersions.get(0)); + + scriptTransferService.rebuildScripts(importedVersions, transferData.getGroovyScripts()); + + if (mode == WorkflowImportMode.REPLACE) { + removeReplacedVersions(identity.workId()); + } + workflowVersionRepository.saveAll(importedVersions); + workflowRepository.save(currentVersions.get(0).toWorkflow()); + return identity.workId(); + } + + private TargetIdentity resolveIdentity(String sourceCode, WorkflowImportMode mode) { + long now = System.currentTimeMillis(); + if (mode == WorkflowImportMode.REPLACE) { + Workflow target = workflowRepository.getByCode(sourceCode); + if (target == null) { + throw WorkflowTransferException.replaceTargetNotFound(sourceCode); + } + return new TargetIdentity( + target.getId(), + target.getCode(), + target.getCreatedTime(), + Math.max(now, target.getUpdatedTime() + 1)); + } + String importedWorkCode = workflowRepository.getByCode(sourceCode) == null + ? sourceCode + : generateAvailableWorkCode(); + return new TargetIdentity( + FlowIDGeneratorGatewayContext.getInstance().generateWorkId(), + importedWorkCode, + now, + now); + } + + private String generateAvailableWorkCode() { + String workCode; + do { + workCode = FlowIDGeneratorGatewayContext.getInstance().generateWorkCode(); + } while (workflowRepository.getByCode(workCode) != null); + return workCode; + } + + private WorkflowVersion toWorkflowVersion(Map versionData, + TargetIdentity identity, + IFlowOperator operator, + long importTime, + boolean currentEnable) { + Map parsableData = new LinkedHashMap<>(versionData); + parsableData.put("createdOperator", String.valueOf(operator.getUserId())); + Workflow sourceWorkflow; + try { + sourceWorkflow = Workflow.formJson(JSON.toJSONString(parsableData)); + } catch (RuntimeException exception) { + throw new WorkflowTransferException( + "workflow.transfer.version.parseError", + String.format("Invalid workflow version: %s", versionData.get("versionName")), + exception); + } + + boolean current = booleanValue(versionData.get("current"), false); + boolean enable = current + ? currentEnable + : booleanValue(versionData.get("enable"), false); + long updatedTime = current ? importTime : sourceWorkflow.getUpdatedTime(); + return new WorkflowVersion( + 0, + stringValue(versionData.get("versionName")), + current, + identity.workId(), + identity.workCode(), + sourceWorkflow.getTitle(), + sourceWorkflow.getDescription(), + operator, + identity.createdTime(), + updatedTime, + sourceWorkflow.getForm(), + sourceWorkflow.getOperatorCreateScript(), + sourceWorkflow.getNodes(), + sourceWorkflow.getStrategies(), + enable, + sourceWorkflow.getMaxNestDepth()); + } + + private void validateCurrentVersion(WorkflowTransferData transferData, WorkflowVersion currentVersion) { + Object expectedCurrentVersion = transferData.getWorkflow().get("currentVersion"); + if (expectedCurrentVersion != null + && !expectedCurrentVersion.toString().equals(currentVersion.getVersionName())) { + throw WorkflowTransferException.invalidSchema( + "workflow.currentVersion does not match versions.current"); + } + } + + private void removeReplacedVersions(String workId) { + List existingVersions = workflowVersionRepository.findVersion(workId); + existingVersions.forEach(WorkflowGroovyScriptUtils::deleteScripts); + // 同时清理主流程脚本,兼容历史数据中主流程与版本脚本key不一致的情况。 + WorkflowGroovyScriptUtils.deleteScripts(workflowRepository.getById(workId)); + workflowVersionRepository.delete(workId); + } + + private Map workflowSummary(Workflow workflow, String currentVersion) { + Map data = workflowMap(workflow); + VERSION_DEFINITION_FIELDS.forEach(data::remove); + data.put("currentVersion", currentVersion); + data.put("enable", workflow.isEnable()); + return data; + } + + private Map versionData(WorkflowVersion version) { + Map data = workflowMap(version.toWorkflow()); + data.put("versionName", version.getVersionName()); + data.put("current", version.isCurrent()); + data.put("enable", version.isEnable()); + return data; + } + + private Map workflowMap(Workflow workflow) { + return new LinkedHashMap<>(JSON.parseObject(workflow.toJson())); + } + + private String stringValue(Object value) { + return value == null ? null : value.toString(); + } + + private boolean booleanValue(Object value, boolean defaultValue) { + return value == null ? defaultValue : Boolean.parseBoolean(value.toString()); + } + + private record TargetIdentity(String workId, String workCode, long createdTime, long updatedTime) { + } +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/LegacyWorkflowImportStrategy.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/LegacyWorkflowImportStrategy.java new file mode 100644 index 00000000..9d3ed03b --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/LegacyWorkflowImportStrategy.java @@ -0,0 +1,39 @@ +package com.codingapi.flow.transfer.strategy; + +import com.alibaba.fastjson.JSONObject; +import com.codingapi.flow.transfer.WorkflowTransferData; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 兼容未包含format/schemaVersion的历史单流程文件。 + */ +public class LegacyWorkflowImportStrategy implements WorkflowImportStrategy { + + public static final int SCHEMA_VERSION = 0; + + @Override + public String format() { + return "legacy-flow-engine-workflow"; + } + + @Override + public int schemaVersion() { + return SCHEMA_VERSION; + } + + @Override + public WorkflowTransferData deserialize(JSONObject data) { + Map version = new LinkedHashMap<>(data); + version.put("versionName", null); + version.put("current", true); + version.putIfAbsent("enable", false); + + Map workflow = new LinkedHashMap<>(data); + workflow.put("currentVersion", null); + workflow.putIfAbsent("enable", false); + return new WorkflowTransferData(workflow, List.of(version), new LinkedHashMap<>()); + } +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowExportStrategy.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowExportStrategy.java new file mode 100644 index 00000000..6158146d --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowExportStrategy.java @@ -0,0 +1,15 @@ +package com.codingapi.flow.transfer.strategy; + +import com.codingapi.flow.transfer.WorkflowTransferData; + +/** + * 流程导出Schema生成策略。 + */ +public interface WorkflowExportStrategy { + + String format(); + + int schemaVersion(); + + String serialize(WorkflowTransferData data); +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowImportStrategy.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowImportStrategy.java new file mode 100644 index 00000000..831dc3f8 --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowImportStrategy.java @@ -0,0 +1,16 @@ +package com.codingapi.flow.transfer.strategy; + +import com.alibaba.fastjson.JSONObject; +import com.codingapi.flow.transfer.WorkflowTransferData; + +/** + * 流程导入Schema解析策略。 + */ +public interface WorkflowImportStrategy { + + String format(); + + int schemaVersion(); + + WorkflowTransferData deserialize(JSONObject data); +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowSchemaStrategyRegistry.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowSchemaStrategyRegistry.java new file mode 100644 index 00000000..b478d97e --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowSchemaStrategyRegistry.java @@ -0,0 +1,55 @@ +package com.codingapi.flow.transfer.strategy; + +import com.alibaba.fastjson.JSONObject; +import com.codingapi.flow.exception.WorkflowTransferException; + +import java.util.HashMap; +import java.util.Map; + +/** + * 按format与schemaVersion匹配导入导出策略。 + */ +public class WorkflowSchemaStrategyRegistry { + + private final Map importStrategies = new HashMap<>(); + private final Map exportStrategies = new HashMap<>(); + private final WorkflowImportStrategy legacyImportStrategy; + + public WorkflowSchemaStrategyRegistry() { + WorkflowV1SchemaStrategy v1Strategy = new WorkflowV1SchemaStrategy(); + this.register(v1Strategy, v1Strategy); + this.legacyImportStrategy = new LegacyWorkflowImportStrategy(); + } + + public void register(WorkflowImportStrategy importStrategy, WorkflowExportStrategy exportStrategy) { + importStrategies.put(new SchemaKey(importStrategy.format(), importStrategy.schemaVersion()), importStrategy); + exportStrategies.put(new SchemaKey(exportStrategy.format(), exportStrategy.schemaVersion()), exportStrategy); + } + + public WorkflowImportStrategy resolveImport(JSONObject data) { + if (!data.containsKey("format") && !data.containsKey("schemaVersion")) { + return legacyImportStrategy; + } + String format = data.getString("format"); + Integer schemaVersion = data.getInteger("schemaVersion"); + if (format == null || schemaVersion == null) { + throw WorkflowTransferException.invalidSchema("format and schemaVersion must be provided together"); + } + WorkflowImportStrategy strategy = importStrategies.get(new SchemaKey(format, schemaVersion)); + if (strategy == null) { + throw WorkflowTransferException.unsupportedSchema(format, schemaVersion); + } + return strategy; + } + + public WorkflowExportStrategy resolveExport(String format, int schemaVersion) { + WorkflowExportStrategy strategy = exportStrategies.get(new SchemaKey(format, schemaVersion)); + if (strategy == null) { + throw WorkflowTransferException.unsupportedSchema(format, schemaVersion); + } + return strategy; + } + + private record SchemaKey(String format, int schemaVersion) { + } +} diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowV1SchemaStrategy.java b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowV1SchemaStrategy.java new file mode 100644 index 00000000..b7e5cccc --- /dev/null +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/transfer/strategy/WorkflowV1SchemaStrategy.java @@ -0,0 +1,77 @@ +package com.codingapi.flow.transfer.strategy; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.codingapi.flow.exception.WorkflowTransferException; +import com.codingapi.flow.transfer.WorkflowTransferData; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * V1流程迁移Schema策略。 + */ +public class WorkflowV1SchemaStrategy implements WorkflowImportStrategy, WorkflowExportStrategy { + + public static final String FORMAT = "flow-engine-workflow"; + public static final int SCHEMA_VERSION = 1; + + @Override + public String format() { + return FORMAT; + } + + @Override + public int schemaVersion() { + return SCHEMA_VERSION; + } + + @Override + public WorkflowTransferData deserialize(JSONObject data) { + JSONObject workflow = data.getJSONObject("workflow"); + JSONArray versions = data.getJSONArray("versions"); + JSONObject groovyScripts = data.getJSONObject("groovyScripts"); + if (workflow == null) { + throw WorkflowTransferException.invalidSchema("workflow is required"); + } + if (versions == null || versions.isEmpty()) { + throw WorkflowTransferException.invalidSchema("versions cannot be empty"); + } + if (groovyScripts == null) { + throw WorkflowTransferException.invalidSchema("groovyScripts is required"); + } + + List> versionList = new ArrayList<>(); + for (Object version : versions) { + if (!(version instanceof JSONObject versionObject)) { + throw WorkflowTransferException.invalidSchema("versions must contain JSON objects"); + } + versionList.add(new LinkedHashMap<>(versionObject)); + } + + Map scripts = new LinkedHashMap<>(); + for (Map.Entry entry : groovyScripts.entrySet()) { + if (!(entry.getValue() instanceof String content)) { + throw WorkflowTransferException.invalidSchema( + String.format("groovyScripts.%s must be a string", entry.getKey())); + } + scripts.put(entry.getKey(), content); + } + return new WorkflowTransferData(new LinkedHashMap<>(workflow), versionList, scripts); + } + + @Override + public String serialize(WorkflowTransferData data) { + Map result = new LinkedHashMap<>(); + result.put("format", format()); + result.put("schemaVersion", schemaVersion()); + result.put("workflow", data.getWorkflow()); + result.put("versions", data.getVersions()); + result.put("groovyScripts", data.getGroovyScripts()); + return JSON.toJSONString(result, SerializerFeature.PrettyFormat); + } +} 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 e83d6f15..75847086 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 @@ -72,6 +72,21 @@ void should_execute_snapshot_content_not_design_content() { assertEquals("NEW", designResult); } + @Test + void should_execute_snapshot_after_design_script_deleted() { + // given 运行时已固化脚本正文,随后设计态脚本被物理删除 + registerGlobalScript(NEW_CONTENT); + FlowRuntimeScriptLocalCache.getInstance().set(Map.of(KEY, OLD_CONTENT)); + GroovyScriptCacheContext.getInstance().remove(KEY); + + // when + GroovyScript runtimeScript = FlowScriptRuntimeContext.getInstance().getGroovyScript(KEY); + + // then 不再依赖设计态脚本对象,仍可使用运行时正文执行 + assertEquals(OLD_CONTENT, runtimeScript.getScript()); + assertEquals("OLD", runtimeScript.invoke("request")); + } + @Test void should_return_global_script_when_no_runtime_cache() { // given 无运行时快照 diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/transfer/WorkflowTransferServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/transfer/WorkflowTransferServiceTest.java new file mode 100644 index 00000000..c85101f5 --- /dev/null +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/transfer/WorkflowTransferServiceTest.java @@ -0,0 +1,471 @@ +package com.codingapi.flow.transfer; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.codingapi.flow.action.IFlowAction; +import com.codingapi.flow.action.actions.CustomAction; +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.node.nodes.ApprovalNode; +import com.codingapi.flow.node.nodes.ConditionBranchNode; +import com.codingapi.flow.node.nodes.ConditionElseBranchNode; +import com.codingapi.flow.node.nodes.ConditionNode; +import com.codingapi.flow.node.nodes.EndNode; +import com.codingapi.flow.node.nodes.HandleNode; +import com.codingapi.flow.node.nodes.ParallelBranchNode; +import com.codingapi.flow.node.nodes.ParallelNode; +import com.codingapi.flow.node.nodes.RouterNode; +import com.codingapi.flow.node.nodes.StartNode; +import com.codingapi.flow.node.nodes.SubProcessNode; +import com.codingapi.flow.node.nodes.TriggerNode; +import com.codingapi.flow.script.factory.FlowGroovyScriptFactory; +import com.codingapi.flow.user.User; +import com.codingapi.flow.workflow.Workflow; +import com.codingapi.flow.workflow.WorkflowBuilder; +import com.codingapi.flow.workflow.WorkflowVersion; +import com.codingapi.flow.workflow.runtime.WorkflowRuntime; +import com.codingapi.springboot.script.GroovyScript; +import com.codingapi.springboot.script.cache.GroovyScriptCacheContext; +import com.codingapi.springboot.script.scanner.GroovyScriptAnnotationScannerUtils; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 流程迁移复杂场景测试。 + * + *

测试数据包含三个历史版本、条件嵌套、并行分支、审批、办理、路由、触发器、 + * 子流程以及自定义动作,覆盖全部12类Groovy脚本引用,不使用简单的单版本线性流程。

+ */ +class WorkflowTransferServiceTest { + + private static final String WORK_CODE = "complex-transfer-workflow"; + + /** + * 测试目标:验证复杂多版本流程导出后通过增量模式完整复制。 + * 前置条件:源流程包含三个结构逐步扩展的版本以及全部脚本类型。 + * 执行步骤:导出V1迁移包,再以增量模式导入同一仓储。 + * 期望断言:流程身份重置,配置ID保持,版本与脚本正文完整,脚本执行元数据按类型正确重建。 + */ + @Test + void shouldExportAndIncrementallyImportComplexMultiVersionWorkflow() { + MyFlowServiceFactory factory = new MyFlowServiceFactory(); + User operator = saveOperator(factory, 1001, "迁移管理员"); + Workflow source = createComplexThreeVersionWorkflow(factory, operator, WORK_CODE); + + String exported = factory.workflowService.exportWorkflow(source.getId()); + JSONObject sourcePackage = JSON.parseObject(exported); + JSONObject sourceScripts = sourcePackage.getJSONObject("groovyScripts"); + JSONArray sourceVersions = sourcePackage.getJSONArray("versions"); + + assertAll("V1复杂迁移包结构", + () -> assertEquals("flow-engine-workflow", sourcePackage.getString("format")), + () -> assertEquals(1, sourcePackage.getIntValue("schemaVersion")), + () -> assertEquals(3, sourceVersions.size()), + () -> assertEquals("v3.0", sourcePackage.getJSONObject("workflow").getString("currentVersion")), + () -> assertTrue(sourcePackage.getJSONObject("workflow").getBooleanValue("enable")), + () -> assertTrue(sourceScripts.size() >= 40, + "三个复杂版本应导出大量独立脚本,而不是少量简单脚本"), + () -> assertTrue(collectConfigurationIds(sourceVersions).size() >= 35, + "复杂流程应包含足够多的节点、动作和表单字段ID")); + + String importedId = factory.workflowService.importWorkflow( + toDataUrl(exported), operator, WorkflowImportMode.INCREMENTAL); + Workflow imported = factory.workflowService.getWorkflowById(importedId); + String reExported = factory.workflowService.exportWorkflow(importedId); + JSONObject importedPackage = JSON.parseObject(reExported); + + Set sourceKeys = sourceScripts.keySet(); + Set importedKeys = importedPackage.getJSONObject("groovyScripts").keySet(); + Set importedScriptTypes = importedKeys.stream() + .map(GroovyScriptCacheContext.getInstance()::getGroovyScript) + .filter(script -> script != null && script.getTypeTwo() != null) + .map(GroovyScript::getTypeTwo) + .collect(Collectors.toSet()); + + assertAll("增量导入结果", + () -> assertNotEquals(source.getId(), imported.getId(), "增量导入必须生成新workId"), + () -> assertNotEquals(source.getCode(), imported.getCode(), "增量导入必须生成新workCode"), + () -> assertEquals(3, factory.workflowVersionRepository.findVersion(importedId).size()), + () -> assertEquals(versionNames(sourcePackage), versionNames(importedPackage)), + () -> assertEquals(versionStates(sourcePackage), versionStates(importedPackage), + "当前版本标记和启用状态必须保持不变"), + () -> assertTrue(imported.isEnable(), "流程启用状态必须保持不变"), + () -> assertEquals(collectConfigurationIds(sourceVersions), + collectConfigurationIds(importedPackage.getJSONArray("versions")), + "节点、分支、动作和表单字段ID必须保持不变"), + () -> assertTrue(disjoint(sourceKeys, importedKeys), "导入脚本必须全部生成新key"), + () -> assertEquals(scriptContentFrequency(sourceScripts), + scriptContentFrequency(importedPackage.getJSONObject("groovyScripts")), + "新旧脚本key不同,但全部正文及重复次数必须一致"), + () -> assertTrue(importedScriptTypes.containsAll(Set.of( + "router-script", "node-title", "condition", "trigger", + "sub-process", "sub-process-result", "operator-load", + "operator-match", "error-trigger", "action-display", + "action-custom", "action-reject")), + "正文Schema虽不携带脚本元数据,导入器仍须重建全部脚本类型")); + + for (String key : importedKeys) { + GroovyScript script = GroovyScriptCacheContext.getInstance().getGroovyScript(key); + assertNotNull(script, "每个新key都必须有持久化脚本对象"); + assertNotNull(script.getMethod(), "脚本执行方法元数据必须被重建"); + assertNotNull(script.getReturnType(), "脚本返回类型元数据必须被重建"); + } + } + + /** + * 测试目标:验证替换导入只保留目标流程身份,其余版本配置来自导入包。 + * 前置条件:目标环境存在同workCode但结构完全不同的单版本流程。 + * 执行步骤:将复杂三版本迁移包以REPLACE方式导入目标环境。 + * 期望断言:目标workId/workCode不变,旧脚本清理,复杂版本和配置ID完整落入。 + */ + @Test + void shouldReplaceExistingWorkflowAndRemoveOldVersionScripts() { + MyFlowServiceFactory sourceFactory = new MyFlowServiceFactory(); + User sourceOperator = saveOperator(sourceFactory, 2001, "源环境管理员"); + Workflow source = createComplexThreeVersionWorkflow(sourceFactory, sourceOperator, WORK_CODE); + String exported = sourceFactory.workflowService.exportWorkflow(source.getId()); + JSONObject sourcePackage = JSON.parseObject(exported); + + MyFlowServiceFactory targetFactory = new MyFlowServiceFactory(); + User targetOperator = saveOperator(targetFactory, 3001, "目标环境管理员"); + Workflow target = createReplacementTarget(targetFactory, targetOperator, WORK_CODE); + String targetId = target.getId(); + Set oldTargetScriptKeys = workflowScriptKeys( + targetFactory.workflowVersionRepository.findVersion(targetId)); + + String importedId = targetFactory.workflowService.importWorkflow( + toDataUrl(exported), targetOperator, WorkflowImportMode.REPLACE); + Workflow replaced = targetFactory.workflowService.getWorkflowById(importedId); + JSONObject replacedPackage = JSON.parseObject( + targetFactory.workflowService.exportWorkflow(importedId)); + + assertAll("替换导入结果", + () -> assertEquals(targetId, importedId, "替换导入必须保留目标workId"), + () -> assertEquals(WORK_CODE, replaced.getCode(), "替换导入必须保留workCode"), + () -> assertEquals("复杂迁移流程", replaced.getTitle()), + () -> assertEquals(3, targetFactory.workflowVersionRepository.findVersion(targetId).size()), + () -> assertEquals(versionNames(sourcePackage), versionNames(replacedPackage)), + () -> assertEquals(collectConfigurationIds(sourcePackage.getJSONArray("versions")), + collectConfigurationIds(replacedPackage.getJSONArray("versions"))), + () -> assertTrue(oldTargetScriptKeys.stream() + .allMatch(key -> GroovyScriptCacheContext.getInstance().getGroovyScript(key) == null), + "被替换版本的旧脚本必须从仓储和缓存清理")); + } + + /** + * 测试目标:验证管理入口导入在源编码未占用时保留编码。 + * 前置条件:源环境有复杂三版本流程,目标环境不存在同编码流程。 + * 执行步骤:在目标环境执行增量导入。 + * 期望断言:生成新的流程ID,但保留文件中的流程编码和全部三个版本。 + */ + @Test + void shouldKeepSourceCodeWhenIncrementalImportHasNoConflict() { + MyFlowServiceFactory sourceFactory = new MyFlowServiceFactory(); + User sourceOperator = saveOperator(sourceFactory, 3501, "无冲突源环境管理员"); + Workflow source = createComplexThreeVersionWorkflow(sourceFactory, sourceOperator, WORK_CODE); + String exported = sourceFactory.workflowService.exportWorkflow(source.getId()); + + MyFlowServiceFactory targetFactory = new MyFlowServiceFactory(); + User targetOperator = saveOperator(targetFactory, 3502, "无冲突目标环境管理员"); + + String importedId = targetFactory.workflowService.importWorkflow( + toDataUrl(exported), targetOperator, WorkflowImportMode.INCREMENTAL); + Workflow imported = targetFactory.workflowRepository.getById(importedId); + + assertAll("无编码冲突的增量导入", + () -> assertNotEquals(source.getId(), importedId), + () -> assertEquals(WORK_CODE, imported.getCode()), + () -> assertEquals(3, targetFactory.workflowVersionRepository.findVersion(importedId).size())); + } + + /** + * 测试目标:验证Schema策略严格匹配版本。 + * 前置条件:导入文件声明尚未支持的schemaVersion。 + * 执行步骤:以增量模式导入。 + * 期望断言:导入在创建任何流程版本前失败。 + */ + @Test + void shouldRejectUnsupportedSchemaVersionBeforePersistence() { + MyFlowServiceFactory factory = new MyFlowServiceFactory(); + User operator = saveOperator(factory, 4001, "版本校验管理员"); + String unsupported = """ + { + "format": "flow-engine-workflow", + "schemaVersion": 99, + "workflow": {}, + "versions": [], + "groovyScripts": {} + } + """; + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> factory.workflowService.importWorkflow( + toDataUrl(unsupported), operator, WorkflowImportMode.INCREMENTAL)); + + assertTrue(exception.getMessage().contains("schemaVersion=99")); + assertNull(factory.workflowRepository.getByCode(WORK_CODE)); + } + + /** + * 测试目标:验证删除复杂多版本流程会物理清理全部设计态脚本,但保留独立运行时快照。 + * 前置条件:流程包含三个版本、40个以上脚本,并已生成持久化运行时快照。 + * 执行步骤:删除整个流程。 + * 期望断言:流程和版本消失,所有版本脚本从仓储及缓存删除,运行时及其脚本正文保持不变。 + */ + @Test + void shouldDeleteAllVersionScriptsAndKeepRuntimeSnapshot() { + MyFlowServiceFactory factory = new MyFlowServiceFactory(); + User operator = saveOperator(factory, 5001, "删除验证管理员"); + Workflow workflow = createComplexThreeVersionWorkflow(factory, operator, WORK_CODE); + List versions = factory.workflowVersionRepository.findVersion(workflow.getId()); + + Set designScriptKeys = workflowScriptKeys(versions); + designScriptKeys.addAll( + GroovyScriptAnnotationScannerUtils.findGroovyScriptFields(workflow).getKeys()); + WorkflowRuntime runtime = new WorkflowRuntime(workflow); + factory.workflowService.saveWorkflowRuntime(runtime); + Map runtimeScripts = new HashMap<>(runtime.getScripts()); + + assertTrue(designScriptKeys.size() >= 40, "删除测试必须覆盖复杂多版本脚本集合"); + assertTrue(runtimeScripts.size() >= 10, "当前运行时必须固化完整流程脚本快照"); + + factory.workflowService.delete(workflow.getId()); + + WorkflowRuntime retainedRuntime = factory.workflowRuntimeRepository.get(runtime.getId()); + assertAll("流程删除后的设计态与运行态数据", + () -> assertNull(factory.workflowRepository.getById(workflow.getId())), + () -> assertTrue(factory.workflowVersionRepository.findVersion(workflow.getId()).isEmpty()), + () -> assertTrue(designScriptKeys.stream() + .allMatch(key -> GroovyScriptCacheContext.getInstance().getGroovyScript(key) == null), + "所有历史版本和主流程的Groovy脚本都必须物理删除并清理缓存"), + () -> assertNotNull(retainedRuntime, "删除流程不能联动删除运行时"), + () -> assertEquals(runtimeScripts, retainedRuntime.getScripts(), + "运行时独立脚本快照不能受设计态脚本删除影响"), + () -> assertEquals(workflow.getTitle(), retainedRuntime.toWorkflow().getTitle())); + } + + private Workflow createComplexThreeVersionWorkflow(MyFlowServiceFactory factory, + User operator, + String workCode) { + FlowForm form = FlowFormBuilder.builder() + .name("复杂迁移表单") + .code("complex-transfer-form") + .addField("申请人", "applicant", DataType.STRING) + .addField("金额", "amount", DataType.DOUBLE) + .addField("紧急", "urgent", DataType.BOOLEAN) + .addField("说明", "description", DataType.STRING) + .build(); + + StartNode start = StartNode.builder().id("node-start-fixed").name("开始").build(); + ApprovalNode firstApproval = approvalNodeWithCustomAction( + "node-approval-first", "部门审批", "action-custom-fixed"); + + ConditionBranchNode highAmount = ConditionBranchNode.builder() + .id("branch-high-amount") + .name("高金额分支") + .conditionScript(FlowGroovyScriptFactory.createConditionScript( + "def run(request) { return request.getFormData('amount') != null }").getKey()) + .blocks( + ApprovalNode.builder().id("node-finance-approval").name("财务审批").build(), + RouterNode.builder().id("node-router").name("路由判断").build(), + TriggerNode.builder().id("node-trigger").name("触发归档").build()) + .build(); + ConditionBranchNode urgent = ConditionBranchNode.builder() + .id("branch-urgent") + .name("紧急分支") + .conditionScript(FlowGroovyScriptFactory.createConditionScript( + "def run(request) { return request.getFormData('urgent') == true }").getKey()) + .blocks( + HandleNode.builder().id("node-urgent-handle").name("紧急办理").build(), + SubProcessNode.builder().id("node-sub-process-v1").name("通知子流程").build()) + .build(); + ConditionElseBranchNode other = ConditionElseBranchNode.builder() + .id("branch-other") + .name("普通分支") + .blocks(HandleNode.builder().id("node-normal-handle").name("普通办理").build()) + .build(); + ConditionNode condition = ConditionNode.builder() + .id("node-condition") + .name("金额与紧急程度判断") + .blocks(highAmount, urgent, other) + .build(); + EndNode end = EndNode.builder().id("node-end-fixed").name("结束").build(); + + Workflow workflow = WorkflowBuilder.builder() + .code(workCode) + .title("复杂迁移流程") + .description("覆盖多版本、嵌套节点和全部脚本类型") + .createdOperator(operator) + .form(form) + .addNode(start) + .addNode(firstApproval) + .addNode(condition) + .addNode(end) + .maxNestDepth(12) + .build(); + + saveVersion(factory, workflow, "v1.0", false); + + Workflow versionTwo = cloneWorkflow(factory.workflowRepository.getById(workflow.getId())); + ParallelBranchNode firstParallel = ParallelBranchNode.builder() + .id("parallel-branch-a") + .name("并行审批A") + .blocks(ApprovalNode.builder().id("node-parallel-approval-a").name("并行审批A").build()) + .build(); + ParallelBranchNode secondParallel = ParallelBranchNode.builder() + .id("parallel-branch-b") + .name("并行审批B") + .blocks(HandleNode.builder().id("node-parallel-handle-b").name("并行办理B").build()) + .build(); + ParallelNode parallel = ParallelNode.builder() + .id("node-parallel") + .name("并行处理") + .blocks(firstParallel, secondParallel) + .build(); + versionTwo.getNodes().add(versionTwo.getNodes().size() - 1, parallel); + versionTwo.enable(); + saveVersion(factory, versionTwo, "v2.0", true); + + Workflow versionThree = cloneWorkflow(factory.workflowRepository.getById(workflow.getId())); + versionThree.getNodes().add(versionThree.getNodes().size() - 1, + SubProcessNode.builder().id("node-sub-process-v3").name("最终子流程").build()); + versionThree.getNodes().add(versionThree.getNodes().size() - 1, + TriggerNode.builder().id("node-final-trigger").name("最终触发器").build()); + versionThree.enable(); + saveVersion(factory, versionThree, "v3.0", true); + + return factory.workflowRepository.getById(workflow.getId()); + } + + private ApprovalNode approvalNodeWithCustomAction(String nodeId, String name, String actionId) { + ApprovalNode approvalNode = ApprovalNode.builder().id(nodeId).name(name).build(); + List actions = new ArrayList<>(approvalNode.getActions()); + CustomAction customAction = CustomAction.defaultAction(); + customAction.setId(actionId); + actions.add(customAction); + approvalNode.setActions(actions); + return approvalNode; + } + + private Workflow createReplacementTarget(MyFlowServiceFactory factory, User operator, String workCode) { + FlowForm form = FlowFormBuilder.builder() + .name("待替换表单") + .code("replacement-target-form") + .addField("旧字段", "legacy", DataType.STRING) + .build(); + Workflow target = WorkflowBuilder.builder() + .code(workCode) + .title("待替换流程") + .createdOperator(operator) + .form(form) + .addNode(StartNode.builder().id("old-start").name("旧开始").build()) + .addNode(ApprovalNode.builder().id("old-approval").name("旧审批").build()) + .addNode(EndNode.builder().id("old-end").name("旧结束").build()) + .build(false); + saveVersion(factory, target, "legacy-v1", false); + return factory.workflowRepository.getById(target.getId()); + } + + private void saveVersion(MyFlowServiceFactory factory, + Workflow workflow, + String versionName, + boolean creatable) { + WorkflowVersion version = new WorkflowVersion(workflow); + version.setVersionName(versionName); + factory.workflowService.saveWorkflowVersion(version, creatable, false); + } + + private Workflow cloneWorkflow(Workflow workflow) { + return Workflow.formJson(workflow.toJson()); + } + + private User saveOperator(MyFlowServiceFactory factory, long id, String name) { + User user = new User(id, name); + factory.userGateway.save(user); + return user; + } + + private Set workflowScriptKeys(List versions) { + Set keys = new HashSet<>(); + versions.forEach(version -> keys.addAll( + GroovyScriptAnnotationScannerUtils.findGroovyScriptFields(version).getKeys())); + return keys; + } + + private List versionNames(JSONObject workflowPackage) { + return workflowPackage.getJSONArray("versions").stream() + .map(JSONObject.class::cast) + .map(version -> version.getString("versionName")) + .toList(); + } + + private List versionStates(JSONObject workflowPackage) { + return workflowPackage.getJSONArray("versions").stream() + .map(JSONObject.class::cast) + .map(version -> String.format("%s:%s:%s", + version.getString("versionName"), + version.getBooleanValue("current"), + version.getBooleanValue("enable"))) + .toList(); + } + + private Set collectConfigurationIds(JSONArray versions) { + Set ids = new HashSet<>(); + for (Object item : versions) { + JSONObject version = (JSONObject) item; + collectIds(version.get("form"), ids); + collectIds(version.get("nodes"), ids); + } + return ids; + } + + private void collectIds(Object value, Set ids) { + if (value instanceof Map map) { + Object id = map.get("id"); + if (id != null) { + ids.add(id.toString()); + } + map.values().forEach(item -> collectIds(item, ids)); + } else if (value instanceof Collection collection) { + collection.forEach(item -> collectIds(item, ids)); + } + } + + private Map scriptContentFrequency(JSONObject scripts) { + Map frequency = new HashMap<>(); + scripts.values().forEach(value -> frequency.merge(value.toString(), 1L, Long::sum)); + return frequency; + } + + private boolean disjoint(Set left, Set right) { + Set intersection = new HashSet<>(left); + intersection.retainAll(right); + return intersection.isEmpty(); + } + + private String toDataUrl(String json) { + return "data:application/json;base64," + Base64.getEncoder() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/WorkflowController.java b/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/WorkflowController.java index 970fb6d0..fb50992e 100644 --- a/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/WorkflowController.java +++ b/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/controller/WorkflowController.java @@ -4,6 +4,7 @@ import com.codingapi.flow.action.actions.CustomAction; import com.codingapi.flow.api.pojo.NodeCreateRequest; import com.codingapi.flow.api.pojo.WorkflowMeta; +import com.codingapi.flow.api.pojo.WorkflowImportRequest; import com.codingapi.flow.api.pojo.WorkflowUpdateVersionNameRequest; import com.codingapi.flow.exception.FlowNotFoundException; import com.codingapi.flow.exception.FlowPermissionException; @@ -121,22 +122,26 @@ public SingleResponse create() { } @PostMapping("/import") - public SingleResponse importWorkflow(@RequestBody JSONObject body) { + public SingleResponse importWorkflow(@RequestBody WorkflowImportRequest request) { IFlowOperator current = (IFlowOperator) UserContext.getInstance().current(); - String workId = workflowService.importWorkflow(body.getString("file"),current); + String workId = workflowService.importWorkflow(request.getFile(), current, request.getMode()); return SingleResponse.of(workId); } + @GetMapping("/exists") + public SingleResponse exists(@RequestParam String code) { + return SingleResponse.of(workflowService.getWorkflowByCode(code) != null); + } + @GetMapping("/export") public void export(IdRequest request, HttpServletResponse response) { - Workflow workflow = workflowService.getWorkflowById(request.getStringId()); - JSONObject jsonObject = JSONObject.parseObject(workflow.toJson()); + String content = workflowService.exportWorkflow(request.getStringId()); try { response.setContentType("application/json;charset=UTF-8"); response.setCharacterEncoding("UTF-8"); String fileName = URLEncoder.encode("workflow_" + request.getStringId() + ".json", StandardCharsets.UTF_8); response.setHeader("Content-Disposition", "attachment;filename=" + fileName); - response.getWriter().write(jsonObject.toJSONString()); + response.getWriter().write(content); response.getWriter().flush(); } catch (Exception e) { throw new LocaleMessageException("export.error", e); diff --git a/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/pojo/WorkflowImportRequest.java b/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/pojo/WorkflowImportRequest.java new file mode 100644 index 00000000..21691a54 --- /dev/null +++ b/flow-engine-starter-api/src/main/java/com/codingapi/flow/api/pojo/WorkflowImportRequest.java @@ -0,0 +1,15 @@ +package com.codingapi.flow.api.pojo; + +import com.codingapi.flow.transfer.WorkflowImportMode; +import lombok.Data; + +/** + * 流程导入请求。 + */ +@Data +public class WorkflowImportRequest { + + private String file; + + private WorkflowImportMode mode = WorkflowImportMode.INCREMENTAL; +} diff --git a/flow-frontend b/flow-frontend index 982bb450..096ed50e 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 982bb4509fe930d37850392ccc2f09c9b3223c6c +Subproject commit 096ed50ee7a89c3919d93a6fdf98e89effd7869d From 8863fe5e93b14a1dc2b00ebf085058474a51a18e Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 20 Aug 2026 10:43:21 +0800 Subject: [PATCH 2/2] 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 096ed50e..a5c83b23 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 096ed50ee7a89c3919d93a6fdf98e89effd7869d +Subproject commit a5c83b236542bd5cb4dda72488a467fe346bc60b