From 47655a728b495d7ba171440f458a05d6924883cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Sun, 12 Apr 2026 13:15:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=8F=92=E4=BB=B6=E5=A4=8D=E7=94=A8=E4=B8=8E=E8=AF=95?= =?UTF-8?q?=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增工作流插件类型、发布快照同步、实时可用性与下线影响检查 - 收口绑定候选、分类权限、间接环路校验与运行态优雅降级 - 补齐管理端工作流插件配置、详情与试运行界面及定向测试 --- .../controller/ai/BotPluginController.java | 21 +- .../ai/PluginCategoryMappingController.java | 6 +- .../admin/controller/ai/PluginController.java | 79 +- .../controller/ai/PluginItemController.java | 150 ++++ .../controller/ai/WorkflowController.java | 6 + .../controller/PublicWorkflowController.java | 9 + .../controller/ai/UcWorkflowController.java | 6 + .../listener/ChainEventListenerForSave.java | 29 +- .../service/WorkflowCheckService.java | 92 +++ .../java/tech/easyflow/ai/entity/Plugin.java | 44 ++ .../easyflow/ai/entity/base/PluginBase.java | 14 + .../ai/entity/base/PluginItemBase.java | 14 + .../tech/easyflow/ai/enums/PluginType.java | 52 ++ .../tech/easyflow/ai/node/PluginToolNode.java | 54 ++ .../WorkflowPluginAvailabilityDecision.java | 67 ++ .../WorkflowPluginAvailabilityService.java | 28 + ...WorkflowPluginAvailabilityServiceImpl.java | 126 ++++ .../binding/WorkflowPluginBindingService.java | 34 + .../WorkflowPluginBindingServiceImpl.java | 227 ++++++ .../WorkflowPluginDependencyService.java | 73 ++ .../WorkflowPluginDependencyServiceImpl.java | 334 +++++++++ .../WorkflowPluginSnapshotResolver.java | 231 ++++++ .../WorkflowApprovalSubjectHandler.java | 22 + .../ai/service/PluginItemService.java | 8 + .../easyflow/ai/service/PluginService.java | 18 + .../ai/service/impl/BotServiceImpl.java | 9 + .../PluginCategoryMappingServiceImpl.java | 50 ++ .../service/impl/PluginItemServiceImpl.java | 141 +++- .../ai/service/impl/PluginServiceImpl.java | 114 ++- .../ResourceOfflineImpactServiceImpl.java | 27 +- .../easyflow/ai/vo/OfflineImpactCheckVo.java | 20 + ...rkflowPluginDependencyServiceImplTest.java | 252 +++++++ .../service/CategoryPermissionService.java | 7 + .../system/service/ResourceAccessService.java | 3 + .../impl/CategoryPermissionServiceImpl.java | 16 + .../impl/ResourceAccessServiceImpl.java | 10 +- ...ysql_workflow_plugin_schema_hash_patch.sql | 2 + .../mysql/V9__mysql_workflow_plugin_patch.sql | 8 + .../src/locales/langs/en-US/aiWorkflow.json | 3 + .../app/src/locales/langs/en-US/plugin.json | 15 +- .../src/locales/langs/en-US/pluginItem.json | 8 +- .../src/locales/langs/zh-CN/aiWorkflow.json | 3 + .../app/src/locales/langs/zh-CN/plugin.json | 15 +- .../src/locales/langs/zh-CN/pluginItem.json | 8 +- .../src/views/ai/plugin/AddPluginModal.vue | 565 +++++++++----- .../app/src/views/ai/plugin/Plugin.vue | 22 +- .../src/views/ai/plugin/PluginRunParams.vue | 154 +++- .../views/ai/plugin/PluginRunTestModal.vue | 461 +++++++++--- .../src/views/ai/plugin/PluginToolEdit.vue | 704 +++++++++--------- .../src/views/ai/plugin/PluginToolTable.vue | 6 +- .../app/src/views/ai/plugin/PluginTools.vue | 73 +- .../app/src/views/ai/shared/offline-impact.ts | 2 + .../src/views/ai/workflow/WorkflowDesign.vue | 20 +- .../src/views/ai/workflow/WorkflowList.vue | 50 +- .../ai/workflow/components/SingleRun.vue | 20 +- .../ai/workflow/components/WorkflowSteps.vue | 200 +++-- .../components/core/ParamTokenEditor.svelte | 66 +- 57 files changed, 4018 insertions(+), 780 deletions(-) create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/PluginType.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityDecision.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityService.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingService.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyService.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/snapshot/WorkflowPluginSnapshotResolver.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImplTest.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V10__mysql_workflow_plugin_schema_hash_patch.sql create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V9__mysql_workflow_plugin_patch.sql diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotPluginController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotPluginController.java index fbc4553..c169490 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotPluginController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotPluginController.java @@ -4,6 +4,7 @@ import org.springframework.web.bind.annotation.PostMapping; import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.enums.PluginType; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.domain.Result; import tech.easyflow.common.tree.Tree; @@ -58,7 +59,14 @@ public class BotPluginController extends BaseCurdController visibleList = new ArrayList<>(); for (BotPlugin relation : botPlugins) { Plugin plugin = relation.getAiPlugin(); - if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) { + if (plugin == null) { + visibleList.add(relation); + continue; + } + if (PluginType.isWorkflow(plugin.getType())) { + continue; + } + if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) { visibleList.add(relation); } } @@ -73,7 +81,13 @@ public class BotPluginController extends BaseCurdController plugins = botPluginService.getList(botId); List visibleList = new ArrayList<>(); for (Plugin plugin : plugins) { - if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) { + if (plugin == null) { + continue; + } + if (PluginType.isWorkflow(plugin.getType())) { + continue; + } + if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) { visibleList.add(plugin); } } @@ -105,6 +119,9 @@ public class BotPluginController extends BaseCurdController { public PluginCategoryMappingController(PluginCategoryMappingService service) { super(service); @@ -30,6 +33,7 @@ public class PluginCategoryMappingController extends BaseCurdController updateRelation( @JsonBody(value="pluginId") BigInteger pluginId, @JsonBody(value="categoryIds") ArrayList categoryIds @@ -42,4 +46,4 @@ public class PluginCategoryMappingController extends BaseCurdController private PluginVisibilityService pluginVisibilityService; @Resource private ModelService modelService; + @Resource + private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper; + @Resource + private WorkflowService workflowService; + @Resource + private ResourceAccessService resourceAccessService; + @Resource + private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; @Override protected Result onSaveOrUpdateBefore(Plugin entity, boolean isSave) { @@ -79,7 +98,8 @@ public class PluginController extends BaseCurdController public Result> getList(){ QueryWrapper queryWrapper = QueryWrapper.create().select(); applyCategoryPermission(queryWrapper); - return Result.ok(service.getMapper().selectListByQuery(queryWrapper)); + List plugins = service.getMapper().selectListWithRelationsByQuery(queryWrapper); + return Result.ok(pluginService.preparePluginsForCurrentUser(plugins, true, false)); } @GetMapping("/pageByCategory") @@ -101,6 +121,23 @@ public class PluginController extends BaseCurdController } } + @GetMapping("/workflowCandidates") + @SaCheckPermission("/api/v1/plugin/query") + public Result> workflowCandidates(String keyword) { + QueryWrapper queryWrapper = QueryWrapper.create(); + workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper); + queryWrapper.eq("publish_status", tech.easyflow.ai.enums.PublishStatus.PUBLISHED.getCode()); + if (keyword != null && !keyword.isBlank()) { + queryWrapper.like("title", keyword.trim()); + } + queryWrapper.orderBy("modified desc"); + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + List workflows = workflowService.list(queryWrapper).stream() + .filter(workflow -> canBindWorkflowCandidate(workflow, loginAccount)) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + return Result.ok(workflows); + } + @GetMapping("/modelList") @SaCheckPermission("/api/v1/plugin/query") public Result> modelList(Model entity, Boolean asTree, String sortKey, String sortType) { @@ -110,14 +147,24 @@ public class PluginController extends BaseCurdController @Override protected Page queryPage(Page page, QueryWrapper queryWrapper) { applyCategoryPermission(queryWrapper); - return service.getMapper().paginateWithRelations(page, queryWrapper); + List totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper); + boolean availableOnly = isAvailableOnly(); + List prepared = pluginService.preparePluginsForCurrentUser(totalList, !availableOnly, availableOnly); + long total = prepared.size(); + int fromIndex = Math.max(0, Math.toIntExact((page.getPageNumber() - 1) * page.getPageSize())); + if (fromIndex >= prepared.size()) { + return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), total); + } + int toIndex = Math.min(prepared.size(), Math.toIntExact(fromIndex + page.getPageSize())); + return new Page<>(prepared.subList(fromIndex, toIndex), page.getPageNumber(), page.getPageSize(), total); } @Override public Result detail(String id) { - Plugin plugin = service.getById(id); + Plugin plugin = service.getMapper().selectOneWithRelationsById(id); if (plugin != null) { pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件"); + pluginService.preparePluginForCurrentUser(plugin); } return Result.ok(plugin); } @@ -138,4 +185,30 @@ public class PluginController extends BaseCurdController } queryWrapper.and(PLUGIN.CREATED_BY.eq(access.getAccountIdAsLong()).or(PLUGIN.ID.in(pluginIds))); } + + private boolean isAvailableOnly() { + HttpServletRequest request = currentHttpRequest(); + return request != null && "true".equalsIgnoreCase(request.getParameter("availableOnly")); + } + + private HttpServletRequest currentHttpRequest() { + org.springframework.web.context.request.ServletRequestAttributes attributes = + (org.springframework.web.context.request.ServletRequestAttributes) + org.springframework.web.context.request.RequestContextHolder.getRequestAttributes(); + return attributes == null ? null : attributes.getRequest(); + } + + private boolean canBindWorkflowCandidate(Workflow workflow, LoginAccount loginAccount) { + if (workflow == null || loginAccount == null || loginAccount.getId() == null) { + return false; + } + if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) { + return false; + } + Workflow publishedWorkflow = workflowService.toPublishedView(workflow); + if (!workflowPluginSnapshotResolver.isSupportedForWorkflowPlugin(publishedWorkflow)) { + return false; + } + return resourceAccessService.canAccess(loginAccount, CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE); + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java index 4da6e18..7b59a55 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java @@ -1,30 +1,49 @@ package tech.easyflow.admin.controller.ai; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.stp.StpUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.mybatisflex.core.query.QueryWrapper; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; +import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; +import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; +import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; +import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; +import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.entity.BotPlugin; +import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PluginType; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.BotPluginService; +import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.constant.Constants; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; import javax.annotation.Resource; import java.io.Serializable; import java.math.BigInteger; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * 控制层。 @@ -45,6 +64,18 @@ public class PluginItemController extends BaseCurdController pluginToolTestAsync(@JsonBody(value = "inputData", required = true) String inputData, + @JsonBody(value = "pluginToolId", required = true) BigInteger pluginToolId) { + PluginItem pluginItem = pluginItemService.getById(pluginToolId); + Plugin plugin = requireWorkflowPlugin(pluginItem); + Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin); + if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) { + throw new BusinessException(preparedPlugin.getReasonMessage()); + } + Workflow workflow = workflowService.getPublishedById(preparedPlugin.getWorkflowId()); + if (workflow == null) { + throw new BusinessException("未找到已发布工作流"); + } + workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); + Map variables = JSON.parseObject(inputData, Map.class); + if (variables == null) { + variables = new HashMap<>(); + } + if (StpUtil.isLogin()) { + variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); + } + String executeId = chainExecutor.executeAsync( + PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId())), + variables + ); + return Result.ok(executeId); + } + + /** + * 获取工作流插件试运行状态。 + * + * @param executeId 执行 ID + * @param nodes 节点列表 + * @return 链路状态 + */ + @PostMapping("/testChainStatus") + @SaCheckPermission("/api/v1/plugin/query") + public Result pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId, + @JsonBody("nodes") List nodes) { + return Result.ok(tinyFlowService.getChainStatus(executeId, nodes)); + } + + /** + * 恢复工作流插件试运行。 + * + * @param executeId 执行 ID + * @param confirmParams 恢复参数 + * @return 空结果 + */ + @PostMapping("/testResume") + @SaCheckPermission("/api/v1/plugin/query") + public Result pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId, + @JsonBody("confirmParams") Map confirmParams) { + chainExecutor.resumeAsync(executeId, confirmParams); + return Result.ok(); + } + private void handleArray(JSONArray array) { for (Object o : array) { JSONObject obj = (JSONObject) o; @@ -134,6 +241,40 @@ public class PluginItemController extends BaseCurdController onRemoveBefore(Collection ids) { @@ -144,6 +285,15 @@ public class PluginItemController extends BaseCurdController(); + } + if (StpUtil.isLogin()) { + variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); + } Map res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables); return Result.ok(res); } diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java index 7405302..0b09f71 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java @@ -1,6 +1,7 @@ package tech.easyflow.publicapi.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.stp.StpUtil; import com.easyagents.flow.core.chain.ChainDefinition; import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.chain.runtime.ChainExecutor; @@ -18,7 +19,9 @@ import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.constant.Constants; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.jsonbody.JsonBody; import java.math.BigInteger; @@ -75,6 +78,12 @@ public class PublicWorkflowController { if (workflow == null) { return Result.fail(1, "工作流不存在"); } + if (variables == null) { + variables = new HashMap<>(); + } + if (StpUtil.isLogin()) { + variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); + } Map res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables); return Result.ok(res); } diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java index 8de29b0..b45c960 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java @@ -83,6 +83,12 @@ public class UcWorkflowController extends BaseCurdController(); + } + if (StpUtil.isLogin()) { + variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); + } Map res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables); return Result.ok(res); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java index 45009f1..ebd6a6b 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java @@ -11,6 +11,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Component; +import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.WorkflowExecResult; import tech.easyflow.ai.entity.WorkflowExecStep; @@ -62,7 +63,11 @@ public class ChainEventListenerForSave implements ChainEventListener { log.info("ChainStartEvent: {}", event); ChainDefinition definition = chain.getDefinition(); ChainState state = chain.getState(); - Workflow workflow = workflowService.getById(definition.getId()); + Workflow workflow = resolveWorkflow(definition); + if (workflow == null) { + log.error("ChainStartEvent: workflow not found, definitionId={}", definition.getId()); + return; + } String instanceId = state.getInstanceId(); WorkflowExecResult existed = workflowExecResultService.getByExecKey(instanceId); if (existed != null) { @@ -176,4 +181,26 @@ public class ChainEventListenerForSave implements ChainEventListener { ChainState chainState = chain.getChainStateRepository().load(parentInstanceId); return findAncestorState(chainState, chain); } + + /** + * 根据定义 ID 解析当前执行所对应的工作流。 + * 已发布快照执行会使用 published 前缀,需要先还原为真实工作流 ID。 + */ + private Workflow resolveWorkflow(ChainDefinition definition) { + if (definition == null || StrUtil.isBlank(definition.getId())) { + return null; + } + String definitionId = definition.getId(); + String workflowId = PublishedWorkflowDefinitionIds.unwrap(definitionId); + try { + java.math.BigInteger id = new java.math.BigInteger(workflowId); + if (PublishedWorkflowDefinitionIds.isPublished(definitionId)) { + return workflowService.getPublishedById(id); + } + return workflowService.getById(id); + } catch (NumberFormatException ex) { + log.error("Unsupported workflow definition id: {}", definitionId, ex); + return null; + } + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java index 0ab647a..bf8a8b0 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java @@ -9,7 +9,11 @@ import org.springframework.util.StringUtils; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckIssue; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; +import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; +import tech.easyflow.ai.service.PluginItemService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse; @@ -40,6 +44,7 @@ public class WorkflowCheckService { private static final String TYPE_END = "endNode"; private static final String TYPE_LOOP = "loopNode"; private static final String TYPE_WORKFLOW = "workflow-node"; + private static final String TYPE_PLUGIN = "plugin-node"; @Resource private WorkflowService workflowService; @@ -47,6 +52,12 @@ public class WorkflowCheckService { private ChainParser chainParser; @Resource private WorkflowDatacenterContentService workflowDatacenterContentService; + @Resource + private WorkflowPluginDependencyService workflowPluginDependencyService; + @Resource + private PluginItemService pluginItemService; + @Resource + private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; public WorkflowCheckResult checkWorkflow(BigInteger workflowId, WorkflowCheckStage stage) { if (workflowId == null) { @@ -66,6 +77,9 @@ public class WorkflowCheckService { List issues = new ArrayList<>(); Set issueKeys = new LinkedHashSet<>(); ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys); + if (parsedWorkflow != null) { + checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys); + } if (stage == WorkflowCheckStage.PRE_EXECUTE && parsedWorkflow != null) { runStrictChecks(content, parsedWorkflow, currentWorkflowId, issues, issueKeys); @@ -394,6 +408,10 @@ public class WorkflowCheckService { for (NodeView node : parsed.nodes) { if (!TYPE_WORKFLOW.equals(node.type)) { + if (!TYPE_PLUGIN.equals(node.type)) { + continue; + } + checkPluginWorkflowReference(node, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys); continue; } String workflowId = getWorkflowIdInNode(node); @@ -510,12 +528,86 @@ public class WorkflowCheckService { refs.add(workflowId); } } + refs.addAll(workflowPluginDependencyService.extractWorkflowIdsFromPluginNodes(content)); } catch (Exception ignored) { // ignore } return refs; } + private void checkPluginWorkflowReference(NodeView node, + String currentWorkflowIdString, + String currentContent, + Map contentCache, + List issues, + Set issueKeys) { + String pluginWorkflowId = getWorkflowIdInPluginNode(node); + if (!StringUtils.hasText(pluginWorkflowId)) { + addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_NOT_FOUND", + "插件节点未绑定有效工作流插件", node.id, null, node.name); + return; + } + if (StringUtils.hasText(currentWorkflowIdString) && currentWorkflowIdString.equals(pluginWorkflowId)) { + addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_CYCLE", + "插件递归引用:工作流不能通过插件引用自身", node.id, null, node.name); + return; + } + String workflowContent = loadWorkflowContent(pluginWorkflowId, currentWorkflowIdString, currentContent, contentCache); + if (!StringUtils.hasText(workflowContent)) { + addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_NOT_FOUND", + "插件绑定工作流不存在: " + pluginWorkflowId, node.id, null, node.name); + } + } + + private void checkPluginSchemaHashes(ParsedWorkflow parsed, + List issues, + Set issueKeys) { + for (NodeView node : parsed.nodes) { + if (!TYPE_PLUGIN.equals(node.type) || node.data == null) { + continue; + } + String pluginItemId = trimToNull(node.data.getString("pluginId")); + if (!StringUtils.hasText(pluginItemId)) { + continue; + } + String workflowId = workflowPluginDependencyService.resolveWorkflowIdByPluginItemId(pluginItemId); + if (!StringUtils.hasText(workflowId)) { + continue; + } + String latestSchemaHash = resolveLatestPluginSchemaHash(pluginItemId, workflowId); + if (!StringUtils.hasText(latestSchemaHash)) { + continue; + } + String currentSchemaHash = trimToNull(node.data.getString("schemaHash")); + if (!latestSchemaHash.equals(currentSchemaHash)) { + addIssue(issues, issueKeys, "PLUGIN_SCHEMA_OUTDATED", + "当前插件节点绑定工作流的已发布参数契约已更新,请重新选择插件同步节点定义", + node.id, null, node.name); + } + } + } + + private String getWorkflowIdInPluginNode(NodeView node) { + if (node == null || node.data == null) { + return null; + } + return workflowPluginDependencyService.resolveWorkflowIdByPluginItemId( + trimToNull(node.data.getString("pluginId")) + ); + } + + private String resolveLatestPluginSchemaHash(String pluginItemId, String workflowId) { + PluginItem pluginItem = pluginItemService.getById(pluginItemId); + if (pluginItem != null && StringUtils.hasText(pluginItem.getSchemaHash())) { + return pluginItem.getSchemaHash(); + } + Workflow workflow = workflowService.getPublishedById(new BigInteger(workflowId)); + if (workflow == null) { + return null; + } + return workflowPluginSnapshotResolver.resolveSchemaHash(workflow); + } + private String formatCyclePath(LinkedHashSet visiting, String cycleStart) { List chain = new ArrayList<>(); boolean started = false; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Plugin.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Plugin.java index 5a3a16d..bd6156b 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Plugin.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Plugin.java @@ -19,6 +19,18 @@ public class Plugin extends PluginBase { @RelationOneToMany(selfField = "id", targetField = "pluginId", targetTable = "tb_plugin_item") private List tools; + @com.mybatisflex.annotation.Column(ignore = true) + private String workflowTitle; + + @com.mybatisflex.annotation.Column(ignore = true) + private Boolean available; + + @com.mybatisflex.annotation.Column(ignore = true) + private String reasonCode; + + @com.mybatisflex.annotation.Column(ignore = true) + private String reasonMessage; + public String getTitle() { return this.getName(); } @@ -30,4 +42,36 @@ public class Plugin extends PluginBase { public void setTools(List tools) { this.tools = tools; } + + public String getWorkflowTitle() { + return workflowTitle; + } + + public void setWorkflowTitle(String workflowTitle) { + this.workflowTitle = workflowTitle; + } + + public Boolean getAvailable() { + return available; + } + + public void setAvailable(Boolean available) { + this.available = available; + } + + public String getReasonCode() { + return reasonCode; + } + + public void setReasonCode(String reasonCode) { + this.reasonCode = reasonCode; + } + + public String getReasonMessage() { + return reasonMessage; + } + + public void setReasonMessage(String reasonMessage) { + this.reasonMessage = reasonMessage; + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginBase.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginBase.java index 47e2c05..c633e2a 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginBase.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginBase.java @@ -42,6 +42,12 @@ public class PluginBase implements Serializable { @Column(comment = "类型") private Integer type; + /** + * 绑定工作流ID + */ + @Column(comment = "绑定工作流ID") + private BigInteger workflowId; + /** * 基础URL */ @@ -148,6 +154,14 @@ public class PluginBase implements Serializable { this.type = type; } + public BigInteger getWorkflowId() { + return workflowId; + } + + public void setWorkflowId(BigInteger workflowId) { + this.workflowId = workflowId; + } + public String getBaseUrl() { return baseUrl; } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginItemBase.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginItemBase.java index 1d1c189..dc860ed 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginItemBase.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/PluginItemBase.java @@ -90,6 +90,12 @@ public class PluginItemBase implements Serializable { @Column(comment = "英文名称") private String englishName; + /** + * 工作流插件输入输出契约哈希 + */ + @Column(comment = "工作流插件输入输出契约哈希") + private String schemaHash; + public BigInteger getId() { return id; } @@ -194,4 +200,12 @@ public class PluginItemBase implements Serializable { this.englishName = englishName; } + public String getSchemaHash() { + return schemaHash; + } + + public void setSchemaHash(String schemaHash) { + this.schemaHash = schemaHash; + } + } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/PluginType.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/PluginType.java new file mode 100644 index 0000000..7c114d5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/PluginType.java @@ -0,0 +1,52 @@ +package tech.easyflow.ai.enums; + +/** + * 插件类型枚举。 + */ +public enum PluginType { + + HTTP(1), + WORKFLOW(2); + + private final int code; + + PluginType(int code) { + this.code = code; + } + + /** + * 获取类型编码。 + * + * @return 类型编码 + */ + public int getCode() { + return code; + } + + /** + * 根据编码解析类型,空值或未知值均按 HTTP 处理,兼容历史数据。 + * + * @param code 类型编码 + * @return 插件类型 + */ + public static PluginType from(Integer code) { + if (code != null) { + for (PluginType value : values()) { + if (value.code == code) { + return value; + } + } + } + return HTTP; + } + + /** + * 判断是否为工作流插件。 + * + * @param code 类型编码 + * @return 是否为工作流插件 + */ + public static boolean isWorkflow(Integer code) { + return from(code) == WORKFLOW; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/PluginToolNode.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/PluginToolNode.java index 8cbcd17..abb00a7 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/PluginToolNode.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/PluginToolNode.java @@ -4,11 +4,23 @@ import com.easyagents.core.model.chat.tool.Tool; import com.alibaba.fastjson.JSON; import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.node.BaseNode; +import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PluginType; +import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision; +import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; +import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.utils.WorkFlowUtil; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.util.SpringContextUtil; import java.math.BigInteger; +import java.util.LinkedHashMap; import java.util.Collections; import java.util.Map; @@ -32,6 +44,11 @@ public class PluginToolNode extends BaseNode { if (tool == null) { return Collections.emptyMap(); } + PluginService pluginService = SpringContextUtil.getBean(PluginService.class); + Plugin plugin = pluginService.getById(tool.getPluginId()); + if (plugin != null && PluginType.isWorkflow(plugin.getType())) { + return executeWorkflowPlugin(chain, map, plugin); + } Tool function = tool.toFunction(); if (function == null) { return Collections.emptyMap(); @@ -49,6 +66,43 @@ public class PluginToolNode extends BaseNode { return JSON.parseObject(JSON.toJSONString(result), Map.class); } + private Map executeWorkflowPlugin(Chain chain, Map map, Plugin plugin) { + WorkflowPluginAvailabilityService availabilityService = + SpringContextUtil.getBean(WorkflowPluginAvailabilityService.class); + LoginAccount operator = WorkFlowUtil.getOperator(chain); + WorkflowPluginAvailabilityDecision decision = availabilityService.evaluate(plugin, operator); + if (!decision.isAvailable()) { + return buildSkippedResult(decision); + } + WorkflowService workflowService = SpringContextUtil.getBean(WorkflowService.class); + Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId()); + if (workflow == null) { + return buildSkippedResult(decision); + } + WorkflowPluginSnapshotResolver snapshotResolver = SpringContextUtil.getBean(WorkflowPluginSnapshotResolver.class); + Map workflowVariables = new LinkedHashMap<>(); + workflowVariables.put(Constants.LOGIN_USER_KEY, operator); + if (map != null && !map.isEmpty()) { + workflowVariables.putAll(map); + } + Object result = snapshotResolver.buildWorkflowTool(workflow).invoke(workflowVariables); + if (result == null) { + return Collections.emptyMap(); + } + if (result instanceof Map resultMap) { + return (Map) resultMap; + } + return JSON.parseObject(JSON.toJSONString(result), Map.class); + } + + private Map buildSkippedResult(WorkflowPluginAvailabilityDecision decision) { + Map result = new LinkedHashMap<>(); + result.put("skipped", true); + result.put("reasonCode", decision.getReasonCode()); + result.put("reasonMessage", decision.getReasonMessage()); + return result; + } + public BigInteger getPluginId() { return pluginId; } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityDecision.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityDecision.java new file mode 100644 index 0000000..5afbf69 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityDecision.java @@ -0,0 +1,67 @@ +package tech.easyflow.ai.plugin.workflow.availability; + +/** + * 工作流插件实时可用性判定结果。 + */ +public class WorkflowPluginAvailabilityDecision { + + private boolean visible; + + private boolean available; + + private boolean snapshotPresent; + + private String reasonCode; + + private String reasonMessage; + + private String workflowTitle; + + public boolean isVisible() { + return visible; + } + + public void setVisible(boolean visible) { + this.visible = visible; + } + + public boolean isAvailable() { + return available; + } + + public void setAvailable(boolean available) { + this.available = available; + } + + public boolean isSnapshotPresent() { + return snapshotPresent; + } + + public void setSnapshotPresent(boolean snapshotPresent) { + this.snapshotPresent = snapshotPresent; + } + + public String getReasonCode() { + return reasonCode; + } + + public void setReasonCode(String reasonCode) { + this.reasonCode = reasonCode; + } + + public String getReasonMessage() { + return reasonMessage; + } + + public void setReasonMessage(String reasonMessage) { + this.reasonMessage = reasonMessage; + } + + public String getWorkflowTitle() { + return workflowTitle; + } + + public void setWorkflowTitle(String workflowTitle) { + this.workflowTitle = workflowTitle; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityService.java new file mode 100644 index 0000000..052ef60 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityService.java @@ -0,0 +1,28 @@ +package tech.easyflow.ai.plugin.workflow.availability; + +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.common.entity.LoginAccount; + +/** + * 工作流插件可用性判定服务。 + */ +public interface WorkflowPluginAvailabilityService { + + /** + * 计算当前登录用户视角下的工作流插件可见性与可用性。 + * + * @param plugin 插件 + * @return 判定结果 + */ + WorkflowPluginAvailabilityDecision evaluateForCurrentUser(Plugin plugin); + + WorkflowPluginAvailabilityDecision evaluate(Plugin plugin, LoginAccount loginAccount); + + /** + * 判断当前用户是否可在管理页继续看到不可用插件。 + * + * @param plugin 插件 + * @return 是否允许在管理视角保留展示 + */ + boolean canViewUnavailableInManagement(Plugin plugin); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityServiceImpl.java new file mode 100644 index 0000000..f5e9553 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/availability/WorkflowPluginAvailabilityServiceImpl.java @@ -0,0 +1,126 @@ +package tech.easyflow.ai.plugin.workflow.availability; + +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PluginType; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.Map; + +/** + * 工作流插件实时可用性判定实现。 + */ +@Service +public class WorkflowPluginAvailabilityServiceImpl implements WorkflowPluginAvailabilityService { + + private final WorkflowService workflowService; + private final ResourceAccessService resourceAccessService; + private final CategoryPermissionService categoryPermissionService; + private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; + + public WorkflowPluginAvailabilityServiceImpl(WorkflowService workflowService, + ResourceAccessService resourceAccessService, + CategoryPermissionService categoryPermissionService, + WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver) { + this.workflowService = workflowService; + this.resourceAccessService = resourceAccessService; + this.categoryPermissionService = categoryPermissionService; + this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; + } + + /** + * {@inheritDoc} + */ + @Override + public WorkflowPluginAvailabilityDecision evaluateForCurrentUser(Plugin plugin) { + return evaluate(plugin, SaTokenUtil.getLoginAccount()); + } + + @Override + public WorkflowPluginAvailabilityDecision evaluate(Plugin plugin, LoginAccount loginAccount) { + WorkflowPluginAvailabilityDecision decision = new WorkflowPluginAvailabilityDecision(); + decision.setVisible(true); + decision.setAvailable(true); + if (plugin == null || !PluginType.isWorkflow(plugin.getType())) { + return decision; + } + BigInteger workflowId = plugin.getWorkflowId(); + if (workflowId == null) { + return unavailable(decision, "WORKFLOW_BINDING_MISSING", "当前插件未绑定工作流"); + } + + Workflow workflow = workflowService.getById(workflowId); + if (workflow == null) { + return unavailable(decision, "WORKFLOW_NOT_FOUND", "当前插件绑定的工作流不存在"); + } + decision.setWorkflowTitle(workflow.getTitle()); + + Map snapshot = workflow.getPublishedSnapshotJson(); + boolean snapshotPresent = !CollectionUtils.isEmpty(snapshot); + decision.setSnapshotPresent(snapshotPresent); + if (!snapshotPresent) { + return unavailable(decision, "WORKFLOW_SNAPSHOT_MISSING", "当前插件绑定工作流没有可用发布快照"); + } + + PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus()); + if (!publishStatus.isExternallyVisible()) { + return unavailable(decision, "WORKFLOW_OFFLINE", "当前节点绑定工作流已下线"); + } + Workflow publishedWorkflow = workflowService.toPublishedView(workflow); + if (!workflowPluginSnapshotResolver.isSupportedForWorkflowPlugin(publishedWorkflow)) { + return unavailable(decision, "WORKFLOW_MULTI_END_UNSUPPORTED", "当前节点绑定工作流包含多个结束节点,暂不支持作为插件使用"); + } + + if (!resourceAccessService.canAccess(loginAccount, CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE)) { + return unavailable(decision, "WORKFLOW_NO_PERMISSION", "当前用户无权使用目标工作流"); + } + return decision; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean canViewUnavailableInManagement(Plugin plugin) { + if (plugin == null) { + return false; + } + if (categoryPermissionService.isCurrentSuperAdmin()) { + return true; + } + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + if (loginAccount == null || loginAccount.getId() == null || plugin.getCreatedBy() == null) { + return false; + } + return loginAccount.getId().equals(BigInteger.valueOf(plugin.getCreatedBy())); + } + + /** + * 标记为不可见且不可用。 + * + * @param decision 判定结果 + * @param reasonCode 原因编码 + * @param reasonMessage 原因说明 + * @return 判定结果 + */ + private WorkflowPluginAvailabilityDecision unavailable(WorkflowPluginAvailabilityDecision decision, + String reasonCode, + String reasonMessage) { + decision.setVisible(false); + decision.setAvailable(false); + decision.setReasonCode(reasonCode); + decision.setReasonMessage(reasonMessage); + return decision; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingService.java new file mode 100644 index 0000000..51d3a70 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingService.java @@ -0,0 +1,34 @@ +package tech.easyflow.ai.plugin.workflow.binding; + +import tech.easyflow.ai.entity.Plugin; + +import java.math.BigInteger; + +/** + * 工作流插件绑定服务。 + */ +public interface WorkflowPluginBindingService { + + /** + * 创建工作流插件并生成系统维护工具。 + * + * @param plugin 插件 + * @return 已保存插件 + */ + Plugin saveWorkflowPlugin(Plugin plugin); + + /** + * 更新工作流插件并刷新系统维护工具。 + * + * @param plugin 插件 + * @return 是否更新成功 + */ + boolean updateWorkflowPlugin(Plugin plugin); + + /** + * 同步某个工作流关联的所有工作流插件。 + * + * @param workflowId 工作流 ID + */ + void syncByWorkflowId(BigInteger workflowId); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingServiceImpl.java new file mode 100644 index 0000000..ace623a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/binding/WorkflowPluginBindingServiceImpl.java @@ -0,0 +1,227 @@ +package tech.easyflow.ai.plugin.workflow.binding; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PluginType; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.mapper.PluginItemMapper; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.Date; +import java.util.List; + +/** + * 工作流插件绑定服务实现。 + */ +@Service +public class WorkflowPluginBindingServiceImpl implements WorkflowPluginBindingService { + + private final PluginMapper pluginMapper; + private final PluginItemMapper pluginItemMapper; + private final WorkflowService workflowService; + private final ResourceAccessService resourceAccessService; + private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; + private final WorkflowPluginDependencyService workflowPluginDependencyService; + + public WorkflowPluginBindingServiceImpl(PluginMapper pluginMapper, + PluginItemMapper pluginItemMapper, + WorkflowService workflowService, + ResourceAccessService resourceAccessService, + WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, + WorkflowPluginDependencyService workflowPluginDependencyService) { + this.pluginMapper = pluginMapper; + this.pluginItemMapper = pluginItemMapper; + this.workflowService = workflowService; + this.resourceAccessService = resourceAccessService; + this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; + this.workflowPluginDependencyService = workflowPluginDependencyService; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Plugin saveWorkflowPlugin(Plugin plugin) { + LoginAccount loginAccount = requireLogin(); + Workflow workflow = requirePublishedWorkflow(plugin.getWorkflowId(), "无权限绑定工作流"); + normalizeWorkflowPlugin(plugin, loginAccount); + int insert = pluginMapper.insert(plugin); + if (insert <= 0) { + throw new BusinessException("保存失败"); + } + PluginItem pluginItem = new PluginItem(); + pluginItem.setCreated(new Date()); + workflowPluginSnapshotResolver.syncPluginItemFromPublishedWorkflow(plugin, pluginItem, workflow.getId()); + if (pluginItemMapper.insert(pluginItem) <= 0) { + throw new BusinessException("保存工作流插件工具失败"); + } + return plugin; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateWorkflowPlugin(Plugin plugin) { + if (plugin.getId() == null) { + throw new BusinessException("插件ID不能为空"); + } + Plugin existed = pluginMapper.selectOneById(plugin.getId()); + if (existed == null) { + throw new BusinessException("插件不存在"); + } + if (PluginType.from(existed.getType()) != PluginType.WORKFLOW) { + throw new BusinessException("暂不支持在现有 HTTP 插件与工作流插件之间切换类型"); + } + + Workflow workflow = requirePublishedWorkflow(plugin.getWorkflowId(), "无权限绑定工作流"); + if (workflowPluginDependencyService.containsPluginReferenceTransitivelyInPublishedSnapshot(workflow.getId(), existed.getId())) { + throw new BusinessException("目标工作流已通过子流程或插件链路引用当前插件,无法形成递归绑定"); + } + normalizeWorkflowPlugin(plugin, null); + int updated = pluginMapper.update(plugin); + if (updated <= 0) { + throw new BusinessException("更新失败"); + } + syncSinglePlugin(existed.getId(), workflow.getId()); + return true; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void syncByWorkflowId(BigInteger workflowId) { + if (workflowId == null) { + return; + } + QueryWrapper wrapper = QueryWrapper.create() + .eq(Plugin::getWorkflowId, workflowId) + .eq(Plugin::getType, PluginType.WORKFLOW.getCode()); + List plugins = pluginMapper.selectListByQuery(wrapper); + for (Plugin plugin : plugins) { + syncSinglePlugin(plugin.getId(), workflowId); + } + } + + private void syncSinglePlugin(BigInteger pluginId, BigInteger workflowId) { + Plugin plugin = pluginMapper.selectOneById(pluginId); + if (plugin == null) { + return; + } + Workflow workflow = requirePublishedWorkflowForSync(workflowId); + PluginItem pluginItem = getOrCreateSystemTool(pluginId); + workflowPluginSnapshotResolver.syncPluginItemFromPublishedWorkflow(plugin, pluginItem, workflow.getId()); + if (pluginItem.getId() == null) { + pluginItem.setCreated(new Date()); + if (pluginItemMapper.insert(pluginItem) <= 0) { + throw new BusinessException("同步工作流插件工具失败"); + } + } else if (pluginItemMapper.update(pluginItem) <= 0) { + throw new BusinessException("同步工作流插件工具失败"); + } + } + + private PluginItem getOrCreateSystemTool(BigInteger pluginId) { + QueryWrapper wrapper = QueryWrapper.create().eq(PluginItem::getPluginId, pluginId); + List pluginItems = pluginItemMapper.selectListByQuery(wrapper); + if (pluginItems == null || pluginItems.isEmpty()) { + PluginItem pluginItem = new PluginItem(); + pluginItem.setPluginId(pluginId); + return pluginItem; + } + PluginItem pluginItem = pluginItems.get(0); + if (pluginItems.size() > 1) { + for (int i = 1; i < pluginItems.size(); i++) { + pluginItemMapper.deleteById(pluginItems.get(i).getId()); + } + } + return pluginItem; + } + + private Workflow requirePublishedWorkflow(BigInteger workflowId, String denyMessage) { + if (workflowId == null) { + throw new BusinessException("请选择已发布工作流"); + } + Workflow workflow = workflowService.getById(workflowId); + if (workflow == null) { + throw new BusinessException("工作流不存在"); + } + resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, denyMessage); + PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus()); + if (publishStatus != PublishStatus.PUBLISHED) { + throw new BusinessException("仅已发布工作流可被封装为插件"); + } + if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException("目标工作流缺少已发布快照"); + } + workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(workflowService.toPublishedView(workflow)); + return workflow; + } + + private Workflow requirePublishedWorkflowForSync(BigInteger workflowId) { + if (workflowId == null) { + throw new BusinessException("目标工作流不存在"); + } + Workflow workflow = workflowService.getById(workflowId); + if (workflow == null) { + throw new BusinessException("目标工作流不存在"); + } + PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus()); + if (publishStatus != PublishStatus.PUBLISHED) { + throw new BusinessException("仅已发布工作流可同步到工作流插件"); + } + if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException("目标工作流缺少已发布快照"); + } + workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(workflowService.toPublishedView(workflow)); + return workflow; + } + + private void normalizeWorkflowPlugin(Plugin plugin, LoginAccount loginAccount) { + plugin.setType(PluginType.WORKFLOW.getCode()); + plugin.setBaseUrl(null); + plugin.setAuthType(null); + plugin.setPosition(null); + plugin.setHeaders(null); + plugin.setTokenKey(null); + plugin.setTokenValue(null); + if (plugin.getCreated() == null) { + plugin.setCreated(new Date()); + } + if (loginAccount != null) { + plugin.setCreatedBy(loginAccount.getId().longValue()); + plugin.setDeptId(loginAccount.getDeptId() == null ? null : loginAccount.getDeptId().longValue()); + plugin.setTenantId(loginAccount.getTenantId() == null ? null : loginAccount.getTenantId().longValue()); + } + if (!StringUtils.hasText(plugin.getAlias())) { + plugin.setAlias(null); + } + } + + private LoginAccount requireLogin() { + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + if (loginAccount == null || loginAccount.getId() == null) { + throw new BusinessException("当前未登录"); + } + return loginAccount; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyService.java new file mode 100644 index 0000000..2b8aeb5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyService.java @@ -0,0 +1,73 @@ +package tech.easyflow.ai.plugin.workflow.dependency; + +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; + +import java.math.BigInteger; +import java.util.List; +import java.util.Set; + +/** + * 工作流插件依赖分析服务。 + */ +public interface WorkflowPluginDependencyService { + + /** + * 查询被某个工作流引用的插件列表。 + * + * @param workflowId 工作流 ID + * @return 插件引用列表 + */ + List listPluginsByWorkflowId(BigInteger workflowId); + + /** + * 解析工作流内容中通过插件间接引用到的工作流 ID。 + * + * @param content 工作流内容 + * @return 引用到的工作流 ID 集合 + */ + Set extractWorkflowIdsFromPluginNodes(String content); + + /** + * 判断工作流内容是否引用了指定插件。 + * + * @param content 工作流内容 + * @param pluginId 插件 ID + * @return 是否引用 + */ + boolean containsPluginReference(String content, BigInteger pluginId); + + /** + * 判断某个工作流是否经由子流程/工作流插件链路递归引用了指定插件。 + * + * @param workflowId 工作流 ID + * @param pluginId 插件 ID + * @return 是否存在递归引用 + */ + boolean containsPluginReferenceTransitively(BigInteger workflowId, BigInteger pluginId); + + /** + * 判断某个工作流的已发布快照是否经由子流程/工作流插件链路递归引用了指定插件。 + * + * @param workflowId 工作流 ID + * @param pluginId 插件 ID + * @return 是否存在递归引用 + */ + boolean containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger workflowId, BigInteger pluginId); + + /** + * 查询工作流插件。 + * + * @param pluginId 插件 ID + * @return 插件 + */ + Plugin getWorkflowPlugin(BigInteger pluginId); + + /** + * 根据插件工具 ID 解析目标工作流 ID。 + * + * @param pluginItemId 插件工具 ID + * @return 工作流 ID + */ + String resolveWorkflowIdByPluginItemId(String pluginItemId); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImpl.java new file mode 100644 index 0000000..29540d7 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImpl.java @@ -0,0 +1,334 @@ +package tech.easyflow.ai.plugin.workflow.dependency; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.enums.PluginType; +import tech.easyflow.ai.mapper.PluginItemMapper; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工作流插件依赖分析实现。 + */ +@Service +public class WorkflowPluginDependencyServiceImpl implements WorkflowPluginDependencyService { + + private static final String TYPE_PLUGIN = "plugin-node"; + private static final String TYPE_WORKFLOW = "workflow-node"; + + private final PluginMapper pluginMapper; + private final PluginItemMapper pluginItemMapper; + private final WorkflowService workflowService; + + public WorkflowPluginDependencyServiceImpl(PluginMapper pluginMapper, + PluginItemMapper pluginItemMapper, + WorkflowService workflowService) { + this.pluginMapper = pluginMapper; + this.pluginItemMapper = pluginItemMapper; + this.workflowService = workflowService; + } + + /** + * {@inheritDoc} + */ + @Override + public List listPluginsByWorkflowId(BigInteger workflowId) { + if (workflowId == null) { + return Collections.emptyList(); + } + QueryWrapper wrapper = QueryWrapper.create().eq(Plugin::getWorkflowId, workflowId); + List plugins = pluginMapper.selectListByQuery(wrapper); + if (plugins == null || plugins.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(plugins.size()); + for (Plugin plugin : plugins) { + if (!PluginType.isWorkflow(plugin.getType())) { + continue; + } + OfflineImpactBindingVo vo = new OfflineImpactBindingVo(); + vo.setId(plugin.getId()); + vo.setTitle(plugin.getName()); + result.add(vo); + } + return result; + } + + /** + * {@inheritDoc} + */ + @Override + public Set extractWorkflowIdsFromPluginNodes(String content) { + if (!StringUtils.hasText(content)) { + return Collections.emptySet(); + } + Set workflowIds = new LinkedHashSet<>(); + try { + Object parsed = JSON.parse(content); + if (!(parsed instanceof JSONObject root)) { + return workflowIds; + } + JSONArray nodes = root.getJSONArray("nodes"); + if (nodes == null) { + return workflowIds; + } + for (int i = 0; i < nodes.size(); i++) { + JSONObject node = nodes.getJSONObject(i); + if (node == null || !TYPE_PLUGIN.equals(node.getString("type"))) { + continue; + } + JSONObject data = node.getJSONObject("data"); + String pluginItemId = data == null ? null : data.getString("pluginId"); + if (!StringUtils.hasText(pluginItemId)) { + continue; + } + Plugin plugin = getPluginByPluginItemId(pluginItemId); + if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) { + continue; + } + workflowIds.add(String.valueOf(plugin.getWorkflowId())); + } + } catch (Exception ignored) { + // ignore + } + return workflowIds; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean containsPluginReference(String content, BigInteger pluginId) { + if (!StringUtils.hasText(content) || pluginId == null) { + return false; + } + try { + Object parsed = JSON.parse(content); + if (!(parsed instanceof JSONObject root)) { + return false; + } + JSONArray nodes = root.getJSONArray("nodes"); + if (nodes == null) { + return false; + } + String expected = String.valueOf(pluginId); + for (int i = 0; i < nodes.size(); i++) { + JSONObject node = nodes.getJSONObject(i); + if (node == null || !TYPE_PLUGIN.equals(node.getString("type"))) { + continue; + } + JSONObject data = node.getJSONObject("data"); + String pluginItemId = data == null ? null : data.getString("pluginId"); + Plugin plugin = getPluginByPluginItemId(pluginItemId); + if (plugin != null && expected.equals(String.valueOf(plugin.getId()))) { + return true; + } + } + } catch (Exception ignored) { + // ignore + } + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean containsPluginReferenceTransitively(BigInteger workflowId, BigInteger pluginId) { + if (workflowId == null || pluginId == null) { + return false; + } + return containsPluginReferenceTransitively( + String.valueOf(workflowId), + pluginId, + false, + new LinkedHashSet<>(), + new HashMap<>() + ); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger workflowId, BigInteger pluginId) { + if (workflowId == null || pluginId == null) { + return false; + } + return containsPluginReferenceTransitively( + String.valueOf(workflowId), + pluginId, + true, + new LinkedHashSet<>(), + new HashMap<>() + ); + } + + /** + * {@inheritDoc} + */ + @Override + public Plugin getWorkflowPlugin(BigInteger pluginId) { + if (pluginId == null) { + return null; + } + Plugin plugin = pluginMapper.selectOneById(pluginId); + if (plugin == null || !PluginType.isWorkflow(plugin.getType())) { + return null; + } + return plugin; + } + + /** + * {@inheritDoc} + */ + @Override + public String resolveWorkflowIdByPluginItemId(String pluginItemId) { + Plugin plugin = getPluginByPluginItemId(pluginItemId); + if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) { + return null; + } + return String.valueOf(plugin.getWorkflowId()); + } + + private Plugin getPluginByPluginItemId(String pluginItemId) { + PluginItem pluginItem = pluginItemMapper.selectOneById(pluginItemId); + if (pluginItem == null || pluginItem.getPluginId() == null) { + return null; + } + return pluginMapper.selectOneById(pluginItem.getPluginId()); + } + + private boolean containsPluginReferenceTransitively(String workflowId, + BigInteger pluginId, + boolean publishedOnly, + Set visitingWorkflowIds, + Map cache) { + if (!StringUtils.hasText(workflowId)) { + return false; + } + Boolean cached = cache.get(workflowId); + if (cached != null) { + return cached; + } + if (!visitingWorkflowIds.add(workflowId)) { + return false; + } + boolean result = false; + try { + String workflowContent = resolveWorkflowContent(workflowId, publishedOnly); + if (!StringUtils.hasText(workflowContent)) { + cache.put(workflowId, false); + return false; + } + result = containsPluginReferenceTransitivelyInContent( + workflowContent, + pluginId, + publishedOnly, + visitingWorkflowIds, + cache + ); + cache.put(workflowId, result); + return result; + } finally { + visitingWorkflowIds.remove(workflowId); + } + } + + private boolean containsPluginReferenceTransitivelyInContent(String content, + BigInteger pluginId, + boolean publishedOnly, + Set visitingWorkflowIds, + Map cache) { + if (!StringUtils.hasText(content) || pluginId == null) { + return false; + } + try { + Object parsed = JSON.parse(content); + if (!(parsed instanceof JSONObject root)) { + return false; + } + JSONArray nodes = root.getJSONArray("nodes"); + if (nodes == null || nodes.isEmpty()) { + return false; + } + String expectedPluginId = String.valueOf(pluginId); + for (int i = 0; i < nodes.size(); i++) { + JSONObject node = nodes.getJSONObject(i); + if (node == null) { + continue; + } + String nodeType = node.getString("type"); + JSONObject data = node.getJSONObject("data"); + if (TYPE_PLUGIN.equals(nodeType)) { + String pluginItemId = data == null ? null : data.getString("pluginId"); + Plugin plugin = getPluginByPluginItemId(pluginItemId); + if (plugin == null) { + continue; + } + if (expectedPluginId.equals(String.valueOf(plugin.getId()))) { + return true; + } + if (PluginType.isWorkflow(plugin.getType()) + && plugin.getWorkflowId() != null + && containsPluginReferenceTransitively( + String.valueOf(plugin.getWorkflowId()), + pluginId, + publishedOnly, + visitingWorkflowIds, + cache + )) { + return true; + } + continue; + } + if (TYPE_WORKFLOW.equals(nodeType)) { + String refWorkflowId = data == null ? null : data.getString("workflowId"); + if (containsPluginReferenceTransitively( + refWorkflowId, + pluginId, + publishedOnly, + visitingWorkflowIds, + cache + )) { + return true; + } + } + } + } catch (Exception ignored) { + // ignore + } + return false; + } + + private String resolveWorkflowContent(String workflowId, boolean publishedOnly) { + tech.easyflow.ai.entity.Workflow workflow = publishedOnly + ? getPublishedWorkflow(workflowId) + : workflowService.getById(workflowId); + return workflow == null ? null : workflow.getContent(); + } + + private tech.easyflow.ai.entity.Workflow getPublishedWorkflow(String workflowId) { + try { + return workflowService.getPublishedById(new BigInteger(workflowId)); + } catch (Exception ignored) { + return null; + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/snapshot/WorkflowPluginSnapshotResolver.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/snapshot/WorkflowPluginSnapshotResolver.java new file mode 100644 index 0000000..5140352 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/workflow/snapshot/WorkflowPluginSnapshotResolver.java @@ -0,0 +1,231 @@ +package tech.easyflow.ai.plugin.workflow.snapshot; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.Node; +import com.easyagents.flow.core.node.EndNode; +import org.springframework.stereotype.Service; +import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; +import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.easyagents.tool.WorkflowTool; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.service.WorkflowService; +import com.easyagents.flow.core.parser.ChainParser; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.List; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * 工作流插件快照解析服务。 + */ +@Service +public class WorkflowPluginSnapshotResolver { + + private final WorkflowService workflowService; + private final ChainParser chainParser; + private final WorkflowDatacenterContentService workflowDatacenterContentService; + + public WorkflowPluginSnapshotResolver(WorkflowService workflowService, + ChainParser chainParser, + WorkflowDatacenterContentService workflowDatacenterContentService) { + this.workflowService = workflowService; + this.chainParser = chainParser; + this.workflowDatacenterContentService = workflowDatacenterContentService; + } + + /** + * 使用已发布视图刷新工作流插件工具定义。 + * + * @param plugin 插件 + * @param pluginItem 插件工具 + * @param workflowId 工作流 ID + */ + public void syncPluginItemFromPublishedWorkflow(Plugin plugin, PluginItem pluginItem, java.math.BigInteger workflowId) { + Workflow workflow = workflowService.getPublishedById(workflowId); + if (workflow == null) { + throw new BusinessException("工作流不存在"); + } + assertSupportedForWorkflowPlugin(workflow); + ChainDefinition definition = parseDefinition(workflow); + pluginItem.setPluginId(plugin.getId()); + pluginItem.setName(plugin.getName()); + pluginItem.setEnglishName(workflow.getEnglishName()); + pluginItem.setDescription(resolveDescription(plugin, workflow)); + pluginItem.setBasePath(null); + pluginItem.setRequestMethod("WORKFLOW"); + JSONArray inputDefinitions = resolveInputDefinitions(definition); + JSONArray outputDefinitions = resolveOutputDefinitions(definition); + pluginItem.setInputData(JSON.toJSONString(inputDefinitions)); + pluginItem.setOutputData(JSON.toJSONString(outputDefinitions)); + pluginItem.setSchemaHash(resolveSchemaHash(inputDefinitions, outputDefinitions)); + pluginItem.setServiceStatus(1); + pluginItem.setStatus(1); + } + + /** + * 构建工作流插件运行工具。 + * + * @param workflow 工作流已发布视图 + * @return 工作流工具 + */ + public WorkflowTool buildWorkflowTool(Workflow workflow) { + return new WorkflowTool( + workflow, + false, + PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId())) + ); + } + + /** + * 解析输入参数定义。 + * + * @param definition 流程定义 + * @return 输入参数数组 + */ + public JSONArray resolveInputDefinitions(ChainDefinition definition) { + JSONArray inputs = JSON.parseArray(JSON.toJSONString(definition.getStartParameters())); + markWorkflowPluginInput(inputs); + return inputs == null ? new JSONArray() : inputs; + } + + /** + * 解析输出参数定义。 + * + * @param definition 流程定义 + * @return 输出参数数组 + */ + public JSONArray resolveOutputDefinitions(ChainDefinition definition) { + JSONArray outputs = new JSONArray(); + List nodes = definition.getNodes(); + if (nodes == null) { + return outputs; + } + for (Node node : nodes) { + if (node instanceof EndNode endNode) { + outputs = JSON.parseArray(JSON.toJSONString(endNode.getOutputDefs())); + } + } + return outputs == null ? new JSONArray() : outputs; + } + + /** + * 解析工作流插件契约哈希。 + * + * @param workflow 工作流 + * @return 哈希值 + */ + public String resolveSchemaHash(Workflow workflow) { + ChainDefinition definition = parseDefinition(workflow); + return resolveSchemaHash(resolveInputDefinitions(definition), resolveOutputDefinitions(definition)); + } + + /** + * 解析工作流插件契约哈希。 + * + * @param inputDefinitions 输入定义 + * @param outputDefinitions 输出定义 + * @return 哈希值 + */ + public String resolveSchemaHash(JSONArray inputDefinitions, JSONArray outputDefinitions) { + JSONObject payload = new JSONObject(); + payload.put("inputs", inputDefinitions == null ? new JSONArray() : inputDefinitions); + payload.put("outputs", outputDefinitions == null ? new JSONArray() : outputDefinitions); + return sha256Hex(JSON.toJSONString(payload)); + } + + /** + * 校验工作流是否支持被封装为工作流插件。 + * + * @param workflow 工作流 + */ + public void assertSupportedForWorkflowPlugin(Workflow workflow) { + ChainDefinition definition = parseDefinition(workflow); + int endNodeCount = countEndNodes(definition); + // 一期先收敛为“单结束节点才能封装为插件”,后续若要支持多结束节点, + // 需要先补齐统一输出契约、父流程节点 schema 同步和结果展示策略。 + if (endNodeCount != 1) { + throw new BusinessException("工作流插件仅支持单一结束节点,当前工作流不可封装为插件"); + } + } + + /** + * 判断工作流是否为单结束节点结构。 + * + * @param workflow 工作流 + * @return 单结束节点返回 true + */ + public boolean isSupportedForWorkflowPlugin(Workflow workflow) { + ChainDefinition definition = parseDefinition(workflow); + return countEndNodes(definition) == 1; + } + + /** + * 解析已发布工作流定义。 + * + * @param workflow 工作流已发布视图 + * @return 流程定义 + */ + public ChainDefinition parseDefinition(Workflow workflow) { + String preparedContent = workflowDatacenterContentService.prepareContent(workflow.getContent()); + return chainParser.parse(preparedContent); + } + + private String resolveDescription(Plugin plugin, Workflow workflow) { + if (plugin.getDescription() != null && !plugin.getDescription().isBlank()) { + return plugin.getDescription(); + } + return workflow.getDescription(); + } + + private void markWorkflowPluginInput(JSONArray parameters) { + if (parameters == null) { + return; + } + for (Object parameter : parameters) { + if (!(parameter instanceof com.alibaba.fastjson2.JSONObject obj)) { + continue; + } + obj.put("refType", "ref"); + JSONArray children = obj.getJSONArray("children"); + if (children != null) { + markWorkflowPluginInput(children); + } + } + } + + private int countEndNodes(ChainDefinition definition) { + List nodes = definition == null ? null : definition.getNodes(); + if (nodes == null || nodes.isEmpty()) { + return 0; + } + int count = 0; + for (Node node : nodes) { + if (node instanceof EndNode) { + count++; + } + } + return count; + } + + private String sha256Hex(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(source.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte current : bytes) { + builder.append(Character.forDigit((current >> 4) & 0xF, 16)); + builder.append(Character.forDigit(current & 0xF, 16)); + } + return builder.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm unavailable", e); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java index 45388b5..efcdb94 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Component; import tech.easyflow.ai.entity.BotWorkflow; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.BotWorkflowService; import tech.easyflow.ai.service.ResourceOfflineImpactService; import tech.easyflow.ai.service.WorkflowService; @@ -31,18 +33,24 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH private final ResourceAccessService resourceAccessService; private final BotWorkflowService botWorkflowService; private final ResourceOfflineImpactService resourceOfflineImpactService; + private final WorkflowPluginBindingService workflowPluginBindingService; + private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; public WorkflowApprovalSubjectHandler(WorkflowService workflowService, ResourceAccessService resourceAccessService, ApprovalInstanceService approvalInstanceService, BotWorkflowService botWorkflowService, ResourceOfflineImpactService resourceOfflineImpactService, + WorkflowPluginBindingService workflowPluginBindingService, + WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, ObjectMapper objectMapper) { super(approvalInstanceService, objectMapper); this.workflowService = workflowService; this.resourceAccessService = resourceAccessService; this.botWorkflowService = botWorkflowService; this.resourceOfflineImpactService = resourceOfflineImpactService; + this.workflowPluginBindingService = workflowPluginBindingService; + this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; } @Override @@ -116,6 +124,16 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH return snapshot; } + @Override + protected Map buildPublishSnapshot(Workflow resource, PublishStatus currentStatus) { + Map snapshot = super.buildPublishSnapshot(resource, currentStatus); + OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId()); + if (impact.isHasPluginBindings()) { + workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(resource); + } + return snapshot; + } + @Override protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) { Workflow update = new Workflow(); @@ -135,6 +153,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH update.setPublishedAt(new java.util.Date()); update.setPublishedBy(operatorId); workflowService.updateById(update); + workflowPluginBindingService.syncByWorkflowId(resourceId); } @Override @@ -162,6 +181,9 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH if (impact.isHasBotBindings()) { snapshot.put("botBindings", impact.getBotBindings()); } + if (impact.isHasPluginBindings()) { + snapshot.put("pluginBindings", impact.getPluginBindings()); + } } @Override diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java index fbdf62a..7ce65b6 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java @@ -28,4 +28,12 @@ public interface PluginItemService extends IService { Result pluginToolTest(String inputData, BigInteger pluginToolId); List getByPluginId(String id); + + /** + * 获取某个插件的系统维护工具。 + * + * @param pluginId 插件 ID + * @return 工具 + */ + PluginItem getSingleByPluginId(BigInteger pluginId); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginService.java index 5442e58..8551e59 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginService.java @@ -23,4 +23,22 @@ public interface PluginService extends IService { Result pageByCategory(Long pageNumber, Long pageSize, int category); boolean updatePlugin(Plugin plugin); + + /** + * 按当前用户视角过滤并补充工作流插件可用性信息。 + * + * @param plugins 插件列表 + * @param managementView 是否为管理视角 + * @param availableOnly 是否仅保留当前可用插件 + * @return 过滤后的插件列表 + */ + List preparePluginsForCurrentUser(List plugins, boolean managementView, boolean availableOnly); + + /** + * 补充单个插件的工作流可用性信息。 + * + * @param plugin 插件 + * @return 原插件 + */ + Plugin preparePluginForCurrentUser(Plugin plugin); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java index 12e8ea5..0793fde 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java @@ -31,6 +31,7 @@ import tech.easyflow.ai.easyagents.memory.RuntimeChatMemory; import tech.easyflow.ai.easyagents.tool.WorkflowTool; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.entity.*; +import tech.easyflow.ai.enums.PluginType; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.mapper.BotMapper; import tech.easyflow.ai.service.*; @@ -117,6 +118,8 @@ public class BotServiceImpl extends ServiceImpl implements BotSe @Resource private BotPluginService botPluginService; @Resource + private PluginService pluginService; + @Resource private PluginItemService pluginItemService; @Resource private BotMcpService botMcpService; @@ -508,6 +511,12 @@ public class BotServiceImpl extends ServiceImpl implements BotSe List pluginItems = pluginItemService.getMapper().selectListWithRelationsByQuery(queryTool); if (pluginItems != null && !pluginItems.isEmpty()) { for (PluginItem pluginItem : pluginItems) { + if (pluginItem.getPluginId() != null) { + Plugin plugin = pluginService.getById(pluginItem.getPluginId()); + if (plugin != null && PluginType.isWorkflow(plugin.getType())) { + continue; + } + } functionList.add(pluginItem.toFunction()); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginCategoryMappingServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginCategoryMappingServiceImpl.java index 0aeb31a..9a374e3 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginCategoryMappingServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginCategoryMappingServiceImpl.java @@ -1,15 +1,21 @@ package tech.easyflow.ai.service.impl; +import cn.hutool.core.collection.CollectionUtil; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginCategory; import tech.easyflow.ai.entity.PluginCategoryMapping; import tech.easyflow.ai.mapper.PluginCategoryMapper; import tech.easyflow.ai.mapper.PluginCategoryMappingMapper; +import tech.easyflow.ai.mapper.PluginMapper; import tech.easyflow.ai.service.PluginCategoryMappingService; +import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.service.CategoryPermissionService; import javax.annotation.Resource; import java.math.BigInteger; @@ -34,6 +40,12 @@ public class PluginCategoryMappingServiceImpl extends ServiceImpl targetCategoryIds = categoryIds == null ? Collections.emptyList() @@ -48,6 +65,8 @@ public class PluginCategoryMappingServiceImpl extends ServiceImpl targetCategoryIds) { + if (CollectionUtil.isEmpty(targetCategoryIds)) { + return; + } + QueryWrapper queryWrapper = QueryWrapper.create() + .select(PluginCategory::getId) + .in(PluginCategory::getId, targetCategoryIds); + List existedCategoryIds = pluginCategoryMapper.selectListByQueryAs(queryWrapper, BigInteger.class); + if (existedCategoryIds == null || existedCategoryIds.size() != new LinkedHashSet<>(targetCategoryIds).size()) { + throw new BusinessException("存在无效的插件分类"); + } + } + + private void assertCategoryAccess(List targetCategoryIds) { + if (CollectionUtil.isEmpty(targetCategoryIds)) { + return; + } + RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess("PLUGIN"); + if (!access.isRestricted()) { + return; + } + Set allowedCategoryIds = access.getCategoryIds(); + if (CollectionUtil.isEmpty(allowedCategoryIds)) { + throw new BusinessException("无权限关联所选插件分类"); + } + boolean allAllowed = targetCategoryIds.stream().allMatch(allowedCategoryIds::contains); + if (!allAllowed) { + throw new BusinessException("无权限关联所选插件分类"); + } + } + @Override public List getPluginCategories(BigInteger pluginId) { QueryWrapper categoryQueryWrapper = QueryWrapper.create().select("category_id") diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java index 2a7ccf0..ee8e2b2 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java @@ -5,14 +5,22 @@ import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.Plugin; -import tech.easyflow.ai.easyagents.tool.PluginTool; import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PluginType; +import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision; +import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.mapper.BotPluginMapper; import tech.easyflow.ai.mapper.PluginMapper; import tech.easyflow.ai.mapper.PluginItemMapper; import tech.easyflow.ai.service.PluginItemService; -import tech.easyflow.common.domain.Result; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.common.domain.Result; import javax.annotation.Resource; import java.math.BigInteger; @@ -37,9 +45,19 @@ public class PluginItemServiceImpl extends ServiceImpl result = new HashMap<>(); result.put("data", pluginItem); result.put("aiPlugin", plugin); + result.put("workflowSnapshot", buildWorkflowSnapshot(plugin)); return Result.ok(result); } @Override public boolean updatePlugin(PluginItem pluginItem) { + Plugin existedPlugin = resolvePluginByPluginItemId(pluginItem.getId()); + if (existedPlugin != null && PluginType.isWorkflow(existedPlugin.getType())) { + throw new BusinessException("工作流插件工具由系统自动维护,不支持手动修改"); + } int update = pluginItemMapper.update(pluginItem); if (update <= 0) { throw new BusinessException("修改失败"); @@ -113,10 +137,37 @@ public class PluginItemServiceImpl extends ServiceImpl pluginToolTest(String inputData, BigInteger pluginToolId) { - PluginItem pluginItem = new PluginItem(); - pluginItem.setId(pluginToolId); - pluginItem.setInputData(inputData); - PluginTool pluginTool = new PluginTool(pluginItem); + PluginItem pluginItem = pluginItemMapper.selectOneById(pluginToolId); + if (pluginItem == null) { + throw new BusinessException("插件工具不存在"); + } + Plugin plugin = pluginMapper.selectOneById(pluginItem.getPluginId()); + if (plugin == null) { + throw new BusinessException("插件不存在"); + } + plugin = preparePluginForCurrentUser(plugin); + if (PluginType.isWorkflow(plugin.getType())) { + WorkflowPluginAvailabilityDecision decision = workflowPluginAvailabilityService.evaluateForCurrentUser(plugin); + if (!decision.isAvailable()) { + return Result.ok(buildUnavailableResult(decision)); + } + Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId()); + if (workflow == null) { + return Result.ok(buildUnavailableResult(decision)); + } + Map args = com.alibaba.fastjson2.JSON.parseObject(inputData, Map.class); + Map variables = new LinkedHashMap<>(); + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + if (loginAccount != null) { + variables.put(Constants.LOGIN_USER_KEY, loginAccount); + } + if (args != null && !args.isEmpty()) { + variables.putAll(args); + } + Object result = workflowPluginSnapshotResolver.buildWorkflowTool(workflow).invoke(variables); + return Result.ok(result); + } + tech.easyflow.ai.easyagents.tool.PluginTool pluginTool = new tech.easyflow.ai.easyagents.tool.PluginTool(pluginItem); return Result.ok(pluginTool.runPluginTool(null, inputData, pluginToolId)); } @@ -129,4 +180,82 @@ public class PluginItemServiceImpl extends ServiceImpl items = pluginItemMapper.selectListByQuery(queryWrapper); + return items == null || items.isEmpty() ? null : items.get(0); + } + + /** + * 按当前用户视角补充单个插件的可用性信息。 + * + * @param plugin 插件 + * @return 已补充可用性信息的插件 + */ + private Plugin preparePluginForCurrentUser(Plugin plugin) { + if (plugin == null) { + return null; + } + plugin.setType(PluginType.from(plugin.getType()).getCode()); + if (!PluginType.isWorkflow(plugin.getType())) { + plugin.setAvailable(true); + plugin.setReasonCode(null); + plugin.setReasonMessage(null); + return plugin; + } + WorkflowPluginAvailabilityDecision decision = workflowPluginAvailabilityService.evaluateForCurrentUser(plugin); + plugin.setWorkflowTitle(decision.getWorkflowTitle()); + plugin.setAvailable(decision.isAvailable()); + plugin.setReasonCode(decision.getReasonCode()); + plugin.setReasonMessage(decision.getReasonMessage()); + return plugin; + } + + private Plugin resolvePluginByPluginItemId(BigInteger pluginItemId) { + if (pluginItemId == null) { + return null; + } + PluginItem existed = pluginItemMapper.selectOneById(pluginItemId); + if (existed == null || existed.getPluginId() == null) { + return null; + } + return pluginMapper.selectOneById(existed.getPluginId()); + } + + private Map buildUnavailableResult(WorkflowPluginAvailabilityDecision decision) { + Map result = new LinkedHashMap<>(); + result.put("skipped", true); + result.put("reasonCode", decision.getReasonCode()); + result.put("reasonMessage", decision.getReasonMessage()); + return result; + } + + /** + * 构建工作流插件对应的已发布快照摘要。 + * + * @param plugin 插件 + * @return 快照摘要,不存在时返回 {@code null} + */ + private Map buildWorkflowSnapshot(Plugin plugin) { + if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) { + return null; + } + Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId()); + if (workflow == null) { + return null; + } + Map snapshot = new LinkedHashMap<>(); + snapshot.put("title", workflow.getTitle()); + snapshot.put("description", workflow.getDescription()); + snapshot.put("content", workflow.getContent()); + return snapshot; + } + } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java index 84721bc..e14eb77 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java @@ -9,14 +9,20 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tech.easyflow.ai.entity.*; +import tech.easyflow.ai.enums.PluginType; import tech.easyflow.ai.mapper.PluginCategoryMappingMapper; import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision; +import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService; +import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.service.BotPluginService; import tech.easyflow.ai.service.PluginItemService; import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.common.domain.Result; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.service.CategoryPermissionService; @@ -60,9 +66,18 @@ public class PluginServiceImpl extends ServiceImpl impleme private CategoryPermissionService categoryPermissionService; @Resource private PluginVisibilityService pluginVisibilityService; + @Resource + private WorkflowPluginBindingService workflowPluginBindingService; + @Resource + private WorkflowPluginAvailabilityService workflowPluginAvailabilityService; @Override public Plugin savePlugin(Plugin plugin) { + PluginType pluginType = PluginType.from(plugin.getType()); + if (pluginType == PluginType.WORKFLOW) { + return workflowPluginBindingService.saveWorkflowPlugin(plugin); + } + normalizeHttpPlugin(plugin, true); plugin.setCreated(new Date()); int insert = pluginMapper.insert(plugin); if (insert <= 0) { @@ -143,23 +158,89 @@ public class PluginServiceImpl extends ServiceImpl impleme return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, 0L)); } + List totalPlugins = preparePluginsForCurrentUser(queryPluginsByIds(visiblePluginIds), true, false); int fromIndex = Math.max(0, Math.toIntExact((pageNumber - 1) * pageSize)); - if (fromIndex >= visiblePluginIds.size()) { - return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, visiblePluginIds.size())); + if (fromIndex >= totalPlugins.size()) { + return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, totalPlugins.size())); } - int toIndex = Math.min(visiblePluginIds.size(), Math.toIntExact(fromIndex + pageSize)); - List currentPagePluginIds = new ArrayList<>(visiblePluginIds.subList(fromIndex, toIndex)); - List plugins = queryPluginsByIds(currentPagePluginIds); - Page aiPluginPage = new Page<>(plugins, pageNumber, pageSize, visiblePluginIds.size()); + int toIndex = Math.min(totalPlugins.size(), Math.toIntExact(fromIndex + pageSize)); + Page aiPluginPage = new Page<>(new ArrayList<>(totalPlugins.subList(fromIndex, toIndex)), + pageNumber, pageSize, totalPlugins.size()); return Result.ok(aiPluginPage); } @Override public boolean updatePlugin(Plugin plugin) { + Plugin existed = pluginMapper.selectOneById(plugin.getId()); + if (existed == null) { + throw new BusinessException("插件不存在"); + } + PluginType existedType = PluginType.from(existed.getType()); + PluginType targetType = PluginType.from(plugin.getType() == null ? existed.getType() : plugin.getType()); + if (existedType != targetType) { + throw new BusinessException("暂不支持切换插件类型"); + } + if (targetType == PluginType.WORKFLOW) { + return workflowPluginBindingService.updateWorkflowPlugin(plugin); + } + normalizeHttpPlugin(plugin, false); pluginMapper.update(plugin); return true; } + /** + * {@inheritDoc} + */ + @Override + public List preparePluginsForCurrentUser(List plugins, boolean managementView, boolean availableOnly) { + if (plugins == null || plugins.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (Plugin plugin : plugins) { + Plugin prepared = preparePluginForCurrentUser(plugin); + if (!PluginType.isWorkflow(prepared.getType())) { + result.add(prepared); + continue; + } + boolean canKeepUnavailable = managementView && workflowPluginAvailabilityService.canViewUnavailableInManagement(prepared); + if (Boolean.TRUE.equals(prepared.getAvailable())) { + result.add(prepared); + continue; + } + if (availableOnly) { + continue; + } + if (canKeepUnavailable) { + result.add(prepared); + } + } + return result; + } + + /** + * {@inheritDoc} + */ + @Override + public Plugin preparePluginForCurrentUser(Plugin plugin) { + if (plugin == null) { + return null; + } + plugin.setType(PluginType.from(plugin.getType()).getCode()); + if (!PluginType.isWorkflow(plugin.getType())) { + plugin.setAvailable(true); + plugin.setReasonCode(null); + plugin.setReasonMessage(null); + return plugin; + } + WorkflowPluginAvailabilityDecision decision = workflowPluginAvailabilityService.evaluateForCurrentUser(plugin); + plugin.setWorkflowTitle(decision.getWorkflowTitle()); + plugin.setAvailable(decision.isAvailable()); + plugin.setReasonCode(decision.getReasonCode()); + plugin.setReasonMessage(decision.getReasonMessage()); + return plugin; + } + private List queryCreatorPluginIds(List pluginIds, Long creatorId) { if (CollectionUtil.isEmpty(pluginIds) || creatorId == null) { return Collections.emptyList(); @@ -175,7 +256,7 @@ public class PluginServiceImpl extends ServiceImpl impleme return Collections.emptyList(); } QueryWrapper queryPluginWrapper = QueryWrapper.create().select().in(Plugin::getId, pluginIds); - List plugins = pluginMapper.selectListByQuery(queryPluginWrapper); + List plugins = pluginMapper.selectListWithRelationsByQuery(queryPluginWrapper); Map pluginMap = plugins.stream().collect(Collectors.toMap( Plugin::getId, item -> item, @@ -192,5 +273,24 @@ public class PluginServiceImpl extends ServiceImpl impleme return orderedPlugins; } + /** + * 归一化 HTTP 插件基础字段。 + * + * @param plugin 插件 + * @param isSave 是否为创建 + */ + private void normalizeHttpPlugin(Plugin plugin, boolean isSave) { + plugin.setType(PluginType.HTTP.getCode()); + plugin.setWorkflowId(null); + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + if (isSave && loginAccount != null && loginAccount.getId() != null) { + plugin.setCreatedBy(loginAccount.getId().longValue()); + plugin.setDeptId(loginAccount.getDeptId() == null ? null : loginAccount.getDeptId().longValue()); + plugin.setTenantId(loginAccount.getTenantId() == null ? null : loginAccount.getTenantId().longValue()); + } + if (plugin.getHeaders() != null && !(plugin.getHeaders() instanceof String)) { + plugin.setHeaders(plugin.getHeaders().toString()); + } + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java index fb7169e..1716053 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java @@ -11,6 +11,7 @@ import tech.easyflow.ai.entity.Bot; import tech.easyflow.ai.entity.BotDocumentCollection; import tech.easyflow.ai.entity.BotWorkflow; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; import tech.easyflow.ai.service.BotDocumentCollectionService; import tech.easyflow.ai.service.BotService; import tech.easyflow.ai.service.BotWorkflowService; @@ -49,17 +50,20 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe private final BotService botService; private final WorkflowService workflowService; private final RedisLockExecutor redisLockExecutor; + private final WorkflowPluginDependencyService workflowPluginDependencyService; public ResourceOfflineImpactServiceImpl(BotWorkflowService botWorkflowService, BotDocumentCollectionService botDocumentCollectionService, BotService botService, WorkflowService workflowService, - RedisLockExecutor redisLockExecutor) { + RedisLockExecutor redisLockExecutor, + WorkflowPluginDependencyService workflowPluginDependencyService) { this.botWorkflowService = botWorkflowService; this.botDocumentCollectionService = botDocumentCollectionService; this.botService = botService; this.workflowService = workflowService; this.redisLockExecutor = redisLockExecutor; + this.workflowPluginDependencyService = workflowPluginDependencyService; } /** @@ -68,15 +72,16 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe @Override public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) { List botBindings = listBotsByWorkflowId(workflowId); + List pluginBindings = workflowPluginDependencyService.listPluginsByWorkflowId(workflowId); OfflineImpactCheckVo result = new OfflineImpactCheckVo(); result.setCanProceed(true); result.setBotBindings(botBindings); result.setHasBotBindings(!botBindings.isEmpty()); + result.setPluginBindings(pluginBindings); + result.setHasPluginBindings(!pluginBindings.isEmpty()); result.setWorkflowUsages(Collections.emptyList()); result.setHasWorkflowUsages(false); - result.setMessage(botBindings.isEmpty() - ? "当前工作流下线后不会影响已有绑定" - : "当前工作流下线成功后,将自动从相关聊天助手中解绑"); + result.setMessage(resolveWorkflowOfflineImpactMessage(botBindings, pluginBindings)); return result; } @@ -198,6 +203,20 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe return result; } + private String resolveWorkflowOfflineImpactMessage(List botBindings, + List pluginBindings) { + if (!pluginBindings.isEmpty() && !botBindings.isEmpty()) { + return "当前工作流被插件和聊天助手引用,下线后插件将不可用,聊天助手将自动解绑"; + } + if (!pluginBindings.isEmpty()) { + return "当前工作流被插件引用,下线后相关插件将不可用"; + } + if (!botBindings.isEmpty()) { + return "当前工作流下线成功后,将自动从相关聊天助手中解绑"; + } + return "当前工作流下线后不会影响已有绑定"; + } + private boolean containsKnowledgeReference(String content, BigInteger knowledgeId) { if (!StringUtils.hasText(content) || knowledgeId == null) { return false; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java index ea58be8..439c30b 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java @@ -14,10 +14,14 @@ public class OfflineImpactCheckVo { private boolean hasWorkflowUsages; + private boolean hasPluginBindings; + private List botBindings = new ArrayList<>(); private List workflowUsages = new ArrayList<>(); + private List pluginBindings = new ArrayList<>(); + private String message; /** @@ -110,6 +114,22 @@ public class OfflineImpactCheckVo { this.workflowUsages = workflowUsages; } + public boolean isHasPluginBindings() { + return hasPluginBindings; + } + + public void setHasPluginBindings(boolean hasPluginBindings) { + this.hasPluginBindings = hasPluginBindings; + } + + public List getPluginBindings() { + return pluginBindings; + } + + public void setPluginBindings(List pluginBindings) { + this.pluginBindings = pluginBindings; + } + /** * 获取提示信息。 * diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImplTest.java new file mode 100644 index 0000000..37d506f --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/plugin/workflow/dependency/WorkflowPluginDependencyServiceImplTest.java @@ -0,0 +1,252 @@ +package tech.easyflow.ai.plugin.workflow.dependency; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PluginType; +import tech.easyflow.ai.mapper.PluginItemMapper; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.service.WorkflowService; + +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; + +public class WorkflowPluginDependencyServiceImplTest { + + @Test + public void testPublishedCheckShouldIgnoreUnpublishedDraftCycle() { + WorkflowPluginDependencyServiceImpl service = newService( + workflows( + workflowVariant(rootWorkflowContent("2"), rootWorkflowContent("2")), + workflowVariant(pluginWorkflowContent("700"), terminalWorkflowContent()) + ), + plugins(), + pluginItems() + ); + + Assert.assertTrue(service.containsPluginReferenceTransitively(BigInteger.ONE, BigInteger.valueOf(900))); + Assert.assertFalse(service.containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger.ONE, BigInteger.valueOf(900))); + } + + @Test + public void testPublishedCheckShouldBlockPublishedCycleEvenWhenDraftClean() { + WorkflowPluginDependencyServiceImpl service = newService( + workflows( + workflowVariant(rootWorkflowContent("2"), rootWorkflowContent("2")), + workflowVariant(terminalWorkflowContent(), pluginWorkflowContent("700")) + ), + plugins(), + pluginItems() + ); + + Assert.assertFalse(service.containsPluginReferenceTransitively(BigInteger.ONE, BigInteger.valueOf(900))); + Assert.assertTrue(service.containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger.ONE, BigInteger.valueOf(900))); + } + + private static WorkflowPluginDependencyServiceImpl newService(Map workflowStore, + Map pluginStore, + Map pluginItemStore) { + return new WorkflowPluginDependencyServiceImpl( + mockPluginMapper(pluginStore), + mockPluginItemMapper(pluginItemStore), + mockWorkflowService(workflowStore) + ); + } + + private static Map workflows(WorkflowVariant root, WorkflowVariant child) { + Map workflows = new HashMap<>(); + workflows.put("1", root); + workflows.put("2", child); + return workflows; + } + + private static Map plugins() { + Map plugins = new HashMap<>(); + Plugin plugin = new Plugin(); + plugin.setId(BigInteger.valueOf(900)); + plugin.setType(PluginType.WORKFLOW.getCode()); + plugin.setWorkflowId(BigInteger.valueOf(30)); + plugins.put("900", plugin); + return plugins; + } + + private static Map pluginItems() { + Map pluginItems = new HashMap<>(); + PluginItem pluginItem = new PluginItem(); + pluginItem.setId(BigInteger.valueOf(700)); + pluginItem.setPluginId(BigInteger.valueOf(900)); + pluginItems.put("700", pluginItem); + return pluginItems; + } + + private static WorkflowService mockWorkflowService(Map workflowStore) { + return (WorkflowService) Proxy.newProxyInstance( + WorkflowService.class.getClassLoader(), + new Class[]{WorkflowService.class}, + (proxy, method, args) -> { + String methodName = method.getName(); + if ("getById".equals(methodName)) { + return buildWorkflow(workflowStore, args == null ? null : args[0], false); + } + if ("getPublishedById".equals(methodName)) { + return buildWorkflow(workflowStore, args == null ? null : args[0], true); + } + if ("equals".equals(methodName)) { + return proxy == args[0]; + } + if ("hashCode".equals(methodName)) { + return System.identityHashCode(proxy); + } + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == long.class) { + return 0L; + } + return null; + } + ); + } + + private static PluginMapper mockPluginMapper(Map pluginStore) { + return (PluginMapper) Proxy.newProxyInstance( + PluginMapper.class.getClassLoader(), + new Class[]{PluginMapper.class}, + (proxy, method, args) -> { + if ("selectOneById".equals(method.getName())) { + return pluginStore.get(String.valueOf(args[0])); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == long.class) { + return 0L; + } + return null; + } + ); + } + + private static PluginItemMapper mockPluginItemMapper(Map pluginItemStore) { + return (PluginItemMapper) Proxy.newProxyInstance( + PluginItemMapper.class.getClassLoader(), + new Class[]{PluginItemMapper.class}, + (proxy, method, args) -> { + if ("selectOneById".equals(method.getName())) { + return pluginItemStore.get(String.valueOf(args[0])); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == long.class) { + return 0L; + } + return null; + } + ); + } + + private static Workflow buildWorkflow(Map workflowStore, Object idValue, boolean published) { + if (idValue == null) { + return null; + } + WorkflowVariant variant = workflowStore.get(String.valueOf(idValue)); + if (variant == null) { + return null; + } + Workflow workflow = new Workflow(); + try { + workflow.setId(new BigInteger(String.valueOf(idValue))); + } catch (Exception ignored) { + workflow.setId(null); + } + workflow.setContent(published ? variant.publishedContent : variant.draftContent); + return workflow; + } + + private static WorkflowVariant workflowVariant(String draftContent, String publishedContent) { + WorkflowVariant variant = new WorkflowVariant(); + variant.draftContent = draftContent; + variant.publishedContent = publishedContent; + return variant; + } + + private static String rootWorkflowContent(String childWorkflowId) { + return workflowJson(array(workflowNode("wf-1", childWorkflowId))); + } + + private static String pluginWorkflowContent(String pluginItemId) { + return workflowJson(array(pluginNode("plugin-1", pluginItemId))); + } + + private static String terminalWorkflowContent() { + return workflowJson(new JSONArray()); + } + + private static String workflowJson(JSONArray nodes) { + JSONObject root = new JSONObject(); + root.put("nodes", nodes); + root.put("edges", new JSONArray()); + return root.toJSONString(); + } + + private static JSONArray array(JSONObject... objects) { + JSONArray array = new JSONArray(); + for (JSONObject object : objects) { + array.add(object); + } + return array; + } + + private static JSONObject workflowNode(String id, String workflowId) { + JSONObject data = new JSONObject(); + data.put("workflowId", workflowId); + return node(id, "workflow-node", data); + } + + private static JSONObject pluginNode(String id, String pluginItemId) { + JSONObject data = new JSONObject(); + data.put("pluginId", pluginItemId); + return node(id, "plugin-node", data); + } + + private static JSONObject node(String id, String type, JSONObject data) { + JSONObject node = new JSONObject(); + node.put("id", id); + node.put("type", type); + node.put("data", data); + return node; + } + + private static class WorkflowVariant { + private String draftContent; + private String publishedContent; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/CategoryPermissionService.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/CategoryPermissionService.java index 14c5c92..1a613c0 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/CategoryPermissionService.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/CategoryPermissionService.java @@ -1,5 +1,6 @@ package tech.easyflow.system.service; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import java.math.BigInteger; @@ -9,11 +10,17 @@ public interface CategoryPermissionService { boolean isCurrentSuperAdmin(); + boolean isSuperAdmin(LoginAccount loginAccount); + RoleCategoryAccessSnapshot getCurrentAccess(String resourceType); + RoleCategoryAccessSnapshot getAccess(String resourceType, LoginAccount loginAccount); + Set getCurrentVisibleCategoryIds(String resourceType); boolean canAccessCategory(String resourceType, BigInteger createdBy, BigInteger categoryId); + boolean canAccessCategory(LoginAccount loginAccount, String resourceType, BigInteger createdBy, BigInteger categoryId); + void assertCategoryResourceVisible(String resourceType, BigInteger createdBy, BigInteger categoryId, String message); } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/ResourceAccessService.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/ResourceAccessService.java index 29a5cbb..c88288b 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/ResourceAccessService.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/ResourceAccessService.java @@ -1,5 +1,6 @@ package tech.easyflow.system.service; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.permission.resource.VisibilityResource; @@ -8,5 +9,7 @@ public interface ResourceAccessService { boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action); + boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action); + void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message); } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java index 0640a0c..9b30361 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/CategoryPermissionServiceImpl.java @@ -44,6 +44,11 @@ public class CategoryPermissionServiceImpl implements CategoryPermissionService return false; } LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + return isSuperAdmin(loginAccount); + } + + @Override + public boolean isSuperAdmin(LoginAccount loginAccount) { return loginAccount != null && isSuperAdmin(loginAccount.getId()); } @@ -53,6 +58,11 @@ public class CategoryPermissionServiceImpl implements CategoryPermissionService return new RoleCategoryAccessSnapshot(resourceType, null, false, true, Collections.emptySet()); } LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + return getAccess(resourceType, loginAccount); + } + + @Override + public RoleCategoryAccessSnapshot getAccess(String resourceType, LoginAccount loginAccount) { if (loginAccount == null) { return new RoleCategoryAccessSnapshot(resourceType, null, false, true, Collections.emptySet()); } @@ -100,6 +110,12 @@ public class CategoryPermissionServiceImpl implements CategoryPermissionService return snapshot.canAccess(createdBy, categoryId); } + @Override + public boolean canAccessCategory(LoginAccount loginAccount, String resourceType, BigInteger createdBy, BigInteger categoryId) { + RoleCategoryAccessSnapshot snapshot = getAccess(resourceType, loginAccount); + return snapshot.canAccess(createdBy, categoryId); + } + @Override public void assertCategoryResourceVisible(String resourceType, BigInteger createdBy, BigInteger categoryId, String message) { if (!canAccessCategory(resourceType, createdBy, categoryId)) { diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java index e88a414..d46bd97 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java @@ -26,15 +26,19 @@ public class ResourceAccessServiceImpl implements ResourceAccessService { @Override public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) { + return canAccess(SaTokenUtil.getLoginAccount(), resourceType, resource, action); + } + + @Override + public boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) { if (resource == null) { return false; } - LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); if (loginAccount == null || loginAccount.getId() == null) { return false; } BigInteger accountId = loginAccount.getId(); - if (categoryPermissionService.isCurrentSuperAdmin()) { + if (categoryPermissionService.isSuperAdmin(loginAccount)) { return true; } if (accountId.equals(resource.getCreatedBy())) { @@ -43,7 +47,7 @@ public class ResourceAccessServiceImpl implements ResourceAccessService { if (ResourceAction.MANAGE == action) { return false; } - if (!categoryPermissionService.canAccessCategory(resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) { + if (!categoryPermissionService.canAccessCategory(loginAccount, resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) { return false; } VisibilityScope scope = VisibilityScope.fromOrDefault(resource.getVisibilityScope(), VisibilityScope.PRIVATE); diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V10__mysql_workflow_plugin_schema_hash_patch.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V10__mysql_workflow_plugin_schema_hash_patch.sql new file mode 100644 index 0000000..737004a --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V10__mysql_workflow_plugin_schema_hash_patch.sql @@ -0,0 +1,2 @@ +ALTER TABLE tb_plugin_item + ADD COLUMN schema_hash varchar(128) NULL COMMENT '工作流插件输入输出契约哈希' AFTER english_name; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V9__mysql_workflow_plugin_patch.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V9__mysql_workflow_plugin_patch.sql new file mode 100644 index 0000000..c27d204 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V9__mysql_workflow_plugin_patch.sql @@ -0,0 +1,8 @@ +ALTER TABLE `tb_plugin` + ADD COLUMN `workflow_id` bigint NULL DEFAULT NULL COMMENT '绑定工作流ID' AFTER `type`; + +UPDATE `tb_plugin` +SET `type` = 1 +WHERE `type` IS NULL; + +CREATE INDEX `idx_tb_plugin_workflow_id` ON `tb_plugin` (`workflow_id`); diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json index c7d4c4d..0af5fdd 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json @@ -75,6 +75,9 @@ "submitDeleteApprovalConfirm": "Delete the current workflow?", "offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:", "offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.", + "offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:", + "offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.", + "offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from bots and mark the related plugins as unavailable.", "publishPendingHint": "There is already an approval in progress for this workflow.", "deletePendingHint": "There is already an approval in progress for this workflow.", "check": "Check", diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/plugin.json b/easyflow-ui-admin/app/src/locales/langs/en-US/plugin.json index a2589e4..b2c883a 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/plugin.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/plugin.json @@ -5,7 +5,11 @@ "name": "Name", "description": "Description", "type": "Type", + "typeHttp": "HTTP Plugin", + "typeWorkflow": "Workflow Plugin", "baseUrl": "BaseUrl", + "workflowId": "Bound workflow", + "workflowTitle": "Workflow title", "authType": "AuthType", "created": "Created", "icon": "Icon", @@ -20,7 +24,8 @@ "placeholder": { "name": "Please enter plugin name", "description": "Please enter plugin description", - "categorize": "Please enter categorize" + "categorize": "Please enter categorize", + "workflow": "Please select a published workflow" }, "button": { "addPlugin": "Add Plugin", @@ -29,5 +34,11 @@ }, "toolsManagement": "Tools Management", "searchUsers": "Search Users", - "parameterValue": "ParameterValue" + "parameterValue": "ParameterValue", + "workflow": "Workflow", + "workflowPluginHint": "Workflow plugins mirror the published snapshot of the target workflow. Availability is evaluated in real time against workflow permissions and approval status.", + "workflowPluginUnavailable": "This workflow plugin is unavailable", + "workflowSnapshotSynced": "Published snapshot synced", + "reasonMessage": "Reason", + "onlyPublishedWorkflow": "Only published workflows that you can currently access are selectable." } diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/pluginItem.json b/easyflow-ui-admin/app/src/locales/langs/en-US/pluginItem.json index 54dc215..2f464b8 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/pluginItem.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/pluginItem.json @@ -13,6 +13,7 @@ "debugStatus": "DebugStatus", "englishName": "EnglishName", "createPluginTool": "Create tool", + "systemManaged": "System synced", "pluginToolEdit": { "basicInfo": "Basic Info", "configureInputParameters": "Configure input parameters", @@ -21,11 +22,16 @@ "toolPath": "Tool path", "requestMethod": "RequestMethod", "runResult": "Run result", - "run": "run" + "run": "run", + "workflowTarget": "Target workflow", + "unavailableHint": "The bound workflow is currently unavailable, so execution will not be started.", + "runWorkflowStepsEmpty": "After starting a trial run, each node execution result will be shown here.", + "workflowStepsPending": "The trial run has started. Waiting for node execution details..." }, "parameterName": "Name", "parameterDescription": "Description", "parameterType": "Type", + "direction": "Direction", "inputMethod": "InputMethod", "required": "Required", "defaultValue": "DefaultValue", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json index 34bdc06..2a94968 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json @@ -75,6 +75,9 @@ "submitDeleteApprovalConfirm": "确认删除当前工作流吗?", "offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:", "offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。", + "offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:", + "offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。", + "offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从聊天助手中解绑该工作流,同时让相关插件进入不可用状态。", "publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "check": "检查", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/plugin.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/plugin.json index a0444b2..669ed90 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/plugin.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/plugin.json @@ -5,7 +5,11 @@ "name": "名称", "description": "描述", "type": "类型", + "typeHttp": "HTTP 插件", + "typeWorkflow": "工作流插件", "baseUrl": "基础URL", + "workflowId": "绑定工作流", + "workflowTitle": "工作流名称", "authType": "认证方式", "created": "创建时间", "icon": "图标地址", @@ -20,7 +24,8 @@ "placeholder": { "name": "请输入插件名称", "description": "请输入插件描述", - "categorize": "请选择分类" + "categorize": "请选择分类", + "workflow": "请选择已发布工作流" }, "button": { "addPlugin": "新增插件", @@ -29,5 +34,11 @@ }, "toolsManagement": "工具管理", "searchUsers": "搜索用户", - "parameterValue": "参数值" + "parameterValue": "参数值", + "workflow": "工作流", + "workflowPluginHint": "工作流插件会自动镜像目标工作流的已发布快照,插件可用性会实时跟随工作流权限和审批状态变化。", + "workflowPluginUnavailable": "当前工作流插件不可用", + "workflowSnapshotSynced": "已同步发布快照", + "reasonMessage": "不可用原因", + "onlyPublishedWorkflow": "仅支持选择已发布且当前可访问的工作流。" } diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/pluginItem.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/pluginItem.json index 781a3c9..cced205 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/pluginItem.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/pluginItem.json @@ -13,6 +13,7 @@ "debugStatus": "调试状态【0失败 1成功】", "englishName": "英文名称", "createPluginTool": "创建工具", + "systemManaged": "系统同步", "pluginToolEdit": { "basicInfo": "基本信息", "configureInputParameters": "配置输入参数", @@ -21,11 +22,16 @@ "toolPath": "工具路径", "requestMethod": "请求方法", "runResult": "运行结果", - "run": "运行" + "run": "运行", + "workflowTarget": "目标工作流", + "unavailableHint": "当前绑定工作流不可用,本次不会发起执行。", + "runWorkflowStepsEmpty": "开始试运行后,这里会展示每个节点的执行结果。", + "workflowStepsPending": "试运行已发起,正在等待节点执行信息..." }, "parameterName": "参数名称", "parameterDescription": "参数描述", "parameterType": "参数类型", + "direction": "方向", "inputMethod": "传入方法", "required": "是否必填", "defaultValue": "默认值", diff --git a/easyflow-ui-admin/app/src/views/ai/plugin/AddPluginModal.vue b/easyflow-ui-admin/app/src/views/ai/plugin/AddPluginModal.vue index e15420e..18ba649 100644 --- a/easyflow-ui-admin/app/src/views/ai/plugin/AddPluginModal.vue +++ b/easyflow-ui-admin/app/src/views/ai/plugin/AddPluginModal.vue @@ -1,12 +1,14 @@ @@ -346,4 +550,11 @@ function removeHeader(index: number) { align-items: center; margin-bottom: 8px; } + +.form-helper-text { + margin-top: 6px; + font-size: 12px; + line-height: 18px; + color: var(--el-text-color-secondary); +} diff --git a/easyflow-ui-admin/app/src/views/ai/plugin/Plugin.vue b/easyflow-ui-admin/app/src/views/ai/plugin/Plugin.vue index 2efa7d1..ca2be18 100644 --- a/easyflow-ui-admin/app/src/views/ai/plugin/Plugin.vue +++ b/easyflow-ui-admin/app/src/views/ai/plugin/Plugin.vue @@ -17,6 +17,7 @@ import { ElInput, ElMessage, ElMessageBox, + ElTag, } from 'element-plus'; import { api } from '#/api/request'; @@ -94,6 +95,10 @@ const actions: ActionButton[] = [ }, }, ]; +const pluginTypeTagMap = { + 1: $t('plugin.typeHttp'), + 2: $t('plugin.typeWorkflow'), +}; const categoryList = ref([]); const controlBtns = [ { @@ -265,11 +270,26 @@ const handleClickCategory = (item: PluginCategory) => { title-field="title" icon-field="icon" desc-field="description" + tag-field="type" + :tag-map="pluginTypeTagMap" :data="pageList" :primary-action="primaryAction" :actions="actions" :default-icon="defaultPluginIcon" - /> + > + + diff --git a/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunParams.vue b/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunParams.vue index 1a97a97..c1d883d 100644 --- a/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunParams.vue +++ b/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunParams.vue @@ -9,12 +9,14 @@ const props = withDefaults(defineProps(), { modelValue: () => [], editable: false, isEditOutput: false, + payloadMode: 'plugin', }); const emit = defineEmits(); export interface TreeTableNode { key: string; + id?: string; name: string; description: string; method?: 'Body' | 'Header' | 'Path' | 'Query'; @@ -29,6 +31,7 @@ interface Props { modelValue?: TreeTableNode[]; editable?: boolean; isEditOutput?: boolean; + payloadMode?: 'plugin' | 'workflow'; } const data = ref([]); @@ -48,8 +51,12 @@ watch( ); // 计算缩进宽度 +const getNodeKey = (record?: Partial): string => { + return String(record?.key || record?.id || ''); +}; + const getIndentWidth = (record: TreeTableNode): number => { - const level = String(record.key).split('-').length - 1; + const level = getNodeKey(record).split('-').length - 1; const indentSize = 20; return level > 0 ? level * indentSize : 0; }; @@ -65,7 +72,7 @@ const onExpand = (_row: TreeTableNode, expandedRows: TreeTableNode[]) => { }; // 验证字段 -const validateFields = (): boolean => { +const validatePluginFields = (): boolean => { const newErrors: Record< string, Partial> @@ -75,6 +82,7 @@ const validateFields = (): boolean => { const checkNode = (node: TreeTableNode): boolean => { const { name, description, method, type } = node; const nodeErrors: Partial> = {}; + const nodeKey = getNodeKey(node); if (!name?.trim()) { nodeErrors.name = $t('message.cannotBeEmpty.name'); @@ -96,8 +104,8 @@ const validateFields = (): boolean => { isValid = false; } - if (Object.keys(nodeErrors).length > 0) { - newErrors[node.key] = nodeErrors; + if (nodeKey && Object.keys(nodeErrors).length > 0) { + newErrors[nodeKey] = nodeErrors; } if (node.children) { @@ -117,17 +125,118 @@ const validateFields = (): boolean => { return isValid; }; +const isBlankValue = (value: unknown): boolean => { + if (value === null || value === undefined) { + return true; + } + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof value === 'object') { + return Object.keys(value as Record).length === 0; + } + return false; +}; + +const normalizeNodeType = (node: TreeTableNode): string => { + return String(node.type || '').trim(); +}; + +const parseWorkflowNodeValue = (node: TreeTableNode): any => { + const type = normalizeNodeType(node); + if (node.children?.length) { + if (type === 'Object') { + return buildWorkflowPayload(node.children); + } + if (type.includes('Array')) { + return parseArrayValue(node); + } + } + if (type.includes('Array')) { + return parseArrayValue(node); + } + return node.defaultValue; +}; + +const parseArrayValue = (node: TreeTableNode): any[] => { + if (Array.isArray(node.defaultValue as any)) { + return node.defaultValue as any[]; + } + const raw = String(node.defaultValue || '').trim(); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return [raw]; + } +}; + +const buildWorkflowPayload = (nodes: TreeTableNode[]): Record => { + const payload: Record = {}; + for (const node of nodes) { + if (!node?.name) { + continue; + } + payload[node.name] = parseWorkflowNodeValue(node); + } + return payload; +}; + +const validateWorkflowFields = (): boolean => { + const newErrors: Record< + string, + Partial> + > = {}; + let isValid = true; + + const checkNode = (node: TreeTableNode) => { + const nodeKey = getNodeKey(node); + const nodeErrors: Partial> = {}; + const value = parseWorkflowNodeValue(node); + + if (node.required && isBlankValue(value)) { + nodeErrors.defaultValue = $t('message.required'); + isValid = false; + } + + if (nodeKey && Object.keys(nodeErrors).length > 0) { + newErrors[nodeKey] = nodeErrors; + } + + node.children?.forEach(checkNode); + }; + + data.value.forEach((node) => { + checkNode(node); + }); + errors.value = newErrors; + return isValid; +}; + // 判断是否为根节点 const isRootNode = (record: TreeTableNode): boolean => { - return !record.key.includes('-'); + return !getNodeKey(record).includes('-'); }; // 提交参数 const handleSubmitParams = () => { - if (!validateFields()) { + const valid = + props.payloadMode === 'workflow' + ? validateWorkflowFields() + : validatePluginFields(); + if (valid !== true) { ElMessage.error($t('message.completeForm')); return; } + if (props.payloadMode === 'workflow') { + return buildWorkflowPayload(data.value); + } return data.value; }; @@ -145,7 +254,7 @@ interface Emits {
{{ row.name || '' }} @@ -175,11 +284,11 @@ interface Emits { />
- {{ errors[row.key]?.name }} + {{ errors[getNodeKey(row)]?.name }}
@@ -195,12 +304,19 @@ interface Emits { @@ -243,6 +359,12 @@ interface Emits { color: #ff4d4f; } +.value-input-wrapper { + display: flex; + flex-direction: column; + gap: 2px; +} + .action-buttons .el-button { display: flex; align-items: center; diff --git a/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue b/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue index 2c21a01..94a7061 100644 --- a/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue +++ b/easyflow-ui-admin/app/src/views/ai/plugin/PluginRunTestModal.vue @@ -1,16 +1,18 @@