From d244a0404d002d0c4fc674f19c90d5a75d95bb49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 7 Aug 2026 12:51:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E5=8F=A3=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E9=A1=B5=E9=9D=A2=E6=9D=83=E9=99=90=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E8=BF=90=E8=A1=8C=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 页面选项接口改用所属页面权限并返回最小数据视图 - 统一校验工作流引用、租户、状态与定时任务执行主体 - 补充聊天记录权限迁移和权限隔离回归测试 --- .../controller/agent/AgentController.java | 46 ++ .../agent/AgentSessionController.java | 31 +- .../controller/ai/ChatHistoryController.java | 20 +- .../admin/controller/ai/ModelController.java | 24 + .../controller/ai/WorkFlowNodeController.java | 97 +-- .../controller/ai/WorkflowController.java | 115 +++ .../controller/common/DictController.java | 17 +- .../dashboard/DashboardController.java | 24 +- .../controller/job/SysJobController.java | 139 +++- .../system/ApprovalFlowController.java | 22 + .../system/SysAccountController.java | 22 +- .../SysRoleCategoryScopeController.java | 4 + .../controller/system/SysRoleController.java | 16 + .../admin/model/SysJobWorkflowOptionView.java | 20 + .../admin/model/SystemFormOptionsView.java | 123 ++++ .../model/ai/ModelGatewayConfigView.java | 9 + .../model/ai/WorkflowDesignerOptionsView.java | 172 +++++ .../ai/WorkflowDesignerOptionService.java | 657 ++++++++++++++++++ .../system/SystemFormOptionService.java | 510 ++++++++++++++ .../PermissionIsolationContractTest.java | 103 +++ .../ai/ChatHistoryControllerTest.java | 7 +- .../controller/job/SysJobControllerTest.java | 73 +- .../system/SysAccountControllerTest.java | 22 +- .../ai/WorkflowDesignerOptionServiceTest.java | 169 +++++ .../easyflow/ai/config/AiDictAutoConfig.java | 21 +- .../WorkflowUsageAuthorizationService.java | 76 ++ .../ai/share/WorkflowSharePolicy.java | 8 + ...WorkflowUsageAuthorizationServiceTest.java | 129 ++++ .../ai/share/WorkflowSharePolicyTest.java | 5 + easyflow-modules/easyflow-module-job/pom.xml | 12 + .../service/WorkflowJobExecutionService.java | 159 +++++ .../java/tech/easyflow/job/util/JobUtil.java | 47 +- .../WorkflowJobExecutionServiceTest.java | 217 ++++++ .../system/config/SysDictAutoConfig.java | 38 +- ...3__mysql_chat_history_query_permission.sql | 27 + .../app/src/views/ai/agent-chat/api.ts | 4 +- .../src/views/ai/chatHistory/index.test.ts | 2 +- .../app/src/views/ai/chatHistory/index.vue | 4 +- .../DocumentCollectionModal.vue | 28 +- .../ai/model/UnifiedGatewayWorkspace.vue | 10 +- .../src/views/ai/resource/ResourceModal.vue | 34 +- .../src/views/ai/workflow/WorkflowDesign.vue | 36 +- .../src/views/ai/workflow/WorkflowList.vue | 2 +- .../src/views/ai/workflow/WorkflowModal.vue | 25 +- .../ai/workflow/customNode/datasetOptions.ts | 19 +- .../src/views/config/settings/Settings.vue | 38 - .../views/dashboard/workspace/index.test.ts | 2 +- .../src/views/dashboard/workspace/index.vue | 2 +- .../system/approval/ApprovalFlowModal.vue | 79 ++- .../system/sysAccount/SysAccountModal.vue | 64 +- .../src/views/system/sysDept/SysDeptModal.vue | 39 +- .../views/system/sysJob/SysJobModal.test.ts | 12 +- .../src/views/system/sysJob/SysJobModal.vue | 44 +- .../src/views/system/sysMenu/SysMenuModal.vue | 28 +- .../src/views/system/sysRole/SysRoleModal.vue | 84 ++- 55 files changed, 3350 insertions(+), 387 deletions(-) create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/SysJobWorkflowOptionView.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/SystemFormOptionsView.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/ModelGatewayConfigView.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/system/SystemFormOptionService.java create mode 100644 easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/PermissionIsolationContractTest.java create mode 100644 easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationService.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationServiceTest.java create mode 100644 easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/WorkflowJobExecutionService.java create mode 100644 easyflow-modules/easyflow-module-job/src/test/java/tech/easyflow/job/service/WorkflowJobExecutionServiceTest.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V53__mysql_chat_history_query_permission.sql diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java index b2cb90c5..8894668f 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java @@ -1,6 +1,7 @@ package tech.easyflow.admin.controller.agent; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; import jakarta.servlet.http.HttpServletRequest; @@ -230,6 +231,7 @@ public class AgentController extends BaseCurdController { * @return SSE Emitter */ @PostMapping("chat") + @SaCheckPermission("/api/v1/agent/session/query") public SseEmitter chat(@JsonBody AgentChatRequest request) { return agentRunService.chat(request); } @@ -241,6 +243,7 @@ public class AgentController extends BaseCurdController { * @return SSE Emitter */ @PostMapping("/chat/draft") + @SaCheckPermission("/api/v1/agent/save") public SseEmitter chatDraft(@JsonBody AgentDraftChatRequest request) { return agentRunService.chatDraft(request); } @@ -255,6 +258,9 @@ public class AgentController extends BaseCurdController { * @return 上传结果 */ @PostMapping(value = "/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result uploadMedia(@RequestParam("file") MultipartFile file, @RequestParam("mode") String mode, @RequestParam("agentId") String agentId, @@ -269,6 +275,9 @@ public class AgentController extends BaseCurdController { * @return 操作结果 */ @PostMapping("/media/delete") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result deleteMedia(@JsonBody(value = "uploadId", required = true) String uploadId) { agentMediaService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount()); return Result.ok(); @@ -281,6 +290,9 @@ public class AgentController extends BaseCurdController { * @return 图片响应 */ @GetMapping("/media/content") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) @LogReporterDisabled public ResponseEntity mediaContent(@RequestParam("reference") String reference) { AgentMediaResource resource = agentMediaService.load(reference, SaTokenUtil.getLoginAccount()); @@ -303,6 +315,9 @@ public class AgentController extends BaseCurdController { * @return 上传与读取状态 */ @PostMapping(value = "/media/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result uploadDocument(@RequestParam("file") MultipartFile file, @RequestParam("mode") String mode, @RequestParam("agentId") String agentId, @@ -320,6 +335,9 @@ public class AgentController extends BaseCurdController { * @return 最新状态 */ @GetMapping("/media/document/status") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result documentStatus(@RequestParam("uploadId") String uploadId) { return Result.ok(agentDocumentService.status(uploadId, SaTokenUtil.getLoginAccount())); } @@ -331,6 +349,9 @@ public class AgentController extends BaseCurdController { * @return 重试后的状态 */ @PostMapping("/media/document/retry") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result retryDocument( @JsonBody(value = "uploadId", required = true) String uploadId) { return Result.ok(agentDocumentService.retry(uploadId, SaTokenUtil.getLoginAccount())); @@ -343,6 +364,9 @@ public class AgentController extends BaseCurdController { * @return 操作结果 */ @PostMapping("/media/document/delete") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result deleteDocument( @JsonBody(value = "uploadId", required = true) String uploadId) { agentDocumentService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount()); @@ -356,6 +380,9 @@ public class AgentController extends BaseCurdController { * @return 文档流 */ @GetMapping("/media/document/content") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) @LogReporterDisabled public ResponseEntity documentContent( @RequestParam("reference") String reference) { @@ -384,6 +411,9 @@ public class AgentController extends BaseCurdController { * @return 会话信息 */ @PostMapping("/composer/session") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result allocateComposerSession( @JsonBody(value = "mode", required = true) String mode) { return Result.ok(agentComposerDraftService.allocateSession(mode)); @@ -396,6 +426,9 @@ public class AgentController extends BaseCurdController { * @return 保存后的草稿 */ @PostMapping("/composer/draft/persist") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result saveComposerDraft(@JsonBody AgentComposerDraft draft) { return Result.ok(agentComposerDraftService.save(draft, SaTokenUtil.getLoginAccount())); } @@ -409,6 +442,9 @@ public class AgentController extends BaseCurdController { * @return 输入草稿 */ @GetMapping("/composer/draft") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result getComposerDraft(@RequestParam("mode") String mode, @RequestParam("agentId") String agentId, @RequestParam(value = "sessionId", required = false) String sessionId) { @@ -428,6 +464,9 @@ public class AgentController extends BaseCurdController { * @return 操作结果 */ @PostMapping("/composer/draft/delete") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result deleteComposerDraft(@JsonBody(value = "mode", required = true) String mode, @JsonBody(value = "agentId", required = true) String agentId, @JsonBody(value = "sessionId", required = true) String sessionId, @@ -446,6 +485,7 @@ public class AgentController extends BaseCurdController { * @return 操作结果 */ @PostMapping("/chat/draft/clear") + @SaCheckPermission("/api/v1/agent/save") public Result clearDraftSession(@JsonBody(value = "sessionId", required = true) String sessionId) { agentRunService.clearDraftSession(sessionId); return Result.ok(); @@ -459,6 +499,9 @@ public class AgentController extends BaseCurdController { * @return 操作结果 */ @PostMapping("/run/approve") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result approve(@JsonBody("requestId") String requestId, @JsonBody(value = "resumeToken", required = true) String resumeToken) { agentRunService.approve(requestId, resumeToken); @@ -474,6 +517,9 @@ public class AgentController extends BaseCurdController { * @return 操作结果 */ @PostMapping("/run/reject") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) public Result reject(@JsonBody("requestId") String requestId, @JsonBody(value = "resumeToken", required = true) String resumeToken, @JsonBody("reason") String reason) { diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSessionController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSessionController.java index d579b640..c506a466 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSessionController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSessionController.java @@ -1,11 +1,15 @@ package tech.easyflow.admin.controller.agent; +import cn.dev33.satoken.annotation.SaCheckPermission; import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator; import org.springframework.web.bind.annotation.*; import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView; import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView; import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage; import tech.easyflow.admin.service.agent.AgentSessionService; +import tech.easyflow.agent.service.AgentOptionQueryService; +import tech.easyflow.agent.vo.AgentOptionView; +import tech.easyflow.agent.vo.AgentResourceOptionsView; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.common.domain.Result; @@ -22,17 +26,42 @@ import java.util.List; */ @RestController @RequestMapping("/api/v1/agent/session") +@SaCheckPermission("/api/v1/agent/session/query") public class AgentSessionController { private final AgentSessionService agentSessionService; + private final AgentOptionQueryService agentOptionQueryService; /** * 创建 Agent 管理端会话控制器。 * * @param agentSessionService Agent 会话服务 + * @param agentOptionQueryService Agent 安全选项服务 */ - public AgentSessionController(AgentSessionService agentSessionService) { + public AgentSessionController(AgentSessionService agentSessionService, + AgentOptionQueryService agentOptionQueryService) { this.agentSessionService = agentSessionService; + this.agentOptionQueryService = agentOptionQueryService; + } + + /** + * 查询正式聊天可使用的已发布 Agent。 + * + * @return Agent 安全选项 + */ + @GetMapping("/options") + public Result> options() { + return Result.ok(agentOptionQueryService.listAgentOptions(true)); + } + + /** + * 查询正式聊天可附加的知识库。 + * + * @return 知识库安全选项 + */ + @GetMapping("/knowledgeOptions") + public Result> knowledgeOptions() { + return Result.ok(agentOptionQueryService.listKnowledgeOptions()); } /** diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ChatHistoryController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ChatHistoryController.java index 8800212e..3b02aee0 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ChatHistoryController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ChatHistoryController.java @@ -1,10 +1,13 @@ package tech.easyflow.admin.controller.ai; +import cn.dev33.satoken.annotation.SaCheckPermission; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.agent.service.AgentOptionQueryService; +import tech.easyflow.agent.vo.AgentOptionView; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.domain.dto.ChatSessionPage; @@ -23,21 +26,36 @@ import java.util.List; @RestController @RequestMapping("/api/v1/chatHistory") +@SaCheckPermission("/api/v1/chatHistory/query") public class ChatHistoryController { private final ChatHistoryManageService chatHistoryManageService; private final CategoryPermissionService categoryPermissionService; + private final AgentOptionQueryService agentOptionQueryService; /** * 创建聊天历史控制器。 * * @param chatHistoryManageService 聊天历史管理服务 * @param categoryPermissionService 账号权限服务 + * @param agentOptionQueryService Agent 安全选项服务 */ public ChatHistoryController(ChatHistoryManageService chatHistoryManageService, - CategoryPermissionService categoryPermissionService) { + CategoryPermissionService categoryPermissionService, + AgentOptionQueryService agentOptionQueryService) { this.chatHistoryManageService = chatHistoryManageService; this.categoryPermissionService = categoryPermissionService; + this.agentOptionQueryService = agentOptionQueryService; + } + + /** + * 查询聊天记录筛选可使用的 Agent。 + * + * @return Agent 安全选项 + */ + @GetMapping("/agentOptions") + public Result> agentOptions() { + return Result.ok(agentOptionQueryService.listAgentOptions(false)); } /** diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java index 71305e80..988a8f96 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ModelController.java @@ -13,6 +13,10 @@ import tech.easyflow.ai.entity.table.ModelTableDef; import tech.easyflow.ai.mapper.ModelMapper; import tech.easyflow.ai.service.AgentResourceReferenceService; import tech.easyflow.ai.service.ModelService; +import tech.easyflow.admin.model.ai.ModelGatewayConfigView; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.system.entity.SysOption; +import tech.easyflow.system.service.SysOptionService; import tech.easyflow.ai.service.capability.ModelCapabilityResolution; import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; @@ -40,6 +44,11 @@ import java.util.stream.Collectors; @RequestMapping("/api/v1/model") public class ModelController extends BaseCurdController { + private static final String CHAT_PUBLISH_BASE_URL = "chat_publish_base_url"; + + @Autowired + private SysOptionService sysOptionService; + public ModelController(ModelService service) { super(service); } @@ -47,6 +56,21 @@ public class ModelController extends BaseCurdController { @Autowired ModelService modelService; + /** + * 查询模型统一网关页面所需的安全配置。 + * + * @return 仅包含模型发布基础地址的配置 + */ + @GetMapping("/gatewayConfig") + @SaCheckPermission("/api/v1/model/query") + public Result gatewayConfig() { + SysOption option = sysOptionService.getByOptionKey( + CHAT_PUBLISH_BASE_URL, + SaTokenUtil.getLoginAccount().getTenantId() + ); + return Result.ok(new ModelGatewayConfigView(option == null ? null : option.getValue())); + } + @Resource ModelMapper modelMapper; @Resource diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkFlowNodeController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkFlowNodeController.java index f61c21a6..a60ed0a7 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkFlowNodeController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkFlowNodeController.java @@ -1,88 +1,45 @@ package tech.easyflow.admin.controller.ai; -import cn.hutool.core.util.IdUtil; -import com.alibaba.fastjson2.JSON; -import com.alibaba.fastjson2.JSONArray; +import cn.dev33.satoken.annotation.SaCheckPermission; 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.ConfirmNode; -import com.easyagents.flow.core.node.EndNode; -import com.easyagents.flow.core.node.StartNode; -import com.easyagents.flow.core.parser.ChainParser; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; -import tech.easyflow.ai.entity.Workflow; -import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService; import tech.easyflow.common.domain.Result; -import tech.easyflow.common.web.exceptions.BusinessException; -import javax.annotation.Resource; -import java.util.List; +import java.math.BigInteger; +/** + * 工作流节点兼容接口。 + */ @RequestMapping("/api/v1/workflowNode") @RestController public class WorkFlowNodeController { - @Resource - private WorkflowService workflowService; - @Resource - private ChainParser chainParser; - @Resource - private WorkflowDatacenterContentService workflowDatacenterContentService; + private final WorkflowDesignerOptionService workflowDesignerOptionService; - @GetMapping("/getChainParams") - public Result getChainParams(String currentId, String workflowId) { - if (workflowId.equals(currentId)) { - throw new BusinessException("工作流不能作为自身子节点"); - } - JSONObject nodeData = new JSONObject(); - Workflow workflow = workflowService.getById(workflowId); - if (workflow == null) { - throw new BusinessException("工作流不存在: " + workflowId); - } - nodeData.put("workflowId", workflow.getId()); - nodeData.put("workflowName", workflow.getTitle()); - - ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent())); - List nodes = definition.getNodes(); - JSONArray inputs = new JSONArray(); - JSONArray outputs = new JSONArray(); - for (Node node : nodes) { - if (node instanceof StartNode) { - inputs = JSON.parseArray(JSON.toJSONString(node.getParameters())); - handleArray(inputs); - } - if (node instanceof EndNode) { - outputs = JSON.parseArray(JSON.toJSONString(((EndNode) node).getOutputDefs())); - handleArray(outputs); - } - if (node instanceof ConfirmNode) { - throw new BusinessException("工作流存在【确认节点】,暂不支持作为子节点"); - } - } - nodeData.put("parameters", inputs); - nodeData.put("outputDefs", outputs); - return Result.ok(nodeData); + /** + * 创建工作流节点兼容控制器。 + * + * @param workflowDesignerOptionService 工作流设计器选项服务 + */ + public WorkFlowNodeController(WorkflowDesignerOptionService workflowDesignerOptionService) { + this.workflowDesignerOptionService = workflowDesignerOptionService; } - private void handleArray(JSONArray array) { - if (array != null) { - for (Object o : array) { - JSONObject obj = (JSONObject) o; - obj.put("id", IdUtil.simpleUUID()); - obj.put("nameDisabled", true); - obj.put("dataTypeDisabled", true); - obj.put("deleteDisabled", true); - obj.put("addChildDisabled", true); - obj.put("refType", "ref"); - JSONArray children = obj.getJSONArray("children"); - if (children != null) { - handleArray(children); - } - } - } + /** + * 查询子流程输入输出定义。 + * + * @param currentId 当前工作流 ID + * @param workflowId 子流程 ID + * @return 子流程节点配置 + * @deprecated 请使用 {@code /api/v1/workflow/designer/childWorkflow} + */ + @Deprecated + @GetMapping("/getChainParams") + @SaCheckPermission("/api/v1/workflow/query") + public Result getChainParams(BigInteger currentId, BigInteger workflowId) { + return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId)); } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java index 03e3f37a..f3a8c955 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java @@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.stp.StpUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.IdUtil; +import com.alibaba.fastjson2.JSONObject; import com.mybatisflex.core.paginate.Page; import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.mybatisflex.core.query.QueryWrapper; @@ -14,6 +15,8 @@ import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView; +import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService; import tech.easyflow.agent.entity.AgentToolBinding; import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.service.AgentToolBindingService; @@ -43,6 +46,7 @@ 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 tech.easyflow.datacenter.execution.model.DatasetRef; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.enums.ResourceLookup; @@ -101,12 +105,116 @@ public class WorkflowController extends BaseCurdController designerOptions() { + return Result.ok(workflowDesignerOptionService.listOptions( + codeEngineCapabilityService.listSupportedCodeEngines())); + } + + /** + * 分页查询工作流设计器可用插件。 + * + * @param pageNumber 页码 + * @param pageSize 每页数量 + * @return 插件安全选项分页 + */ + @GetMapping("/designer/plugins") + @SaCheckPermission("/api/v1/workflow/query") + public Result> designerPlugins( + Long pageNumber, + Long pageSize) { + return Result.ok(workflowDesignerOptionService.pagePlugins(pageNumber, pageSize)); + } + + /** + * 查询工作流插件节点配置。 + * + * @param id 插件工具 ID + * @return 插件节点配置 + */ + @GetMapping("/designer/pluginTinyFlow") + @SaCheckPermission("/api/v1/workflow/query") + public Result designerPluginTinyFlow(BigInteger id) { + return Result.ok(workflowDesignerOptionService.getPluginTinyFlowData(id)); + } + + /** + * 查询工作流设计器的子流程节点配置。 + * + * @param currentId 当前工作流 ID + * @param workflowId 子流程 ID + * @return 子流程输入输出定义 + */ + @GetMapping("/designer/childWorkflow") + @SaCheckPermission("/api/v1/workflow/query") + public Result designerChildWorkflow(BigInteger currentId, BigInteger workflowId) { + return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId)); + } + + /** + * 查询工作流数据节点可见的数据源。 + * + * @return 数据源安全选项 + */ + @GetMapping("/designer/dataSources") + @SaCheckPermission("/api/v1/workflow/query") + public Result> designerDataSources() { + return Result.ok(workflowDesignerOptionService.listDataSources()); + } + + /** + * 查询工作流数据节点的数据目录。 + * + * @param sourceId 数据源 ID + * @return 目录安全选项 + */ + @GetMapping("/designer/catalogs") + @SaCheckPermission("/api/v1/workflow/query") + public Result> designerCatalogs(BigInteger sourceId) { + return Result.ok(workflowDesignerOptionService.listCatalogs(sourceId)); + } + + /** + * 查询工作流数据节点的已接入数据集。 + * + * @param sourceId 数据源 ID + * @param catalogId 目录 ID + * @return 数据集安全选项 + */ + @GetMapping("/designer/managedTables") + @SaCheckPermission("/api/v1/workflow/query") + public Result> designerManagedTables( + BigInteger sourceId, + BigInteger catalogId) { + return Result.ok(workflowDesignerOptionService.listManagedDatasets(sourceId, catalogId)); + } + + /** + * 查询工作流数据节点的数据集结构。 + * + * @param datasetRef 数据集引用 + * @return 数据集结构安全视图 + */ + @GetMapping("/designer/schema") + @SaCheckPermission("/api/v1/workflow/query") + public Result designerSchema(DatasetRef datasetRef) { + return Result.ok(workflowDesignerOptionService.getDatasetSchema(datasetRef)); + } + /** * 节点单独运行 */ @@ -128,6 +236,9 @@ public class WorkflowController extends BaseCurdController(); } @@ -161,6 +272,7 @@ public class WorkflowController extends BaseCurdController res = workflowRunningParameterResolver.buildRunningParametersView(workflow); if (res == null) { return Result.fail(2, "节点配置错误,请检查! "); @@ -431,6 +544,7 @@ public class WorkflowController extends BaseCurdController> items(@PathVariable("code") String code, String keyword, HttpServletRequest request) { DictLoader loader = dictManager.getLoader(code); if (loader == null) { - return Result.ok(Collections.emptyList()); + throw new BusinessException("字典不存在或不支持公共查询"); } Map parameterMap = request.getParameterMap(); Dict dict = loader.load(keyword, parameterMap); diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java index 52d45efd..2d6e194c 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dashboard/DashboardController.java @@ -10,6 +10,8 @@ import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery; import tech.easyflow.admin.service.dashboard.DashboardService; +import tech.easyflow.agent.service.AgentOptionQueryService; +import tech.easyflow.agent.vo.AgentOptionView; import tech.easyflow.common.domain.Result; import tech.easyflow.common.satoken.util.SaTokenUtil; @@ -28,9 +30,29 @@ import java.util.List; public class DashboardController { private final DashboardService dashboardService; + private final AgentOptionQueryService agentOptionQueryService; - public DashboardController(DashboardService dashboardService) { + /** + * 创建管理端工作台控制器。 + * + * @param dashboardService 工作台统计服务 + * @param agentOptionQueryService Agent 安全选项服务 + */ + public DashboardController(DashboardService dashboardService, + AgentOptionQueryService agentOptionQueryService) { this.dashboardService = dashboardService; + this.agentOptionQueryService = agentOptionQueryService; + } + + /** + * 查询工作台筛选可使用的 Agent。 + * + * @return Agent 安全选项 + */ + @GetMapping("/agentOptions") + @SaCheckPermission("/api/v1/dashboard/query") + public Result> agentOptions() { + return Result.ok(agentOptionQueryService.listAgentOptions(false)); } @GetMapping("/overview") diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java index 6ca5942e..1269488c 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/job/SysJobController.java @@ -3,6 +3,7 @@ package tech.easyflow.admin.controller.job; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.hutool.core.date.DateUtil; import com.easyagents.flow.core.chain.Parameter; +import com.mybatisflex.core.query.QueryWrapper; import org.quartz.CronExpression; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; @@ -11,6 +12,9 @@ import org.springframework.web.bind.annotation.RestController; import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.service.WorkflowUsageAuthorizationService; +import tech.easyflow.admin.model.SysJobWorkflowOptionView; +import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.constant.enums.EnumJobType; import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; @@ -33,6 +37,7 @@ import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Objects; /** * 系统任务表 控制层。 @@ -47,6 +52,9 @@ public class SysJobController extends BaseCurdController /** 工作流服务。 */ private final WorkflowService workflowService; + /** 工作流使用权限校验服务。 */ + private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService; + /** 资源访问控制服务。 */ private final ResourceAccessService resourceAccessService; @@ -58,15 +66,18 @@ public class SysJobController extends BaseCurdController * * @param service 定时任务服务 * @param workflowService 工作流服务 + * @param workflowUsageAuthorizationService 工作流使用权限校验服务 * @param resourceAccessService 资源访问控制服务 * @param workflowRunningParameterResolver 工作流运行参数解析器 */ public SysJobController(SysJobService service, WorkflowService workflowService, + WorkflowUsageAuthorizationService workflowUsageAuthorizationService, ResourceAccessService resourceAccessService, WorkflowRunningParameterResolver workflowRunningParameterResolver) { super(service); this.workflowService = workflowService; + this.workflowUsageAuthorizationService = workflowUsageAuthorizationService; this.resourceAccessService = resourceAccessService; this.workflowRunningParameterResolver = workflowRunningParameterResolver; } @@ -75,6 +86,9 @@ public class SysJobController extends BaseCurdController @SaCheckPermission("/api/v1/sysJob/save") @LogRecord("启动定时任务") public Result start(BigInteger id) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + SysJob job = requireExistingJob(id); + validateWorkflowReference(job, account); service.startJob(id); return Result.ok(); } @@ -88,6 +102,7 @@ public class SysJobController extends BaseCurdController } @GetMapping("/getNextTimes") + @SaCheckPermission("/api/v1/sysJob/save") public Result> getNextTimes(String cronExpression) throws Exception{ CronExpression ex = new CronExpression(cronExpression); List times = new ArrayList<>(); @@ -100,16 +115,72 @@ public class SysJobController extends BaseCurdController return Result.ok(times); } + /** + * 查询当前账号可用于定时任务的工作流。 + * + * @return 工作流安全选项 + */ + @GetMapping("/workflowOptions") + @SaCheckPermission("/api/v1/sysJob/save") + public Result> workflowOptions() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + List options = workflowService.list(QueryWrapper.create() + .eq(Workflow::getTenantId, account.getTenantId()) + .eq(Workflow::getStatus, EnumDataStatus.AVAILABLE.getCode()) + .orderBy(Workflow::getModified, false)) + .stream() + .filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId())) + .filter(workflow -> resourceAccessService.canAccess( + account, + CategoryResourceType.WORKFLOW, + workflow, + ResourceAction.USE)) + .map(workflow -> new SysJobWorkflowOptionView( + workflow.getId(), + workflow.getTitle(), + workflow.getDescription())) + .toList(); + return Result.ok(options); + } + + /** + * 查询定时任务所选工作流的运行参数。 + * + * @param id 工作流 ID + * @return 工作流运行参数 + * @throws BusinessException 工作流不存在或无运行权限时抛出 + */ + @GetMapping("/workflowRunningParameters") + @SaCheckPermission("/api/v1/sysJob/save") + public Result> workflowRunningParameters(BigInteger id) { + Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow( + id, + SaTokenUtil.getLoginAccount(), + "工作流不存在、已禁用或无权运行"); + Map result = workflowRunningParameterResolver.buildRunningParametersView(workflow); + if (result == null) { + throw new BusinessException("工作流参数配置无效,请检查工作流后重试"); + } + return Result.ok(result); + } + @Override protected Result onSaveOrUpdateBefore(SysJob entity, boolean isSave) { + if (entity == null) { + throw new BusinessException("定时任务不能为空"); + } LoginAccount loginUser = SaTokenUtil.getLoginAccount(); + SysJob effectiveEntity = entity; if (isSave) { commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId()); } else { + SysJob existing = requireExistingJob(entity.getId()); + preserveServerControlledFields(entity, existing); + effectiveEntity = mergeForValidation(entity, existing); entity.setModified(new Date()); entity.setModifiedBy(loginUser.getId()); } - validateWorkflowReference(entity); + validateWorkflowReference(effectiveEntity, loginUser); return super.onSaveOrUpdateBefore(entity, isSave); } @@ -117,27 +188,71 @@ public class SysJobController extends BaseCurdController * 校验工作流类型任务引用的工作流可被当前用户运行。 * * @param entity 待保存的定时任务 + * @param account 当前账号 * @throws BusinessException 工作流不存在、参数非法或无运行权限时抛出 */ - private void validateWorkflowReference(SysJob entity) { + private void validateWorkflowReference(SysJob entity, LoginAccount account) { if (entity == null || !Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(entity.getJobType())) { return; } BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(entity); - Workflow workflow = workflowService.getById(workflowId); - if (workflow == null) { - throw new BusinessException("工作流不存在,请重新选择"); - } - resourceAccessService.assertAccess( - CategoryResourceType.WORKFLOW, - workflow, - ResourceAction.USE, - "无权限运行所选工作流" - ); + Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow( + workflowId, + account, + "工作流不存在、已禁用或无权运行"); validateRequiredWorkflowParams(entity, workflow); } + /** + * 获取当前租户内存在的定时任务。 + * + * @param id 定时任务 ID + * @return 已存在的定时任务 + * @throws BusinessException ID 缺失或任务不存在时抛出 + */ + private SysJob requireExistingJob(BigInteger id) { + if (id == null) { + throw new BusinessException("定时任务ID不能为空"); + } + SysJob existing = service.getById(id); + if (existing == null) { + throw new BusinessException("定时任务不存在"); + } + return existing; + } + + /** + * 保留更新请求不能修改的服务端控制字段。 + * + * @param entity 更新请求 + * @param existing 数据库中的定时任务 + */ + private void preserveServerControlledFields(SysJob entity, SysJob existing) { + entity.setTenantId(existing.getTenantId()); + entity.setDeptId(existing.getDeptId()); + entity.setCreated(existing.getCreated()); + entity.setCreatedBy(existing.getCreatedBy()); + } + + /** + * 合并部分更新请求与原记录,生成用于权限和参数校验的有效任务状态。 + * + * @param entity 更新请求 + * @param existing 数据库中的定时任务 + * @return 合并后的校验对象 + */ + private SysJob mergeForValidation(SysJob entity, SysJob existing) { + SysJob effective = new SysJob(); + effective.setJobType(entity.getJobType() == null + ? existing.getJobType() + : entity.getJobType()); + effective.setJobParams(entity.getJobParams() == null + ? existing.getJobParams() + : entity.getJobParams()); + return effective; + } + /** * 校验定时任务已填写工作流的全部必填运行参数。 * diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/ApprovalFlowController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/ApprovalFlowController.java index 960df22d..424d151b 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/ApprovalFlowController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/ApprovalFlowController.java @@ -10,6 +10,8 @@ import tech.easyflow.common.domain.Result; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.admin.model.SystemFormOptionsView; +import tech.easyflow.admin.service.system.SystemFormOptionService; import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo; import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo; import tech.easyflow.approval.entity.vo.ApprovalFlowPageVo; @@ -36,6 +38,20 @@ public class ApprovalFlowController { @Resource private ApprovalAssigneeService approvalAssigneeService; + @Resource + private SystemFormOptionService systemFormOptionService; + + /** + * 查询审批流程配置所需的资源范围选项。 + * + * @return 非 Bot 分类和部门树 + */ + @GetMapping("/resourceScopeOptions") + @SaCheckPermission("/api/v1/approvalFlow/save") + public Result resourceScopeOptions() { + assertSuperAdmin(); + return Result.ok(systemFormOptionService.approvalResourceScopeOptions()); + } /** * 分页查询审批流程。 @@ -102,6 +118,9 @@ public class ApprovalFlowController { @SaCheckPermission("/api/v1/approvalFlow/save") public Result save(@JsonBody ApprovalFlowDetailVo request) { assertSuperAdmin(); + systemFormOptionService.validateApprovalScopes( + request == null ? null : request.getResourceType(), + request == null ? null : request.getScopes()); BigInteger operatorId = SaTokenUtil.getLoginAccount().getId(); return Result.ok(approvalFlowService.saveFlow(request, operatorId)); } @@ -116,6 +135,9 @@ public class ApprovalFlowController { @SaCheckPermission("/api/v1/approvalFlow/save") public Result update(@JsonBody ApprovalFlowDetailVo request) { assertSuperAdmin(); + systemFormOptionService.validateApprovalScopes( + request == null ? null : request.getResourceType(), + request == null ? null : request.getScopes()); BigInteger operatorId = SaTokenUtil.getLoginAccount().getId(); approvalFlowService.updateFlow(request, operatorId); return Result.ok(); diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java index b3f63e9a..67eb0802 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java @@ -31,6 +31,8 @@ import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.log.annotation.LogRecord; import tech.easyflow.admin.controller.system.vo.SysAccountProfileVo; +import tech.easyflow.admin.model.SystemFormOptionsView; +import tech.easyflow.admin.service.system.SystemFormOptionService; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.vo.SysAccountBatchActionResultVo; @@ -43,8 +45,8 @@ import javax.annotation.Resource; import java.io.Serializable; import java.math.BigInteger; import java.net.URLEncoder; -import java.util.Collection; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.LinkedHashSet; import java.util.List; @@ -71,6 +73,7 @@ public class SysAccountController extends BaseCurdController formOptions() { + return Result.ok(systemFormOptionService.accountFormOptions()); + } + /** * 填充账号创建的公共字段。 * diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleCategoryScopeController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleCategoryScopeController.java index a2704e03..a4319b99 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleCategoryScopeController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleCategoryScopeController.java @@ -9,6 +9,7 @@ import tech.easyflow.common.domain.Result; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.admin.service.system.SystemFormOptionService; import tech.easyflow.system.entity.vo.SysRoleCategoryScopeDetailVo; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysRoleCategoryScopeService; @@ -25,6 +26,8 @@ public class SysRoleCategoryScopeController { @Resource private CategoryPermissionService categoryPermissionService; + @Resource + private SystemFormOptionService systemFormOptionService; @GetMapping("/detail") @SaCheckPermission("/api/v1/sysRole/query") @@ -41,6 +44,7 @@ public class SysRoleCategoryScopeController { if (request == null || request.getRoleId() == null) { throw new BusinessException("角色ID不能为空"); } + systemFormOptionService.validateCategoryScopes(request.getScopes()); BigInteger operatorId = SaTokenUtil.getLoginAccount().getId(); sysRoleCategoryScopeService.saveRoleScopes(request.getRoleId(), request.getScopes(), operatorId); return Result.ok(); diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleController.java index 40ab8ee6..04036db8 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysRoleController.java @@ -9,6 +9,8 @@ import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.admin.model.SystemFormOptionsView; +import tech.easyflow.admin.service.system.SystemFormOptionService; import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.SysRoleDept; import tech.easyflow.system.entity.SysRoleMenu; @@ -38,11 +40,24 @@ public class SysRoleController extends BaseCurdController formOptions() { + return Result.ok(systemFormOptionService.roleFormOptions()); + } + @PostMapping("saveRoleMenu/{roleId}") @SaCheckPermission("/api/v1/sysRole/save") @Deprecated @@ -85,6 +100,7 @@ public class SysRoleController extends BaseCurdController> categories, + List departments + ) { + } + + /** + * 角色表单选项。 + * + * @param menus 菜单树 + * @param categories 按资源类型分组的非 Bot 分类选项 + */ + public record RoleFormOptions( + List menus, + Map> categories + ) { + } + + /** + * 账号表单选项。 + * + * @param departments 部门树 + * @param roles 可用角色 + * @param positions 可用岗位 + */ + public record AccountFormOptions( + List departments, + List roles, + List positions + ) { + } + + /** + * 分类安全选项。 + * + * @param id 分类 ID + * @param categoryName 分类名称 + */ + public record CategoryOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String categoryName + ) { + } + + /** + * 部门树安全选项。 + * + * @param id 部门 ID + * @param parentId 上级部门 ID + * @param deptName 部门名称 + * @param children 下级部门 + */ + public record DepartmentOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + @JsonSerialize(using = ToStringSerializer.class) BigInteger parentId, + String deptName, + List children + ) { + } + + /** + * 菜单树安全选项。 + * + * @param id 菜单 ID + * @param parentId 上级菜单 ID + * @param menuTitle 菜单标题 + * @param children 下级菜单 + */ + public record MenuOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + @JsonSerialize(using = ToStringSerializer.class) BigInteger parentId, + String menuTitle, + List children + ) { + } + + /** + * 角色安全选项。 + * + * @param id 角色 ID + * @param roleName 角色名称 + */ + public record RoleOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String roleName + ) { + } + + /** + * 岗位安全选项。 + * + * @param id 岗位 ID + * @param positionName 岗位名称 + */ + public record PositionOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String positionName + ) { + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/ModelGatewayConfigView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/ModelGatewayConfigView.java new file mode 100644 index 00000000..f3beae0c --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/ModelGatewayConfigView.java @@ -0,0 +1,9 @@ +package tech.easyflow.admin.model.ai; + +/** + * 模型统一网关页面所需的安全配置。 + * + * @param publishBaseUrl 模型发布基础地址 + */ +public record ModelGatewayConfigView(String publishBaseUrl) { +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java new file mode 100644 index 00000000..5b7674f9 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java @@ -0,0 +1,172 @@ +package tech.easyflow.admin.model.ai; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * 工作流设计器所需的安全选项视图。 + * + * @param models 模型选项 + * @param knowledges 知识库选项 + * @param codeEngines 代码执行引擎选项 + */ +public record WorkflowDesignerOptionsView( + List models, + List knowledges, + List> codeEngines +) { + + /** + * 模型安全选项。 + * + * @param id 模型 ID + * @param title 模型标题 + * @param description 模型描述 + * @param modelProvider 供应商安全摘要 + */ + public record ModelOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String title, + String description, + ProviderOption modelProvider + ) { + } + + /** + * 模型供应商安全摘要。 + * + * @param providerName 供应商名称 + * @param providerType 供应商类型 + * @param icon 供应商图标 + */ + public record ProviderOption(String providerName, String providerType, String icon) { + } + + /** + * 知识库安全选项。 + * + * @param id 知识库 ID + * @param title 知识库标题 + * @param description 知识库描述 + */ + public record KnowledgeOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String title, + String description + ) { + } + + /** + * 插件安全选项。 + * + * @param id 插件 ID + * @param name 插件名称 + * @param description 插件描述 + * @param icon 插件图标 + * @param tools 可用工具 + */ + public record PluginOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String name, + String description, + String icon, + List tools + ) { + } + + /** + * 插件工具安全选项。 + * + * @param id 工具 ID + * @param name 工具名称 + * @param description 工具描述 + */ + public record PluginToolOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String name, + String description + ) { + } + + /** + * 数据源安全选项。 + * + * @param id 数据源 ID + * @param sourceName 数据源名称 + * @param sourceType 数据源类型 + */ + public record DataSourceOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + String sourceName, + String sourceType + ) { + } + + /** + * 数据目录安全选项。 + * + * @param id 目录 ID + * @param sourceId 数据源 ID + * @param catalogName 目录名称 + * @param catalogDesc 目录描述 + */ + public record CatalogOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + @JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId, + String catalogName, + String catalogDesc + ) { + } + + /** + * 已接入数据集安全选项。 + * + * @param id 数据集 ID + * @param sourceId 数据源 ID + * @param catalogId 目录 ID + * @param tableName 数据表名称 + * @param tableDesc 数据表描述 + */ + public record DatasetOption( + @JsonSerialize(using = ToStringSerializer.class) BigInteger id, + @JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId, + @JsonSerialize(using = ToStringSerializer.class) BigInteger catalogId, + String tableName, + String tableDesc + ) { + } + + /** + * 数据集字段安全视图。 + * + * @param fieldName 字段名称 + * @param fieldDesc 字段描述 + * @param jdbcType JDBC 类型 + * @param fieldType 业务字段类型 + */ + public record DatasetFieldOption( + String fieldName, + String fieldDesc, + String jdbcType, + Integer fieldType + ) { + } + + /** + * 数据集结构安全视图。 + * + * @param tableName 数据表名称 + * @param tableDesc 数据表描述 + * @param fields 字段列表 + */ + public record DatasetSchemaOption( + String tableName, + String tableDesc, + List fields + ) { + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java new file mode 100644 index 00000000..ddb939e0 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java @@ -0,0 +1,657 @@ +package tech.easyflow.admin.service.ai; + +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.ConfirmNode; +import com.easyagents.flow.core.node.EndNode; +import com.easyagents.flow.core.node.StartNode; +import com.easyagents.flow.core.parser.ChainParser; +import com.mybatisflex.core.paginate.Page; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView; +import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.ModelProvider; +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.DocumentCollectionService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.service.WorkflowUsageAuthorizationService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.datacenter.entity.DatacenterTable; +import tech.easyflow.datacenter.entity.DatacenterTableField; +import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse; +import tech.easyflow.datacenter.execution.model.DatasetRef; +import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService; +import tech.easyflow.datacenter.meta.entity.DatacenterSource; +import tech.easyflow.datacenter.meta.enums.DatacenterSourceType; +import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta; +import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService; +import tech.easyflow.datacenter.meta.service.DatacenterSourceService; +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.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 查询工作流设计器所需的场景化安全选项。 + */ +@Service +public class WorkflowDesignerOptionService { + + private final ModelService modelService; + private final DocumentCollectionService documentCollectionService; + private final PluginService pluginService; + private final PluginItemService pluginItemService; + private final PluginVisibilityService pluginVisibilityService; + private final WorkflowService workflowService; + private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService; + private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; + private final ChainParser chainParser; + private final WorkflowDatacenterContentService workflowDatacenterContentService; + private final ResourceAccessService resourceAccessService; + private final DatacenterSourceService datacenterSourceService; + private final DatacenterDatasetRegistryService datacenterDatasetRegistryService; + private final DatacenterDatasetQueryService datacenterDatasetQueryService; + + /** + * 创建工作流设计器选项服务。 + * + * @param modelService 模型服务 + * @param documentCollectionService 知识库服务 + * @param pluginService 插件服务 + * @param pluginItemService 插件工具服务 + * @param pluginVisibilityService 插件可见性服务 + * @param workflowService 工作流服务 + * @param workflowUsageAuthorizationService 工作流使用权限校验服务 + * @param workflowPluginSnapshotResolver 工作流插件快照解析器 + * @param chainParser 工作流解析器 + * @param workflowDatacenterContentService 工作流数据中心内容服务 + * @param resourceAccessService 资源访问服务 + * @param datacenterSourceService 数据源服务 + * @param datacenterDatasetRegistryService 数据集注册服务 + * @param datacenterDatasetQueryService 数据集查询服务 + */ + public WorkflowDesignerOptionService( + ModelService modelService, + DocumentCollectionService documentCollectionService, + PluginService pluginService, + PluginItemService pluginItemService, + PluginVisibilityService pluginVisibilityService, + WorkflowService workflowService, + WorkflowUsageAuthorizationService workflowUsageAuthorizationService, + WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, + ChainParser chainParser, + WorkflowDatacenterContentService workflowDatacenterContentService, + ResourceAccessService resourceAccessService, + DatacenterSourceService datacenterSourceService, + DatacenterDatasetRegistryService datacenterDatasetRegistryService, + DatacenterDatasetQueryService datacenterDatasetQueryService) { + this.modelService = modelService; + this.documentCollectionService = documentCollectionService; + this.pluginService = pluginService; + this.pluginItemService = pluginItemService; + this.pluginVisibilityService = pluginVisibilityService; + this.workflowService = workflowService; + this.workflowUsageAuthorizationService = workflowUsageAuthorizationService; + this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; + this.chainParser = chainParser; + this.workflowDatacenterContentService = workflowDatacenterContentService; + this.resourceAccessService = resourceAccessService; + this.datacenterSourceService = datacenterSourceService; + this.datacenterDatasetRegistryService = datacenterDatasetRegistryService; + this.datacenterDatasetQueryService = datacenterDatasetQueryService; + } + + /** + * 查询设计器初始化所需的模型和知识库。 + * + * @param codeEngines 代码执行引擎选项 + * @return 设计器初始化选项 + */ + public WorkflowDesignerOptionsView listOptions(List> codeEngines) { + LoginAccount account = requireAccount(); + return new WorkflowDesignerOptionsView( + listModelOptions(account), + listKnowledgeOptions(account), + codeEngines == null ? List.of() : codeEngines + ); + } + + /** + * 校验工作流内容引用的场景资源是否仍允许当前账号使用。 + * + * @param content 工作流内容 + * @throws BusinessException 内容引用越权、跨租户或资源失效时抛出 + */ + public void assertContentReferences(String content) { + if (content == null || content.isBlank()) { + return; + } + JSONObject root; + try { + root = JSON.parseObject(content); + } catch (Exception exception) { + throw new BusinessException("工作流内容不是合法JSON"); + } + JSONArray nodes = root.getJSONArray("nodes"); + if (nodes == null || nodes.isEmpty()) { + return; + } + LoginAccount account = requireAccount(); + Set modelIds = new HashSet<>(); + Set knowledgeIds = new HashSet<>(); + Set checkedPluginItemIds = new HashSet<>(); + Set checkedWorkflowIds = new HashSet<>(); + Set checkedSourceIds = new HashSet<>(); + Set checkedTableIds = new HashSet<>(); + + for (int index = 0; index < nodes.size(); index++) { + JSONObject node = nodes.getJSONObject(index); + if (node == null) { + continue; + } + JSONObject data = node.getJSONObject("data"); + if (data == null) { + continue; + } + String nodeType = data.getString("type"); + if (nodeType == null || nodeType.isBlank()) { + nodeType = node.getString("type"); + } + if ("llmNode".equals(nodeType)) { + addReferenceId(modelIds, readReferenceId(data, "llmId", "模型")); + } else if ("knowledgeNode".equals(nodeType)) { + addReferenceId(knowledgeIds, readReferenceId(data, "knowledgeId", "知识库")); + } else if ("plugin-node".equals(nodeType)) { + BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具"); + if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) { + getPluginTinyFlowData(pluginItemId); + } + } else if ("workflow-node".equals(nodeType)) { + assertWorkflowReference(readReferenceId(data, "workflowId", "子流程"), + account, checkedWorkflowIds); + } + assertDatasetReference(data, account, checkedSourceIds, checkedTableIds); + } + assertModelReferences(modelIds, account); + assertKnowledgeReferences(knowledgeIds, account); + } + + /** + * 分页查询当前账号可用于工作流的插件。 + * + * @param pageNumber 页码 + * @param pageSize 每页数量 + * @return 插件安全选项分页 + */ + public Page pagePlugins(Long pageNumber, Long pageSize) { + LoginAccount account = requireAccount(); + QueryWrapper wrapper = QueryWrapper.create() + .eq(Plugin::getTenantId, account.getTenantId().longValue()) + .orderBy(Plugin::getCreated, false); + List plugins = pluginService.getMapper().selectListWithRelationsByQuery(wrapper); + List availablePlugins = pluginService.preparePluginsForCurrentUser(plugins, false, true); + List options = availablePlugins.stream() + .map(this::toPluginOption) + .toList(); + long actualPageNumber = pageNumber == null || pageNumber < 1 ? 1L : pageNumber; + long actualPageSize = pageSize == null || pageSize < 1 ? 10L : Math.min(pageSize, 100L); + int fromIndex = Math.toIntExact(Math.min(options.size(), (actualPageNumber - 1) * actualPageSize)); + int toIndex = Math.toIntExact(Math.min(options.size(), fromIndex + actualPageSize)); + return new Page<>( + options.subList(fromIndex, toIndex), + actualPageNumber, + actualPageSize, + options.size() + ); + } + + /** + * 查询一个插件工具的工作流节点安全配置。 + * + * @param pluginItemId 插件工具 ID + * @return 节点配置 + * @throws BusinessException 插件工具不存在或不可用时抛出 + */ + public JSONObject getPluginTinyFlowData(BigInteger pluginItemId) { + if (pluginItemId == null) { + throw new BusinessException("插件工具不能为空"); + } + PluginItem record = pluginItemService.getById(pluginItemId); + if (record == null || !Integer.valueOf(1).equals(record.getStatus())) { + throw new BusinessException("插件工具不存在或已禁用"); + } + Plugin plugin = pluginService.getById(record.getPluginId()); + if (plugin == null) { + throw new BusinessException("插件不存在"); + } + LoginAccount account = requireAccount(); + if (!Objects.equals(plugin.getTenantId(), account.getTenantId().longValue())) { + throw new BusinessException("无权限访问插件"); + } + pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件"); + Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin); + if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) { + throw new BusinessException(preparedPlugin.getReasonMessage()); + } + + JSONObject nodeData = new JSONObject(); + nodeData.put("pluginId", record.getId().toString()); + nodeData.put("pluginName", record.getName()); + nodeData.put("pluginType", preparedPlugin.getType()); + nodeData.put("workflowId", preparedPlugin.getWorkflowId()); + nodeData.put("workflowTitle", preparedPlugin.getWorkflowTitle()); + nodeData.put("available", preparedPlugin.getAvailable()); + nodeData.put("reasonCode", preparedPlugin.getReasonCode()); + nodeData.put("reasonMessage", preparedPlugin.getReasonMessage()); + nodeData.put("parameters", parseSchemaArray(record.getInputData())); + nodeData.put("outputDefs", parseSchemaArray(record.getOutputData())); + nodeData.put("schemaHash", resolveSchemaHash(record, preparedPlugin)); + return nodeData; + } + + /** + * 查询子流程节点所需的输入输出定义。 + * + * @param currentWorkflowId 当前工作流 ID + * @param childWorkflowId 子流程 ID + * @return 子流程节点配置 + * @throws BusinessException 子流程不存在、不可用或无权使用时抛出 + */ + public JSONObject getChildWorkflowNodeData( + BigInteger currentWorkflowId, + BigInteger childWorkflowId) { + if (childWorkflowId == null) { + throw new BusinessException("子流程不能为空"); + } + if (Objects.equals(childWorkflowId, currentWorkflowId)) { + throw new BusinessException("工作流不能作为自身子节点"); + } + LoginAccount account = requireAccount(); + Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow( + childWorkflowId, + account, + "子流程不存在、已禁用或无权使用"); + assertContentReferences(workflow.getContent()); + + ChainDefinition definition = chainParser.parse( + workflowDatacenterContentService.prepareContent(workflow.getContent())); + JSONArray inputs = new JSONArray(); + JSONArray outputs = new JSONArray(); + for (Node node : definition.getNodes()) { + if (node instanceof StartNode) { + inputs = JSON.parseArray(JSON.toJSONString(node.getParameters())); + decorateChildWorkflowSchemaArray(inputs); + } + if (node instanceof EndNode endNode) { + outputs = JSON.parseArray(JSON.toJSONString(endNode.getOutputDefs())); + decorateChildWorkflowSchemaArray(outputs); + } + if (node instanceof ConfirmNode) { + throw new BusinessException("工作流存在【确认节点】,暂不支持作为子节点"); + } + } + + JSONObject nodeData = new JSONObject(); + nodeData.put("workflowId", workflow.getId()); + nodeData.put("workflowName", workflow.getTitle()); + nodeData.put("parameters", inputs); + nodeData.put("outputDefs", outputs); + return nodeData; + } + + /** + * 查询当前租户可用于工作流的数据源安全选项。 + * + * @return 数据源安全选项 + */ + public List listDataSources() { + LoginAccount account = requireAccount(); + datacenterDatasetRegistryService.ensureBuiltinSource(DatacenterSourceType.PROJECT_MYSQL, account); + return datacenterSourceService.list(QueryWrapper.create() + .eq(DatacenterSource::getTenantId, account.getTenantId()) + .orderBy(DatacenterSource::getModified, false)) + .stream() + .map(source -> new WorkflowDesignerOptionsView.DataSourceOption( + source.getId(), source.getSourceName(), source.getSourceType())) + .toList(); + } + + /** + * 查询一个数据源的目录安全选项。 + * + * @param sourceId 数据源 ID + * @return 目录安全选项 + */ + public List listCatalogs(BigInteger sourceId) { + LoginAccount account = requireAccount(); + requireTenantSource(sourceId, account); + return datacenterSourceService.listCatalogs(sourceId, account).stream() + .map(this::toCatalogOption) + .toList(); + } + + /** + * 查询一个数据源目录下已接入的数据集。 + * + * @param sourceId 数据源 ID + * @param catalogId 目录 ID + * @return 数据集安全选项 + */ + public List listManagedDatasets( + BigInteger sourceId, + BigInteger catalogId) { + LoginAccount account = requireAccount(); + requireTenantSource(sourceId, account); + return datacenterDatasetRegistryService.listManagedTables(sourceId, catalogId).stream() + .filter(table -> Objects.equals(table.getTenantId(), account.getTenantId())) + .filter(table -> Objects.equals(table.getSourceId(), sourceId)) + .filter(table -> catalogId == null || Objects.equals(table.getCatalogId(), catalogId)) + .map(this::toDatasetOption) + .toList(); + } + + /** + * 查询工作流数据节点所需的数据集结构。 + * + * @param datasetRef 数据集引用 + * @return 数据集结构安全视图 + */ + public WorkflowDesignerOptionsView.DatasetSchemaOption getDatasetSchema(DatasetRef datasetRef) { + if (datasetRef == null || datasetRef.getSourceId() == null || datasetRef.getTableId() == null) { + throw new BusinessException("数据集引用不完整"); + } + LoginAccount account = requireAccount(); + requireTenantSource(datasetRef.getSourceId(), account); + DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(datasetRef.getTableId()); + if (table == null + || !Objects.equals(table.getTenantId(), account.getTenantId()) + || !Objects.equals(table.getSourceId(), datasetRef.getSourceId())) { + throw new BusinessException("数据集不存在或无权访问"); + } + DatacenterSchemaResponse schema = datacenterDatasetQueryService.getSchema(datasetRef); + List fields = schema == null || schema.getFields() == null + ? Collections.emptyList() + : schema.getFields(); + return new WorkflowDesignerOptionsView.DatasetSchemaOption( + table.getTableName(), + table.getTableDesc(), + fields.stream() + .map(field -> new WorkflowDesignerOptionsView.DatasetFieldOption( + field.getFieldName(), + field.getFieldDesc(), + field.getJdbcType(), + field.getFieldType() + )) + .toList() + ); + } + + private List listModelOptions(LoginAccount account) { + Model query = new Model(); + query.setTenantId(account.getTenantId()); + query.setModelType(Model.MODEL_TYPES[0]); + return modelService.listSelectableModels(query, false, "id", "desc").stream() + .filter(model -> Objects.equals(model.getTenantId(), account.getTenantId())) + .map(model -> { + ModelProvider provider = model.getModelProvider(); + WorkflowDesignerOptionsView.ProviderOption providerOption = provider == null + ? null + : new WorkflowDesignerOptionsView.ProviderOption( + provider.getProviderName(), + provider.getProviderType(), + provider.getIcon() + ); + return new WorkflowDesignerOptionsView.ModelOption( + model.getId(), + model.getTitle(), + model.getDescription(), + providerOption + ); + }) + .toList(); + } + + private List listKnowledgeOptions(LoginAccount account) { + return documentCollectionService.list(QueryWrapper.create() + .eq(DocumentCollection::getTenantId, account.getTenantId()) + .orderBy(DocumentCollection::getModified, false)) + .stream() + .filter(item -> resourceAccessService.canAccess( + account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE)) + .map(item -> new WorkflowDesignerOptionsView.KnowledgeOption( + item.getId(), item.getTitle(), item.getDescription())) + .toList(); + } + + private void addReferenceId(Set resourceIds, BigInteger resourceId) { + if (resourceId != null) { + resourceIds.add(resourceId); + } + } + + private void assertModelReferences(Set modelIds, LoginAccount account) { + if (modelIds.isEmpty()) { + return; + } + List models = modelService.listByIds(modelIds); + boolean valid = models.size() == modelIds.size() + && models.stream().allMatch(model -> + modelIds.contains(model.getId()) + && Objects.equals(model.getTenantId(), account.getTenantId()) + && Model.MODEL_TYPES[0].equals(model.getModelType())); + if (!valid) { + throw new BusinessException("模型不存在、已失效或无权使用"); + } + } + + private void assertKnowledgeReferences(Set knowledgeIds, LoginAccount account) { + if (knowledgeIds.isEmpty()) { + return; + } + List knowledges = documentCollectionService.listByIds(knowledgeIds); + boolean valid = knowledges.size() == knowledgeIds.size() + && knowledges.stream().allMatch(knowledge -> + knowledgeIds.contains(knowledge.getId()) + && Objects.equals(knowledge.getTenantId(), account.getTenantId()) + && resourceAccessService.canAccess( + account, CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE)); + if (!valid) { + throw new BusinessException("知识库不存在、已失效或无权使用"); + } + } + + private void assertWorkflowReference( + BigInteger workflowId, + LoginAccount account, + Set checkedWorkflowIds) { + if (workflowId == null || !checkedWorkflowIds.add(workflowId)) { + return; + } + workflowUsageAuthorizationService.requireUsableWorkflow( + workflowId, + account, + "子流程不存在、已禁用或无权使用"); + } + + private void assertDatasetReference( + JSONObject data, + LoginAccount account, + Set checkedSourceIds, + Set checkedTableIds) { + JSONObject datasetRef = data.getJSONObject("datasetRef"); + if (datasetRef == null) { + return; + } + BigInteger sourceId = readReferenceId(datasetRef, "sourceId", "数据源"); + if (sourceId != null && checkedSourceIds.add(sourceId)) { + requireTenantSource(sourceId, account); + } + BigInteger tableId = readReferenceId(datasetRef, "tableId", "数据集"); + if (tableId == null) { + return; + } + if (sourceId == null) { + throw new BusinessException("数据集缺少数据源引用"); + } + String checkedKey = sourceId + ":" + tableId; + if (!checkedTableIds.add(checkedKey)) { + return; + } + DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(tableId); + if (table == null + || !Objects.equals(table.getTenantId(), account.getTenantId()) + || !Objects.equals(table.getSourceId(), sourceId)) { + throw new BusinessException("数据集不存在或无权使用"); + } + } + + private BigInteger readReferenceId(JSONObject data, String key, String resourceName) { + Object value = data.get(key); + if (value == null || String.valueOf(value).isBlank()) { + return null; + } + try { + return new BigInteger(String.valueOf(value)); + } catch (NumberFormatException exception) { + throw new BusinessException(resourceName + "引用格式无效"); + } + } + + private WorkflowDesignerOptionsView.PluginOption toPluginOption(Plugin plugin) { + List tools = + plugin.getTools() == null ? List.of() : plugin.getTools().stream() + .filter(tool -> Integer.valueOf(1).equals(tool.getStatus())) + .map(tool -> new WorkflowDesignerOptionsView.PluginToolOption( + tool.getId(), tool.getName(), tool.getDescription())) + .toList(); + return new WorkflowDesignerOptionsView.PluginOption( + plugin.getId(), + plugin.getName(), + plugin.getDescription(), + plugin.getIcon(), + tools + ); + } + + private JSONArray parseSchemaArray(String content) { + if (content == null || content.isBlank()) { + return new JSONArray(); + } + JSONArray array = JSON.parseArray(content); + decorateSchemaArray(array); + return array; + } + + private void decorateSchemaArray(JSONArray array) { + for (Object item : array) { + if (!(item instanceof JSONObject value)) { + continue; + } + value.put("id", cn.hutool.core.util.IdUtil.simpleUUID()); + value.put("nameDisabled", true); + value.put("dataTypeDisabled", true); + value.put("deleteDisabled", true); + value.put("addChildDisabled", true); + JSONArray children = value.getJSONArray("children"); + if (children != null) { + decorateSchemaArray(children); + } + } + } + + /** + * 为子流程参数补充设计器只读元数据。 + * + * @param array 子流程参数定义 + */ + private void decorateChildWorkflowSchemaArray(JSONArray array) { + for (Object item : array) { + if (!(item instanceof JSONObject value)) { + continue; + } + value.put("id", cn.hutool.core.util.IdUtil.simpleUUID()); + value.put("nameDisabled", true); + value.put("dataTypeDisabled", true); + value.put("deleteDisabled", true); + value.put("addChildDisabled", true); + value.put("refType", "ref"); + JSONArray children = value.getJSONArray("children"); + if (children != null) { + decorateChildWorkflowSchemaArray(children); + } + } + } + + private String resolveSchemaHash(PluginItem record, Plugin plugin) { + if (record.getSchemaHash() != null && !record.getSchemaHash().isBlank()) { + return record.getSchemaHash(); + } + if (!PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) { + return null; + } + Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId()); + return workflow == null ? null : workflowPluginSnapshotResolver.resolveSchemaHash(workflow); + } + + private WorkflowDesignerOptionsView.CatalogOption toCatalogOption(DatacenterCatalogMeta catalog) { + return new WorkflowDesignerOptionsView.CatalogOption( + catalog.getId(), + catalog.getSourceId(), + catalog.getCatalogName(), + catalog.getCatalogDesc() + ); + } + + private WorkflowDesignerOptionsView.DatasetOption toDatasetOption(DatacenterTable table) { + return new WorkflowDesignerOptionsView.DatasetOption( + table.getId(), + table.getSourceId(), + table.getCatalogId(), + table.getTableName(), + table.getTableDesc() + ); + } + + private DatacenterSource requireTenantSource(BigInteger sourceId, LoginAccount account) { + if (sourceId == null) { + throw new BusinessException("数据源不能为空"); + } + DatacenterSource source = datacenterSourceService.getById(sourceId); + if (source == null || !Objects.equals(source.getTenantId(), account.getTenantId())) { + throw new BusinessException("数据源不存在或无权访问"); + } + return source; + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/system/SystemFormOptionService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/system/SystemFormOptionService.java new file mode 100644 index 00000000..aef444ee --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/system/SystemFormOptionService.java @@ -0,0 +1,510 @@ +package tech.easyflow.admin.service.system; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import tech.easyflow.admin.model.SystemFormOptionsView; +import tech.easyflow.approval.entity.vo.ApprovalFlowScopeVo; +import tech.easyflow.approval.enums.ApprovalScopeType; +import tech.easyflow.agent.entity.AgentCategory; +import tech.easyflow.agent.service.AgentCategoryService; +import tech.easyflow.ai.entity.DocumentCollectionCategory; +import tech.easyflow.ai.entity.PluginCategory; +import tech.easyflow.ai.entity.ResourceCategory; +import tech.easyflow.ai.entity.WorkflowCategory; +import tech.easyflow.ai.service.DocumentCollectionCategoryService; +import tech.easyflow.ai.service.PluginCategoryService; +import tech.easyflow.ai.service.ResourceCategoryService; +import tech.easyflow.ai.service.WorkflowCategoryService; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.entity.SysDept; +import tech.easyflow.system.entity.SysMenu; +import tech.easyflow.system.entity.SysPosition; +import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.entity.vo.SysRoleCategoryScopeItemVo; +import tech.easyflow.system.service.SysDeptService; +import tech.easyflow.system.service.SysMenuService; +import tech.easyflow.system.service.SysPositionService; +import tech.easyflow.system.service.SysRoleService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +/** + * 查询并校验系统管理表单所需的安全选项。 + */ +@Service +public class SystemFormOptionService { + + private final AgentCategoryService agentCategoryService; + private final WorkflowCategoryService workflowCategoryService; + private final DocumentCollectionCategoryService documentCollectionCategoryService; + private final PluginCategoryService pluginCategoryService; + private final ResourceCategoryService resourceCategoryService; + private final SysDeptService sysDeptService; + private final SysMenuService sysMenuService; + private final SysRoleService sysRoleService; + private final SysPositionService sysPositionService; + + /** + * 创建系统表单选项服务。 + * + * @param agentCategoryService Agent 分类服务 + * @param workflowCategoryService 工作流分类服务 + * @param documentCollectionCategoryService 知识库分类服务 + * @param pluginCategoryService 插件分类服务 + * @param resourceCategoryService 素材分类服务 + * @param sysDeptService 部门服务 + * @param sysMenuService 菜单服务 + * @param sysRoleService 角色服务 + * @param sysPositionService 岗位服务 + */ + public SystemFormOptionService( + AgentCategoryService agentCategoryService, + WorkflowCategoryService workflowCategoryService, + DocumentCollectionCategoryService documentCollectionCategoryService, + PluginCategoryService pluginCategoryService, + ResourceCategoryService resourceCategoryService, + SysDeptService sysDeptService, + SysMenuService sysMenuService, + SysRoleService sysRoleService, + SysPositionService sysPositionService) { + this.agentCategoryService = agentCategoryService; + this.workflowCategoryService = workflowCategoryService; + this.documentCollectionCategoryService = documentCollectionCategoryService; + this.pluginCategoryService = pluginCategoryService; + this.resourceCategoryService = resourceCategoryService; + this.sysDeptService = sysDeptService; + this.sysMenuService = sysMenuService; + this.sysRoleService = sysRoleService; + this.sysPositionService = sysPositionService; + } + + /** + * 查询审批流程资源范围选项。 + * + * @return 审批流程资源范围选项 + */ + public SystemFormOptionsView.ApprovalResourceScopeOptions approvalResourceScopeOptions() { + Map> categories = new LinkedHashMap<>(); + categories.put("AGENT", listAgentCategories()); + categories.put("WORKFLOW", listWorkflowCategories()); + categories.put("KNOWLEDGE", listKnowledgeCategories()); + return new SystemFormOptionsView.ApprovalResourceScopeOptions( + categories, + listDepartments() + ); + } + + /** + * 查询角色管理表单选项。 + * + * @return 角色管理表单选项 + */ + public SystemFormOptionsView.RoleFormOptions roleFormOptions() { + Map> categories = new LinkedHashMap<>(); + categories.put("AGENT", listAgentCategories()); + categories.put("PLUGIN", listPluginCategories()); + categories.put("WORKFLOW", listWorkflowCategories()); + categories.put("KNOWLEDGE", listKnowledgeCategories()); + categories.put("RESOURCE", listResourceCategories()); + return new SystemFormOptionsView.RoleFormOptions( + listMenus(), + categories + ); + } + + /** + * 查询账号管理表单选项。 + * + * @return 账号管理表单选项 + */ + public SystemFormOptionsView.AccountFormOptions accountFormOptions() { + BigInteger tenantId = requireAccount().getTenantId(); + List roles = sysRoleService.list(QueryWrapper.create() + .eq(SysRole::getTenantId, tenantId) + .eq(SysRole::getStatus, EnumDataStatus.AVAILABLE.getCode()) + .orderBy(SysRole::getId, true)) + .stream() + .map(role -> new SystemFormOptionsView.RoleOption(role.getId(), role.getRoleName())) + .toList(); + List positions = sysPositionService.list(QueryWrapper.create() + .eq(SysPosition::getTenantId, tenantId) + .eq(SysPosition::getStatus, EnumDataStatus.AVAILABLE.getCode()) + .orderBy(SysPosition::getSortNo, true)) + .stream() + .map(position -> new SystemFormOptionsView.PositionOption( + position.getId(), position.getPositionName())) + .toList(); + return new SystemFormOptionsView.AccountFormOptions( + listDepartments(), + roles, + positions + ); + } + + /** + * 校验账号表单引用的部门、角色和岗位。 + * + * @param account 账号表单 + * @throws BusinessException 引用不存在或已禁用时抛出 + */ + public void validateAccountReferences(SysAccount account) { + if (account == null) { + throw new BusinessException("账号信息不能为空"); + } + if (account.getDeptId() == null) { + throw new BusinessException("部门不能为空"); + } + SysDept dept = sysDeptService.getById(account.getDeptId()); + BigInteger tenantId = requireAccount().getTenantId(); + if (dept == null + || !Objects.equals(dept.getTenantId(), tenantId) + || !EnumDataStatus.AVAILABLE.getCode().equals(dept.getStatus())) { + throw new BusinessException("部门不存在或已禁用"); + } + assertAvailableIds( + account.getRoleIds(), + sysRoleService::listByIds, + SysRole::getId, + SysRole::getStatus, + SysRole::getTenantId, + tenantId, + "角色" + ); + if (account.getPositionIds() != null && !account.getPositionIds().isEmpty()) { + assertAvailableIds( + account.getPositionIds(), + sysPositionService::listByIds, + SysPosition::getId, + SysPosition::getStatus, + SysPosition::getTenantId, + tenantId, + "岗位" + ); + } + } + + /** + * 校验角色引用的菜单和部门。 + * + * @param role 角色表单 + * @throws BusinessException 引用不存在时抛出 + */ + public void validateRoleReferences(SysRole role) { + if (role == null) { + throw new BusinessException("角色信息不能为空"); + } + assertExistingIds(role.getMenuIds(), sysMenuService::listByIds, SysMenu::getId, "菜单"); + if (role.getDeptIds() != null && !role.getDeptIds().isEmpty()) { + assertTenantIds( + role.getDeptIds(), + sysDeptService::listByIds, + SysDept::getId, + SysDept::getTenantId, + requireAccount().getTenantId(), + "部门"); + } + } + + /** + * 校验角色分类授权中提交的非 Bot 分类 ID。 + * + * @param scopes 分类权限范围 + * @throws BusinessException 分类类型或分类 ID 非法时抛出 + */ + public void validateCategoryScopes(List scopes) { + if (scopes == null) { + return; + } + for (SysRoleCategoryScopeItemVo scope : scopes) { + if (scope == null || scope.getCategoryIds() == null || scope.getCategoryIds().isEmpty()) { + continue; + } + switch (String.valueOf(scope.getResourceType()).toUpperCase()) { + case "AGENT" -> assertTenantIds( + scope.getCategoryIds(), + agentCategoryService::listByIds, + AgentCategory::getId, + AgentCategory::getTenantId, + requireAccount().getTenantId(), + "Agent 分类"); + case "PLUGIN" -> assertExistingIds( + scope.getCategoryIds(), pluginCategoryService::listByIds, PluginCategory::getId, "插件分类"); + case "WORKFLOW" -> assertExistingIds( + scope.getCategoryIds(), workflowCategoryService::listByIds, WorkflowCategory::getId, "工作流分类"); + case "KNOWLEDGE" -> assertExistingIds( + scope.getCategoryIds(), + documentCollectionCategoryService::listByIds, + DocumentCollectionCategory::getId, + "知识库分类"); + case "RESOURCE" -> assertExistingIds( + scope.getCategoryIds(), resourceCategoryService::listByIds, ResourceCategory::getId, "素材分类"); + case "BOT" -> throw new BusinessException("Bot 分类授权已停止维护"); + default -> throw new BusinessException("不支持的分类资源类型"); + } + } + } + + /** + * 校验审批流程提交的分类和部门范围。 + * + * @param resourceType 审批资源类型 + * @param scopes 审批范围 + * @throws BusinessException 范围引用不存在或资源类型不匹配时抛出 + */ + public void validateApprovalScopes(String resourceType, List scopes) { + if (scopes == null) { + return; + } + for (ApprovalFlowScopeVo scope : scopes) { + if (scope == null || scope.getScopeValue() == null) { + continue; + } + String scopeType = String.valueOf(scope.getScopeType()).toUpperCase(); + if (ApprovalScopeType.DEPT.getCode().equals(scopeType)) { + assertTenantIds( + List.of(scope.getScopeValue()), + sysDeptService::listByIds, + SysDept::getId, + SysDept::getTenantId, + requireAccount().getTenantId(), + "部门"); + continue; + } + if (!ApprovalScopeType.CATEGORY.getCode().equals(scopeType)) { + throw new BusinessException("不支持的审批范围类型"); + } + switch (String.valueOf(resourceType).toUpperCase()) { + case "AGENT" -> assertTenantIds( + List.of(scope.getScopeValue()), + agentCategoryService::listByIds, + AgentCategory::getId, + AgentCategory::getTenantId, + requireAccount().getTenantId(), + "Agent 分类"); + case "WORKFLOW" -> assertExistingIds( + List.of(scope.getScopeValue()), + workflowCategoryService::listByIds, + WorkflowCategory::getId, + "工作流分类"); + case "KNOWLEDGE" -> assertExistingIds( + List.of(scope.getScopeValue()), + documentCollectionCategoryService::listByIds, + DocumentCollectionCategory::getId, + "知识库分类"); + default -> throw new BusinessException("当前资源类型不支持分类审批范围"); + } + } + } + + private List listDepartments() { + BigInteger tenantId = requireAccount().getTenantId(); + List departments = sysDeptService.list(QueryWrapper.create() + .eq(SysDept::getTenantId, tenantId) + .eq(SysDept::getStatus, EnumDataStatus.AVAILABLE.getCode()) + .orderBy(SysDept::getSortNo, true)); + return buildDepartmentTree(departments); + } + + private List listMenus() { + List menus = sysMenuService.list(QueryWrapper.create() + .orderBy(SysMenu::getSortNo, true)); + return buildMenuTree(menus); + } + + private List listAgentCategories() { + BigInteger tenantId = requireAccount().getTenantId(); + return agentCategoryService.list(QueryWrapper.create() + .eq(AgentCategory::getTenantId, tenantId) + .eq(AgentCategory::getStatus, EnumDataStatus.AVAILABLE.getCode()) + .orderBy(AgentCategory::getSortNo, true)) + .stream() + .map(category -> new SystemFormOptionsView.CategoryOption( + category.getId(), category.getCategoryName())) + .toList(); + } + + private List listWorkflowCategories() { + return workflowCategoryService.list(QueryWrapper.create() + .orderBy(WorkflowCategory::getSortNo, true)) + .stream() + .map(category -> new SystemFormOptionsView.CategoryOption( + category.getId(), category.getCategoryName())) + .toList(); + } + + private List listKnowledgeCategories() { + return documentCollectionCategoryService.list(QueryWrapper.create() + .orderBy(DocumentCollectionCategory::getSortNo, true)) + .stream() + .map(category -> new SystemFormOptionsView.CategoryOption( + category.getId(), category.getCategoryName())) + .toList(); + } + + private List listPluginCategories() { + return pluginCategoryService.list(QueryWrapper.create() + .orderBy(PluginCategory::getId, true)) + .stream() + .map(category -> new SystemFormOptionsView.CategoryOption( + category.getId(), category.getName())) + .toList(); + } + + private List listResourceCategories() { + return resourceCategoryService.list(QueryWrapper.create() + .orderBy(ResourceCategory::getSortNo, true)) + .stream() + .map(category -> new SystemFormOptionsView.CategoryOption( + category.getId(), category.getCategoryName())) + .toList(); + } + + private List buildDepartmentTree(List departments) { + Set ids = new HashSet<>(); + departments.forEach(item -> ids.add(item.getId())); + Map> children = new LinkedHashMap<>(); + List roots = new ArrayList<>(); + for (SysDept department : departments) { + BigInteger parentId = department.getParentId(); + if (parentId == null || BigInteger.ZERO.equals(parentId) || !ids.contains(parentId)) { + roots.add(department); + } else { + children.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(department); + } + } + return roots.stream().map(item -> toDepartmentOption(item, children)).toList(); + } + + private SystemFormOptionsView.DepartmentOption toDepartmentOption( + SysDept department, + Map> children) { + return new SystemFormOptionsView.DepartmentOption( + department.getId(), + department.getParentId(), + department.getDeptName(), + children.getOrDefault(department.getId(), List.of()).stream() + .map(item -> toDepartmentOption(item, children)) + .toList() + ); + } + + private List buildMenuTree(List menus) { + Set ids = new HashSet<>(); + menus.forEach(item -> ids.add(item.getId())); + Map> children = new LinkedHashMap<>(); + List roots = new ArrayList<>(); + for (SysMenu menu : menus) { + BigInteger parentId = menu.getParentId(); + if (parentId == null || BigInteger.ZERO.equals(parentId) || !ids.contains(parentId)) { + roots.add(menu); + } else { + children.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(menu); + } + } + return roots.stream().map(item -> toMenuOption(item, children)).toList(); + } + + private SystemFormOptionsView.MenuOption toMenuOption( + SysMenu menu, + Map> children) { + return new SystemFormOptionsView.MenuOption( + menu.getId(), + menu.getParentId(), + menu.getMenuTitle(), + children.getOrDefault(menu.getId(), List.of()).stream() + .map(item -> toMenuOption(item, children)) + .toList() + ); + } + + private void assertAvailableIds( + Collection rawIds, + Function, List> loader, + Function idGetter, + Function statusGetter, + Function tenantGetter, + BigInteger tenantId, + String label) { + Set ids = normalizeIds(rawIds); + if (ids.isEmpty()) { + throw new BusinessException(label + "不能为空"); + } + List records = loader.apply(ids); + boolean valid = records.size() == ids.size() + && records.stream().allMatch(item -> + ids.contains(idGetter.apply(item)) + && EnumDataStatus.AVAILABLE.getCode().equals(statusGetter.apply(item)) + && Objects.equals(tenantGetter.apply(item), tenantId)); + if (!valid) { + throw new BusinessException(label + "不存在或已禁用"); + } + } + + private void assertExistingIds( + Collection rawIds, + Function, List> loader, + Function idGetter, + String label) { + Set ids = normalizeIds(rawIds); + if (ids.isEmpty()) { + return; + } + List records = loader.apply(ids); + boolean valid = records.size() == ids.size() + && records.stream().allMatch(item -> ids.contains(idGetter.apply(item))); + if (!valid) { + throw new BusinessException(label + "不存在或无权访问"); + } + } + + private void assertTenantIds( + Collection rawIds, + Function, List> loader, + Function idGetter, + Function tenantGetter, + BigInteger tenantId, + String label) { + Set ids = normalizeIds(rawIds); + if (ids.isEmpty()) { + return; + } + List records = loader.apply(ids); + boolean valid = records.size() == ids.size() + && records.stream().allMatch(item -> + ids.contains(idGetter.apply(item)) + && Objects.equals(tenantGetter.apply(item), tenantId)); + if (!valid) { + throw new BusinessException(label + "不存在或无权访问"); + } + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; + } + + private Set normalizeIds(Collection rawIds) { + Set ids = new LinkedHashSet<>(); + if (rawIds != null) { + rawIds.stream().filter(Objects::nonNull).forEach(ids::add); + } + return ids; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/PermissionIsolationContractTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/PermissionIsolationContractTest.java new file mode 100644 index 00000000..86021298 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/PermissionIsolationContractTest.java @@ -0,0 +1,103 @@ +package tech.easyflow.admin.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.admin.controller.agent.AgentSessionController; +import tech.easyflow.admin.controller.ai.ChatHistoryController; +import tech.easyflow.admin.controller.ai.ModelController; +import tech.easyflow.admin.controller.ai.WorkFlowNodeController; +import tech.easyflow.admin.controller.ai.WorkflowController; +import tech.easyflow.admin.controller.dashboard.DashboardController; +import tech.easyflow.admin.controller.job.SysJobController; +import tech.easyflow.admin.controller.system.ApprovalFlowController; +import tech.easyflow.admin.controller.system.SysAccountController; +import tech.easyflow.admin.controller.system.SysRoleController; + +import java.lang.reflect.Method; +import java.util.Arrays; + +/** + * 管理端页面能力接口权限归属契约测试。 + */ +public class PermissionIsolationContractTest { + + /** + * 验证工作流设计器依赖的选项接口只要求工作流查询权限。 + */ + @Test + public void workflowDesignerOptionsBelongToWorkflowPermission() { + assertMethodPermission( + WorkflowController.class, + "designerOptions", + "/api/v1/workflow/query" + ); + assertMethodPermission( + WorkflowController.class, + "designerChildWorkflow", + "/api/v1/workflow/query" + ); + assertMethodPermission( + WorkFlowNodeController.class, + "getChainParams", + "/api/v1/workflow/query" + ); + } + + /** + * 验证各管理页面的辅助能力接口使用页面自身权限。 + */ + @Test + public void pageOptionsBelongToOwningPagePermissions() { + assertMethodPermission(ModelController.class, "gatewayConfig", "/api/v1/model/query"); + assertMethodPermission(DashboardController.class, "agentOptions", "/api/v1/dashboard/query"); + assertMethodPermission(SysJobController.class, "workflowOptions", "/api/v1/sysJob/save"); + assertMethodPermission(SysJobController.class, "getNextTimes", "/api/v1/sysJob/save"); + assertMethodPermission(ApprovalFlowController.class, "resourceScopeOptions", "/api/v1/approvalFlow/save"); + assertMethodPermission(SysRoleController.class, "formOptions", "/api/v1/sysRole/query"); + assertMethodPermission(SysAccountController.class, "formOptions", "/api/v1/sysAccount/save"); + } + + /** + * 验证 Agent 会话和聊天历史接口分别使用各自页面权限。 + */ + @Test + public void agentSessionAndHistoryUseIndependentPermissions() { + assertClassPermission(AgentSessionController.class, "/api/v1/agent/session/query"); + assertClassPermission(ChatHistoryController.class, "/api/v1/chatHistory/query"); + } + + /** + * 断言控制器方法只声明指定权限。 + * + * @param controllerType 控制器类型 + * @param methodName 方法名 + * @param expectedPermission 期望权限 + */ + private void assertMethodPermission( + Class controllerType, + String methodName, + String expectedPermission) { + Method method = Arrays.stream(controllerType.getDeclaredMethods()) + .filter(candidate -> methodName.equals(candidate.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("未找到控制器方法:" + methodName)); + SaCheckPermission permission = method.getAnnotation(SaCheckPermission.class); + + Assert.assertNotNull(permission, methodName + " 缺少权限注解"); + Assert.assertEquals(permission.value(), new String[]{expectedPermission}); + } + + /** + * 断言控制器类只声明指定权限。 + * + * @param controllerType 控制器类型 + * @param expectedPermission 期望权限 + */ + private void assertClassPermission(Class controllerType, String expectedPermission) { + SaCheckPermission permission = controllerType.getAnnotation(SaCheckPermission.class); + + Assert.assertNotNull(permission, controllerType.getSimpleName() + " 缺少权限注解"); + Assert.assertEquals(permission.value(), new String[]{expectedPermission}); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/ChatHistoryControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/ChatHistoryControllerTest.java index 83f155b7..b8dd2081 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/ChatHistoryControllerTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/ChatHistoryControllerTest.java @@ -6,6 +6,7 @@ import tech.easyflow.chatlog.domain.dto.ChatSessionPage; import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery; import tech.easyflow.chatlog.service.ChatHistoryManageService; +import tech.easyflow.agent.service.AgentOptionQueryService; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.system.service.CategoryPermissionService; @@ -30,7 +31,8 @@ public class ChatHistoryControllerTest { BigInteger accountId = BigInteger.valueOf(20); ChatHistoryManageService service = mock(ChatHistoryManageService.class); CategoryPermissionService permissionService = mock(CategoryPermissionService.class); - ChatHistoryController controller = new ChatHistoryController(service, permissionService); + ChatHistoryController controller = new ChatHistoryController( + service, permissionService, mock(AgentOptionQueryService.class)); ChatSessionFilterQuery query = new ChatSessionFilterQuery(); LoginAccount account = loginAccount(accountId); when(permissionService.isSuperAdmin(account)).thenReturn(false); @@ -54,7 +56,8 @@ public class ChatHistoryControllerTest { BigInteger sessionId = BigInteger.valueOf(30); ChatHistoryManageService service = mock(ChatHistoryManageService.class); CategoryPermissionService permissionService = mock(CategoryPermissionService.class); - ChatHistoryController controller = new ChatHistoryController(service, permissionService); + ChatHistoryController controller = new ChatHistoryController( + service, permissionService, mock(AgentOptionQueryService.class)); LoginAccount account = loginAccount(accountId); when(permissionService.isSuperAdmin(account)).thenReturn(true); when(service.getAdminSession(accountId, true, sessionId)).thenReturn(new ChatSessionSummary()); diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/job/SysJobControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/job/SysJobControllerTest.java index 89520e75..b9a7a8a2 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/job/SysJobControllerTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/job/SysJobControllerTest.java @@ -7,6 +7,7 @@ import org.testng.annotations.Test; import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.service.WorkflowUsageAuthorizationService; import tech.easyflow.common.constant.enums.EnumJobType; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; @@ -22,6 +23,7 @@ import java.util.Map; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -37,13 +39,19 @@ public class SysJobControllerTest { BigInteger workflowId = BigInteger.valueOf(101); SysJobService jobService = mock(SysJobService.class); WorkflowService workflowService = mock(WorkflowService.class); + WorkflowUsageAuthorizationService workflowAuthorizationService = + mock(WorkflowUsageAuthorizationService.class); ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); WorkflowRunningParameterResolver parameterResolver = mock(WorkflowRunningParameterResolver.class); Workflow workflow = new Workflow(); workflow.setId(workflowId); workflow.setContent("{}"); - when(workflowService.getById(workflowId)).thenReturn(workflow); + when(workflowAuthorizationService.requireUsableWorkflow( + org.mockito.ArgumentMatchers.eq(workflowId), + org.mockito.ArgumentMatchers.any(LoginAccount.class), + org.mockito.ArgumentMatchers.anyString())) + .thenReturn(workflow); Parameter requiredParameter = mock(Parameter.class); when(requiredParameter.isRequired()).thenReturn(true); when(requiredParameter.getName()).thenReturn("user_input"); @@ -53,6 +61,7 @@ public class SysJobControllerTest { SysJobController controller = new SysJobController( jobService, workflowService, + workflowAuthorizationService, resourceAccessService, parameterResolver ); @@ -76,4 +85,66 @@ public class SysJobControllerTest { Assert.assertTrue(exception.getMessage().contains("用户问题")); } } + + /** + * 验证部分更新省略任务类型时仍按数据库中的工作流任务类型完成引用校验。 + */ + @Test + public void shouldValidateMergedWorkflowReferenceOnPartialUpdate() { + BigInteger jobId = BigInteger.valueOf(201); + BigInteger oldWorkflowId = BigInteger.valueOf(301); + BigInteger newWorkflowId = BigInteger.valueOf(302); + SysJobService jobService = mock(SysJobService.class); + WorkflowService workflowService = mock(WorkflowService.class); + WorkflowUsageAuthorizationService workflowAuthorizationService = + mock(WorkflowUsageAuthorizationService.class); + ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + WorkflowRunningParameterResolver parameterResolver = + mock(WorkflowRunningParameterResolver.class); + + SysJob existing = new SysJob(); + existing.setId(jobId); + existing.setJobType(EnumJobType.TINY_FLOW.getCode()); + existing.setJobParams(Map.of( + JobConstant.WORKFLOW_KEY, oldWorkflowId.toString(), + JobConstant.WORKFLOW_PARAMS_KEY, Map.of() + )); + when(jobService.getById(jobId)).thenReturn(existing); + when(workflowAuthorizationService.requireUsableWorkflow( + org.mockito.ArgumentMatchers.eq(newWorkflowId), + org.mockito.ArgumentMatchers.any(LoginAccount.class), + org.mockito.ArgumentMatchers.anyString())) + .thenThrow(new BusinessException("无权限运行所选工作流")); + + SysJob update = new SysJob(); + update.setId(jobId); + update.setJobParams(Map.of( + JobConstant.WORKFLOW_KEY, newWorkflowId.toString(), + JobConstant.WORKFLOW_PARAMS_KEY, Map.of() + )); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.ONE); + + SysJobController controller = new SysJobController( + jobService, + workflowService, + workflowAuthorizationService, + resourceAccessService, + parameterResolver + ); + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); + + BusinessException exception = Assert.expectThrows( + BusinessException.class, + () -> controller.onSaveOrUpdateBefore(update, false) + ); + + Assert.assertTrue(exception.getMessage().contains("无权限")); + verify(workflowAuthorizationService).requireUsableWorkflow( + org.mockito.ArgumentMatchers.eq(newWorkflowId), + org.mockito.ArgumentMatchers.eq(account), + org.mockito.ArgumentMatchers.anyString()); + } + } } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java index 6d61a201..8b39abfa 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java @@ -10,6 +10,7 @@ import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.admin.controller.system.vo.SysAccountProfileVo; +import tech.easyflow.admin.service.system.SystemFormOptionService; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.service.SysAccountService; @@ -46,7 +47,8 @@ public class SysAccountControllerTest { SysAccountController controller = new SysAccountController( mock(SysAccountService.class), mock(AuthCredentialKeyService.class), - mock(SysRoleService.class) + mock(SysRoleService.class), + mock(SystemFormOptionService.class) ); HttpServletRequest request = mock(HttpServletRequest.class); when(request.getParameter("keyword")).thenReturn(" search-user "); @@ -74,7 +76,8 @@ public class SysAccountControllerTest { SysAccountController controller = new SysAccountController( mock(SysAccountService.class), mock(AuthCredentialKeyService.class), - mock(SysRoleService.class) + mock(SysRoleService.class), + mock(SystemFormOptionService.class) ); HttpServletRequest request = mock(HttpServletRequest.class); when(request.getParameter("keyword")).thenReturn(" "); @@ -93,7 +96,8 @@ public class SysAccountControllerTest { SysAccountController controller = new SysAccountController( mock(SysAccountService.class), mock(AuthCredentialKeyService.class), - mock(SysRoleService.class) + mock(SysRoleService.class), + mock(SystemFormOptionService.class) ); HttpServletRequest request = mock(HttpServletRequest.class); when(request.getParameter("keyword")).thenReturn("a_b%c\\d"); @@ -118,7 +122,8 @@ public class SysAccountControllerTest { SysAccountController controller = new SysAccountController( accountService, credentialKeyService, - roleService + roleService, + mock(SystemFormOptionService.class) ); SysAccount entity = createAccount(selectedDeptId); LoginAccount loginAccount = createLoginAccount(operatorId, tenantId, operatorDeptId); @@ -167,7 +172,8 @@ public class SysAccountControllerTest { SysAccountController controller = new SysAccountController( accountService, credentialKeyService, - roleService + roleService, + mock(SystemFormOptionService.class) ); SysAccount entity = createAccount(BigInteger.valueOf(200)); entity.setRoleIds(List.of()); @@ -202,7 +208,8 @@ public class SysAccountControllerTest { SysAccountController controller = new SysAccountController( accountService, credentialKeyService, - roleService + roleService, + mock(SystemFormOptionService.class) ); SysAccount account = new SysAccount(); account.setId(accountId); @@ -233,7 +240,8 @@ public class SysAccountControllerTest { SysAccountController controller = new SysAccountController( accountService, credentialKeyService, - roleService + roleService, + mock(SystemFormOptionService.class) ); SysAccount account = new SysAccount(); account.setId(accountId); diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java new file mode 100644 index 00000000..003a9ed7 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java @@ -0,0 +1,169 @@ +package tech.easyflow.admin.service.ai; + +import com.mybatisflex.core.query.QueryWrapper; +import com.easyagents.flow.core.parser.ChainParser; +import org.mockito.MockedStatic; +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.service.WorkflowUsageAuthorizationService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService; +import tech.easyflow.datacenter.meta.entity.DatacenterSource; +import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService; +import tech.easyflow.datacenter.meta.service.DatacenterSourceService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * {@link WorkflowDesignerOptionService} 工作流引用权限测试。 + */ +public class WorkflowDesignerOptionServiceTest { + + /** + * 验证客户端提交候选列表之外的模型 ID 时服务端拒绝保存。 + */ + @Test + public void shouldRejectModelOutsideSelectableOptions() { + ModelService modelService = mock(ModelService.class); + DocumentCollectionService knowledgeService = mock(DocumentCollectionService.class); + when(modelService.listByIds(any())).thenReturn(List.of()); + when(knowledgeService.list(any(QueryWrapper.class))).thenReturn(List.of()); + WorkflowDesignerOptionService service = createService( + modelService, knowledgeService, mock(DatacenterSourceService.class)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount()); + + BusinessException exception = Assert.expectThrows( + BusinessException.class, + () -> service.assertContentReferences( + "{\"nodes\":[{\"type\":\"llmNode\",\"data\":{\"llmId\":\"99\"}}]}") + ); + + Assert.assertTrue(exception.getMessage().contains("模型")); + } + } + + /** + * 验证工作流数据节点不能引用其他租户的数据源。 + */ + @Test + public void shouldRejectCrossTenantDataSource() { + ModelService modelService = mock(ModelService.class); + DocumentCollectionService knowledgeService = mock(DocumentCollectionService.class); + DatacenterSourceService sourceService = mock(DatacenterSourceService.class); + when(modelService.listSelectableModels(any(Model.class), eq(false), eq("id"), eq("desc"))) + .thenReturn(List.of()); + when(knowledgeService.list(any(QueryWrapper.class))).thenReturn(List.of()); + DatacenterSource source = new DatacenterSource(); + source.setId(BigInteger.valueOf(9)); + source.setTenantId(BigInteger.valueOf(200)); + when(sourceService.getById(BigInteger.valueOf(9))).thenReturn(source); + WorkflowDesignerOptionService service = createService( + modelService, knowledgeService, sourceService); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount()); + + BusinessException exception = Assert.expectThrows( + BusinessException.class, + () -> service.assertContentReferences(""" + {"nodes":[{"type":"search-dataset-node","data":{ + "datasetRef":{"sourceId":"9"} + }}]} + """) + ); + + Assert.assertTrue(exception.getMessage().contains("数据源")); + } + } + + /** + * 验证子流程节点配置拒绝读取其他租户的工作流。 + */ + @Test + public void shouldRejectCrossTenantChildWorkflow() { + WorkflowService workflowService = mock(WorkflowService.class); + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.valueOf(19)); + workflow.setTenantId(BigInteger.valueOf(200)); + when(workflowService.getById(BigInteger.valueOf(19))).thenReturn(workflow); + WorkflowDesignerOptionService service = createService( + mock(ModelService.class), + mock(DocumentCollectionService.class), + mock(DatacenterSourceService.class), + workflowService + ); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount()); + + BusinessException exception = Assert.expectThrows( + BusinessException.class, + () -> service.getChildWorkflowNodeData( + BigInteger.valueOf(10), + BigInteger.valueOf(19)) + ); + + Assert.assertTrue(exception.getMessage().contains("子流程")); + } + } + + private WorkflowDesignerOptionService createService( + ModelService modelService, + DocumentCollectionService knowledgeService, + DatacenterSourceService sourceService) { + return createService(modelService, knowledgeService, sourceService, mock(WorkflowService.class)); + } + + private WorkflowDesignerOptionService createService( + ModelService modelService, + DocumentCollectionService knowledgeService, + DatacenterSourceService sourceService, + WorkflowService workflowService) { + ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + return new WorkflowDesignerOptionService( + modelService, + knowledgeService, + mock(PluginService.class), + mock(PluginItemService.class), + mock(PluginVisibilityService.class), + workflowService, + new WorkflowUsageAuthorizationService(workflowService, resourceAccessService), + mock(WorkflowPluginSnapshotResolver.class), + mock(ChainParser.class), + mock(WorkflowDatacenterContentService.class), + resourceAccessService, + sourceService, + mock(DatacenterDatasetRegistryService.class), + mock(DatacenterDatasetQueryService.class) + ); + } + + private LoginAccount loginAccount() { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.ONE); + account.setTenantId(BigInteger.valueOf(100)); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiDictAutoConfig.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiDictAutoConfig.java index c72376d1..79c8e0fa 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiDictAutoConfig.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiDictAutoConfig.java @@ -3,36 +3,31 @@ package tech.easyflow.ai.config; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Configuration; import org.springframework.context.event.EventListener; -import tech.easyflow.ai.mapper.*; +import tech.easyflow.ai.mapper.BotCategoryMapper; import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.dict.DictManager; import tech.easyflow.common.dict.loader.DbDataLoader; import javax.annotation.Resource; +/** + * 注册仍由 Bot 兼容页面使用的数据库字典。 + */ @Configuration public class AiDictAutoConfig { - @Resource - private WorkflowMapper workflowMapper; - @Resource - private WorkflowCategoryMapper workflowCategoryMapper; + /** Bot 分类字典数据访问器。 */ @Resource private BotCategoryMapper botCategoryMapper; - @Resource - private ResourceCategoryMapper resourceCategoryMapper; - @Resource - private DocumentCollectionCategoryMapper documentCollectionCategoryMapper; + /** + * 应用启动完成后注册 Bot 兼容字典。 + */ @EventListener(ApplicationReadyEvent.class) public void onApplicationStartup() { DictManager dictManager = SpringContextUtil.getBean(DictManager.class); - dictManager.putLoader(new DbDataLoader<>("aiWorkFlow", workflowMapper, "id", "title", null, null, false)); - dictManager.putLoader(new DbDataLoader<>("aiWorkFlowCategory", workflowCategoryMapper, "id", "category_name", null, null, false)); dictManager.putLoader(new DbDataLoader<>("aiBotCategory", botCategoryMapper, "id", "category_name", null, null, false)); - dictManager.putLoader(new DbDataLoader<>("aiResourceCategory", resourceCategoryMapper, "id", "category_name", null, null, false)); - dictManager.putLoader(new DbDataLoader<>("aiDocumentCollectionCategory", documentCollectionCategoryMapper, "id", "category_name", null, null, false)); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationService.java new file mode 100644 index 00000000..ed217d9d --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationService.java @@ -0,0 +1,76 @@ +package tech.easyflow.ai.service; + +import org.springframework.stereotype.Service; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.entity.LoginAccount; +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.Objects; + +/** + * 工作流使用权限校验服务。 + * + *

统一封装工作流存在性、租户、启用状态和资源使用权限校验,供页面能力和后台任务复用。

+ */ +@Service +public class WorkflowUsageAuthorizationService { + + /** 工作流服务。 */ + private final WorkflowService workflowService; + + /** 资源访问控制服务。 */ + private final ResourceAccessService resourceAccessService; + + /** + * 创建工作流使用权限校验服务。 + * + * @param workflowService 工作流服务 + * @param resourceAccessService 资源访问控制服务 + */ + public WorkflowUsageAuthorizationService( + WorkflowService workflowService, + ResourceAccessService resourceAccessService) { + this.workflowService = workflowService; + this.resourceAccessService = resourceAccessService; + } + + /** + * 获取当前账号可使用的启用工作流。 + * + * @param workflowId 工作流 ID + * @param account 使用工作流的账号 + * @param denyMessage 校验失败提示 + * @return 可使用的工作流 + * @throws BusinessException 工作流不存在、未启用、跨租户或无使用权限时抛出 + */ + public Workflow requireUsableWorkflow( + BigInteger workflowId, + LoginAccount account, + String denyMessage) { + String message = denyMessage == null || denyMessage.isBlank() + ? "工作流不存在、已禁用或无权使用" + : denyMessage; + if (workflowId == null || account == null || account.getId() == null + || account.getTenantId() == null) { + throw new BusinessException(403, 403, message); + } + Workflow workflow = workflowService.getById(workflowId); + boolean usable = workflow != null + && Objects.equals(workflow.getTenantId(), account.getTenantId()) + && EnumDataStatus.AVAILABLE.getCode().equals(workflow.getStatus()) + && resourceAccessService.canAccess( + account, + CategoryResourceType.WORKFLOW, + workflow, + ResourceAction.USE); + if (!usable) { + throw new BusinessException(403, 403, message); + } + return workflow; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/share/WorkflowSharePolicy.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/share/WorkflowSharePolicy.java index 1251b1eb..02ff78c1 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/share/WorkflowSharePolicy.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/share/WorkflowSharePolicy.java @@ -30,6 +30,14 @@ public final class WorkflowSharePolicy { private static final Set ALLOWED_REQUESTS = Set.of( permissionKey("GET", "/api/v1/workflow/detail", ResourceAction.READ), permissionKey("GET", "/api/v1/workflow/getRunningParameters", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/options", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/plugins", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/pluginTinyFlow", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/childWorkflow", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/dataSources", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/catalogs", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/managedTables", ResourceAction.READ), + permissionKey("GET", "/api/v1/workflow/designer/schema", ResourceAction.READ), permissionKey("POST", "/api/v1/workflow/update", ResourceAction.MANAGE), permissionKey("POST", "/api/v1/workflow/check", ResourceAction.MANAGE), permissionKey("POST", "/api/v1/workflow/singleRun", ResourceAction.USE), diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationServiceTest.java new file mode 100644 index 00000000..1a8186b8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/WorkflowUsageAuthorizationServiceTest.java @@ -0,0 +1,129 @@ +package tech.easyflow.ai.service; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.entity.LoginAccount; +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link WorkflowUsageAuthorizationService} 工作流使用权限校验测试。 + */ +public class WorkflowUsageAuthorizationServiceTest { + + /** + * 验证禁用工作流即使资源权限允许也不能被使用。 + */ + @Test + public void shouldRejectDisabledWorkflow() { + BigInteger workflowId = BigInteger.valueOf(101); + WorkflowService workflowService = mock(WorkflowService.class); + ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + Workflow workflow = workflow(workflowId, BigInteger.TEN, EnumDataStatus.UNAVAILABLE.getCode()); + LoginAccount account = account(BigInteger.ONE, BigInteger.TEN); + when(workflowService.getById(workflowId)).thenReturn(workflow); + when(resourceAccessService.canAccess( + account, + CategoryResourceType.WORKFLOW, + workflow, + ResourceAction.USE)).thenReturn(true); + WorkflowUsageAuthorizationService service = + new WorkflowUsageAuthorizationService(workflowService, resourceAccessService); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.requireUsableWorkflow(workflowId, account, "工作流不可用") + ); + + Assert.assertEquals(exception.getMessage(), "工作流不可用"); + } + + /** + * 验证工作流与账号租户不一致时拒绝使用。 + */ + @Test + public void shouldRejectCrossTenantWorkflow() { + BigInteger workflowId = BigInteger.valueOf(102); + WorkflowService workflowService = mock(WorkflowService.class); + ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + Workflow workflow = workflow( + workflowId, + BigInteger.valueOf(20), + EnumDataStatus.AVAILABLE.getCode()); + LoginAccount account = account(BigInteger.ONE, BigInteger.TEN); + when(workflowService.getById(workflowId)).thenReturn(workflow); + WorkflowUsageAuthorizationService service = + new WorkflowUsageAuthorizationService(workflowService, resourceAccessService); + + Assert.assertThrows( + BusinessException.class, + () -> service.requireUsableWorkflow(workflowId, account, "工作流不可用") + ); + } + + /** + * 验证启用、同租户且具有使用权限的工作流可以返回。 + */ + @Test + public void shouldReturnUsableWorkflow() { + BigInteger workflowId = BigInteger.valueOf(103); + WorkflowService workflowService = mock(WorkflowService.class); + ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + Workflow workflow = workflow( + workflowId, + BigInteger.TEN, + EnumDataStatus.AVAILABLE.getCode()); + LoginAccount account = account(BigInteger.ONE, BigInteger.TEN); + when(workflowService.getById(workflowId)).thenReturn(workflow); + when(resourceAccessService.canAccess( + account, + CategoryResourceType.WORKFLOW, + workflow, + ResourceAction.USE)).thenReturn(true); + WorkflowUsageAuthorizationService service = + new WorkflowUsageAuthorizationService(workflowService, resourceAccessService); + + Workflow result = service.requireUsableWorkflow(workflowId, account, "工作流不可用"); + + Assert.assertSame(result, workflow); + } + + /** + * 创建工作流测试数据。 + * + * @param id 工作流 ID + * @param tenantId 租户 ID + * @param status 工作流状态 + * @return 工作流 + */ + private Workflow workflow(BigInteger id, BigInteger tenantId, Integer status) { + Workflow workflow = new Workflow(); + workflow.setId(id); + workflow.setTenantId(tenantId); + workflow.setStatus(status); + return workflow; + } + + /** + * 创建登录账号测试数据。 + * + * @param id 账号 ID + * @param tenantId 租户 ID + * @return 登录账号 + */ + private LoginAccount account(BigInteger id, BigInteger tenantId) { + LoginAccount account = new LoginAccount(); + account.setId(id); + account.setTenantId(tenantId); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowSharePolicyTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowSharePolicyTest.java index a26388b9..68a9c2f5 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowSharePolicyTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowSharePolicyTest.java @@ -61,6 +61,11 @@ public class WorkflowSharePolicyTest { "/api/v1/workflow/detail", ResourceAction.READ )); + Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest( + "GET", + "/api/v1/workflow/designer/childWorkflow", + ResourceAction.READ + )); Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest( "POST", "/api/v1/workflow/update", diff --git a/easyflow-modules/easyflow-module-job/pom.xml b/easyflow-modules/easyflow-module-job/pom.xml index e39a1776..4a3e21b0 100644 --- a/easyflow-modules/easyflow-module-job/pom.xml +++ b/easyflow-modules/easyflow-module-job/pom.xml @@ -42,5 +42,17 @@ tech.easyflow easyflow-module-ai + + org.mockito + mockito-core + 5.12.0 + test + + + junit + junit + ${junit.version} + test + diff --git a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/WorkflowJobExecutionService.java b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/WorkflowJobExecutionService.java new file mode 100644 index 00000000..ab8ed733 --- /dev/null +++ b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/service/WorkflowJobExecutionService.java @@ -0,0 +1,159 @@ +package tech.easyflow.job.service; + +import cn.hutool.core.bean.BeanUtil; +import com.alibaba.fastjson2.JSONObject; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import com.mybatisflex.core.tenant.TenantManager; +import org.springframework.stereotype.Service; +import tech.easyflow.ai.service.WorkflowUsageAuthorizationService; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.constant.enums.EnumJobStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.job.entity.SysJob; +import tech.easyflow.job.job.JobConstant; +import tech.easyflow.job.support.SysJobWorkflowReferenceSupport; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.SysAccountService; + +import java.math.BigInteger; +import java.util.Map; +import java.util.Objects; + +/** + * 工作流定时任务执行服务。 + * + *

每次触发都重新加载任务、账号和工作流,并按服务端记录恢复执行主体及重新授权。

+ */ +@Service +public class WorkflowJobExecutionService { + + /** 定时任务服务。 */ + private final SysJobService sysJobService; + + /** 系统账号服务。 */ + private final SysAccountService sysAccountService; + + /** 工作流使用权限校验服务。 */ + private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService; + + /** 工作流执行器。 */ + private final ChainExecutor chainExecutor; + + /** + * 创建工作流定时任务执行服务。 + * + * @param sysJobService 定时任务服务 + * @param sysAccountService 系统账号服务 + * @param workflowUsageAuthorizationService 工作流使用权限校验服务 + * @param chainExecutor 工作流执行器 + */ + public WorkflowJobExecutionService( + SysJobService sysJobService, + SysAccountService sysAccountService, + WorkflowUsageAuthorizationService workflowUsageAuthorizationService, + ChainExecutor chainExecutor) { + this.sysJobService = sysJobService; + this.sysAccountService = sysAccountService; + this.workflowUsageAuthorizationService = workflowUsageAuthorizationService; + this.chainExecutor = chainExecutor; + } + + /** + * 使用当前数据库状态执行工作流定时任务。 + * + * @param scheduledJob Quartz 中保存的任务快照 + * @return 工作流执行结果 + * @throws IllegalStateException 任务、账号或租户状态非法时抛出 + */ + public Object execute(SysJob scheduledJob) { + if (scheduledJob == null || scheduledJob.getId() == null) { + throw new IllegalStateException("定时任务不存在或缺少ID"); + } + return TenantManager.withoutTenantCondition( + () -> executeWithoutTenantCondition( + scheduledJob.getId(), + scheduledJob.getTenantId())); + } + + /** + * 在已关闭 ORM 租户条件的作用域中执行任务,并显式完成租户边界校验。 + * + * @param jobId 定时任务 ID + * @param scheduledTenantId Quartz 任务快照中的租户 ID + * @return 工作流执行结果 + * @throws IllegalStateException 任务、账号或租户状态非法时抛出 + */ + private Object executeWithoutTenantCondition( + BigInteger jobId, + BigInteger scheduledTenantId) { + SysJob job = sysJobService.getById(jobId); + if (job == null) { + throw new IllegalStateException("定时任务不存在或已删除,id=" + jobId); + } + if (scheduledTenantId == null + || job.getTenantId() == null + || !Objects.equals(scheduledTenantId, job.getTenantId())) { + throw new IllegalStateException("定时任务租户信息不一致,id=" + jobId); + } + if (!Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(job.getStatus())) { + throw new IllegalStateException("定时任务未处于运行状态,id=" + jobId); + } + if (!SysJobWorkflowReferenceSupport.isWorkflowJob(job)) { + throw new IllegalStateException("定时任务类型已变更,id=" + jobId); + } + + SysAccount account = requireAvailableOwner(job); + LoginAccount loginAccount = new LoginAccount(); + BeanUtil.copyProperties(account, loginAccount); + BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(job); + workflowUsageAuthorizationService.requireUsableWorkflow( + workflowId, + loginAccount, + "定时任务关联的工作流不存在、已禁用或无权运行"); + + JSONObject workflowParams = resolveWorkflowParams(job.getJobParams()); + workflowParams.put(Constants.LOGIN_USER_KEY, loginAccount); + return chainExecutor.execute(workflowId.toString(), workflowParams); + } + + /** + * 获取任务创建账号并校验账号仍可用于执行任务。 + * + * @param job 当前数据库中的定时任务 + * @return 可用的任务创建账号 + * @throws IllegalStateException 创建账号缺失、禁用或跨租户时抛出 + */ + private SysAccount requireAvailableOwner(SysJob job) { + BigInteger accountId = job.getCreatedBy(); + if (accountId == null) { + throw new IllegalStateException("定时任务缺少服务端归属账号,id=" + job.getId()); + } + SysAccount account = sysAccountService.getById(accountId); + if (account == null) { + throw new IllegalStateException("定时任务归属账号不存在,id=" + accountId); + } + if (!EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())) { + throw new IllegalStateException("定时任务归属账号未启用,id=" + accountId); + } + if (!Objects.equals(job.getTenantId(), account.getTenantId())) { + throw new IllegalStateException("定时任务与归属账号租户不一致,id=" + job.getId()); + } + return account; + } + + /** + * 解析工作流运行参数并返回可写对象。 + * + * @param jobParams 定时任务参数 + * @return 工作流运行参数 + */ + private JSONObject resolveWorkflowParams(Map jobParams) { + if (jobParams == null) { + return new JSONObject(); + } + JSONObject params = new JSONObject(jobParams) + .getJSONObject(JobConstant.WORKFLOW_PARAMS_KEY); + return params == null ? new JSONObject() : new JSONObject(params); + } +} diff --git a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java index 0d87e0fe..5ea61e84 100644 --- a/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java +++ b/easyflow-modules/easyflow-module-job/src/main/java/tech/easyflow/job/util/JobUtil.java @@ -2,24 +2,15 @@ package tech.easyflow.job.util; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.StrUtil; -import com.alibaba.fastjson2.JSONObject; -import com.mybatisflex.core.tenant.TenantManager; -import com.easyagents.flow.core.chain.ChainDefinition; -import com.easyagents.flow.core.chain.runtime.ChainExecutor; import org.quartz.JobKey; import org.quartz.TriggerKey; -import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.enums.EnumJobType; -import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.job.entity.SysJob; import tech.easyflow.job.job.JobConstant; -import tech.easyflow.job.support.SysJobWorkflowReferenceSupport; -import tech.easyflow.system.entity.SysAccount; -import tech.easyflow.system.service.SysAccountService; +import tech.easyflow.job.service.WorkflowJobExecutionService; import java.lang.reflect.Method; -import java.math.BigInteger; import java.util.Arrays; import java.util.Map; @@ -68,34 +59,16 @@ public class JobUtil { return null; } + /** + * 通过任务模块的受控执行服务运行工作流。 + * + * @param job Quartz 中保存的任务快照 + * @return 工作流执行结果 + */ public static Object execWorkFlow(SysJob job) { - Map jobParams = job.getJobParams(); - JSONObject obj = new JSONObject(jobParams); - BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(job); - JSONObject params = obj.getJSONObject(JobConstant.WORKFLOW_PARAMS_KEY); - - ChainExecutor executor = SpringContextUtil.getBean(ChainExecutor.class); - Object accountId = obj.get(JobConstant.ACCOUNT_ID); - SysAccountService accountService = SpringContextUtil.getBean(SysAccountService.class); - - try { - TenantManager.ignoreTenantCondition(); - - ChainDefinition chain = executor.getDefinitionRepository().getChainDefinitionById(workflowId.toString()); - if (chain == null) { - throw new IllegalStateException("定时任务关联的工作流不存在或已删除,id=" + workflowId); - } - if (accountId != null) { - // 设置的归属者 - SysAccount account = accountService.getById(accountId.toString()); - if (account != null) { - params.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); - } - } - return executor.execute(workflowId.toString(), params); - } finally { - TenantManager.restoreTenantCondition(); - } + WorkflowJobExecutionService executionService = + SpringContextUtil.getBean(WorkflowJobExecutionService.class); + return executionService.execute(job); } public static Object execute(SysJob job) { diff --git a/easyflow-modules/easyflow-module-job/src/test/java/tech/easyflow/job/service/WorkflowJobExecutionServiceTest.java b/easyflow-modules/easyflow-module-job/src/test/java/tech/easyflow/job/service/WorkflowJobExecutionServiceTest.java new file mode 100644 index 00000000..817d06d1 --- /dev/null +++ b/easyflow-modules/easyflow-module-job/src/test/java/tech/easyflow/job/service/WorkflowJobExecutionServiceTest.java @@ -0,0 +1,217 @@ +package tech.easyflow.job.service; + +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import tech.easyflow.ai.service.WorkflowUsageAuthorizationService; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.constant.enums.EnumJobStatus; +import tech.easyflow.common.constant.enums.EnumJobType; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.job.entity.SysJob; +import tech.easyflow.job.job.JobConstant; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.service.SysAccountService; + +import java.math.BigInteger; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link WorkflowJobExecutionService} 运行时授权测试。 + */ +public class WorkflowJobExecutionServiceTest { + + /** + * 验证每次触发都会按数据库当前状态授权,并将任务创建账号注入工作流参数。 + */ + @Test + public void shouldReauthorizeAndRestoreServerControlledOwner() { + BigInteger jobId = BigInteger.valueOf(101); + BigInteger tenantId = BigInteger.valueOf(201); + BigInteger accountId = BigInteger.valueOf(301); + BigInteger workflowId = BigInteger.valueOf(401); + SysJobService jobService = mock(SysJobService.class); + SysAccountService accountService = mock(SysAccountService.class); + WorkflowUsageAuthorizationService authorizationService = + mock(WorkflowUsageAuthorizationService.class); + ChainExecutor chainExecutor = mock(ChainExecutor.class); + SysJob currentJob = workflowJob(jobId, tenantId, accountId, workflowId); + SysAccount account = account(accountId, tenantId, EnumDataStatus.AVAILABLE.getCode()); + when(jobService.getById(jobId)).thenReturn(currentJob); + when(accountService.getById(accountId)).thenReturn(account); + Map executionResult = Map.of("status", "done"); + when(chainExecutor.execute(eq(workflowId.toString()), anyMap())) + .thenReturn(executionResult); + WorkflowJobExecutionService service = new WorkflowJobExecutionService( + jobService, + accountService, + authorizationService, + chainExecutor); + + Object result = service.execute(scheduledJob(jobId, tenantId)); + + Assert.assertSame(executionResult, result); + ArgumentCaptor accountCaptor = + ArgumentCaptor.forClass(LoginAccount.class); + verify(authorizationService).requireUsableWorkflow( + eq(workflowId), + accountCaptor.capture(), + anyString()); + Assert.assertEquals(accountCaptor.getValue().getId(), accountId); + Assert.assertEquals(accountCaptor.getValue().getTenantId(), tenantId); + + @SuppressWarnings("unchecked") + ArgumentCaptor> paramsCaptor = + ArgumentCaptor.forClass(Map.class); + verify(chainExecutor).execute(eq(workflowId.toString()), paramsCaptor.capture()); + Object loginUser = paramsCaptor.getValue().get(Constants.LOGIN_USER_KEY); + Assert.assertTrue(loginUser instanceof LoginAccount); + Assert.assertEquals(((LoginAccount) loginUser).getId(), accountId); + } + + /** + * 验证权限已撤销时执行器不会启动工作流。 + */ + @Test + public void shouldRejectExecutionAfterPermissionRevoked() { + BigInteger jobId = BigInteger.valueOf(102); + BigInteger tenantId = BigInteger.valueOf(202); + BigInteger accountId = BigInteger.valueOf(302); + BigInteger workflowId = BigInteger.valueOf(402); + SysJobService jobService = mock(SysJobService.class); + SysAccountService accountService = mock(SysAccountService.class); + WorkflowUsageAuthorizationService authorizationService = + mock(WorkflowUsageAuthorizationService.class); + ChainExecutor chainExecutor = mock(ChainExecutor.class); + when(jobService.getById(jobId)) + .thenReturn(workflowJob(jobId, tenantId, accountId, workflowId)); + when(accountService.getById(accountId)) + .thenReturn(account(accountId, tenantId, EnumDataStatus.AVAILABLE.getCode())); + when(authorizationService.requireUsableWorkflow( + eq(workflowId), + any(LoginAccount.class), + anyString())) + .thenThrow(new BusinessException("工作流权限已撤销")); + WorkflowJobExecutionService service = new WorkflowJobExecutionService( + jobService, + accountService, + authorizationService, + chainExecutor); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.execute(scheduledJob(jobId, tenantId)) + ); + + Assert.assertTrue(exception.getMessage().contains("权限已撤销")); + verify(chainExecutor, never()).execute(anyString(), anyMap()); + } + + /** + * 验证任务快照与数据库租户不一致时执行失败。 + */ + @Test + public void shouldRejectCrossTenantJobSnapshot() { + BigInteger jobId = BigInteger.valueOf(103); + BigInteger tenantId = BigInteger.valueOf(203); + SysJobService jobService = mock(SysJobService.class); + SysAccountService accountService = mock(SysAccountService.class); + WorkflowUsageAuthorizationService authorizationService = + mock(WorkflowUsageAuthorizationService.class); + ChainExecutor chainExecutor = mock(ChainExecutor.class); + when(jobService.getById(jobId)).thenReturn(workflowJob( + jobId, + tenantId, + BigInteger.valueOf(303), + BigInteger.valueOf(403))); + WorkflowJobExecutionService service = new WorkflowJobExecutionService( + jobService, + accountService, + authorizationService, + chainExecutor); + + IllegalStateException exception = Assert.assertThrows( + IllegalStateException.class, + () -> service.execute(scheduledJob(jobId, BigInteger.valueOf(999))) + ); + + Assert.assertTrue(exception.getMessage().contains("租户")); + verify(authorizationService, never()).requireUsableWorkflow( + any(BigInteger.class), + any(LoginAccount.class), + anyString()); + } + + /** + * 创建 Quartz 任务快照。 + * + * @param jobId 定时任务 ID + * @param tenantId 租户 ID + * @return 任务快照 + */ + private SysJob scheduledJob(BigInteger jobId, BigInteger tenantId) { + SysJob job = new SysJob(); + job.setId(jobId); + job.setTenantId(tenantId); + job.setJobType(EnumJobType.TINY_FLOW.getCode()); + return job; + } + + /** + * 创建数据库中的工作流定时任务。 + * + * @param jobId 定时任务 ID + * @param tenantId 租户 ID + * @param accountId 任务创建账号 ID + * @param workflowId 工作流 ID + * @return 工作流定时任务 + */ + private SysJob workflowJob( + BigInteger jobId, + BigInteger tenantId, + BigInteger accountId, + BigInteger workflowId) { + SysJob job = new SysJob(); + job.setId(jobId); + job.setTenantId(tenantId); + job.setCreatedBy(accountId); + job.setStatus(EnumJobStatus.RUNNING.getCode()); + job.setJobType(EnumJobType.TINY_FLOW.getCode()); + job.setJobParams(Map.of( + JobConstant.WORKFLOW_KEY, workflowId.toString(), + JobConstant.WORKFLOW_PARAMS_KEY, Map.of("question", "hello") + )); + return job; + } + + /** + * 创建系统账号。 + * + * @param accountId 账号 ID + * @param tenantId 租户 ID + * @param status 账号状态 + * @return 系统账号 + */ + private SysAccount account( + BigInteger accountId, + BigInteger tenantId, + Integer status) { + SysAccount account = new SysAccount(); + account.setId(accountId); + account.setTenantId(tenantId); + account.setStatus(status); + return account; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/config/SysDictAutoConfig.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/config/SysDictAutoConfig.java index 26b45545..521bf309 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/config/SysDictAutoConfig.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/config/SysDictAutoConfig.java @@ -2,52 +2,38 @@ package tech.easyflow.system.config; import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.dict.DictManager; -import tech.easyflow.common.dict.loader.DbDataLoader; import tech.easyflow.system.entity.SysDict; -import tech.easyflow.system.mapper.*; import tech.easyflow.system.service.SysDictService; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Configuration; import org.springframework.context.event.EventListener; -import javax.annotation.Resource; import java.util.List; +/** + * 注册由字典表维护的静态业务字典。 + */ @Configuration public class SysDictAutoConfig { - private SysDictService service; - - @Resource - private SysMenuMapper sysMenuMapper; - @Resource - private SysDeptMapper sysDeptMapper; - @Resource - private SysRoleMapper sysRoleMapper; - @Resource - private SysPositionMapper sysPositionMapper; - @Resource - private SysAccountMapper sysAccountMapper; + private final SysDictService service; + /** + * 创建系统字典自动配置。 + * + * @param service 系统字典服务 + */ public SysDictAutoConfig(SysDictService service) { this.service = service; } + /** + * 应用启动完成后注册静态字典。 + */ @EventListener(ApplicationReadyEvent.class) public void onApplicationStartup() { DictManager dictManager = SpringContextUtil.getBean(DictManager.class); - // 菜单表字典 - dictManager.putLoader(new DbDataLoader<>("sysMenu", sysMenuMapper, "id", "menu_title", "parent_id", "sort_no asc", false)); - // 部门表字典 - dictManager.putLoader(new DbDataLoader<>("sysDept", sysDeptMapper, "id", "dept_name", "parent_id", "sort_no asc", false)); - // 角色表字典 - dictManager.putLoader(new DbDataLoader<>("sysRole", sysRoleMapper, "id", "role_name", null, null, true)); - // 职位字典 - dictManager.putLoader(new DbDataLoader<>("sysPosition", sysPositionMapper, "id", "position_name", null, null, true)); - // 用户字典 - dictManager.putLoader(new DbDataLoader<>("sysAccount", sysAccountMapper, "id", "login_name", null, null, true)); - List sysDicts = service.list(); if (sysDicts != null) { sysDicts.forEach(sysDict -> dictManager.putLoader(sysDict.buildLoader())); diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V53__mysql_chat_history_query_permission.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V53__mysql_chat_history_query_permission.sql new file mode 100644 index 00000000..d391b28b --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V53__mysql_chat_history_query_permission.sql @@ -0,0 +1,27 @@ +SET NAMES utf8mb4; + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +VALUES ( + 399900000000000001, 366200000000000003, 1, '查询', '', '', '', + 0, '/api/v1/chatHistory/query', 1, 0, NOW(), 1, NOW(), 1, '聊天历史查询权限' +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT + 399900000000000100 + ROW_NUMBER() OVER (ORDER BY candidate.`role_id`), + candidate.`role_id`, + 399900000000000001 +FROM ( + SELECT DISTINCT parent_grant.`role_id` + FROM `tb_sys_role_menu` parent_grant + WHERE parent_grant.`menu_id` = 366200000000000003 +) candidate +WHERE NOT EXISTS ( + SELECT 1 + FROM `tb_sys_role_menu` existing_grant + WHERE existing_grant.`role_id` = candidate.`role_id` + AND existing_grant.`menu_id` = 399900000000000001 +); diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts index 8eea656a..f42b8cc1 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts @@ -78,7 +78,7 @@ export interface AgentChatCapabilityPayload { } export function getPublishedAgents() { - return api.get>('/api/v1/agent/options', { + return api.get>('/api/v1/agent/session/options', { params: { publishedOnly: true }, }); } @@ -95,7 +95,7 @@ export function getAgentSession(sessionId: number | string) { export function getPublishedKnowledges() { return api.get>( - '/api/v1/agent/knowledgeOptions', + '/api/v1/agent/session/knowledgeOptions', ); } diff --git a/easyflow-ui-admin/app/src/views/ai/chatHistory/index.test.ts b/easyflow-ui-admin/app/src/views/ai/chatHistory/index.test.ts index a03d9a7e..dce8705a 100644 --- a/easyflow-ui-admin/app/src/views/ai/chatHistory/index.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/chatHistory/index.test.ts @@ -6,7 +6,7 @@ import pageSource from './index.vue?raw'; describe('管理端智能体聊天历史契约', () => { it('使用智能体候选接口并保留 assistantId 查询参数', () => { - expect(pageSource).toContain("'/api/v1/agent/options'"); + expect(pageSource).toContain("'/api/v1/chatHistory/agentOptions'"); expect(pageSource).not.toContain('/api/v1/bot/list'); expect(pageSource).toContain('label: item.name'); expect(pageSource).toContain('assistantId: query.value.assistantId'); diff --git a/easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue b/easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue index 326cc915..ea2df33c 100644 --- a/easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue +++ b/easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue @@ -103,7 +103,9 @@ onMounted(async () => { async function fetchAgents() { agentLoading.value = true; - const [error, res] = await tryit(api.get)('/api/v1/agent/options'); + const [error, res] = await tryit(api.get)( + '/api/v1/chatHistory/agentOptions', + ); agentLoading.value = false; if (error || res?.errorCode !== 0) { agentOptions.value = []; diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollectionModal.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollectionModal.vue index d6ba356a..86d1965f 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollectionModal.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollectionModal.vue @@ -19,7 +19,6 @@ import { } from 'element-plus'; import { api } from '#/api/request'; -import DictSelect from '#/components/dict/DictSelect.vue'; import UploadAvatar from '#/components/upload/UploadAvatar.vue'; import { $t } from '#/locales'; @@ -102,6 +101,8 @@ const normalizeEntity = (raw: any = {}) => { const entity = ref(normalizeEntity(defaultEntity)); const btnLoading = ref(false); +const categoryOptions = ref([]); +let categoryOptionsLoaded = false; const rules = ref({ collectionType: [ { required: true, message: $t('message.required'), trigger: 'change' }, @@ -145,7 +146,7 @@ const collectionTypeList = [ }, ]; -function openDialog(row: any = {}) { +async function openDialog(row: any = {}) { if (row.id) { isAdd.value = false; entity.value = normalizeEntity(row); @@ -154,6 +155,17 @@ function openDialog(row: any = {}) { entity.value = normalizeEntity(defaultEntity); } dialogVisible.value = true; + if (!categoryOptionsLoaded) { + try { + const res = await api.get( + '/api/v1/documentCollectionCategory/visibleList', + ); + categoryOptions.value = Array.isArray(res.data) ? res.data : []; + categoryOptionsLoaded = true; + } catch { + categoryOptions.value = []; + } + } } async function save() { @@ -251,10 +263,14 @@ defineExpose({ prop="categoryId" :label="$t('documentCollection.categoryId')" > - + + + { const loadPublishBaseUrl = async () => { try { - const res = await api.get('/api/v1/sysOption/list', { - params: { - keys: ['chat_publish_base_url'], - }, - }); + const res = await api.get('/api/v1/model/gatewayConfig'); if (res.errorCode === 0) { - publishBaseUrl.value = String( - res.data?.chat_publish_base_url || '', - ).trim(); + publishBaseUrl.value = String(res.data?.publishBaseUrl || '').trim(); } } catch { publishBaseUrl.value = ''; diff --git a/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue b/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue index b69fe391..24e81d8e 100644 --- a/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue +++ b/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue @@ -1,6 +1,13 @@