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 b89bd280..78cee52c 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 @@ -35,22 +35,23 @@ import tech.easyflow.agent.runtime.document.AgentDocumentUploadView; import tech.easyflow.agent.runtime.media.AgentMediaService; import tech.easyflow.agent.runtime.media.AgentMediaUploadView; import com.easyagents.agent.runtime.media.AgentMediaResource; +import tech.easyflow.agent.security.AgentVisibilityQueryHelper; import tech.easyflow.agent.service.AgentApprovalStateService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; +import tech.easyflow.agent.service.AgentOptionQueryService; import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.agent.vo.AgentOptionView; +import tech.easyflow.agent.vo.AgentResourceOptionsView; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.log.annotation.LogReporterDisabled; -import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; -import tech.easyflow.system.enums.CategoryResourceType; -import tech.easyflow.system.enums.ResourceAction; -import tech.easyflow.system.service.CategoryPermissionService; -import tech.easyflow.system.service.ResourceAccessService; import javax.annotation.Resource; import java.io.Serializable; @@ -60,8 +61,6 @@ import java.util.Collection; import java.util.Collections; import java.util.List; -import static tech.easyflow.agent.entity.table.AgentTableDef.AGENT; - /** * Agent 管理端控制器。 */ @@ -78,10 +77,6 @@ public class AgentController extends BaseCurdController { @Resource private AgentPublishAppService agentPublishAppService; @Resource - private ResourceAccessService resourceAccessService; - @Resource - private CategoryPermissionService categoryPermissionService; - @Resource private AgentApprovalStateService agentApprovalStateService; @Resource private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; @@ -91,6 +86,10 @@ public class AgentController extends BaseCurdController { private AgentDocumentService agentDocumentService; @Resource private AgentComposerDraftService agentComposerDraftService; + @Resource + private AgentOptionQueryService agentOptionQueryService; + @Resource + private AgentVisibilityQueryHelper agentVisibilityQueryHelper; /** * 创建 Agent 控制器。 @@ -151,20 +150,67 @@ public class AgentController extends BaseCurdController { public Result> list(Agent entity, Boolean asTree, String sortKey, String sortType) { HttpServletRequest request = currentRequest(); QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request); - if (!applyCategoryPermission(queryWrapper)) { - return Result.ok(Collections.emptyList()); - } + agentVisibilityQueryHelper.applyReadableAccess(queryWrapper); applyPublishedOnlyFilter(queryWrapper); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); List agents = service.list(queryWrapper); if (isPublishedOnlyRequest()) { agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList(); } + agents.forEach(this::sanitizeListItem); agentApprovalStateService.fillAgentApprovalState(agents); aiResourceCreatorNameSupport.fillAgentCreatorNames(agents); return Result.ok(agents); } + /** + * 查询 Agent 安全选择项。 + * + * @param publishedOnly 是否仅返回已发布 Agent + * @return Agent 安全选择项 + */ + @GetMapping("/options") + @SaCheckPermission("/api/v1/agent/query") + public Result> options( + @RequestParam(value = "publishedOnly", defaultValue = "false") boolean publishedOnly) { + return Result.ok(agentOptionQueryService.listAgentOptions(publishedOnly)); + } + + /** + * 查询 Agent 设计器的安全资源选项。 + * + * @return 设计器资源选项 + */ + @GetMapping("/resourceOptions") + @SaCheckPermission("/api/v1/agent/save") + public Result resourceOptions() { + return Result.ok(agentOptionQueryService.listDesignerResourceOptions()); + } + + /** + * 查询 Agent 会话可使用的知识库安全选项。 + * + * @return 知识库选项 + */ + @GetMapping("/knowledgeOptions") + @SaCheckPermission("/api/v1/agent/query") + public Result> knowledgeOptions() { + return Result.ok(agentOptionQueryService.listKnowledgeOptions()); + } + + /** + * 查询指定 MCP 的安全工具列表。 + * + * @param id MCP ID + * @return MCP 工具列表 + */ + @GetMapping("/mcpToolOptions") + @SaCheckPermission("/api/v1/agent/save") + public Result> mcpToolOptions( + @RequestParam BigInteger id) { + return Result.ok(agentOptionQueryService.listMcpTools(id)); + } + /** * 运行 Agent 纯文本聊天。 * @@ -488,16 +534,13 @@ public class AgentController extends BaseCurdController { } @Override - protected Result onRemoveBefore(Collection ids) { - for (Serializable id : ids) { - Agent agent = service.getById(String.valueOf(id)); - if (agent != null) { - resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限删除该 Agent"); - } - } - agentToolBindingService.remove(QueryWrapper.create().in("agent_id", ids)); - agentKnowledgeBindingService.remove(QueryWrapper.create().in("agent_id", ids)); - return super.onRemoveBefore(ids); + public Result remove(Serializable id) { + throw new BusinessException("Agent 仅支持通过生命周期审批删除"); + } + + @Override + public Result removeBatch(Collection ids) { + throw new BusinessException("Agent 仅支持通过生命周期审批删除"); } /** @@ -509,38 +552,54 @@ public class AgentController extends BaseCurdController { */ @Override protected Page queryPage(Page page, QueryWrapper queryWrapper) { - if (!applyCategoryPermission(queryWrapper)) { - return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L); - } + agentVisibilityQueryHelper.applyReadableAccess(queryWrapper); applyPublishedOnlyFilter(queryWrapper); - Page result = super.queryPage(page, queryWrapper); + Page result = service.page(page, queryWrapper); if (isPublishedOnlyRequest()) { result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList()); } + result.getRecords().forEach(this::sanitizeListItem); agentApprovalStateService.fillAgentApprovalState(result.getRecords()); aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords()); return result; } - private boolean applyCategoryPermission(QueryWrapper queryWrapper) { - RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode()); - if (!access.isRestricted()) { - return true; + /** + * 清理列表无需返回的配置和发布快照,避免敏感运行配置进入浏览器。 + * + * @param agent Agent 列表项 + */ + private void sanitizeListItem(Agent agent) { + if (agent == null) { + return; } - if (access.getCategoryIds().isEmpty()) { - queryWrapper.eq(Agent::getCreatedBy, access.getAccountId()); - return true; - } - queryWrapper.and(AGENT.CREATED_BY.eq(access.getAccountId()).or(AGENT.CATEGORY_ID.in(access.getCategoryIds()))); - return true; + agent.setModelConfigJson(Collections.emptyMap()); + agent.setGenerationConfigJson(Collections.emptyMap()); + agent.setPromptConfigJson(Collections.emptyMap()); + agent.setMemoryConfigJson(Collections.emptyMap()); + agent.setExecutionConfigJson(Collections.emptyMap()); + agent.setInteractionConfigJson(Collections.emptyMap()); + agent.setPublishedSnapshotJson(Collections.emptyMap()); + agent.setToolBindings(null); + agent.setKnowledgeBindings(null); } + /** + * 为仅发布查询追加发布状态条件。 + * + * @param queryWrapper Agent 查询条件 + */ private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) { if (isPublishedOnlyRequest()) { queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode()); } } + /** + * 判断当前请求是否只查询已发布 Agent。 + * + * @return 是否仅查询已发布 Agent + */ private boolean isPublishedOnlyRequest() { HttpServletRequest request = currentRequest(); if (request == null) { @@ -562,6 +621,14 @@ public class AgentController extends BaseCurdController { return attributes.getRequest(); } + /** + * 将审批执行结果转换为统一响应。 + * + * @param actionResult 审批动作结果 + * @param approvalMessage 进入审批时的提示 + * @param directMessage 直接执行时的提示 + * @return 审批实例响应 + */ private Result buildApprovalActionResult(ApprovalActionResult actionResult, String approvalMessage, String directMessage) { 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 7259de13..d579b640 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 @@ -11,6 +11,7 @@ import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; import java.math.BigInteger; @@ -130,7 +131,17 @@ public class AgentSessionController { return Result.ok(); } + /** + * 获取当前登录账号。 + * + * @return 当前登录账号 + * @throws BusinessException 登录信息失效时抛出 + */ private LoginAccount currentAccount() { - return SaTokenUtil.getLoginAccount(); + 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/controller/ai/BotController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java index 046f5606..3ee2f0b4 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java @@ -18,7 +18,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport; import tech.easyflow.admin.service.ai.ChatWorkspaceService; import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener; @@ -82,9 +82,9 @@ public class BotController extends BaseCurdController { @Resource private ChatRoundOperateService chatRoundOperateService; @Resource - private AiResourceApprovalStateService aiResourceApprovalStateService; + private BotApprovalStateService botApprovalStateService; @Resource - private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; + private BotResourceCreatorNameSupport botResourceCreatorNameSupport; @Resource private ChatWorkspaceService chatWorkspaceService; @@ -240,7 +240,7 @@ public class BotController extends BaseCurdController { bot = botService.toPublishedView(rawBot); } if (StpUtil.isLogin()) { - aiResourceApprovalStateService.fillBotApprovalState(bot); + botApprovalStateService.fillApprovalState(bot); } return Result.ok(bot); } @@ -275,7 +275,7 @@ public class BotController extends BaseCurdController { if (data.getModelId() == null) { if (StpUtil.isLogin()) { - aiResourceApprovalStateService.fillBotApprovalState(data); + botApprovalStateService.fillApprovalState(data); } return Result.ok(data); } @@ -286,7 +286,7 @@ public class BotController extends BaseCurdController { if (llm == null) { data.setModelId(null); if (StpUtil.isLogin()) { - aiResourceApprovalStateService.fillBotApprovalState(data); + botApprovalStateService.fillApprovalState(data); } return Result.ok(data); } @@ -302,7 +302,7 @@ public class BotController extends BaseCurdController { } if (StpUtil.isLogin()) { - aiResourceApprovalStateService.fillBotApprovalState(data); + botApprovalStateService.fillApprovalState(data); } return Result.ok(data); } @@ -369,7 +369,7 @@ public class BotController extends BaseCurdController { if (isPublishedOnlyRequest()) { bots = bots.stream().map(botService::toPublishedView).toList(); } - aiResourceApprovalStateService.fillBotApprovalState(bots); + botApprovalStateService.fillApprovalState(bots); return Result.ok(bots); } @@ -381,8 +381,8 @@ public class BotController extends BaseCurdController { if (isPublishedOnlyRequest()) { result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList()); } - aiResourceApprovalStateService.fillBotApprovalState(result.getRecords()); - aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords()); + botApprovalStateService.fillApprovalState(result.getRecords()); + botResourceCreatorNameSupport.fillCreatorNames(result.getRecords()); return result; } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotPluginItemController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotPluginItemController.java new file mode 100644 index 00000000..984393f8 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotPluginItemController.java @@ -0,0 +1,64 @@ +package tech.easyflow.admin.controller.ai; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.service.BotPluginService; +import tech.easyflow.common.annotation.UsePermission; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.jsonbody.JsonBody; + +import java.math.BigInteger; +import java.util.List; + +/** + * 旧 Bot 插件工具绑定查询控制器。 + * + *

保留历史接口地址,同时将 Bot 表依赖限制在 Bot 专属代码中。

+ */ +@RestController +@RequestMapping("/api/v1/pluginItem") +@UsePermission(moduleName = "/api/v1/plugin") +public class BotPluginItemController { + + private final BotPluginService botPluginService; + + /** + * 创建 Bot 插件工具绑定查询控制器。 + * + * @param botPluginService Bot 插件绑定服务 + */ + public BotPluginItemController(BotPluginService botPluginService) { + this.botPluginService = botPluginService; + } + + /** + * 查询插件工具,并标记指定 Bot 已绑定的工具。 + * + * @param pluginId 插件 ID + * @param botId Bot ID + * @return 插件工具列表 + */ + @PostMapping("/toolsList") + @SaCheckPermission("/api/v1/plugin/query") + public Result> searchPluginTools( + @JsonBody(value = "pluginId", required = true) BigInteger pluginId, + @JsonBody(value = "botId", required = false) BigInteger botId) { + return Result.ok(botPluginService.searchPluginTools(pluginId, botId)); + } + + /** + * 查询指定 Bot 已绑定的插件工具。 + * + * @param botId Bot ID + * @return 已绑定插件工具列表 + */ + @PostMapping("/tool/list") + @SaCheckPermission("/api/v1/plugin/query") + public Result> getPluginTools( + @JsonBody(value = "botId", required = true) BigInteger botId) { + return Result.ok(botPluginService.getPluginTools(botId)); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java index e4838186..a99aff78 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java @@ -14,10 +14,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper; import tech.easyflow.ai.documentimport.DocumentImportDtos; import tech.easyflow.ai.dto.KnowledgeSearchResultItem; -import tech.easyflow.ai.entity.BotDocumentCollection; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.enums.PublishStatus; @@ -27,7 +28,6 @@ import tech.easyflow.ai.vo.OfflineImpactCheckVo; import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; import tech.easyflow.ai.rag.KnowledgeRetrievalModes; -import tech.easyflow.ai.service.BotDocumentCollectionService; import tech.easyflow.ai.service.DocumentChunkService; import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.ModelService; @@ -68,7 +68,7 @@ public class DocumentCollectionController extends BaseCurdController { } @Resource - private BotMcpService botMcpService; + private AgentResourceReferenceService agentResourceReferenceService; @Override public Result save(Mcp entity) { return service.saveMcp(entity); @@ -45,11 +48,29 @@ public class McpController extends BaseCurdController { return service.updateMcp(entity); } + /** + * 删除未被 Agent 绑定的 MCP。 + * + * @param id MCP ID + * @return 删除结果 + */ @Override - @Transactional + @Transactional(rollbackFor = Exception.class) public Result remove(Serializable id) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + // 锁定 MCP 资源行,与 Agent 绑定校验串行,避免检查后并发写入绑定。 + Mcp mcp = service.getOne(QueryWrapper.create() + .eq(Mcp::getId, id) + .eq(Mcp::getTenantId, account.getTenantId()) + .forUpdate()); + if (mcp == null) { + throw new BusinessException("MCP 不存在或无权删除"); + } + agentResourceReferenceService.assertMcpUnused(new BigInteger(String.valueOf(id))); service.removeMcp(id); - botMcpService.remove(QueryWrapper.create().eq(BotMcp::getMcpId, id)); return Result.ok(); } 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 a08a5471..71305e80 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 @@ -11,6 +11,7 @@ import tech.easyflow.ai.dto.ModelInvokeConfigDtos; import tech.easyflow.ai.entity.Model; 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.ai.service.capability.ModelCapabilityResolution; import tech.easyflow.common.domain.Result; @@ -18,6 +19,7 @@ import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.tree.Tree; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; import javax.annotation.Resource; @@ -47,6 +49,8 @@ public class ModelController extends BaseCurdController { @Resource ModelMapper modelMapper; + @Resource + AgentResourceReferenceService agentResourceReferenceService; @GetMapping("list") @SaCheckPermission("/api/v1/model/query") @@ -99,7 +103,22 @@ public class ModelController extends BaseCurdController { @PostMapping("/removeByEntity") @SaCheckPermission("/api/v1/model/remove") + @Transactional(rollbackFor = Exception.class) public Result removeByEntity(@RequestBody Model entity) { + LoginAccount account = requireAccount(); + List models = service.list(QueryWrapper.create() + .select(Model::getId) + .eq(Model::getProviderId, entity.getProviderId()) + .eq(Model::getGroupName, entity.getGroupName()) + .eq(Model::getTenantId, account.getTenantId()) + .orderBy(Model::getId, true) + .forUpdate()); + if (models.isEmpty()) { + throw new BusinessException("模型不存在或无权删除"); + } + agentResourceReferenceService.assertModelsUnused( + models.stream().map(Model::getId).toList()); + entity.setTenantId(account.getTenantId()); modelService.removeByEntity(entity); return Result.ok(); } @@ -172,11 +191,39 @@ public class ModelController extends BaseCurdController { } @PostMapping("removeLlmByIds") - @Transactional + @SaCheckPermission("/api/v1/model/remove") + @Transactional(rollbackFor = Exception.class) public Result removeLlm(@JsonBody(value = "id", required = true) Serializable id) { + LoginAccount account = requireAccount(); List ids = Collections.singletonList(id); - QueryWrapper queryWrapper = QueryWrapper.create().in(Model::getId, ids); - service.remove(queryWrapper); + QueryWrapper queryWrapper = QueryWrapper.create() + .in(Model::getId, ids) + .eq(Model::getTenantId, account.getTenantId()) + .orderBy(Model::getId, true) + .forUpdate(); + List models = service.list(queryWrapper); + if (models.isEmpty()) { + throw new BusinessException("模型不存在或无权删除"); + } + agentResourceReferenceService.assertModelsUnused( + models.stream().map(Model::getId).toList()); + service.remove(QueryWrapper.create() + .in(Model::getId, ids) + .eq(Model::getTenantId, account.getTenantId())); return Result.ok(); } + + /** + * 获取带租户信息的当前登录账号。 + * + * @return 当前登录账号 + * @throws BusinessException 登录状态无效时抛出 + */ + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginController.java index b14b828f..ebc3b78f 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginController.java @@ -12,6 +12,7 @@ import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.ModelService; +import tech.easyflow.ai.service.PluginItemService; import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.service.WorkflowService; @@ -47,8 +48,14 @@ import static tech.easyflow.ai.entity.table.PluginTableDef.PLUGIN; @RestController @RequestMapping("/api/v1/plugin") public class PluginController extends BaseCurdController { + /** + * 创建插件控制器。 + * + * @param service 插件服务 + */ public PluginController(PluginService service) { super(service); + this.pluginService = service; } @Resource @@ -89,10 +96,15 @@ public class PluginController extends BaseCurdController return Result.ok(pluginService.updatePlugin(plugin)); } + /** + * 删除插件。 + * + * @param id 插件 ID + * @return 删除结果 + */ @PostMapping("/plugin/remove") @SaCheckPermission("/api/v1/plugin/remove") public Result removePlugin(@JsonBody(value = "id", required = true) String id){ - return Result.ok(pluginService.removePlugin(id)); } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java index 7b59a555..a3e43cac 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PluginItemController.java @@ -19,19 +19,20 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; -import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.enums.PluginType; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; -import tech.easyflow.ai.service.BotPluginService; import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.AgentResourceReferenceService; +import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.common.constant.Constants; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.exceptions.BusinessException; @@ -42,8 +43,10 @@ import java.io.Serializable; import java.math.BigInteger; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * 控制层。 @@ -55,6 +58,11 @@ import java.util.Map; @RequestMapping("/api/v1/pluginItem") @UsePermission(moduleName = "/api/v1/plugin") public class PluginItemController extends BaseCurdController { + /** + * 创建插件工具控制器。 + * + * @param service 插件工具服务 + */ public PluginItemController(PluginItemService service) { super(service); } @@ -63,10 +71,12 @@ public class PluginItemController extends BaseCurdController> searchPluginToolByPluginId(@JsonBody(value = "pluginId", required = true) BigInteger pluginId, - @JsonBody(value = "botId", required = false) BigInteger botId){ - return Result.ok(pluginItemService.searchPluginToolByPluginId(pluginId, botId)); - } - @PostMapping("/tool/update") @SaCheckPermission("/api/v1/plugin/save") public Result updatePlugin(@JsonBody PluginItem pluginItem){ return Result.ok(pluginItemService.updatePlugin(pluginItem)); } - @PostMapping("/tool/list") - @SaCheckPermission("/api/v1/plugin/query") - public Result> getPluginToolList(@JsonBody(value = "botId", required = true) BigInteger botId){ - return Result.ok(pluginItemService.getPluginToolList(botId)); - } - @GetMapping("/getTinyFlowData") @SaCheckPermission("/api/v1/plugin/query") public Result getTinyFlowData(BigInteger id) { @@ -275,26 +272,64 @@ public class PluginItemController extends BaseCurdController onRemoveBefore(Collection ids) { - - QueryWrapper queryWrapper = QueryWrapper.create(); - queryWrapper.in(BotPlugin::getPluginItemId, ids); - - boolean exists = botPluginService.exists(queryWrapper); - if (exists){ - return Result.fail(1, "此工具还关联着bot,请先取消关联!"); - } - if (ids.size() == 1) { - PluginItem pluginItem = pluginItemService.getById(ids.iterator().next()); - if (pluginItem != null) { - Plugin plugin = pluginService.getById(pluginItem.getPluginId()); - if (plugin != null && PluginType.isWorkflow(plugin.getType())) { - return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除"); + Set uniquePluginItemIds = new LinkedHashSet<>(); + try { + for (Serializable id : ids) { + if (id == null) { + throw new NumberFormatException("null"); } + uniquePluginItemIds.add(new BigInteger(String.valueOf(id))); + } + } catch (NumberFormatException exception) { + throw new BusinessException("插件工具 ID 不合法"); + } + + // BaseCurdController#remove 已开启事务;锁定工具行后校验权限和 Agent 引用。 + List lockedPluginItems = pluginItemService.list(QueryWrapper.create() + .in(PluginItem::getId, uniquePluginItemIds) + .orderBy(PluginItem::getId, true) + .forUpdate()); + if (lockedPluginItems == null || lockedPluginItems.size() != uniquePluginItemIds.size()) { + throw new BusinessException("插件工具不存在或已被删除"); + } + + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + if (loginAccount == null || loginAccount.getTenantId() == null) { + throw new BusinessException("当前登录信息无效"); + } + Map plugins = new HashMap<>(); + for (PluginItem pluginItem : lockedPluginItems) { + if (pluginItem.getPluginId() == null) { + throw new BusinessException("插件工具关联的插件不存在"); + } + Plugin plugin = plugins.get(pluginItem.getPluginId()); + if (plugin == null) { + plugin = pluginService.getById(pluginItem.getPluginId()); + if (plugin == null || plugin.getTenantId() == null + || !loginAccount.getTenantId().toString().equals(plugin.getTenantId().toString())) { + throw new BusinessException("无权限删除该插件工具"); + } + pluginVisibilityService.assertPluginVisible( + plugin.getCreatedBy(), + plugin.getId(), + "无权限删除该插件工具" + ); + plugins.put(pluginItem.getPluginId(), plugin); + } + if (PluginType.isWorkflow(plugin.getType())) { + return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除"); } } + agentResourceReferenceService.assertPluginItemsUnused(List.copyOf(uniquePluginItemIds)); return null; } } 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 9e1b942b..03e3f37a 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 @@ -14,6 +14,9 @@ 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.agent.entity.AgentToolBinding; +import tech.easyflow.agent.enums.AgentToolType; +import tech.easyflow.agent.service.AgentToolBindingService; import tech.easyflow.ai.permission.WorkflowShareResourceAccessGrantProvider; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; @@ -31,7 +34,6 @@ import tech.easyflow.ai.publish.WorkflowPublishAppService; import tech.easyflow.ai.service.AiResourceApprovalStateService; import tech.easyflow.ai.vo.OfflineImpactCheckVo; import tech.easyflow.approval.entity.vo.ApprovalActionResult; -import tech.easyflow.ai.service.BotWorkflowService; import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.common.constant.Constants; @@ -74,7 +76,7 @@ public class WorkflowController extends BaseCurdController bots) { - fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName); - } - /** * 批量填充知识库创建人名称。 * @@ -94,7 +84,7 @@ public class AiResourceCreatorNameSupport { * @param createdByNameSetter 创建人名称回填函数 * @param 资源类型 */ - private void fillCreatorNames( + void fillCreatorNames( Collection resources, Function createdByGetter, BiConsumer createdByNameSetter diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/BotResourceCreatorNameSupport.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/BotResourceCreatorNameSupport.java new file mode 100644 index 00000000..8f261129 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/BotResourceCreatorNameSupport.java @@ -0,0 +1,33 @@ +package tech.easyflow.admin.controller.ai.support; + +import org.springframework.stereotype.Component; +import tech.easyflow.ai.entity.Bot; + +import java.util.Collection; + +/** + * 为旧 Bot 资源批量补充创建人展示名称。 + */ +@Component +public class BotResourceCreatorNameSupport { + + private final AiResourceCreatorNameSupport creatorNameSupport; + + /** + * 创建 Bot 创建人名称填充组件。 + * + * @param creatorNameSupport 通用 AI 资源创建人名称组件 + */ + public BotResourceCreatorNameSupport(AiResourceCreatorNameSupport creatorNameSupport) { + this.creatorNameSupport = creatorNameSupport; + } + + /** + * 批量填充 Bot 创建人名称。 + * + * @param bots Bot 集合 + */ + public void fillCreatorNames(Collection bots) { + creatorNameSupport.fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java index bb7a61d8..f66ab39e 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java @@ -17,8 +17,6 @@ public class DashboardDistributionItemVo { private Long activeUserTotal; - private Long botTotal; - private Long workflowTotal; private Long knowledgeBaseTotal; @@ -73,14 +71,6 @@ public class DashboardDistributionItemVo { this.activeUserTotal = activeUserTotal; } - public Long getBotTotal() { - return botTotal; - } - - public void setBotTotal(Long botTotal) { - this.botTotal = botTotal; - } - public Long getWorkflowTotal() { return workflowTotal; } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java index 2395f348..9908fabf 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java @@ -9,7 +9,8 @@ public class DashboardSummaryVo { private Long activeUserTotal; - private Long botTotal; + /** 智能体总数。 */ + private Long agentTotal; private Long workflowTotal; @@ -39,12 +40,22 @@ public class DashboardSummaryVo { this.activeUserTotal = activeUserTotal; } - public Long getBotTotal() { - return botTotal; + /** + * 获取智能体总数。 + * + * @return 智能体总数 + */ + public Long getAgentTotal() { + return agentTotal; } - public void setBotTotal(Long botTotal) { - this.botTotal = botTotal; + /** + * 设置智能体总数。 + * + * @param agentTotal 智能体总数 + */ + public void setAgentTotal(Long agentTotal) { + this.agentTotal = agentTotal; } public Long getWorkflowTotal() { diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java index 94e1147b..f12eb349 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java @@ -8,8 +8,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; -import tech.easyflow.ai.entity.Bot; -import tech.easyflow.ai.service.BotService; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.service.AgentService; import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; @@ -33,6 +33,7 @@ import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountRoleService; @@ -81,7 +82,7 @@ public class DashboardServiceImpl implements DashboardService { private ChatDashboardQueryService chatDashboardQueryService; @Resource - private BotService botService; + private AgentService agentService; @Resource private CategoryPermissionService categoryPermissionService; @@ -156,7 +157,7 @@ public class DashboardServiceImpl implements DashboardService { DashboardSummaryVo summary = new DashboardSummaryVo(); summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context)); summary.setActiveUserTotal(countActiveUsers(context)); - summary.setBotTotal(countScopedTable("tb_bot", "b", false, context)); + summary.setAgentTotal(countScopedTable("tb_agent", "a", false, context)); summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context)); summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context)); summary.setChatMessageTotal(0L); @@ -863,18 +864,18 @@ public class DashboardServiceImpl implements DashboardService { if (assistantId == null) { return null; } - Bot bot = botService.getById(assistantId); - if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) { - throw new BusinessException("聊天助手不存在或未启用"); + Agent agent = agentService.getById(assistantId); + if (agent == null) { + throw new BusinessException("智能体不存在或不可见"); } boolean visible = categoryPermissionService.canAccessCategory( loginAccount, - "BOT", - bot.getCreatedBy(), - bot.getCategoryId() + CategoryResourceType.AGENT.getCode(), + agent.getCreatedBy(), + agent.getCategoryId() ); if (!visible) { - throw new BusinessException("聊天助手不存在或未启用"); + throw new BusinessException("智能体不存在或不可见"); } return assistantId; } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/BotControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/BotControllerTest.java index d91a3351..bc6e4b86 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/BotControllerTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/BotControllerTest.java @@ -6,8 +6,9 @@ import org.testng.Assert; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport; import tech.easyflow.ai.entity.Bot; -import tech.easyflow.ai.service.AiResourceApprovalStateService; +import tech.easyflow.ai.service.BotApprovalStateService; import tech.easyflow.ai.service.BotDocumentCollectionService; import tech.easyflow.ai.service.BotMessageService; import tech.easyflow.ai.service.BotService; @@ -38,7 +39,7 @@ public class BotControllerTest { private BotDocumentCollectionService botDocumentCollectionService; private BotMessageService botMessageService; private CategoryPermissionService categoryPermissionService; - private AiResourceApprovalStateService aiResourceApprovalStateService; + private BotApprovalStateService botApprovalStateService; private SysAccountService sysAccountService; /** @@ -52,7 +53,7 @@ public class BotControllerTest { botDocumentCollectionService = mock(BotDocumentCollectionService.class); botMessageService = mock(BotMessageService.class); categoryPermissionService = mock(CategoryPermissionService.class); - aiResourceApprovalStateService = mock(AiResourceApprovalStateService.class); + botApprovalStateService = mock(BotApprovalStateService.class); sysAccountService = mock(SysAccountService.class); } @@ -69,10 +70,12 @@ public class BotControllerTest { botMessageService ); AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport(); + BotResourceCreatorNameSupport botCreatorNameSupport = + new BotResourceCreatorNameSupport(creatorNameSupport); setField(creatorNameSupport, "sysAccountService", sysAccountService); setField(controller, "categoryPermissionService", categoryPermissionService); - setField(controller, "aiResourceApprovalStateService", aiResourceApprovalStateService); - setField(controller, "aiResourceCreatorNameSupport", creatorNameSupport); + setField(controller, "botApprovalStateService", botApprovalStateService); + setField(controller, "botResourceCreatorNameSupport", botCreatorNameSupport); Bot bot = new Bot(); bot.setId(BigInteger.valueOf(101)); @@ -84,7 +87,7 @@ public class BotControllerTest { when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page); when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7)))) .thenReturn(Map.of(BigInteger.valueOf(7), "管理员")); - doNothing().when(aiResourceApprovalStateService).fillBotApprovalState(page.getRecords()); + doNothing().when(botApprovalStateService).fillApprovalState(page.getRecords()); Page result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create()); diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/McpControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/McpControllerTest.java new file mode 100644 index 00000000..b17fa368 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/McpControllerTest.java @@ -0,0 +1,71 @@ +package tech.easyflow.admin.controller.ai; + +import com.mybatisflex.core.query.QueryWrapper; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.service.AgentResourceReferenceService; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; + +import java.math.BigInteger; +import java.util.Locale; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link McpController} 删除锁测试。 + */ +public class McpControllerTest { + + /** + * 验证 MCP 删除先锁定资源行,再执行删除。 + */ + @Test + public void removeShouldLockMcpBeforeRemoval() { + McpService mcpService = mock(McpService.class); + AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class); + when(mcpService.getOne(any(QueryWrapper.class))).thenReturn(new Mcp()); + McpController controller = new McpController(mcpService); + setField(controller, "agentResourceReferenceService", referenceService); + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setTenantId(BigInteger.ONE); + + try (MockedStatic login = mockStatic(SaTokenUtil.class)) { + login.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount); + controller.remove(BigInteger.TEN); + } + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mcpService).getOne(queryCaptor.capture()); + Assert.assertTrue( + queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE") + ); + verify(referenceService).assertMcpUnused(BigInteger.TEN); + verify(mcpService).removeMcp(BigInteger.TEN); + } + + /** + * 通过反射设置字段值。 + * + * @param target 目标对象 + * @param fieldName 字段名 + * @param value 字段值 + */ + private static void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("设置测试字段失败: " + fieldName, e); + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginControllerTest.java new file mode 100644 index 00000000..7c3fbcaf --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginControllerTest.java @@ -0,0 +1,30 @@ +package tech.easyflow.admin.controller.ai; + +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.ai.service.PluginService; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PluginController} 删除接口测试。 + */ +public class PluginControllerTest { + + /** + * 插件删除接口必须委托事务服务执行完整引用校验和删除。 + */ + @Test + public void removeShouldDelegateToTransactionalService() { + PluginService pluginService = mock(PluginService.class); + when(pluginService.removePlugin("10")).thenReturn(true); + PluginController controller = new PluginController(pluginService); + + boolean removed = controller.removePlugin("10").getData(); + + Assert.assertTrue(removed); + verify(pluginService).removePlugin("10"); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginItemControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginItemControllerTest.java new file mode 100644 index 00000000..a63dc5b8 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/PluginItemControllerTest.java @@ -0,0 +1,101 @@ +package tech.easyflow.admin.controller.ai; + +import com.mybatisflex.core.query.QueryWrapper; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.service.AgentResourceReferenceService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; + +import java.math.BigInteger; +import java.util.List; +import java.util.Locale; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PluginItemController} 删除锁测试。 + */ +public class PluginItemControllerTest { + + /** + * 验证插件工具删除按稳定顺序锁定资源行。 + */ + @Test + public void removeCheckShouldLockPluginItemsInStableOrder() { + PluginItemService pluginItemService = mock(PluginItemService.class); + AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class); + PluginService pluginService = mock(PluginService.class); + PluginVisibilityService visibilityService = mock(PluginVisibilityService.class); + PluginItem first = pluginItem(BigInteger.ONE, BigInteger.TEN); + PluginItem second = pluginItem(BigInteger.TWO, BigInteger.TEN); + Plugin plugin = new Plugin(); + plugin.setId(BigInteger.TEN); + plugin.setTenantId(1L); + plugin.setCreatedBy(1L); + when(pluginItemService.list(any(QueryWrapper.class))).thenReturn(List.of(first, second)); + when(pluginService.getById(BigInteger.TEN)).thenReturn(plugin); + PluginItemController controller = new PluginItemController(pluginItemService); + setField(controller, "pluginItemService", pluginItemService); + setField(controller, "agentResourceReferenceService", referenceService); + setField(controller, "pluginService", pluginService); + setField(controller, "pluginVisibilityService", visibilityService); + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setTenantId(BigInteger.ONE); + + try (MockedStatic login = mockStatic(SaTokenUtil.class)) { + login.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount); + controller.onRemoveBefore(List.of(BigInteger.TWO, BigInteger.ONE)); + } + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(pluginItemService).list(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT); + Assert.assertTrue(sql.contains("ORDER BY")); + Assert.assertTrue(sql.contains("FOR UPDATE")); + verify(referenceService).assertPluginItemsUnused(List.of(BigInteger.TWO, BigInteger.ONE)); + verify(visibilityService).assertPluginVisible(1L, BigInteger.TEN, "无权限删除该插件工具"); + } + + /** + * 创建插件工具。 + * + * @param id 工具 ID + * @param pluginId 插件 ID + * @return 插件工具 + */ + private static PluginItem pluginItem(BigInteger id, BigInteger pluginId) { + PluginItem pluginItem = new PluginItem(); + pluginItem.setId(id); + pluginItem.setPluginId(pluginId); + return pluginItem; + } + + /** + * 通过反射设置字段值。 + * + * @param target 目标对象 + * @param fieldName 字段名 + * @param value 字段值 + */ + private static void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("设置测试字段失败: " + fieldName, e); + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java index f2c4e680..440a39c7 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java @@ -4,8 +4,8 @@ import com.mybatisflex.core.query.QueryWrapper; import org.apache.poi.ss.usermodel.WorkbookFactory; import org.testng.Assert; import org.testng.annotations.Test; -import tech.easyflow.ai.entity.Bot; -import tech.easyflow.ai.service.BotService; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.service.AgentService; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; @@ -22,6 +22,7 @@ import tech.easyflow.chatlog.service.ChatDashboardQueryService; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountRoleService; @@ -339,29 +340,34 @@ public class DashboardServiceImplTest { public void shouldQueryUserRanksWithAssistantFilter() throws Exception { DashboardServiceImpl service = new DashboardServiceImpl(); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); - BotService botService = mock(BotService.class); + AgentService agentService = mock(AgentService.class); CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); SysAccountService sysAccountService = mock(SysAccountService.class); SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class); SysRoleService sysRoleService = mock(SysRoleService.class); - Bot bot = new Bot(); - bot.setId(BigInteger.TEN); - bot.setStatus(1); - bot.setCreatedBy(BigInteger.ONE); - bot.setCategoryId(BigInteger.valueOf(8)); + Agent agent = new Agent(); + agent.setId(BigInteger.TEN); + agent.setStatus(1); + agent.setCreatedBy(BigInteger.ONE); + agent.setCategoryId(BigInteger.valueOf(8)); when(chatDashboardQueryService.available()).thenReturn(true); when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5))) .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L))); - when(botService.getById(BigInteger.TEN)).thenReturn(bot); - when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8)))) + when(agentService.getById(BigInteger.TEN)).thenReturn(agent); + when(categoryPermissionService.canAccessCategory( + any(LoginAccount.class), + eq(CategoryResourceType.AGENT.getCode()), + eq(BigInteger.ONE), + eq(BigInteger.valueOf(8)) + )) .thenReturn(true); when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户"))); when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList()); setField(service, "chatDashboardQueryService", chatDashboardQueryService); - setField(service, "botService", botService); + setField(service, "agentService", agentService); setField(service, "categoryPermissionService", categoryPermissionService); setField(service, "sysAccountService", sysAccountService); setField(service, "sysAccountRoleService", sysAccountRoleService); @@ -382,26 +388,59 @@ public class DashboardServiceImplTest { } /** - * 验证未启用智能体会被拒绝。 + * 验证停用智能体仍可用于筛选历史统计。 */ - @Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用") - public void shouldRejectDisabledAssistantFilter() { + @Test + public void shouldAllowDisabledAgentFilterForHistoricalStatistics() { DashboardServiceImpl service = new DashboardServiceImpl(); - BotService botService = mock(BotService.class); + AgentService agentService = mock(AgentService.class); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); - Bot bot = new Bot(); - bot.setId(BigInteger.TEN); - bot.setStatus(0); + Agent agent = new Agent(); + agent.setId(BigInteger.TEN); + agent.setStatus(0); + agent.setCreatedBy(BigInteger.ONE); + agent.setCategoryId(BigInteger.valueOf(8)); - when(botService.getById(BigInteger.TEN)).thenReturn(bot); + when(agentService.getById(BigInteger.TEN)).thenReturn(agent); when(chatDashboardQueryService.available()).thenReturn(true); + when(categoryPermissionService.canAccessCategory( + any(LoginAccount.class), + eq(CategoryResourceType.AGENT.getCode()), + eq(BigInteger.ONE), + eq(BigInteger.valueOf(8)) + )).thenReturn(true); - setFieldSilently(service, "botService", botService); + setFieldSilently(service, "agentService", agentService); setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService); - setFieldSilently(service, "categoryPermissionService", mock(CategoryPermissionService.class)); + setFieldSilently(service, "categoryPermissionService", categoryPermissionService); setFieldSilently(service, "sysAccountService", mock(SysAccountService.class)); + DashboardUserRankQuery query = new DashboardUserRankQuery(); + query.setRange("7d"); + query.setAssistantId(BigInteger.TEN); + List userRanks = service.getUserRanks(new LoginAccount(), query); + + Assert.assertTrue(userRanks.isEmpty()); + verify(chatDashboardQueryService).queryActiveUserRanks( + any(), + any(), + any(), + eq(BigInteger.TEN), + eq(5) + ); + } + + /** + * 验证不存在的智能体筛选会被拒绝。 + */ + @Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "智能体不存在或不可见") + public void shouldRejectMissingAgentFilter() { + DashboardServiceImpl service = new DashboardServiceImpl(); + AgentService agentService = mock(AgentService.class); + setFieldSilently(service, "agentService", agentService); + DashboardUserRankQuery query = new DashboardUserRankQuery(); query.setRange("7d"); query.setAssistantId(BigInteger.TEN); @@ -411,25 +450,30 @@ public class DashboardServiceImplTest { /** * 验证当前作用域不可见的智能体会被拒绝。 */ - @Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用") + @Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "智能体不存在或不可见") public void shouldRejectInvisibleAssistantFilter() { DashboardServiceImpl service = new DashboardServiceImpl(); - BotService botService = mock(BotService.class); + AgentService agentService = mock(AgentService.class); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); - Bot bot = new Bot(); - bot.setId(BigInteger.TEN); - bot.setStatus(1); - bot.setCreatedBy(BigInteger.ONE); - bot.setCategoryId(BigInteger.valueOf(8)); + Agent agent = new Agent(); + agent.setId(BigInteger.TEN); + agent.setStatus(1); + agent.setCreatedBy(BigInteger.ONE); + agent.setCategoryId(BigInteger.valueOf(8)); - when(botService.getById(BigInteger.TEN)).thenReturn(bot); + when(agentService.getById(BigInteger.TEN)).thenReturn(agent); when(chatDashboardQueryService.available()).thenReturn(true); - when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8)))) + when(categoryPermissionService.canAccessCategory( + any(LoginAccount.class), + eq(CategoryResourceType.AGENT.getCode()), + eq(BigInteger.ONE), + eq(BigInteger.valueOf(8)) + )) .thenReturn(false); - setFieldSilently(service, "botService", botService); + setFieldSilently(service, "agentService", agentService); setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService); setFieldSilently(service, "categoryPermissionService", categoryPermissionService); setFieldSilently(service, "sysAccountService", mock(SysAccountService.class)); diff --git a/easyflow-api/easyflow-api-public/pom.xml b/easyflow-api/easyflow-api-public/pom.xml index 727dad53..0e7c2b75 100644 --- a/easyflow-api/easyflow-api-public/pom.xml +++ b/easyflow-api/easyflow-api-public/pom.xml @@ -12,6 +12,10 @@ easyflow-api-public + + tech.easyflow + easyflow-module-agent + tech.easyflow easyflow-module-ai @@ -34,4 +38,4 @@ mysql-connector-j - \ No newline at end of file + diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicAgentController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicAgentController.java new file mode 100644 index 00000000..1a21d9c1 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicAgentController.java @@ -0,0 +1,77 @@ +package tech.easyflow.publicapi.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.agent.runtime.AgentChatRequest; +import tech.easyflow.agent.runtime.AgentRunService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysApiKey; +import tech.easyflow.system.service.SysApiKeyService; + +/** + * Agent 公共调用接口。 + */ +@RestController +@RequestMapping("/public-api/agent") +public class PublicAgentController { + + private final AgentRunService agentRunService; + private final SysApiKeyService sysApiKeyService; + + /** + * 创建 Agent 公共接口控制器。 + * + * @param agentRunService Agent 运行服务 + * @param sysApiKeyService API Key 服务 + */ + public PublicAgentController(AgentRunService agentRunService, + SysApiKeyService sysApiKeyService) { + this.agentRunService = agentRunService; + this.sysApiKeyService = sysApiKeyService; + } + + /** + * 通过 API Key 调用已发布 Agent。 + * + * @param chatRequest Agent 聊天请求 + * @param request HTTP 请求 + * @return SSE Emitter + */ + @PostMapping("/chat") + public SseEmitter chat(@RequestBody AgentChatRequest chatRequest, + HttpServletRequest request) { + String apiKey = request.getHeader(SysApiKey.KEY_Apikey); + if (!StringUtils.hasText(apiKey)) { + throw new BusinessException(401, 401, "Apikey不能为空!"); + } + sysApiKeyService.checkApikeyPermission(apiKey, request.getRequestURI()); + SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey); + return agentRunService.chatPublic(chatRequest, buildApiAccount(sysApiKey)); + } + + /** + * 将 API Key 转换为独立的聊天调用身份。 + * + * @param sysApiKey API Key 记录 + * @return 调用身份 + */ + private LoginAccount buildApiAccount(SysApiKey sysApiKey) { + LoginAccount account = new LoginAccount(); + account.setId(sysApiKey.getId()); + account.setTenantId(sysApiKey.getTenantId() == null + ? java.math.BigInteger.ZERO + : sysApiKey.getTenantId()); + account.setDeptId(sysApiKey.getDeptId() == null + ? java.math.BigInteger.ZERO + : sysApiKey.getDeptId()); + account.setLoginName("apikey:" + sysApiKey.getId()); + account.setNickname("API 调用方"); + return account; + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiConfig.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiConfig.java index 0b5e2d07..d97bad02 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiConfig.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiConfig.java @@ -16,7 +16,10 @@ public class PublicApiConfig implements WebMvcConfigurer { registry.addInterceptor(publicApiInterceptor) .addPathPatterns("/public-api/**") - .excludePathPatterns("/public-api/bot/chat") + .excludePathPatterns( + "/public-api/agent/chat", + "/public-api/bot/chat" + ) ; } } diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicAgentControllerTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicAgentControllerTest.java new file mode 100644 index 00000000..37c7848a --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicAgentControllerTest.java @@ -0,0 +1,151 @@ +package tech.easyflow.publicapi.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.agent.runtime.AgentChatRequest; +import tech.easyflow.agent.runtime.AgentRunService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysApiKey; +import tech.easyflow.system.service.SysApiKeyService; + +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * {@link PublicAgentController} API Key 身份边界测试。 + */ +public class PublicAgentControllerTest { + + /** + * 验证缺少 API Key 时返回明确的 HTTP 401 业务异常。 + */ + @Test + public void chatShouldRejectMissingApiKey() { + PublicAgentController controller = new PublicAgentController( + new RecordingAgentRunService(new ArrayList<>()), + proxy( + SysApiKeyService.class, + (instance, method, args) -> { + throw new AssertionError("缺少 API Key 时不应调用服务"); + } + ) + ); + HttpServletRequest request = proxy( + HttpServletRequest.class, + (instance, method, args) -> { + if ("getHeader".equals(method.getName())) { + return " "; + } + throw new AssertionError("测试路径不应调用 HttpServletRequest." + method.getName()); + } + ); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> controller.chat(new AgentChatRequest(), request) + ); + + Assert.assertEquals(401, exception.getHttpStatus()); + Assert.assertEquals("Apikey不能为空!", exception.getMessage()); + } + + /** + * 验证接口先校验 API Key,并将其租户与部门传入 Agent 运行时。 + */ + @Test + public void chatShouldAuthorizeBeforeRunningWithApiKeyTenant() { + List calls = new ArrayList<>(); + SysApiKey apiKey = new SysApiKey(); + apiKey.setId(BigInteger.valueOf(101)); + apiKey.setTenantId(BigInteger.valueOf(201)); + apiKey.setDeptId(BigInteger.valueOf(301)); + SysApiKeyService apiKeyService = proxy( + SysApiKeyService.class, + (instance, method, args) -> { + if ("checkApikeyPermission".equals(method.getName())) { + calls.add("permission"); + Assert.assertEquals("test-key", args[0]); + Assert.assertEquals("/public-api/agent/chat", args[1]); + return null; + } + if ("getSysApiKey".equals(method.getName())) { + calls.add("load"); + return apiKey; + } + throw new AssertionError("测试路径不应调用 SysApiKeyService." + method.getName()); + } + ); + RecordingAgentRunService runService = new RecordingAgentRunService(calls); + PublicAgentController controller = new PublicAgentController(runService, apiKeyService); + HttpServletRequest request = proxy( + HttpServletRequest.class, + (instance, method, args) -> { + if ("getHeader".equals(method.getName())) { + return "test-key"; + } + if ("getRequestURI".equals(method.getName())) { + return "/public-api/agent/chat"; + } + throw new AssertionError("测试路径不应调用 HttpServletRequest." + method.getName()); + } + ); + + SseEmitter result = controller.chat(new AgentChatRequest(), request); + + Assert.assertSame(runService.emitter, result); + Assert.assertEquals(List.of("permission", "load", "chat"), calls); + Assert.assertEquals(apiKey.getId(), runService.account.getId()); + Assert.assertEquals(apiKey.getTenantId(), runService.account.getTenantId()); + Assert.assertEquals(apiKey.getDeptId(), runService.account.getDeptId()); + } + + /** + * 创建接口代理。 + * + * @param type 接口类型 + * @param handler 调用处理器 + * @param 接口类型 + * @return 代理实例 + */ + private T proxy(Class type, java.lang.reflect.InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } + + /** + * 记录公共 Agent 调用身份的运行服务。 + */ + private static class RecordingAgentRunService extends AgentRunService { + + private final List calls; + private final SseEmitter emitter = new SseEmitter(); + private LoginAccount account; + + /** + * 创建运行服务桩。 + * + * @param calls 调用顺序记录 + */ + private RecordingAgentRunService(List calls) { + this.calls = calls; + } + + /** + * 记录 API Key 调用身份。 + * + * @param chatRequest 聊天请求 + * @param apiAccount API Key 调用身份 + * @return 测试用 SSE Emitter + */ + @Override + public SseEmitter chatPublic(AgentChatRequest chatRequest, LoginAccount apiAccount) { + calls.add("chat"); + account = apiAccount; + return emitter; + } + } +} diff --git a/easyflow-api/easyflow-api-usercenter/pom.xml b/easyflow-api/easyflow-api-usercenter/pom.xml index 81ca7ef7..5adbdfb9 100644 --- a/easyflow-api/easyflow-api-usercenter/pom.xml +++ b/easyflow-api/easyflow-api-usercenter/pom.xml @@ -12,6 +12,10 @@ easyflow-api-usercenter + + tech.easyflow + easyflow-module-agent + tech.easyflow easyflow-module-auth diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/agent/UcAgentController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/agent/UcAgentController.java new file mode 100644 index 00000000..a2ca404a --- /dev/null +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/agent/UcAgentController.java @@ -0,0 +1,99 @@ +package tech.easyflow.usercenter.controller.agent; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.security.AgentVisibilityQueryHelper; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.annotation.UsePermission; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.usercenter.model.agent.UcAgentListItemVo; + +import java.util.Collections; +import java.util.List; + +/** + * 用户中心 Agent 查询接口。 + */ +@RestController +@RequestMapping("/userCenter/agent") +@UsePermission(moduleName = "/api/v1/agent") +public class UcAgentController { + + private final AgentService agentService; + private final AgentVisibilityQueryHelper agentVisibilityQueryHelper; + + /** + * 创建用户中心 Agent 控制器。 + * + * @param agentService Agent 服务 + * @param agentVisibilityQueryHelper Agent 可见性查询助手 + */ + public UcAgentController(AgentService agentService, + AgentVisibilityQueryHelper agentVisibilityQueryHelper) { + this.agentService = agentService; + this.agentVisibilityQueryHelper = agentVisibilityQueryHelper; + } + + /** + * 查询当前用户可见且已发布的 Agent。 + * + * @return Agent 列表项 + */ + @GetMapping("/list") + public Result> list() { + requireCurrentAccount(); + QueryWrapper queryWrapper = QueryWrapper.create() + .select( + Agent::getId, + Agent::getPublishedSnapshotJson + ) + .eq(Agent::getStatus, 1) + .eq(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode()); + agentVisibilityQueryHelper.applyReadableAccess(queryWrapper); + queryWrapper.orderBy(Agent::getModified, false); + List agents = agentService.list(queryWrapper); + if (agents == null || agents.isEmpty()) { + return Result.ok(Collections.emptyList()); + } + return Result.ok(agents.stream() + .map(agent -> toListItem(agent, agentService.fromSnapshot(agent.getPublishedSnapshotJson()))) + .toList()); + } + + /** + * 获取并校验当前登录账号。 + * + * @return 当前登录账号 + * @throws BusinessException 登录信息失效时抛出 + */ + protected LoginAccount requireCurrentAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; + } + + /** + * 将已发布快照转换为最小用户端列表项。 + * + * @param liveAgent 当前 Agent 记录 + * @param publishedAgent 已发布 Agent 快照 + * @return 用户端列表项 + */ + private UcAgentListItemVo toListItem(Agent liveAgent, Agent publishedAgent) { + return new UcAgentListItemVo( + liveAgent.getId(), + publishedAgent.getName(), + publishedAgent.getDescription(), + publishedAgent.getAvatar() + ); + } +} diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/model/agent/UcAgentListItemVo.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/model/agent/UcAgentListItemVo.java new file mode 100644 index 00000000..11bb2a49 --- /dev/null +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/model/agent/UcAgentListItemVo.java @@ -0,0 +1,65 @@ +package tech.easyflow.usercenter.model.agent; + +import java.math.BigInteger; + +/** + * 用户中心已发布 Agent 列表项。 + */ +public class UcAgentListItemVo { + + private final BigInteger id; + private final String name; + private final String description; + private final String avatar; + + /** + * 创建 Agent 列表项。 + * + * @param id Agent ID + * @param name Agent 名称 + * @param description Agent 描述 + * @param avatar Agent 头像 + */ + public UcAgentListItemVo(BigInteger id, String name, String description, String avatar) { + this.id = id; + this.name = name; + this.description = description; + this.avatar = avatar; + } + + /** + * 获取 Agent ID。 + * + * @return Agent ID + */ + public BigInteger getId() { + return id; + } + + /** + * 获取 Agent 名称。 + * + * @return Agent 名称 + */ + public String getName() { + return name; + } + + /** + * 获取 Agent 描述。 + * + * @return Agent 描述 + */ + public String getDescription() { + return description; + } + + /** + * 获取 Agent 头像。 + * + * @return Agent 头像 + */ + public String getAvatar() { + return avatar; + } +} diff --git a/easyflow-api/easyflow-api-usercenter/src/test/java/tech/easyflow/usercenter/controller/agent/UcAgentControllerTest.java b/easyflow-api/easyflow-api-usercenter/src/test/java/tech/easyflow/usercenter/controller/agent/UcAgentControllerTest.java new file mode 100644 index 00000000..03a5ce2e --- /dev/null +++ b/easyflow-api/easyflow-api-usercenter/src/test/java/tech/easyflow/usercenter/controller/agent/UcAgentControllerTest.java @@ -0,0 +1,149 @@ +package tech.easyflow.usercenter.controller.agent; + +import com.mybatisflex.core.query.QueryWrapper; +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.security.AgentVisibilityQueryHelper; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.usercenter.model.agent.UcAgentListItemVo; + +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * {@link UcAgentController} 用户端 Agent 可见性测试。 + */ +public class UcAgentControllerTest { + + /** + * 验证列表把可见性限制下推到查询,并仅返回发布快照的展示字段。 + */ + @Test + public void listShouldFilterVisibilityAndReturnPublishedSummary() { + Agent visibleAgent = liveAgent(BigInteger.ONE, "草稿名称一", "发布名称一"); + AgentService agentService = proxy( + AgentService.class, + (instance, method, args) -> { + if ("list".equals(method.getName())) { + return List.of(visibleAgent); + } + if ("fromSnapshot".equals(method.getName())) { + Map snapshot = (Map) args[0]; + Agent published = new Agent(); + published.setName(String.valueOf(snapshot.get("name"))); + published.setDescription(String.valueOf(snapshot.get("description"))); + published.setAvatar(String.valueOf(snapshot.get("avatar"))); + return published; + } + throw new AssertionError("测试路径不应调用 AgentService." + method.getName()); + } + ); + RecordingVisibilityQueryHelper visibilityQueryHelper = new RecordingVisibilityQueryHelper(); + LoginAccount account = new LoginAccount(); + account.setTenantId(BigInteger.ONE); + UcAgentController controller = new TestUcAgentController( + agentService, + visibilityQueryHelper, + account + ); + + List result = controller.list().getData(); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals(visibleAgent.getId(), result.get(0).getId()); + Assert.assertEquals("发布名称一", result.get(0).getName()); + Assert.assertEquals("发布描述一", result.get(0).getDescription()); + Assert.assertEquals("avatar-一", result.get(0).getAvatar()); + Assert.assertTrue(visibilityQueryHelper.applied); + } + + /** + * 创建带草稿字段和发布快照的 Agent。 + * + * @param id Agent ID + * @param draftName 草稿名称 + * @param publishedName 发布名称 + * @return Agent + */ + private Agent liveAgent(BigInteger id, String draftName, String publishedName) { + Agent agent = new Agent(); + agent.setId(id); + agent.setName(draftName); + String suffix = BigInteger.ONE.equals(id) ? "一" : "二"; + agent.setPublishedSnapshotJson(Map.of( + "name", publishedName, + "description", "发布描述" + suffix, + "avatar", "avatar-" + suffix + )); + return agent; + } + + /** + * 创建接口代理。 + * + * @param type 接口类型 + * @param handler 调用处理器 + * @param 接口类型 + * @return 代理实例 + */ + private T proxy(Class type, java.lang.reflect.InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } + + /** + * 记录可见性条件是否已下推到查询。 + */ + private static final class RecordingVisibilityQueryHelper extends AgentVisibilityQueryHelper { + + private boolean applied; + + /** + * 创建记录型可见性查询助手。 + */ + private RecordingVisibilityQueryHelper() { + super(null, null); + } + + /** + * {@inheritDoc} + */ + @Override + public void applyReadableAccess(QueryWrapper queryWrapper) { + applied = true; + } + } + + /** + * 使用固定登录账号的用户中心 Agent 控制器。 + */ + private static final class TestUcAgentController extends UcAgentController { + + private final LoginAccount account; + + /** + * 创建测试控制器。 + * + * @param agentService Agent 服务 + * @param visibilityQueryHelper 可见性查询助手 + * @param account 当前账号 + */ + private TestUcAgentController(AgentService agentService, + AgentVisibilityQueryHelper visibilityQueryHelper, + LoginAccount account) { + super(agentService, visibilityQueryHelper); + this.account = account; + } + + /** + * {@inheritDoc} + */ + @Override + protected LoginAccount requireCurrentAccount() { + return account; + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandAction.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandAction.java index 763bc185..fc9402be 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandAction.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandAction.java @@ -18,5 +18,10 @@ public enum AgentRuntimeCommandAction { /** * 审批过期并取消工具执行。 */ - EXPIRE + EXPIRE, + + /** + * 取消指定 Agent 在目标节点上的全部运行。 + */ + CANCEL_AGENT } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java index 02c484cb..2e235b87 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java @@ -93,6 +93,8 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler { } else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) { agentRunService.expireApprovalLocal( command.getRequestId(), command.getResumeToken(), command.getReason()); + } else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) { + agentRunService.cancelAgentLocal(command.getAgentId()); } else { markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令")); LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}", diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java index 13e1dc9e..687ddd9d 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java @@ -15,6 +15,7 @@ public class AgentRuntimeCommandMessage { private String reason; private BigInteger operatorId; private String userId; + private String agentId; private String targetNodeId; private Date occurredAt; @@ -74,6 +75,24 @@ public class AgentRuntimeCommandMessage { this.userId = userId; } + /** + * 获取待取消运行所属的 Agent ID。 + * + * @return Agent ID + */ + public String getAgentId() { + return agentId; + } + + /** + * 设置待取消运行所属的 Agent ID。 + * + * @param agentId Agent ID + */ + public void setAgentId(String agentId) { + this.agentId = agentId; + } + public String getTargetNodeId() { return targetNodeId; } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java index 5d93197a..825708a4 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java @@ -69,7 +69,10 @@ public class AgentRuntimeCommandProducer { String resumeToken, BigInteger operatorId, String userId) { - sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId); + sendAndWait( + targetNodeId, requestId, resumeToken, null, + AgentRuntimeCommandAction.APPROVE, null, operatorId, userId + ); } /** @@ -88,7 +91,10 @@ public class AgentRuntimeCommandProducer { String reason, BigInteger operatorId, String userId) { - sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId); + sendAndWait( + targetNodeId, requestId, resumeToken, null, + AgentRuntimeCommandAction.REJECT, reason, operatorId, userId + ); } /** @@ -103,12 +109,43 @@ public class AgentRuntimeCommandProducer { String requestId, String resumeToken, String reason) { - sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.EXPIRE, reason, null, null); + sendAndWait( + targetNodeId, requestId, resumeToken, null, + AgentRuntimeCommandAction.EXPIRE, reason, null, null + ); } + /** + * 投递远程 Agent 全部运行取消命令。 + * + * @param targetNodeId 目标节点 ID + * @param agentId Agent ID + * @param reason 取消原因 + */ + public void sendCancelAgent(String targetNodeId, String agentId, String reason) { + sendAndWait( + targetNodeId, null, null, agentId, + AgentRuntimeCommandAction.CANCEL_AGENT, reason, null, null + ); + } + + /** + * 投递远程运行命令并等待目标节点确认。 + * + * @param targetNodeId 目标节点 ID + * @param requestId 请求 ID + * @param resumeToken 恢复令牌 + * @param agentId Agent ID + * @param action 命令动作 + * @param reason 操作原因 + * @param operatorId 操作人 ID + * @param userId 用户 ID + * @throws BusinessException 命令投递、处理或确认失败时抛出 + */ private void sendAndWait(String targetNodeId, String requestId, String resumeToken, + String agentId, AgentRuntimeCommandAction action, String reason, BigInteger operatorId, @@ -120,6 +157,7 @@ public class AgentRuntimeCommandProducer { command.setCommandId(UUID.randomUUID().toString()); command.setRequestId(requestId); command.setResumeToken(resumeToken); + command.setAgentId(agentId); command.setAction(action); command.setReason(reason); command.setOperatorId(operatorId); @@ -135,8 +173,8 @@ public class AgentRuntimeCommandProducer { try { message.setBody(objectMapper.writeValueAsString(command)); String recordId = mqProducer.send(message); - LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, targetNodeId={}, recordId={}", - action, requestId, targetNodeId, recordId); + LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, agentId={}, targetNodeId={}, recordId={}", + action, requestId, agentId, targetNodeId, recordId); AgentRuntimeCommandResult result = resultRegistry.waitForResult(command.getCommandId()); if (!result.isSuccess()) { throw new BusinessException(result.getMessage()); @@ -146,18 +184,29 @@ public class AgentRuntimeCommandProducer { } catch (BusinessException e) { throw e; } catch (RuntimeException e) { - LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, targetNodeId={}", - action, requestId, targetNodeId, e); + LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, agentId={}, targetNodeId={}", + action, requestId, agentId, targetNodeId, e); throw new BusinessException("Agent 运行节点不可用,请重新发起对话"); } finally { deleteResultQuietly(command.getCommandId()); } } + /** + * 构建目标节点命令主题。 + * + * @param nodeId 节点 ID + * @return 命令主题 + */ private String commandTopic(String nodeId) { return properties.getCommandTopicPrefix() + ":" + nodeId; } + /** + * 清理远程命令确认结果,失败时由 Redis TTL 兜底。 + * + * @param commandId 命令 ID + */ private void deleteResultQuietly(String commandId) { try { resultRegistry.deleteResult(commandId); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRoute.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRoute.java index 2bac4770..c7fd895a 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRoute.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRoute.java @@ -7,6 +7,7 @@ public class AgentRuntimeRoute { private String nodeId; private String bootId; + private String agentId; /** * 获取 owner 节点 ID。 @@ -43,4 +44,22 @@ public class AgentRuntimeRoute { public void setBootId(String bootId) { this.bootId = bootId; } + + /** + * 获取运行所属 Agent ID。 + * + * @return Agent ID + */ + public String getAgentId() { + return agentId; + } + + /** + * 设置运行所属 Agent ID。 + * + * @param agentId Agent ID + */ + public void setAgentId(String agentId) { + this.agentId = agentId; + } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java index e665f7ed..e6c679ad 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java @@ -9,6 +9,9 @@ import org.springframework.stereotype.Component; import tech.easyflow.agent.config.AgentRuntimeProperties; import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; /** * Agent 运行态 Redis 路由注册表。 @@ -21,6 +24,7 @@ public class AgentRuntimeRouteRegistry { private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:"; private static final String TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:"; private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:"; + private static final String AGENT_RUNS_PREFIX = "easyflow:agent:runtime:agent:"; private final StringRedisTemplate stringRedisTemplate; private final AgentRuntimeProperties properties; @@ -47,10 +51,29 @@ public class AgentRuntimeRouteRegistry { * @param requestId 请求 ID */ public void registerRun(String requestId) { + registerRun(requestId, null); + } + + /** + * 注册运行请求 owner 节点及所属 Agent。 + * + * @param requestId 请求 ID + * @param agentId Agent ID + */ + public void registerRun(String requestId, String agentId) { if (requestId == null || requestId.isBlank()) { return; } - stringRedisTemplate.opsForValue().set(requestKey(requestId), serializeRoute(currentRoute()), properties.getRouteTtl()); + stringRedisTemplate.opsForValue().set( + requestKey(requestId), + serializeRoute(currentRoute(agentId)), + properties.getRouteTtl() + ); + if (agentId != null && !agentId.isBlank()) { + String key = agentRunsKey(agentId); + stringRedisTemplate.opsForSet().add(key, requestId); + stringRedisTemplate.expire(key, properties.getRouteTtl()); + } } /** @@ -107,6 +130,35 @@ public class AgentRuntimeRouteRegistry { return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken)); } + /** + * 查询指定 Agent 当前活跃运行所在的节点。 + * + * @param agentId Agent ID + * @return 去重后的 owner 节点 ID + */ + public Set findOwnerNodesByAgent(String agentId) { + if (agentId == null || agentId.isBlank()) { + return Collections.emptySet(); + } + Set requestIds = stringRedisTemplate.opsForSet().members(agentRunsKey(agentId)); + if (requestIds == null || requestIds.isEmpty()) { + return Collections.emptySet(); + } + Set nodeIds = new LinkedHashSet<>(); + for (String requestId : requestIds) { + AgentRuntimeRoute route = findOwnerRoute(requestId); + if (route == null || route.getNodeId() == null || route.getNodeId().isBlank() + || !agentId.equals(route.getAgentId()) + || route.getBootId() == null + || !route.getBootId().equals(currentNodeBootId(route.getNodeId()))) { + removeAgentRunIndexQuietly(agentId, requestId); + continue; + } + nodeIds.add(route.getNodeId()); + } + return nodeIds; + } + /** * 删除指定运行请求的路由。 * @@ -116,7 +168,16 @@ public class AgentRuntimeRouteRegistry { if (requestId == null || requestId.isBlank()) { return; } + AgentRuntimeRoute route = null; + try { + route = findOwnerRoute(requestId); + } catch (RuntimeException exception) { + LOG.warn("读取待清理的 Agent 运行路由失败: requestId={}", requestId, exception); + } deleteQuietly(requestKey(requestId)); + if (route != null && route.getAgentId() != null && !route.getAgentId().isBlank()) { + removeAgentRunIndexQuietly(route.getAgentId(), requestId); + } } /** @@ -184,10 +245,15 @@ public class AgentRuntimeRouteRegistry { return NODE_HEARTBEAT_PREFIX + nodeId; } - private AgentRuntimeRoute currentRoute() { + private String agentRunsKey(String agentId) { + return AGENT_RUNS_PREFIX + agentId; + } + + private AgentRuntimeRoute currentRoute(String agentId) { AgentRuntimeRoute route = new AgentRuntimeRoute(); route.setNodeId(properties.getInstanceId()); route.setBootId(properties.getBootId()); + route.setAgentId(agentId); return route; } @@ -219,4 +285,19 @@ public class AgentRuntimeRouteRegistry { LOG.warn("清理 Agent 运行态 Redis 路由失败: key={}", key, e); } } + + /** + * 从 Agent 反向运行索引中移除请求。 + * + * @param agentId Agent ID + * @param requestId 请求 ID + */ + private void removeAgentRunIndexQuietly(String agentId, String requestId) { + try { + stringRedisTemplate.opsForSet().remove(agentRunsKey(agentId), requestId); + } catch (RuntimeException exception) { + LOG.warn("清理 Agent 运行反向索引失败: agentId={}, requestId={}", + agentId, requestId, exception); + } + } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java index 62c6ad1c..ed99aae6 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java @@ -4,12 +4,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.update.UpdateChain; import org.springframework.stereotype.Component; +import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer; +import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.runtime.AgentRunRegistry; +import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler; import tech.easyflow.approval.enums.ApprovalResourceType; @@ -22,6 +27,7 @@ import tech.easyflow.system.service.ResourceAccessService; import java.math.BigInteger; import java.util.Date; import java.util.Map; +import java.util.Set; /** * Agent 审批资源处理器。 @@ -33,6 +39,11 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand private final AgentToolBindingService agentToolBindingService; private final AgentKnowledgeBindingService agentKnowledgeBindingService; private final ResourceAccessService resourceAccessService; + private final AgentBindingLockExecutor agentBindingLockExecutor; + private final AgentRunRegistry agentRunRegistry; + private final AgentHitlPendingService agentHitlPendingService; + private final AgentRuntimeRouteRegistry agentRuntimeRouteRegistry; + private final AgentRuntimeCommandProducer agentRuntimeCommandProducer; /** * 创建 Agent 审批资源处理器。 @@ -43,18 +54,33 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand * @param agentToolBindingService Agent 工具绑定服务 * @param agentKnowledgeBindingService Agent 知识库绑定服务 * @param resourceAccessService 资源访问服务 + * @param agentBindingLockExecutor Agent 配置锁执行器 + * @param agentRunRegistry Agent 运行态注册表 + * @param agentHitlPendingService Agent 待审批运行服务 + * @param agentRuntimeRouteRegistry Agent 分布式运行路由注册表 + * @param agentRuntimeCommandProducer Agent 远程运行命令生产者 */ public AgentApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService, ObjectMapper objectMapper, AgentService agentService, AgentToolBindingService agentToolBindingService, AgentKnowledgeBindingService agentKnowledgeBindingService, - ResourceAccessService resourceAccessService) { + ResourceAccessService resourceAccessService, + AgentBindingLockExecutor agentBindingLockExecutor, + AgentRunRegistry agentRunRegistry, + AgentHitlPendingService agentHitlPendingService, + AgentRuntimeRouteRegistry agentRuntimeRouteRegistry, + AgentRuntimeCommandProducer agentRuntimeCommandProducer) { super(approvalInstanceService, objectMapper); this.agentService = agentService; this.agentToolBindingService = agentToolBindingService; this.agentKnowledgeBindingService = agentKnowledgeBindingService; this.resourceAccessService = resourceAccessService; + this.agentBindingLockExecutor = agentBindingLockExecutor; + this.agentRunRegistry = agentRunRegistry; + this.agentHitlPendingService = agentHitlPendingService; + this.agentRuntimeRouteRegistry = agentRuntimeRouteRegistry; + this.agentRuntimeCommandProducer = agentRuntimeCommandProducer; } /** @@ -123,44 +149,76 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand @Override protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) { - // 生命周期操作仅更新状态字段,避免实体默认空配置覆盖 Agent 草稿配置。 - UpdateChain updateChain = agentService.updateChain(); - updateChain.set(Agent::getPublishStatus, publishStatus.getCode()); - updateChain.set(Agent::getCurrentApprovalInstanceId, currentApprovalInstanceId); - updateChain.eq(Agent::getId, resourceId); - updateChain.update(); + agentBindingLockExecutor.execute(resourceId, () -> { + // 生命周期操作仅更新状态字段,避免实体默认空配置覆盖 Agent 草稿配置。 + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, publishStatus.getCode()); + updateChain.set(Agent::getCurrentApprovalInstanceId, currentApprovalInstanceId); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); + return null; + }); } @Override protected void publishResource(BigInteger resourceId, Map resourceSnapshot, BigInteger operatorId) { - UpdateChain updateChain = agentService.updateChain(); - updateChain.set(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode()); - updateChain.set(Agent::getPublishedSnapshotJson, resourceSnapshot); - updateChain.set(Agent::getPublishedAt, new Date()); - updateChain.set(Agent::getPublishedBy, operatorId); - updateChain.set(Agent::getCurrentApprovalInstanceId, null); - updateChain.eq(Agent::getId, resourceId); - updateChain.update(); + agentBindingLockExecutor.execute(resourceId, () -> { + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode()); + updateChain.set(Agent::getPublishedSnapshotJson, resourceSnapshot); + updateChain.set(Agent::getPublishedAt, new Date()); + updateChain.set(Agent::getPublishedBy, operatorId); + updateChain.set(Agent::getCurrentApprovalInstanceId, null); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); + return null; + }); } @Override protected void markResourceOffline(BigInteger resourceId) { - UpdateChain updateChain = agentService.updateChain(); - updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode()); - updateChain.set(Agent::getCurrentApprovalInstanceId, null); - updateChain.eq(Agent::getId, resourceId); - updateChain.update(); + agentBindingLockExecutor.execute(resourceId, () -> { + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode()); + updateChain.set(Agent::getCurrentApprovalInstanceId, null); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); + cancelActiveRuns(resourceId, "Agent 已下线,待审批运行已取消"); + return null; + }); } @Override protected void removeResource(BigInteger resourceId) { - agentService.removeById(resourceId); + agentBindingLockExecutor.execute(resourceId, () -> { + cancelActiveRuns(resourceId, "Agent 已删除,待审批运行已取消"); + agentToolBindingService.remove( + QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId)); + agentKnowledgeBindingService.remove( + QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId)); + agentService.removeById(resourceId); + return null; + }); } - @Override - protected void beforeRemove(BigInteger resourceId) { - agentToolBindingService.remove(QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId)); - agentKnowledgeBindingService.remove(QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId)); + /** + * 取消指定 Agent 的集群运行态和持久化待审批请求。 + * + * @param resourceId Agent ID + * @param reason 取消原因 + */ + private void cancelActiveRuns(BigInteger resourceId, String reason) { + String agentId = resourceId.toString(); + Set ownerNodeIds = agentRuntimeRouteRegistry.findOwnerNodesByAgent(agentId); + agentHitlPendingService.cancelByAgentId(resourceId, reason); + agentRunRegistry.cancelAgent(agentId); + String currentNodeId = agentRuntimeRouteRegistry.currentNodeId(); + for (String ownerNodeId : ownerNodeIds) { + if (ownerNodeId == null || ownerNodeId.isBlank() || ownerNodeId.equals(currentNodeId)) { + continue; + } + agentRuntimeCommandProducer.sendCancelAgent(ownerNodeId, agentId, reason); + } } @Override diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentPublishAppService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentPublishAppService.java index 6765052a..a3816045 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentPublishAppService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentPublishAppService.java @@ -5,6 +5,7 @@ import tech.easyflow.ai.publish.AiResourceLifecycleService; import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.approval.enums.ApprovalActionType; import tech.easyflow.approval.enums.ApprovalResourceType; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; @@ -57,15 +58,27 @@ public class AgentPublishAppService { return submit(id, ApprovalActionType.DELETE); } + /** + * 提交 Agent 生命周期审批。 + * + * @param id Agent ID + * @param actionType 审批动作 + * @return 审批动作结果 + * @throws BusinessException 资源 ID 或登录信息无效时抛出 + */ private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) { if (id == null) { throw new BusinessException("Agent 审批时资源ID不能为空"); } + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } return aiResourceLifecycleService.submitAction( ApprovalResourceType.AGENT.getCode(), id, actionType.getCode(), - SaTokenUtil.getLoginAccount().getId() + account.getId() ); } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java index ad3dd8e9..fca248c4 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java @@ -16,6 +16,7 @@ import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; import tech.easyflow.core.runtime.ChatAssistantAccumulator; import tech.easyflow.core.runtime.ChatRuntimeContext; +import java.util.ArrayList; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -71,7 +72,10 @@ public class AgentRunRegistry { } owners.put(context.requestId(), context.owner()); if (routeRegistry != null) { - routeRegistry.registerRun(context.requestId()); + routeRegistry.registerRun( + context.requestId(), + context.owner() == null ? null : context.owner().agentId() + ); } } @@ -132,6 +136,28 @@ public class AgentRunRegistry { remove(requestId); } + /** + * 取消当前节点上指定 Agent 的全部活跃运行。 + * + * @param agentId Agent ID + */ + public void cancelAgent(String agentId) { + if (agentId == null || agentId.isBlank()) { + return; + } + for (Map.Entry entry : new ArrayList<>(owners.entrySet())) { + RunOwner owner = entry.getValue(); + if (owner == null || !agentId.equals(owner.agentId())) { + continue; + } + AgentRunContext context = runs.get(entry.getKey()); + if (context != null) { + context.cancelAndComplete(); + } + remove(entry.getKey()); + } + } + /** * 记录等待审批的恢复令牌。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java index 06eea038..54f933f8 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java @@ -39,6 +39,7 @@ import tech.easyflow.agent.runtime.document.AgentDocumentContext; import tech.easyflow.agent.runtime.document.AgentDocumentContextSelector; import tech.easyflow.agent.runtime.document.AgentDocumentService; import tech.easyflow.agent.service.AgentService; +import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Mcp; import tech.easyflow.ai.entity.PluginItem; @@ -99,6 +100,10 @@ public class AgentRunService { @Resource private AgentRunRegistry agentRunRegistry; @Resource + private AgentBindingLockExecutor agentBindingLockExecutor; + @Resource + private AgentRunStartGuard agentRunStartGuard; + @Resource private AgentRuntimeRouteRegistry agentRuntimeRouteRegistry; @Resource private AgentRuntimeCommandProducer agentRuntimeCommandProducer; @@ -183,6 +188,75 @@ public class AgentRunService { ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore); } + /** + * 通过公共 API 启动已发布 Agent 的纯文本聊天。 + * + * @param chatRequest 聊天请求 + * @param apiAccount API Key 对应的隔离调用身份 + * @return SSE Emitter + */ + public SseEmitter chatPublic(AgentChatRequest chatRequest, LoginAccount apiAccount) { + validateChatRequest(chatRequest); + if (apiAccount == null || apiAccount.getId() == null) { + throw new BusinessException("API 调用身份不能为空"); + } + if (chatRequest.getImageUploadIds() != null && !chatRequest.getImageUploadIds().isEmpty()) { + throw new BusinessException("公共 Agent API 暂不支持图片附件"); + } + if (chatRequest.getDocumentUploadIds() != null && !chatRequest.getDocumentUploadIds().isEmpty()) { + throw new BusinessException("公共 Agent API 暂不支持文档附件"); + } + if (chatRequest.getCapabilities() != null && !chatRequest.getCapabilities().isEmpty()) { + throw new BusinessException("公共 Agent API 暂不支持临时能力"); + } + Agent liveAgent = agentService.getById(chatRequest.getAgentId()); + if (liveAgent == null || !Objects.equals(liveAgent.getTenantId(), apiAccount.getTenantId())) { + throw new BusinessException("Agent 不存在或不可用"); + } + assertAgentRunnable(liveAgent); + BigInteger sessionId = chatRequest.getSessionId() == null + ? BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId()) + : chatRequest.getSessionId(); + ChatSessionSummary existingSession = + resolveExistingSession(apiAccount, sessionId, chatRequest.getAgentId()); + Agent agent = agentService.getPublishedView(chatRequest.getAgentId()); + assertPublicHitlUnsupported(agent); + String requestId = UUID.randomUUID().toString(); + String traceId = UUID.randomUUID().toString(); + String titlePrompt = effectivePrompt(chatRequest.getPrompt(), false, false); + ChatRuntimeContext chatContext = buildChatRuntimeContext( + agent, sessionId, titlePrompt, apiAccount, ASSISTANT_CODE, ChatChannel.PUBLIC_API, true); + applyFormalSessionTitle(chatContext, titlePrompt, existingSession); + return run(agent, chatRequest.getPrompt(), Collections.emptyList(), Collections.emptyList(), + apiAccount, requestId, traceId, sessionId.toString(), + ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore); + } + + /** + * 公共 API 当前没有审批恢复入口,因此拒绝包含 HITL 工具的 Agent。 + * + * @param agent 已发布 Agent 运行视图 + */ + private void assertPublicHitlUnsupported(Agent agent) { + if (agent == null || agent.getToolBindings() == null) { + return; + } + for (AgentToolBinding binding : agent.getToolBindings()) { + if (binding == null || !Boolean.TRUE.equals(binding.getEnabled())) { + continue; + } + if (Boolean.TRUE.equals(binding.getHitlEnabled())) { + throw new BusinessException("公共 Agent API 暂不支持需要执行确认的工具"); + } + Object approvalRequired = binding.getResourceSnapshot() == null + ? null : binding.getResourceSnapshot().get("approvalRequired"); + if ("MCP".equalsIgnoreCase(binding.getToolType()) + && Boolean.parseBoolean(String.valueOf(approvalRequired))) { + throw new BusinessException("公共 Agent API 暂不支持需要执行确认的 MCP"); + } + } + } + /** * 启动 Agent 草稿态纯文本试用。 * @@ -615,6 +689,23 @@ public class AgentRunService { return resolvedRequestId; } + /** + * 在 Agent 生命周期锁内启动正式运行,避免与下线或删除并发穿透。 + * + * @param agent Agent 运行视图 + * @param userMessage 用户消息 + * @param documentContext 文档上下文 + * @param account 当前账号 + * @param requestId 请求 ID + * @param traceId 链路 ID + * @param runtimeSessionId 运行会话 ID + * @param assistantCode 助手类型 + * @param chatContext 聊天上下文 + * @param chatSseEmitter SSE 发射器 + * @param persistChatlog 是否持久化聊天日志 + * @param runtimeSessionStore 运行会话存储 + * @param initialLockHandle 会话运行锁 + */ private void startRuntime(Agent agent, AgentMessage userMessage, AgentDocumentContext documentContext, @@ -628,6 +719,73 @@ public class AgentRunService { boolean persistChatlog, AgentSessionStore runtimeSessionStore, AgentRunLock.Handle initialLockHandle) { + if (!persistChatlog || agent == null || agent.getId() == null) { + startRuntimeLocked( + agent, userMessage, documentContext, account, requestId, traceId, + runtimeSessionId, assistantCode, chatContext, chatSseEmitter, + persistChatlog, runtimeSessionStore, initialLockHandle + ); + return; + } + try { + agentBindingLockExecutor.execute(agent.getId(), () -> { + agentRunStartGuard.assertRunnable(agent.getId()); + startRuntimeLocked( + agent, userMessage, documentContext, account, requestId, traceId, + runtimeSessionId, assistantCode, chatContext, chatSseEmitter, + true, runtimeSessionStore, initialLockHandle + ); + return null; + }); + } catch (Exception exception) { + AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId); + if (runContext != null) { + runContext.cancel(); + agentRunRegistry.remove(requestId); + } else if (initialLockHandle != null) { + initialLockHandle.release(); + } + handleRuntimeError( + exception, + requestId, + chatSseEmitter, + chatContext, + new AtomicBoolean(false), + true + ); + } + } + + /** + * 初始化、注册并订阅单次 Agent 运行。 + * + * @param agent Agent 运行视图 + * @param userMessage 用户消息 + * @param documentContext 文档上下文 + * @param account 当前账号 + * @param requestId 请求 ID + * @param traceId 链路 ID + * @param runtimeSessionId 运行会话 ID + * @param assistantCode 助手类型 + * @param chatContext 聊天上下文 + * @param chatSseEmitter SSE 发射器 + * @param persistChatlog 是否持久化聊天日志 + * @param runtimeSessionStore 运行会话存储 + * @param initialLockHandle 会话运行锁 + */ + private void startRuntimeLocked(Agent agent, + AgentMessage userMessage, + AgentDocumentContext documentContext, + LoginAccount account, + String requestId, + String traceId, + String runtimeSessionId, + String assistantCode, + ChatRuntimeContext chatContext, + ChatSseEmitter chatSseEmitter, + boolean persistChatlog, + AgentSessionStore runtimeSessionStore, + AgentRunLock.Handle initialLockHandle) { AtomicBoolean finished = new AtomicBoolean(false); StringBuilder answer = new StringBuilder(); ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator(); @@ -705,6 +863,18 @@ public class AgentRunService { } } + /** + * 取消当前节点上指定 Agent 的全部运行。 + * + * @param agentId Agent ID + */ + public void cancelAgentLocal(String agentId) { + if (agentId == null || agentId.isBlank()) { + throw new BusinessException("Agent ID 不能为空"); + } + agentRunRegistry.cancelAgent(agentId); + } + private void bindAgentSession(Agent agent, String runtimeSessionId, ChatRuntimeContext chatContext) { if (easyFlowAgentSessionStore == null || runtimeSessionId == null || runtimeSessionId.isBlank()) { return; @@ -1268,8 +1438,31 @@ public class AgentRunService { String prompt, LoginAccount account, String assistantCode) { + return buildChatRuntimeContext( + agent, sessionId, prompt, account, assistantCode, ChatChannel.ADMIN, false); + } + + /** + * 构建指定渠道的 Agent 聊天上下文。 + * + * @param agent Agent 运行视图 + * @param sessionId 会话 ID + * @param prompt 用户输入 + * @param account 调用身份 + * @param assistantCode 助手类型编码 + * @param channel 调用渠道 + * @param anonymous 是否匿名调用 + * @return 聊天运行上下文 + */ + private ChatRuntimeContext buildChatRuntimeContext(Agent agent, + BigInteger sessionId, + String prompt, + LoginAccount account, + String assistantCode, + ChatChannel channel, + boolean anonymous) { ChatRuntimeContext context = new ChatRuntimeContext(); - context.setChannel(ChatChannel.ADMIN); + context.setChannel(channel); context.setSessionId(sessionId); context.setTenantId(account.getTenantId()); context.setDeptId(account.getDeptId()); @@ -1280,6 +1473,7 @@ public class AgentRunService { context.setAssistantCode(assistantCode); context.setAssistantName(agent.getName()); context.setSessionTitle(toSessionTitle(prompt)); + context.setAnonymous(anonymous); return context; } @@ -1491,10 +1685,23 @@ public class AgentRunService { } } + /** + * 获取当前登录账号。 + * + * @return 当前登录账号 + * @throws BusinessException 登录信息失效时抛出 + */ private LoginAccount requireCurrentLoginAccount() { try { - return SaTokenUtil.getLoginAccount(); + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; } catch (Exception e) { + if (e instanceof BusinessException businessException) { + throw businessException; + } throw new BusinessException("当前登录状态失效,请重新登录后再试"); } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunStartGuard.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunStartGuard.java new file mode 100644 index 00000000..8422b9ef --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunStartGuard.java @@ -0,0 +1,48 @@ +package tech.easyflow.agent.runtime; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; + +/** + * 在正式 Agent 运行注册前通过数据库行锁确认最新生命周期状态。 + */ +@Service +public class AgentRunStartGuard { + + private final AgentService agentService; + + /** + * 创建 Agent 运行启动守卫。 + * + * @param agentService Agent 服务 + */ + public AgentRunStartGuard(AgentService agentService) { + this.agentService = agentService; + } + + /** + * 锁定 Agent 行并确认当前仍可启动正式运行。 + * + * @param agentId Agent ID + * @throws BusinessException Agent 已下线、删除或不可用时抛出 + */ + @Transactional(rollbackFor = Exception.class) + public void assertRunnable(BigInteger agentId) { + Agent agent = agentService.getOne(QueryWrapper.create() + .select(Agent::getId, Agent::getStatus, Agent::getPublishStatus) + .eq(Agent::getId, agentId) + .forUpdate()); + if (agent == null + || !Integer.valueOf(1).equals(agent.getStatus()) + || PublishStatus.from(agent.getPublishStatus()) != PublishStatus.PUBLISHED) { + throw new BusinessException("当前 Agent 已下线或不可继续会话"); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingService.java index 85d43fe8..6357134a 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingService.java @@ -48,6 +48,14 @@ public interface AgentHitlPendingService { */ void cancelByRequestId(String requestId, String reason); + /** + * 取消指定 Agent 的全部待审批运行。 + * + * @param agentId Agent ID + * @param reason 取消原因 + */ + void cancelByAgentId(BigInteger agentId, String reason); + /** * 删除指定聊天会话的 pending。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java index ee315f78..5236b887 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java @@ -105,6 +105,34 @@ public class AgentHitlPendingServiceImpl implements AgentHitlPendingService { } } + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelByAgentId(BigInteger agentId, String reason) { + if (agentId == null) { + return; + } + List records = pendingMapper.selectListByQuery(QueryWrapper.create() + .eq("agent_id", agentId) + .eq("status", AgentHitlPendingStatus.PENDING.name()) + .eq("is_deleted", 0) + .forUpdate()); + Date now = new Date(); + for (AgentHitlPending record : records) { + AgentHitlPending update = new AgentHitlPending(); + update.setStatus(AgentHitlPendingStatus.CANCELLED.name()); + update.setRejectReason(reason); + update.setConsumedAt(now); + update.setModified(now); + pendingMapper.updateByQuery(update, QueryWrapper.create() + .eq("id", record.getId()) + .eq("status", AgentHitlPendingStatus.PENDING.name()) + .eq("is_deleted", 0)); + } + } + @Override public void deleteByChatSessionId(BigInteger chatSessionId) { if (chatSessionId == null) { diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/security/AgentVisibilityQueryHelper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/security/AgentVisibilityQueryHelper.java new file mode 100644 index 00000000..f92e6384 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/security/AgentVisibilityQueryHelper.java @@ -0,0 +1,79 @@ +package tech.easyflow.agent.security; + +import com.mybatisflex.core.query.QueryCondition; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Component; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.VisibilityScope; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.SysDeptService; + +import java.math.BigInteger; +import java.util.Collections; +import java.util.Set; + +import static tech.easyflow.agent.entity.table.AgentTableDef.AGENT; + +/** + * 将 Agent 的租户、分类、归属人与可见范围转换为数据库查询条件。 + */ +@Component +public class AgentVisibilityQueryHelper { + + private final CategoryPermissionService categoryPermissionService; + private final SysDeptService sysDeptService; + + /** + * 创建 Agent 可见性查询助手。 + * + * @param categoryPermissionService 分类权限服务 + * @param sysDeptService 部门服务 + */ + public AgentVisibilityQueryHelper(CategoryPermissionService categoryPermissionService, + SysDeptService sysDeptService) { + this.categoryPermissionService = categoryPermissionService; + this.sysDeptService = sysDeptService; + } + + /** + * 将当前账号可读 Agent 范围追加到查询条件。 + * + * @param queryWrapper Agent 查询条件 + */ + public void applyReadableAccess(QueryWrapper queryWrapper) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + BigInteger accountId = account == null ? null : account.getId(); + BigInteger tenantId = account == null ? null : account.getTenantId(); + if (accountId == null || tenantId == null) { + queryWrapper.and(AGENT.ID.eq(BigInteger.valueOf(-1))); + return; + } + // 项目没有启用全局租户过滤器,超级管理员也必须限制在当前租户。 + queryWrapper.and(AGENT.TENANT_ID.eq(tenantId)); + RoleCategoryAccessSnapshot access = + categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode()); + if (access.isSuperAdmin()) { + return; + } + QueryCondition owner = AGENT.CREATED_BY.eq(accountId); + if (access.isRestricted() && access.getCategoryIds().isEmpty()) { + queryWrapper.and(owner); + return; + } + Set readableDeptIds = account.getDeptId() == null + ? Collections.emptySet() + : sysDeptService.getSelfAndAncestorDeptIds(account.getDeptId()); + QueryCondition visible = AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.PUBLIC.name()); + if (!readableDeptIds.isEmpty()) { + visible = visible.or(AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.DEPT.name()) + .and(AGENT.DEPT_ID.in(readableDeptIds))); + } + if (access.isRestricted()) { + visible = AGENT.CATEGORY_ID.in(access.getCategoryIds()).and(visible); + } + queryWrapper.and(owner.or(visible)); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentDependencyAccessService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentDependencyAccessService.java new file mode 100644 index 00000000..c319b350 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentDependencyAccessService.java @@ -0,0 +1,236 @@ +package tech.easyflow.agent.service; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentCategory; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.Objects; + +/** + * Agent 依赖资源的租户、权限和可用性校验服务。 + */ +@Service +public class AgentDependencyAccessService { + + private final ModelService modelService; + private final WorkflowService workflowService; + private final PluginItemService pluginItemService; + private final PluginMapper pluginMapper; + private final PluginVisibilityService pluginVisibilityService; + private final McpService mcpService; + private final DocumentCollectionService documentCollectionService; + private final AgentCategoryService agentCategoryService; + private final CategoryPermissionService categoryPermissionService; + private final ResourceAccessService resourceAccessService; + + /** + * 创建 Agent 依赖资源校验服务。 + * + * @param modelService 模型服务 + * @param workflowService 工作流服务 + * @param pluginItemService 插件工具服务 + * @param pluginMapper 插件 Mapper + * @param pluginVisibilityService 插件可见性服务 + * @param mcpService MCP 服务 + * @param documentCollectionService 知识库服务 + * @param agentCategoryService Agent 分类服务 + * @param categoryPermissionService 分类权限服务 + * @param resourceAccessService 资源权限服务 + */ + public AgentDependencyAccessService(ModelService modelService, + WorkflowService workflowService, + PluginItemService pluginItemService, + PluginMapper pluginMapper, + PluginVisibilityService pluginVisibilityService, + McpService mcpService, + DocumentCollectionService documentCollectionService, + AgentCategoryService agentCategoryService, + CategoryPermissionService categoryPermissionService, + ResourceAccessService resourceAccessService) { + this.modelService = modelService; + this.workflowService = workflowService; + this.pluginItemService = pluginItemService; + this.pluginMapper = pluginMapper; + this.pluginVisibilityService = pluginVisibilityService; + this.mcpService = mcpService; + this.documentCollectionService = documentCollectionService; + this.agentCategoryService = agentCategoryService; + this.categoryPermissionService = categoryPermissionService; + this.resourceAccessService = resourceAccessService; + } + + /** + * 校验 Agent 模型并锁定模型行。 + * + * @param agent Agent + * @param modelId 模型 ID + * @return 模型 + */ + public Model requireModel(Agent agent, BigInteger modelId) { + if (modelId == null) { + throw new BusinessException("Agent 模型不能为空"); + } + Model model = modelService.getOne(QueryWrapper.create() + .eq(Model::getId, modelId) + .forUpdate()); + if (model == null) { + throw new BusinessException("Agent 模型不存在"); + } + assertSameTenant(agent, model.getTenantId(), "无权限使用该模型"); + if (!Model.MODEL_TYPES[0].equals(model.getModelType())) { + throw new BusinessException("Agent 仅支持聊天模型"); + } + return model; + } + + /** + * 校验并锁定工作流。 + * + * @param agent Agent + * @param workflowId 工作流 ID + * @return 已发布工作流 + */ + public Workflow requireWorkflow(Agent agent, BigInteger workflowId) { + Workflow workflow = workflowService.getOne(QueryWrapper.create() + .eq(Workflow::getId, workflowId) + .forUpdate()); + if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) { + throw new BusinessException("绑定工作流不存在或未发布"); + } + assertSameTenant(agent, workflow.getTenantId(), "无权限绑定该工作流"); + resourceAccessService.assertAccess( + CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, "无权限绑定该工作流"); + return workflow; + } + + /** + * 校验插件工具及其所属插件,并按父子顺序锁定资源行。 + * + * @param agent Agent + * @param pluginItemId 插件工具 ID + * @return 插件工具 + */ + public PluginItem requirePluginItem(Agent agent, BigInteger pluginItemId) { + PluginItem current = pluginItemService.getById(pluginItemId); + if (current == null || current.getPluginId() == null) { + throw new BusinessException("绑定插件不存在"); + } + Plugin plugin = pluginMapper.selectOneByQuery(QueryWrapper.create() + .eq(Plugin::getId, current.getPluginId()) + .forUpdate()); + PluginItem pluginItem = pluginItemService.getOne(QueryWrapper.create() + .eq(PluginItem::getId, pluginItemId) + .forUpdate()); + if (plugin == null || pluginItem == null || !Objects.equals(plugin.getId(), pluginItem.getPluginId())) { + throw new BusinessException("绑定插件不存在"); + } + if (!Integer.valueOf(1).equals(pluginItem.getStatus())) { + throw new BusinessException("绑定插件未启用"); + } + assertSameTenant(agent, plugin.getTenantId(), "无权限绑定该插件"); + pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定该插件"); + return pluginItem; + } + + /** + * 校验并锁定 MCP。 + * + * @param agent Agent + * @param mcpId MCP ID + * @return MCP + */ + public Mcp requireMcp(Agent agent, BigInteger mcpId) { + Mcp mcp = mcpService.getOne(QueryWrapper.create() + .eq(Mcp::getId, mcpId) + .forUpdate()); + if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) { + throw new BusinessException("绑定 MCP 不存在或未启用"); + } + assertSameTenant(agent, mcp.getTenantId(), "无权限绑定该 MCP"); + return mcp; + } + + /** + * 校验并锁定知识库。 + * + * @param agent Agent + * @param knowledgeId 知识库 ID + * @return 已发布知识库 + */ + public DocumentCollection requireKnowledge(Agent agent, BigInteger knowledgeId) { + DocumentCollection knowledge = documentCollectionService.getOne(QueryWrapper.create() + .eq(DocumentCollection::getId, knowledgeId) + .forUpdate()); + if (knowledge == null || PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) { + throw new BusinessException("绑定知识库不存在或未发布"); + } + assertSameTenant(agent, knowledge.getTenantId(), "无权限绑定该知识库"); + resourceAccessService.assertAccess( + CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE, "无权限绑定该知识库"); + return knowledge; + } + + /** + * 校验 Agent 分类属于当前租户并在当前账号授权范围内。 + * + * @param agent Agent + */ + public void validateCategory(Agent agent) { + if (agent == null || agent.getCategoryId() == null) { + return; + } + AgentCategory category = agentCategoryService.getById(agent.getCategoryId()); + if (category == null || !Integer.valueOf(1).equals(category.getStatus())) { + throw new BusinessException("Agent 分类不存在或未启用"); + } + assertSameTenant(agent, category.getTenantId(), "无权限使用该 Agent 分类"); + RoleCategoryAccessSnapshot access = + categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode()); + if (access.isRestricted() && !access.getCategoryIds().contains(agent.getCategoryId())) { + throw new BusinessException("无权限使用该 Agent 分类"); + } + } + + /** + * 校验资源与 Agent 属于同一租户。 + * + * @param agent Agent + * @param resourceTenantId 资源租户 ID + * @param message 拒绝消息 + */ + private void assertSameTenant(Agent agent, Object resourceTenantId, String message) { + BigInteger agentTenantId = agent == null ? null : agent.getTenantId(); + if (agentTenantId == null) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + agentTenantId = account == null ? null : account.getTenantId(); + } + if (agentTenantId == null || resourceTenantId == null + || !agentTenantId.toString().equals(String.valueOf(resourceTenantId))) { + throw new BusinessException(message); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentOptionQueryService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentOptionQueryService.java new file mode 100644 index 00000000..c54929e2 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentOptionQueryService.java @@ -0,0 +1,378 @@ +package tech.easyflow.agent.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.security.AgentVisibilityQueryHelper; +import tech.easyflow.agent.vo.AgentOptionView; +import tech.easyflow.agent.vo.AgentResourceOptionsView; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.McpService; +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.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Agent 与设计器依赖资源的安全选项查询服务。 + */ +@Service +public class AgentOptionQueryService { + + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private final AgentService agentService; + private final ModelService modelService; + private final DocumentCollectionService documentCollectionService; + private final WorkflowService workflowService; + private final PluginService pluginService; + private final PluginItemService pluginItemService; + private final PluginVisibilityService pluginVisibilityService; + private final McpService mcpService; + private final AgentVisibilityQueryHelper agentVisibilityQueryHelper; + private final ResourceAccessService resourceAccessService; + private final ObjectMapper objectMapper; + + /** + * 创建 Agent 安全选项查询服务。 + * + * @param agentService Agent 服务 + * @param modelService 模型服务 + * @param documentCollectionService 知识库服务 + * @param workflowService 工作流服务 + * @param pluginService 插件服务 + * @param pluginItemService 插件工具服务 + * @param pluginVisibilityService 插件可见性服务 + * @param mcpService MCP 服务 + * @param agentVisibilityQueryHelper Agent 可见性查询助手 + * @param resourceAccessService 资源访问服务 + * @param objectMapper JSON 映射器 + */ + public AgentOptionQueryService(AgentService agentService, + ModelService modelService, + DocumentCollectionService documentCollectionService, + WorkflowService workflowService, + PluginService pluginService, + PluginItemService pluginItemService, + PluginVisibilityService pluginVisibilityService, + McpService mcpService, + AgentVisibilityQueryHelper agentVisibilityQueryHelper, + ResourceAccessService resourceAccessService, + ObjectMapper objectMapper) { + this.agentService = agentService; + this.modelService = modelService; + this.documentCollectionService = documentCollectionService; + this.workflowService = workflowService; + this.pluginService = pluginService; + this.pluginItemService = pluginItemService; + this.pluginVisibilityService = pluginVisibilityService; + this.mcpService = mcpService; + this.agentVisibilityQueryHelper = agentVisibilityQueryHelper; + this.resourceAccessService = resourceAccessService; + this.objectMapper = objectMapper; + } + + /** + * 查询当前账号可见的 Agent 安全选项。 + * + * @param publishedOnly 是否仅查询已发布 Agent + * @return Agent 选项 + */ + public List listAgentOptions(boolean publishedOnly) { + LoginAccount account = requireAccount(); + QueryWrapper wrapper = QueryWrapper.create(); + agentVisibilityQueryHelper.applyReadableAccess(wrapper); + wrapper.orderBy(Agent::getModified, false); + if (publishedOnly) { + wrapper.eq(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode()) + .eq(Agent::getStatus, 1); + } + List result = new ArrayList<>(); + ResourceAction action = publishedOnly ? ResourceAction.USE : ResourceAction.READ; + for (Agent agent : agentService.list(wrapper)) { + if (!resourceAccessService.canAccess(CategoryResourceType.AGENT, agent, action)) { + continue; + } + result.add(toAgentOption(agent, publishedOnly)); + } + return result; + } + + /** + * 查询 Agent 设计器所需的安全资源选项。 + * + * @return 资源选项集合 + */ + public AgentResourceOptionsView listDesignerResourceOptions() { + LoginAccount account = requireAccount(); + return new AgentResourceOptionsView( + listModelOptions(account), + listKnowledgeOptions(account), + listWorkflowOptions(account), + listPluginToolOptions(account), + listMcpOptions(account) + ); + } + + /** + * 查询当前账号可用于 Agent 会话的知识库安全选项。 + * + * @return 知识库选项 + */ + public List listKnowledgeOptions() { + return listKnowledgeOptions(requireAccount()); + } + + /** + * 查询指定 MCP 的安全工具列表。 + * + * @param mcpId MCP ID + * @return MCP 工具选项 + */ + public List listMcpTools(BigInteger mcpId) { + LoginAccount account = requireAccount(); + Mcp mcp = mcpService.getOne(QueryWrapper.create() + .eq(Mcp::getId, mcpId) + .eq(Mcp::getTenantId, account.getTenantId()) + .eq(Mcp::getStatus, true)); + if (mcp == null) { + throw new BusinessException("MCP 不存在或不可用"); + } + Mcp detail = mcpService.getMcpTools(mcpId.toString()); + if (detail == null || detail.getTools() == null) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (Object tool : detail.getTools()) { + Map value = objectMapper.convertValue(tool, MAP_TYPE); + result.add(new AgentResourceOptionsView.McpToolOption( + text(value.get("name")), + text(value.get("description")) + )); + } + return result; + } + + /** + * 查询当前租户可用于 Agent 的模型选项。 + * + * @param account 当前登录账号 + * @return 模型选项 + */ + 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 -> new AgentResourceOptionsView.ModelOption( + model.getId(), + model.getTitle(), + model.getContextWindowTokens(), + model.getMaxOutputTokens() + )) + .toList(); + } + + /** + * 查询当前账号可使用的已发布知识库。 + * + * @param account 当前登录账号 + * @return 知识库选项 + */ + private List listKnowledgeOptions(LoginAccount account) { + return documentCollectionService.list(QueryWrapper.create() + .eq(DocumentCollection::getTenantId, account.getTenantId()) + .eq(DocumentCollection::getPublishStatus, PublishStatus.PUBLISHED.getCode()) + .orderBy(DocumentCollection::getModified, false)) + .stream() + .filter(item -> resourceAccessService.canAccess( + CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE)) + .map(item -> new AgentResourceOptionsView.ResourceOption( + item.getId(), item.getTitle(), item.getDescription(), null)) + .toList(); + } + + /** + * 查询当前账号可使用的已发布工作流。 + * + * @param account 当前登录账号 + * @return 工作流选项 + */ + private List listWorkflowOptions(LoginAccount account) { + return workflowService.list(QueryWrapper.create() + .eq(Workflow::getTenantId, account.getTenantId()) + .eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode()) + .eq(Workflow::getStatus, 1) + .orderBy(Workflow::getModified, false)) + .stream() + .filter(item -> resourceAccessService.canAccess( + CategoryResourceType.WORKFLOW, item, ResourceAction.USE)) + .map(item -> new AgentResourceOptionsView.ResourceOption( + item.getId(), item.getTitle(), item.getDescription(), item.getEnglishName())) + .toList(); + } + + /** + * 查询当前账号可使用的插件工具。 + * + * @param account 当前登录账号 + * @return 插件工具选项 + */ + private List listPluginToolOptions(LoginAccount account) { + List plugins = pluginService.list(QueryWrapper.create() + .eq(Plugin::getTenantId, account.getTenantId())) + .stream() + .filter(plugin -> pluginVisibilityService.canAccessPlugin( + plugin.getCreatedBy(), plugin.getId())) + .toList(); + if (plugins.isEmpty()) { + return Collections.emptyList(); + } + Map pluginNames = new LinkedHashMap<>(); + plugins.forEach(plugin -> pluginNames.put(plugin.getId(), plugin.getName())); + return pluginItemService.list(QueryWrapper.create() + .in(PluginItem::getPluginId, pluginNames.keySet()) + .eq(PluginItem::getStatus, 1) + .orderBy(PluginItem::getId, false)) + .stream() + .map(item -> new AgentResourceOptionsView.PluginToolOption( + item.getId(), + item.getName(), + item.getDescription(), + item.getEnglishName(), + pluginNames.get(item.getPluginId()) + )) + .toList(); + } + + /** + * 查询当前租户可使用的 MCP。 + * + * @param account 当前登录账号 + * @return MCP 选项 + */ + private List listMcpOptions(LoginAccount account) { + return mcpService.list(QueryWrapper.create() + .eq(Mcp::getTenantId, account.getTenantId()) + .eq(Mcp::getStatus, true) + .orderBy(Mcp::getModified, false)) + .stream() + .map(item -> new AgentResourceOptionsView.McpOption( + item.getId(), + item.getTitle(), + item.getDescription(), + item.getApprovalRequired() + )) + .toList(); + } + + /** + * 将 Agent 转换为不包含运行配置的安全选项。 + * + * @param agent Agent 数据 + * @param publishedOnly 是否读取发布快照中的展示信息 + * @return Agent 安全选项 + */ + private AgentOptionView toAgentOption(Agent agent, boolean publishedOnly) { + Map snapshot = publishedOnly ? agent.getPublishedSnapshotJson() : Map.of(); + Map basic = snapshot == null ? Map.of() : map(snapshot.get("basicSummary")); + Map model = snapshot == null ? Map.of() : map(snapshot.get("modelSummary")); + Map interaction = publishedOnly + ? map(snapshot == null ? null : snapshot.get("interactionConfigJson")) + : agent.getInteractionConfigJson(); + return new AgentOptionView( + agent.getId(), + firstText(text(basic.get("name")), agent.getName()), + firstText(text(basic.get("description")), agent.getDescription()), + firstText(text(basic.get("avatar")), agent.getAvatar()), + interaction, + publishedOnly ? booleanValue(model.get("supportImage")) : null + ); + } + + /** + * 将对象安全转换为字符串键 Map。 + * + * @param value 待转换值 + * @return Map;非 Map 值返回空 Map + */ + private Map map(Object value) { + if (!(value instanceof Map raw)) { + return Collections.emptyMap(); + } + Map result = new LinkedHashMap<>(); + raw.forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + + /** + * 转换可空布尔值。 + * + * @param value 原始值 + * @return 布尔值或 null + */ + private Boolean booleanValue(Object value) { + return value == null ? null : Boolean.parseBoolean(String.valueOf(value)); + } + + /** + * 返回首个非空文本。 + * + * @param value 首选文本 + * @param fallback 备用文本 + * @return 最终文本 + */ + private String firstText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + /** + * 将对象转换为可空文本。 + * + * @param value 原始值 + * @return 文本或 null + */ + private String text(Object value) { + return value == null ? null : String.valueOf(value); + } + + /** + * 获取带租户信息的当前登录账号。 + * + * @return 当前登录账号 + * @throws BusinessException 登录状态无效时抛出 + */ + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java index 2b3196db..01550830 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java @@ -8,11 +8,10 @@ import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.mapper.AgentKnowledgeBindingMapper; import tech.easyflow.agent.mapper.AgentMapper; +import tech.easyflow.agent.service.AgentDependencyAccessService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; -import tech.easyflow.ai.entity.DocumentCollection; -import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.ai.rag.KnowledgeRetrievalModes; -import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; @@ -22,9 +21,13 @@ import tech.easyflow.system.service.ResourceAccessService; import javax.annotation.Resource; import java.math.BigInteger; +import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.Date; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** * Agent 知识库绑定服务实现。 @@ -38,9 +41,11 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl replaceBindings(BigInteger agentId, List bindings) { - Agent agent = requireAgent(agentId); - resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent"); - remove(QueryWrapper.create().where("agent_id = ?", agentId)); - if (bindings == null || bindings.isEmpty()) { - return Collections.emptyList(); - } - for (int i = 0; i < bindings.size(); i++) { - AgentKnowledgeBinding binding = bindings.get(i); - validateBinding(binding); - applyBindingDefaults(agent, binding, i); - } - saveBatch(bindings); - return listEnabled(agentId); + return agentBindingLockExecutor.execute(agentId, () -> { + Agent agent = requireAgentForUpdate(agentId); + resourceAccessService.assertAccess( + CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent"); + validateBindings(agent, bindings); + remove(QueryWrapper.create().where("agent_id = ?", agentId)); + if (bindings == null || bindings.isEmpty()) { + return Collections.emptyList(); + } + for (int i = 0; i < bindings.size(); i++) { + applyBindingDefaults(agent, bindings.get(i), i); + } + saveBatch(bindings); + return listEnabled(agentId); + }); } /** @@ -74,26 +81,70 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl bindings) { + if (bindings == null || bindings.isEmpty()) { + return; + } + Set knowledgeIds = new LinkedHashSet<>(); + for (AgentKnowledgeBinding binding : bindings) { + if (binding != null && binding.getKnowledgeId() != null + && !knowledgeIds.add(binding.getKnowledgeId())) { + throw new BusinessException("同一知识库不能重复绑定"); + } + } + List validationOrder = new ArrayList<>(bindings); + validationOrder.sort(Comparator.comparing(binding -> + binding == null || binding.getKnowledgeId() == null + ? BigInteger.ZERO + : binding.getKnowledgeId())); + validationOrder.forEach(binding -> validateBinding(agent, binding)); + } + + /** + * 写入知识库绑定的归属、审计与排序默认值。 + * + * @param agent 当前 Agent + * @param binding 知识库绑定 + * @param index 绑定顺序 + */ private void applyBindingDefaults(Agent agent, AgentKnowledgeBinding binding, int index) { LoginAccount account = requireCurrentLoginAccount(); Date now = new Date(); @@ -113,10 +164,23 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl listAgentsByWorkflowId(BigInteger workflowId) { + return listAgents(collectToolResourceAgentIds(AgentToolType.WORKFLOW, workflowId, false)); + } + + /** + * {@inheritDoc} + */ + @Override + public List listAgentsByKnowledgeId(BigInteger knowledgeId) { + return listAgents(collectKnowledgeAgentIds(knowledgeId, false)); + } + + /** + * {@inheritDoc} + */ + @Override + public List listAgentsByPluginItemId(BigInteger pluginItemId) { + return listAgents(collectToolResourceAgentIds(AgentToolType.PLUGIN, pluginItemId, false)); + } + + /** + * {@inheritDoc} + */ + @Override + public List listAgentsByMcpId(BigInteger mcpId) { + return listAgents(collectToolResourceAgentIds(AgentToolType.MCP, mcpId, false)); + } + + /** + * {@inheritDoc} + */ + @Override + public List listAgentsByModelId(BigInteger modelId) { + Set agentIds = collectAgentIdsFromAgents(agentService.list(QueryWrapper.create() + .select(Agent::getId) + .eq(Agent::getModelId, modelId))); + for (Agent agent : listPublishedSnapshotAgents()) { + if (sameId(agent.getPublishedSnapshotJson().get("modelId"), modelId)) { + agentIds.add(agent.getId()); + } + } + return listAgents(agentIds); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void unbindWorkflow(BigInteger workflowId) { + Set agentIds = collectToolResourceAgentIds( + AgentToolType.WORKFLOW, workflowId, true); + for (BigInteger agentId : sortedAgentIds(agentIds)) { + agentBindingLockExecutor.execute(agentId, () -> { + agentToolBindingService.remove(QueryWrapper.create() + .eq(AgentToolBinding::getAgentId, agentId) + .eq(AgentToolBinding::getToolType, AgentToolType.WORKFLOW.name()) + .eq(AgentToolBinding::getTargetId, workflowId)); + trimPublishedSnapshot( + agentId, + workflowId, + AgentToolType.WORKFLOW.name(), + "toolBindings", + "toolSummaries" + ); + return null; + }); + } + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void unbindKnowledge(BigInteger knowledgeId) { + Set agentIds = collectKnowledgeAgentIds(knowledgeId, true); + for (BigInteger agentId : sortedAgentIds(agentIds)) { + agentBindingLockExecutor.execute(agentId, () -> { + agentKnowledgeBindingService.remove(QueryWrapper.create() + .eq(AgentKnowledgeBinding::getAgentId, agentId) + .eq(AgentKnowledgeBinding::getKnowledgeId, knowledgeId)); + trimPublishedSnapshot( + agentId, + knowledgeId, + null, + "knowledgeBindings", + "knowledgeSummaries" + ); + return null; + }); + } + } + + /** + * 查询 Agent 摘要并保持绑定中首次出现的顺序。 + * + * @param agentIds Agent ID 集合 + * @return Agent 摘要列表 + */ + private List listAgents(Set agentIds) { + if (agentIds.isEmpty()) { + return Collections.emptyList(); + } + Map agentMap = new LinkedHashMap<>(); + for (Agent agent : agentService.listByIds(agentIds)) { + agentMap.put(agent.getId(), agent); + } + List result = new ArrayList<>(agentIds.size()); + for (BigInteger agentId : agentIds) { + Agent agent = agentMap.get(agentId); + OfflineImpactBindingVo item = new OfflineImpactBindingVo(); + item.setId(agentId); + item.setTitle(agent == null ? "已删除智能体(悬空绑定)" : agent.getName()); + result.add(item); + } + return result; + } + + /** + * 收集草稿绑定和已发布快照中引用指定工具资源的 Agent ID。 + * + * @param toolType 工具类型 + * @param resourceId 资源 ID + * @param lockBindings 是否锁定实时绑定行 + * @return Agent ID 集合 + */ + private Set collectToolResourceAgentIds(AgentToolType toolType, + BigInteger resourceId, + boolean lockBindings) { + QueryWrapper wrapper = QueryWrapper.create() + .eq(AgentToolBinding::getToolType, toolType.name()) + .eq(AgentToolBinding::getTargetId, resourceId); + if (lockBindings) { + wrapper.forUpdate(); + } + Set agentIds = collectAgentIdsFromToolBindings( + agentToolBindingService.list(wrapper)); + for (Agent agent : listPublishedSnapshotAgents()) { + if (snapshotContainsToolResource(agent.getPublishedSnapshotJson(), toolType, resourceId)) { + agentIds.add(agent.getId()); + } + } + return agentIds; + } + + /** + * 收集草稿绑定和已发布快照中引用指定知识库的 Agent ID。 + * + * @param knowledgeId 知识库 ID + * @param lockBindings 是否锁定实时绑定行 + * @return Agent ID 集合 + */ + private Set collectKnowledgeAgentIds(BigInteger knowledgeId, boolean lockBindings) { + QueryWrapper wrapper = QueryWrapper.create() + .eq(AgentKnowledgeBinding::getKnowledgeId, knowledgeId); + if (lockBindings) { + wrapper.forUpdate(); + } + Set agentIds = collectAgentIdsFromKnowledgeBindings( + agentKnowledgeBindingService.list(wrapper)); + for (Agent agent : listPublishedSnapshotAgents()) { + if (snapshotContainsKnowledge(agent.getPublishedSnapshotJson(), knowledgeId)) { + agentIds.add(agent.getId()); + } + } + return agentIds; + } + + /** + * 查询带发布快照的 Agent 最小字段。 + * + * @return Agent 发布快照记录 + */ + private List listPublishedSnapshotAgents() { + return agentService.list(QueryWrapper.create() + .select( + Agent::getId, + Agent::getName, + Agent::getModelId, + Agent::getPublishedSnapshotJson + ) + .isNotNull(Agent::getPublishedSnapshotJson)); + } + + /** + * 判断发布快照是否引用指定工具资源。 + * + * @param snapshot 发布快照 + * @param toolType 工具类型 + * @param resourceId 资源 ID + * @return 是否引用 + */ + private boolean snapshotContainsToolResource(Map snapshot, + AgentToolType toolType, + BigInteger resourceId) { + return snapshotListContains(snapshot, "toolBindings", resourceId, toolType.name()) + || snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name()); + } + + /** + * 判断发布快照是否引用指定知识库。 + * + * @param snapshot 发布快照 + * @param knowledgeId 知识库 ID + * @return 是否引用 + */ + private boolean snapshotContainsKnowledge(Map snapshot, BigInteger knowledgeId) { + return snapshotListContains(snapshot, "knowledgeBindings", knowledgeId, null) + || snapshotListContains(snapshot, "knowledgeSummaries", knowledgeId, null); + } + + /** + * 判断快照列表中是否存在指定资源。 + * + * @param snapshot 发布快照 + * @param key 列表字段 + * @param resourceId 资源 ID + * @param toolType 工具类型;知识库为空 + * @return 是否存在 + */ + private boolean snapshotListContains(Map snapshot, + String key, + BigInteger resourceId, + String toolType) { + if (snapshot == null || snapshot.isEmpty()) { + return false; + } + Object value = snapshot.get(key); + if (!(value instanceof List items)) { + return false; + } + return items.stream().anyMatch(item -> matchesResourceBinding(item, resourceId, toolType)); + } + + /** + * 收集工具绑定中的 Agent ID。 + * + * @param bindings 工具绑定 + * @return 去重后的 Agent ID + */ + private Set collectAgentIdsFromToolBindings(List bindings) { + Set result = new LinkedHashSet<>(); + if (bindings == null) { + return result; + } + for (AgentToolBinding binding : bindings) { + if (binding != null && binding.getAgentId() != null) { + result.add(binding.getAgentId()); + } + } + return result; + } + + /** + * 收集知识库绑定中的 Agent ID。 + * + * @param bindings 知识库绑定 + * @return 去重后的 Agent ID + */ + private Set collectAgentIdsFromKnowledgeBindings(List bindings) { + Set result = new LinkedHashSet<>(); + if (bindings == null) { + return result; + } + for (AgentKnowledgeBinding binding : bindings) { + if (binding != null && binding.getAgentId() != null) { + result.add(binding.getAgentId()); + } + } + return result; + } + + /** + * 收集 Agent 实体中的 ID。 + * + * @param agents Agent 列表 + * @return Agent ID 集合 + */ + private Set collectAgentIdsFromAgents(List agents) { + Set result = new LinkedHashSet<>(); + if (agents == null) { + return result; + } + for (Agent agent : agents) { + if (agent != null && agent.getId() != null) { + result.add(agent.getId()); + } + } + return result; + } + + /** + * 按 Agent ID 升序返回锁定顺序,避免并发批量解绑以相反顺序持锁。 + * + * @param agentIds Agent ID 集合 + * @return 稳定排序后的 Agent ID + */ + private List sortedAgentIds(Set agentIds) { + if (agentIds == null || agentIds.isEmpty()) { + return Collections.emptyList(); + } + return agentIds.stream() + .sorted(Comparator.naturalOrder()) + .toList(); + } + + /** + * 从 Agent 发布快照中移除指定资源绑定。 + * + * @param agentId Agent ID + * @param resourceId 资源 ID + * @param toolType 工具类型;知识库绑定时为空 + * @param bindingsKeys 快照绑定字段 + */ + private void trimPublishedSnapshot(BigInteger agentId, + BigInteger resourceId, + String toolType, + String... bindingsKeys) { + Agent agent = agentService.getOne(QueryWrapper.create() + .eq(Agent::getId, agentId) + .forUpdate()); + if (agent == null || agent.getPublishedSnapshotJson() == null || agent.getPublishedSnapshotJson().isEmpty()) { + return; + } + Map snapshot = new LinkedHashMap<>(agent.getPublishedSnapshotJson()); + boolean changed = false; + for (String bindingsKey : bindingsKeys) { + Object rawBindings = snapshot.get(bindingsKey); + if (!(rawBindings instanceof List bindings)) { + continue; + } + List filtered = new ArrayList<>(bindings.size()); + for (Object item : bindings) { + if (matchesResourceBinding(item, resourceId, toolType)) { + changed = true; + continue; + } + filtered.add(item); + } + snapshot.put(bindingsKey, filtered); + } + if (!changed) { + return; + } + // 仅更新发布快照,避免并发草稿编辑被旧实体中的其他字段覆盖。 + agentService.updateChain() + .set(Agent::getPublishedSnapshotJson, snapshot) + .eq(Agent::getId, agentId) + .update(); + } + + /** + * 判断快照项是否指向指定资源。 + * + * @param item 快照项 + * @param resourceId 资源 ID + * @param toolType 工具类型;知识库绑定时为空 + * @return 是否匹配 + */ + private boolean matchesResourceBinding(Object item, BigInteger resourceId, String toolType) { + if (!(item instanceof Map binding)) { + return false; + } + Object currentId = toolType == null ? binding.get("knowledgeId") : binding.get("targetId"); + if (!Objects.equals(String.valueOf(currentId), String.valueOf(resourceId))) { + return false; + } + return toolType == null || toolType.equalsIgnoreCase(String.valueOf(binding.get("toolType"))); + } + + /** + * 比较快照中的资源 ID 与数据库 ID。 + * + * @param snapshotId 快照 ID + * @param resourceId 数据库 ID + * @return 是否相同 + */ + private boolean sameId(Object snapshotId, BigInteger resourceId) { + return snapshotId != null + && resourceId != null + && Objects.equals(String.valueOf(snapshotId), resourceId.toString()); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java index f07ee16a..9e8f1723 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java @@ -2,6 +2,7 @@ package tech.easyflow.agent.service.impl; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -10,9 +11,12 @@ import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; import tech.easyflow.agent.mapper.AgentMapper; +import tech.easyflow.agent.runtime.AgentRuntimeCompiler; +import tech.easyflow.agent.service.AgentDependencyAccessService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.ai.entity.*; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.service.*; @@ -58,6 +62,12 @@ public class AgentServiceImpl extends ServiceImpl implements private ResourceAccessService resourceAccessService; @Resource private ObjectMapper objectMapper; + @Resource + private AgentDependencyAccessService agentDependencyAccessService; + @Resource + private AgentBindingLockExecutor agentBindingLockExecutor; + @Resource + private AgentRuntimeCompiler agentRuntimeCompiler; /** * {@inheritDoc} @@ -77,8 +87,8 @@ public class AgentServiceImpl extends ServiceImpl implements @Override @Transactional(rollbackFor = Exception.class) public Agent saveDraft(Agent agent) { - validateDraft(agent); applyDraftDefaults(agent); + validateDraft(agent); save(agent); return getDetail(agent.getId()); } @@ -92,12 +102,16 @@ public class AgentServiceImpl extends ServiceImpl implements if (agent == null || agent.getId() == null) { throw new BusinessException("Agent ID 不能为空"); } - Agent existing = requireAgent(agent.getId()); - resourceAccessService.assertAccess(CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent"); - validateDraft(agent); - applyDraftUpdate(existing, agent); - updateById(existing); - return getDetail(existing.getId()); + return agentBindingLockExecutor.execute(agent.getId(), () -> { + Agent existing = requireAgentForUpdate(agent.getId()); + resourceAccessService.assertAccess( + CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent"); + agent.setTenantId(existing.getTenantId()); + validateDraft(agent); + applyDraftUpdate(existing, agent); + updateById(existing); + return getDetail(existing.getId()); + }); } /** @@ -117,8 +131,27 @@ public class AgentServiceImpl extends ServiceImpl implements * {@inheritDoc} */ @Override + @Transactional(rollbackFor = Exception.class) public Map buildPublishSnapshot(Agent agent) { - Agent detail = getDetail(agent.getId()); + if (agent == null || agent.getId() == null) { + throw new BusinessException("Agent ID 不能为空"); + } + return agentBindingLockExecutor.execute(agent.getId(), () -> buildPublishSnapshotLocked(agent.getId())); + } + + /** + * 在 Agent 锁和数据库行锁内构建并校验发布快照。 + * + * @param agentId Agent ID + * @return 发布快照 + */ + private Map buildPublishSnapshotLocked(BigInteger agentId) { + Agent detail = requireAgentForUpdate(agentId); + resourceAccessService.assertAccess( + CategoryResourceType.AGENT, detail, ResourceAction.MANAGE, "无权限管理该 Agent"); + detail.setToolBindings(agentToolBindingService.listEnabled(agentId)); + detail.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(agentId)); + validateDraft(detail); Map snapshot = new LinkedHashMap<>(); snapshot.put("id", detail.getId()); snapshot.put("tenantId", detail.getTenantId()); @@ -136,8 +169,8 @@ public class AgentServiceImpl extends ServiceImpl implements snapshot.put("executionConfigJson", detail.getExecutionConfigJson()); snapshot.put("interactionConfigJson", detail.getInteractionConfigJson()); snapshot.put("visibilityScope", detail.getVisibilityScope()); - snapshot.put("toolBindings", snapshotToolBindings(detail.getToolBindings())); - snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail.getKnowledgeBindings())); + snapshot.put("toolBindings", snapshotToolBindings(detail, detail.getToolBindings())); + snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail, detail.getKnowledgeBindings())); snapshot.put("basicSummary", basicSummary(detail)); snapshot.put("modelSummary", modelSummary(detail.getModelId())); snapshot.put("parameterSummary", parameterSummary(detail)); @@ -145,6 +178,8 @@ public class AgentServiceImpl extends ServiceImpl implements snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings())); snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings())); snapshot.put("snapshotAt", new Date()); + // 发布前完整编译一次,提前暴露工具运行名冲突和运行定义错误。 + agentRuntimeCompiler.compile(fromSnapshot(snapshot)); return snapshot; } @@ -179,6 +214,22 @@ public class AgentServiceImpl extends ServiceImpl implements return agent; } + /** + * 查询并锁定 Agent 数据行。 + * + * @param id Agent ID + * @return Agent + */ + private Agent requireAgentForUpdate(BigInteger id) { + Agent agent = getOne(QueryWrapper.create() + .eq(Agent::getId, id) + .forUpdate()); + if (agent == null) { + throw new BusinessException("Agent 不存在"); + } + return agent; + } + private void validateDraft(Agent agent) { if (agent == null) { throw new BusinessException("Agent 不能为空"); @@ -186,13 +237,8 @@ public class AgentServiceImpl extends ServiceImpl implements if (agent.getName() == null || agent.getName().isBlank()) { throw new BusinessException("Agent 名称不能为空"); } - if (agent.getModelId() == null) { - throw new BusinessException("Agent 模型不能为空"); - } - Model model = modelService.getModelInstance(agent.getModelId()); - if (model == null) { - throw new BusinessException("Agent 模型不存在"); - } + agentDependencyAccessService.requireModel(agent, agent.getModelId()); + agentDependencyAccessService.validateCategory(agent); agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name()); agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson())); @@ -334,57 +380,47 @@ public class AgentServiceImpl extends ServiceImpl implements return summary; } - private List snapshotToolBindings(List bindings) { + private List snapshotToolBindings(Agent agent, List bindings) { if (bindings == null) { return List.of(); } return bindings.stream().map(binding -> { AgentToolBinding snapshot = objectMapper.convertValue(binding, AgentToolBinding.class); + snapshot.setResourceSnapshot(toolResourceSnapshot(agent, binding)); snapshot.setResourceSummary(toolSummary(binding)); - snapshot.setResourceSnapshot(toolResourceSnapshot(binding)); return snapshot; }).toList(); } - private Map toolResourceSnapshot(AgentToolBinding binding) { + private Map toolResourceSnapshot(Agent agent, AgentToolBinding binding) { if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) { - Workflow workflow = workflowService.getPublishedById(binding.getTargetId()); - if (workflow == null || !PublishStatus.from(workflow.getPublishStatus()).isExternallyVisible()) { - throw new BusinessException("绑定工作流不存在或未发布"); - } + Workflow workflow = agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId()); return objectMapper.convertValue(workflow, new TypeReference>() {}); } if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) { - PluginItem pluginItem = pluginItemService.getById(binding.getTargetId()); - if (pluginItem == null) { - throw new BusinessException("绑定插件不存在"); - } + PluginItem pluginItem = agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId()); return objectMapper.convertValue(pluginItem, new TypeReference>() {}); } - Mcp mcp = mcpService.getById(binding.getTargetId()); - if (mcp == null) { - throw new BusinessException("绑定 MCP 不存在"); - } + Mcp mcp = agentDependencyAccessService.requireMcp(agent, binding.getTargetId()); return objectMapper.convertValue(mcp, new TypeReference>() {}); } - private List snapshotKnowledgeBindings(List bindings) { + private List snapshotKnowledgeBindings( + Agent agent, List bindings) { if (bindings == null) { return List.of(); } return bindings.stream().map(binding -> { AgentKnowledgeBinding snapshot = objectMapper.convertValue(binding, AgentKnowledgeBinding.class); + snapshot.setResourceSnapshot(knowledgeResourceSnapshot(agent, binding)); snapshot.setResourceSummary(knowledgeSummary(binding)); - snapshot.setResourceSnapshot(knowledgeResourceSnapshot(binding)); return snapshot; }).toList(); } - private Map knowledgeResourceSnapshot(AgentKnowledgeBinding binding) { - DocumentCollection knowledge = documentCollectionService.getPublishedById(binding.getKnowledgeId()); - if (knowledge == null || !PublishStatus.from(knowledge.getPublishStatus()).isExternallyVisible()) { - throw new BusinessException("绑定知识库不存在或未发布"); - } + private Map knowledgeResourceSnapshot(Agent agent, AgentKnowledgeBinding binding) { + DocumentCollection knowledge = + agentDependencyAccessService.requireKnowledge(agent, binding.getKnowledgeId()); return objectMapper.convertValue(knowledge, new TypeReference>() {}); } @@ -408,10 +444,23 @@ public class AgentServiceImpl extends ServiceImpl implements return summary; } + /** + * 获取当前登录账号。 + * + * @return 当前登录账号 + * @throws BusinessException 登录信息失效时抛出 + */ private LoginAccount requireCurrentLoginAccount() { try { - return SaTokenUtil.getLoginAccount(); + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; } catch (Exception e) { + if (e instanceof BusinessException businessException) { + throw businessException; + } throw new BusinessException("当前登录状态失效,请重新登录后再试"); } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java index b6409c1a..d3c27566 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java @@ -9,14 +9,9 @@ import tech.easyflow.agent.entity.AgentToolBinding; import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.mapper.AgentMapper; import tech.easyflow.agent.mapper.AgentToolBindingMapper; +import tech.easyflow.agent.service.AgentDependencyAccessService; import tech.easyflow.agent.service.AgentToolBindingService; -import tech.easyflow.ai.entity.Mcp; -import tech.easyflow.ai.entity.PluginItem; -import tech.easyflow.ai.entity.Workflow; -import tech.easyflow.ai.enums.PublishStatus; -import tech.easyflow.ai.service.McpService; -import tech.easyflow.ai.service.PluginItemService; -import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; @@ -26,9 +21,13 @@ import tech.easyflow.system.service.ResourceAccessService; import javax.annotation.Resource; import java.math.BigInteger; +import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.Date; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** * Agent 工具绑定服务实现。 @@ -40,13 +39,11 @@ public class AgentToolBindingServiceImpl extends ServiceImpl replaceBindings(BigInteger agentId, List bindings) { - Agent agent = requireAgent(agentId); - resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent"); - remove(QueryWrapper.create().where("agent_id = ?", agentId)); - if (bindings == null || bindings.isEmpty()) { - return Collections.emptyList(); - } - for (int i = 0; i < bindings.size(); i++) { - AgentToolBinding binding = bindings.get(i); - validateBinding(binding); - applyBindingDefaults(agent, binding, i); - } - saveBatch(bindings); - return listEnabled(agentId); + return agentBindingLockExecutor.execute(agentId, () -> { + Agent agent = requireAgentForUpdate(agentId); + resourceAccessService.assertAccess( + CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent"); + validateBindings(agent, bindings); + remove(QueryWrapper.create().where("agent_id = ?", agentId)); + if (bindings == null || bindings.isEmpty()) { + return Collections.emptyList(); + } + for (int i = 0; i < bindings.size(); i++) { + applyBindingDefaults(agent, bindings.get(i), i); + } + saveBatch(bindings); + return listEnabled(agentId); + }); } /** @@ -80,40 +79,89 @@ public class AgentToolBindingServiceImpl extends ServiceImpl bindings) { + if (bindings == null || bindings.isEmpty()) { + return; + } + Set resourceKeys = new LinkedHashSet<>(); + Set declaredToolNames = new LinkedHashSet<>(); + for (AgentToolBinding binding : bindings) { + if (binding == null || binding.getToolType() == null || binding.getTargetId() == null) { + continue; + } + String resourceKey = AgentToolType.from(binding.getToolType()).name() + ":" + binding.getTargetId(); + if (!resourceKeys.add(resourceKey)) { + throw new BusinessException("同一工具资源不能重复绑定"); + } + String toolName = binding.getToolName(); + if (toolName != null && !toolName.isBlank() && !declaredToolNames.add(toolName.trim())) { + throw new BusinessException("Agent 工具运行名冲突:" + toolName.trim() + ",请调整工具名称"); + } + } + List validationOrder = new ArrayList<>(bindings); + validationOrder.sort(Comparator + .comparing((AgentToolBinding binding) -> + binding == null || binding.getToolType() == null ? "" : binding.getToolType()) + .thenComparing(binding -> + binding == null || binding.getTargetId() == null + ? BigInteger.ZERO + : binding.getTargetId())); + validationOrder.forEach(binding -> validateBinding(agent, binding)); + } + + /** + * 写入工具绑定的归属、审计与排序默认值。 + * + * @param agent 当前 Agent + * @param binding 工具绑定 + * @param index 绑定顺序 + */ private void applyBindingDefaults(Agent agent, AgentToolBinding binding, int index) { LoginAccount account = requireCurrentLoginAccount(); Date now = new Date(); @@ -129,10 +177,23 @@ public class AgentToolBindingServiceImpl extends ServiceImpl存在活动事务时,锁会在事务完成后释放,避免提交前出现并发写入窗口。

+ * + * @param agentId Agent ID + * @param task 绑定变更任务 + * @param 返回类型 + * @return 任务结果 + */ + public T execute(BigInteger agentId, Supplier task) { + if (agentId == null) { + throw new IllegalArgumentException("agentId 不能为空"); + } + String lockKey = LOCK_KEY_PREFIX + agentId; + if (TransactionSynchronizationManager.hasResource(lockKey)) { + return task.get(); + } + RedisLockExecutor.LockHandle lockHandle = redisLockExecutor.acquire( + lockKey, + LOCK_WAIT_TIMEOUT, + LOCK_LEASE_TIMEOUT + ); + AtomicBoolean leaseValid = new AtomicBoolean(true); + ScheduledFuture renewTask = scheduleRenew(agentId, lockHandle, leaseValid); + Runnable releaseAction = () -> { + renewTask.cancel(false); + lockHandle.release(); + }; + boolean releaseAfterTransaction = false; + try { + releaseAfterTransaction = + registerTransactionRelease(lockKey, releaseAction, leaseValid); + T result = task.get(); + if (!leaseValid.get()) { + throw new IllegalStateException("Agent 绑定锁已失效,当前操作已取消"); + } + return result; + } finally { + if (!releaseAfterTransaction) { + releaseAction.run(); + } + } + } + + /** + * 注册事务完成后的锁释放动作。 + * + * @param lockKey 锁键 + * @param releaseAction 锁释放动作 + * @param leaseValid 锁租期有效标记 + * @return 已注册事务回调时返回 {@code true} + */ + private boolean registerTransactionRelease(String lockKey, + Runnable releaseAction, + AtomicBoolean leaseValid) { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + return false; + } + TransactionSynchronizationManager.bindResource(lockKey, Boolean.TRUE); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void beforeCommit(boolean readOnly) { + if (!leaseValid.get()) { + throw new IllegalStateException("Agent 绑定锁已失效,事务禁止提交"); + } + } + + @Override + public void afterCompletion(int status) { + try { + TransactionSynchronizationManager.unbindResourceIfPossible(lockKey); + } finally { + releaseAction.run(); + } + } + }); + return true; + } + + /** + * 定期续期 Agent 绑定锁,保证长事务中的早期锁不会在提交前过期。 + * + * @param agentId Agent ID + * @param lockHandle 锁句柄 + * @param leaseValid 锁租期有效标记 + * @return 续期任务 + */ + private ScheduledFuture scheduleRenew(BigInteger agentId, + RedisLockExecutor.LockHandle lockHandle, + AtomicBoolean leaseValid) { + long renewIntervalMillis = Math.max(LOCK_LEASE_TIMEOUT.toMillis() / 3L, 1000L); + return renewExecutor.scheduleWithFixedDelay(() -> { + try { + if (lockHandle.renew()) { + return; + } + leaseValid.set(false); + LOG.warn("Agent 绑定锁续期失败,agentId={}", agentId); + } catch (RuntimeException exception) { + leaseValid.set(false); + LOG.error("Agent 绑定锁续期异常,agentId={}", agentId, exception); + } + }, renewIntervalMillis, renewIntervalMillis, TimeUnit.MILLISECONDS); + } + + /** + * 关闭锁续期线程。 + */ + @PreDestroy + public void destroy() { + renewExecutor.shutdownNow(); + } + + /** + * Agent 绑定锁续期线程工厂。 + */ + private static final class AgentBindingLockRenewThreadFactory implements ThreadFactory { + + private final AtomicInteger index = new AtomicInteger(1); + + /** + * 创建守护续期线程。 + * + * @param runnable 续期任务 + * @return 续期线程 + */ + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable); + thread.setName("agent-binding-lock-renew-" + index.getAndIncrement()); + thread.setDaemon(true); + return thread; + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentOptionView.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentOptionView.java new file mode 100644 index 00000000..e1bf391b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentOptionView.java @@ -0,0 +1,24 @@ +package tech.easyflow.agent.vo; + +import java.math.BigInteger; +import java.util.Map; + +/** + * Agent 选择项,只包含聊天和筛选所需的安全字段。 + * + * @param id Agent ID + * @param name Agent 名称 + * @param description Agent 描述 + * @param avatar Agent 头像 + * @param interactionConfigJson 对话交互配置 + * @param supportImage 模型是否支持图片 + */ +public record AgentOptionView( + BigInteger id, + String name, + String description, + String avatar, + Map interactionConfigJson, + Boolean supportImage +) { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentResourceOptionsView.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentResourceOptionsView.java new file mode 100644 index 00000000..244e20f8 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentResourceOptionsView.java @@ -0,0 +1,97 @@ +package tech.easyflow.agent.vo; + +import java.math.BigInteger; +import java.util.List; + +/** + * Agent 设计器依赖资源的安全选择项集合。 + * + * @param models 模型选项 + * @param knowledges 知识库选项 + * @param workflows 工作流选项 + * @param pluginTools 插件工具选项 + * @param mcps MCP 选项 + */ +public record AgentResourceOptionsView( + List models, + List knowledges, + List workflows, + List pluginTools, + List mcps +) { + + /** + * 模型安全选择项。 + * + * @param id 模型 ID + * @param title 展示名称 + * @param contextWindowTokens 上下文窗口 + * @param maxOutputTokens 最大输出 Token + */ + public record ModelOption( + BigInteger id, + String title, + Long contextWindowTokens, + Long maxOutputTokens + ) { + } + + /** + * 通用可发布资源选择项。 + * + * @param id 资源 ID + * @param title 标题 + * @param description 描述 + * @param englishName 英文运行名 + */ + public record ResourceOption( + BigInteger id, + String title, + String description, + String englishName + ) { + } + + /** + * 插件工具安全选择项。 + * + * @param id 插件工具 ID + * @param name 工具名称 + * @param description 工具描述 + * @param englishName 英文运行名 + * @param pluginName 所属插件名称 + */ + public record PluginToolOption( + BigInteger id, + String name, + String description, + String englishName, + String pluginName + ) { + } + + /** + * MCP 安全选择项。 + * + * @param id MCP ID + * @param title MCP 标题 + * @param description MCP 描述 + * @param approvalRequired 是否默认要求执行确认 + */ + public record McpOption( + BigInteger id, + String title, + String description, + Boolean approvalRequired + ) { + } + + /** + * MCP 工具安全展示项。 + * + * @param name 工具名称 + * @param description 工具描述 + */ + public record McpToolOption(String name, String description) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java index 62f0ad76..700a9e72 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java @@ -113,6 +113,34 @@ public class AgentRuntimeCommandConsumerTest { Assert.assertEquals("cmd-expire", resultRegistry.lastSuccessCommandId); } + /** + * 验证 Agent 集群取消命令只取消目标节点的对应 Agent 运行。 + * + * @throws Exception 消息序列化异常 + */ + @Test + public void consumerShouldHandleCancelAgentCommand() throws Exception { + AgentRuntimeProperties properties = new AgentRuntimeProperties(); + properties.setInstanceId("node-a"); + RecordingAgentRunService service = new RecordingAgentRunService(); + RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry(); + AgentRuntimeCommandConsumer consumer = new AgentRuntimeCommandConsumer( + new ObjectMapper(), + properties, + new MQProperties(), + service, + resultRegistry + ); + AgentRuntimeCommandMessage command = command("cmd-cancel", "node-a"); + command.setAction(AgentRuntimeCommandAction.CANCEL_AGENT); + command.setAgentId("1001"); + + consumer.handle(List.of(message(command))); + + Assert.assertEquals("1001", service.lastCancelledAgentId); + Assert.assertEquals("cmd-cancel", resultRegistry.lastSuccessCommandId); + } + private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) { AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage(); command.setCommandId(commandId); @@ -138,6 +166,7 @@ public class AgentRuntimeCommandConsumerTest { private int expireCount; private String lastRequestId; private String lastReason; + private String lastCancelledAgentId; @Override public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) { @@ -151,6 +180,11 @@ public class AgentRuntimeCommandConsumerTest { lastRequestId = requestId; lastReason = reason; } + + @Override + public void cancelAgentLocal(String agentId) { + lastCancelledAgentId = agentId; + } } private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry { diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistryTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistryTest.java index 9152ce3d..f04923fb 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistryTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistryTest.java @@ -6,11 +6,13 @@ import org.junit.Test; import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.ValueOperations; import tech.easyflow.agent.config.AgentRuntimeProperties; import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import java.time.Duration; +import java.util.Set; /** * {@link AgentRuntimeRouteRegistry} 回归测试。 @@ -40,12 +42,49 @@ public class AgentRuntimeRouteRegistryTest { "easyflow:agent:runtime:resume-token:token-1", "request-1", Duration.ofHours(24)); } + /** + * 验证正式运行会写入 Agent 反向索引,并可解析全部 owner 节点。 + */ + @Test + public void agentRunIndexShouldTrackOwnerNodes() { + StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = Mockito.mock(ValueOperations.class); + @SuppressWarnings("unchecked") + SetOperations setOperations = Mockito.mock(SetOperations.class); + Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations); + Mockito.when(redisTemplate.opsForSet()).thenReturn(setOperations); + Mockito.when(setOperations.members("easyflow:agent:runtime:agent:1001")) + .thenReturn(Set.of("request-1", "request-2")); + Mockito.when(valueOperations.get("easyflow:agent:runtime:request:request-1")) + .thenReturn("{\"nodeId\":\"node-a\",\"bootId\":\"boot-a\",\"agentId\":\"1001\"}"); + Mockito.when(valueOperations.get("easyflow:agent:runtime:request:request-2")) + .thenReturn("{\"nodeId\":\"node-b\",\"bootId\":\"boot-b\",\"agentId\":\"1001\"}"); + Mockito.when(valueOperations.get("easyflow:agent:runtime:node:node-a")).thenReturn("boot-a"); + Mockito.when(valueOperations.get("easyflow:agent:runtime:node:node-b")).thenReturn("boot-b"); + AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties("node-a")); + + registry.registerRun("request-1", "1001"); + + Mockito.verify(valueOperations).set( + ArgumentMatchers.eq("easyflow:agent:runtime:request:request-1"), + ArgumentMatchers.contains("\"agentId\":\"1001\""), + ArgumentMatchers.eq(Duration.ofHours(24)) + ); + Mockito.verify(setOperations).add("easyflow:agent:runtime:agent:1001", "request-1"); + Mockito.verify(redisTemplate).expire("easyflow:agent:runtime:agent:1001", Duration.ofHours(24)); + Assert.assertEquals(Set.of("node-a", "node-b"), registry.findOwnerNodesByAgent("1001")); + } + /** * 验证运行结束时清理 Redis 路由。 */ @Test public void removeShouldDeleteRunAndTokenRoutes() { StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = Mockito.mock(ValueOperations.class); + Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations); AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties("node-a")); registry.removeRun("request-1"); diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java index 4b9f57d5..c603d0db 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java @@ -5,17 +5,24 @@ import com.mybatisflex.core.update.UpdateChain; import com.mybatisflex.core.util.LambdaGetter; import org.junit.Assert; import org.junit.Test; +import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer; +import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.runtime.AgentRunRegistry; +import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.ai.enums.PublishStatus; import java.lang.reflect.Proxy; import java.math.BigInteger; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -82,27 +89,50 @@ public class AgentApprovalSubjectHandlerTest { } /** - * 审批删除 Agent 前必须同步清理工具绑定和知识库绑定,避免留下孤儿数据。 + * 审批删除 Agent 时必须在同一配置锁内取消运行并清理关联数据。 */ @Test - public void beforeRemoveShouldCleanAgentBindings() { + public void removeResourceShouldCancelRunsAndCleanBindings() { AtomicInteger toolRemoveCalls = new AtomicInteger(); AtomicInteger knowledgeRemoveCalls = new AtomicInteger(); AgentToolBindingService toolBindingService = proxy(AgentToolBindingService.class, toolRemoveCalls); AgentKnowledgeBindingService knowledgeBindingService = proxy(AgentKnowledgeBindingService.class, knowledgeRemoveCalls); + AgentService agentService = mock(AgentService.class); + AgentRunRegistry runRegistry = mock(AgentRunRegistry.class); + AgentHitlPendingService pendingService = mock(AgentHitlPendingService.class); + AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class); + AgentRuntimeCommandProducer commandProducer = mock(AgentRuntimeCommandProducer.class); + when(routeRegistry.findOwnerNodesByAgent("1001")).thenReturn(Set.of("node-a", "node-b")); + when(routeRegistry.currentNodeId()).thenReturn("node-a"); AgentApprovalSubjectHandler handler = new AgentApprovalSubjectHandler( null, new ObjectMapper(), - null, + agentService, toolBindingService, knowledgeBindingService, - null + null, + immediateLockExecutor(), + runRegistry, + pendingService, + routeRegistry, + commandProducer ); - handler.beforeRemove(BigInteger.valueOf(1001)); + handler.removeResource(BigInteger.valueOf(1001)); Assert.assertEquals(1, toolRemoveCalls.get()); Assert.assertEquals(1, knowledgeRemoveCalls.get()); + verify(pendingService).cancelByAgentId( + BigInteger.valueOf(1001), + "Agent 已删除,待审批运行已取消" + ); + verify(runRegistry).cancelAgent("1001"); + verify(commandProducer).sendCancelAgent( + "node-b", + "1001", + "Agent 已删除,待审批运行已取消" + ); + verify(agentService).removeById(BigInteger.valueOf(1001)); } /** @@ -112,16 +142,36 @@ public class AgentApprovalSubjectHandlerTest { * @return Agent 审批资源处理器 */ private static AgentApprovalSubjectHandler handler(AgentService agentService) { + AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class); + when(routeRegistry.findOwnerNodesByAgent("1001")).thenReturn(Set.of()); return new AgentApprovalSubjectHandler( null, new ObjectMapper(), agentService, null, null, - null + null, + immediateLockExecutor(), + mock(AgentRunRegistry.class), + mock(AgentHitlPendingService.class), + routeRegistry, + mock(AgentRuntimeCommandProducer.class) ); } + /** + * 创建同步执行任务的 Agent 配置锁测试桩。 + * + * @return Agent 配置锁执行器 + */ + @SuppressWarnings("unchecked") + private static AgentBindingLockExecutor immediateLockExecutor() { + AgentBindingLockExecutor executor = mock(AgentBindingLockExecutor.class); + when(executor.execute(any(BigInteger.class), any(Supplier.class))) + .thenAnswer(invocation -> ((Supplier) invocation.getArgument(1)).get()); + return executor; + } + /** * 准备字段更新对象。 * diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java index e0859bfc..1d8c84df 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java @@ -1246,6 +1246,11 @@ public class AgentRunServiceDraftAndHitlTest { cancelByRequestIdCount++; } + @Override + public void cancelByAgentId(BigInteger agentId, String reason) { + // 测试桩无需处理。 + } + @Override public void deleteByChatSessionId(BigInteger chatSessionId) { // 测试桩无需处理。 diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServicePublicTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServicePublicTest.java new file mode 100644 index 00000000..60916800 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServicePublicTest.java @@ -0,0 +1,108 @@ +package tech.easyflow.agent.runtime; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.chatlog.service.ChatSessionQueryService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.lang.reflect.Field; +import java.math.BigInteger; +import java.util.List; + +/** + * {@link AgentRunService} 公共 API 租户边界测试。 + */ +public class AgentRunServicePublicTest { + + /** + * 验证 API Key 不能运行其他租户的 Agent。 + * + * @throws Exception 注入测试依赖失败 + */ + @Test + public void chatPublicShouldRejectCrossTenantAgent() throws Exception { + BigInteger agentId = BigInteger.valueOf(1001); + Agent agent = new Agent(); + agent.setId(agentId); + agent.setTenantId(BigInteger.valueOf(2001)); + AgentService agentService = Mockito.mock(AgentService.class); + Mockito.when(agentService.getById(agentId)).thenReturn(agent); + AgentRunService service = new AgentRunService(); + setField(service, "agentService", agentService); + AgentChatRequest request = new AgentChatRequest(); + request.setAgentId(agentId); + request.setPrompt("hello"); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(3001)); + account.setTenantId(BigInteger.valueOf(2002)); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.chatPublic(request, account) + ); + + Assert.assertEquals("Agent 不存在或不可用", exception.getMessage()); + Mockito.verify(agentService, Mockito.never()).getPublishedView(agentId); + } + + /** + * 公共 API 没有恢复入口时必须拒绝包含 HITL 工具的 Agent。 + * + * @throws Exception 注入测试依赖失败 + */ + @Test + public void chatPublicShouldRejectHitlTool() throws Exception { + BigInteger agentId = BigInteger.valueOf(1001); + BigInteger tenantId = BigInteger.valueOf(2001); + Agent liveAgent = new Agent(); + liveAgent.setId(agentId); + liveAgent.setTenantId(tenantId); + liveAgent.setStatus(1); + liveAgent.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + AgentToolBinding binding = new AgentToolBinding(); + binding.setEnabled(true); + binding.setHitlEnabled(true); + Agent publishedAgent = new Agent(); + publishedAgent.setId(agentId); + publishedAgent.setToolBindings(List.of(binding)); + AgentService agentService = Mockito.mock(AgentService.class); + Mockito.when(agentService.getById(agentId)).thenReturn(liveAgent); + Mockito.when(agentService.getPublishedView(agentId)).thenReturn(publishedAgent); + AgentRunService service = new AgentRunService(); + setField(service, "agentService", agentService); + setField(service, "chatSessionQueryService", Mockito.mock(ChatSessionQueryService.class)); + AgentChatRequest request = new AgentChatRequest(); + request.setAgentId(agentId); + request.setPrompt("hello"); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(3001)); + account.setTenantId(tenantId); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.chatPublic(request, account) + ); + + Assert.assertEquals("公共 Agent API 暂不支持需要执行确认的工具", exception.getMessage()); + } + + /** + * 写入被测对象私有字段。 + * + * @param target 被测对象 + * @param fieldName 字段名 + * @param value 字段值 + * @throws Exception 字段不存在或不可访问 + */ + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunStartGuardTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunStartGuardTest.java new file mode 100644 index 00000000..b81ad093 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunStartGuardTest.java @@ -0,0 +1,71 @@ +package tech.easyflow.agent.runtime; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Test; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link AgentRunStartGuard} 单元测试。 + */ +public class AgentRunStartGuardTest { + + /** + * 已启用且已发布的 Agent 允许启动正式运行。 + */ + @Test + public void publishedAgentShouldBeRunnable() { + AgentService agentService = mock(AgentService.class); + Agent agent = agent(1, PublishStatus.PUBLISHED); + when(agentService.getOne(any(QueryWrapper.class))).thenReturn(agent); + + new AgentRunStartGuard(agentService).assertRunnable(BigInteger.valueOf(1001)); + } + + /** + * 已下线 Agent 必须拒绝新运行。 + */ + @Test(expected = BusinessException.class) + public void offlineAgentShouldBeRejected() { + AgentService agentService = mock(AgentService.class); + when(agentService.getOne(any(QueryWrapper.class))) + .thenReturn(agent(1, PublishStatus.OFFLINE)); + + new AgentRunStartGuard(agentService).assertRunnable(BigInteger.valueOf(1001)); + } + + /** + * 已禁用 Agent 必须拒绝新运行。 + */ + @Test(expected = BusinessException.class) + public void disabledAgentShouldBeRejected() { + AgentService agentService = mock(AgentService.class); + when(agentService.getOne(any(QueryWrapper.class))) + .thenReturn(agent(0, PublishStatus.PUBLISHED)); + + new AgentRunStartGuard(agentService).assertRunnable(BigInteger.valueOf(1001)); + } + + /** + * 创建测试 Agent。 + * + * @param status 启用状态 + * @param publishStatus 发布状态 + * @return 测试 Agent + */ + private static Agent agent(Integer status, PublishStatus publishStatus) { + Agent agent = new Agent(); + agent.setId(BigInteger.valueOf(1001)); + agent.setStatus(status); + agent.setPublishStatus(publishStatus.getCode()); + return agent; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingValidationLockTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingValidationLockTest.java new file mode 100644 index 00000000..96e8351e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingValidationLockTest.java @@ -0,0 +1,217 @@ +package tech.easyflow.agent.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.service.AgentCategoryService; +import tech.easyflow.agent.service.AgentDependencyAccessService; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.Locale; + +/** + * Agent 绑定资源状态锁测试。 + */ +public class AgentBindingValidationLockTest { + + /** + * 验证工作流绑定使用锁定读校验最新发布状态。 + * + * @throws Exception 反射调用失败 + */ + @Test + public void workflowBindingShouldValidateWithForUpdate() { + Workflow workflow = new Workflow(); + workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + workflow.setTenantId(BigInteger.ONE); + WorkflowService workflowService = Mockito.mock(WorkflowService.class); + Mockito.when(workflowService.getOne(Mockito.any(QueryWrapper.class))).thenReturn(workflow); + AgentDependencyAccessService service = createService( + workflowService, + Mockito.mock(PluginItemService.class), + Mockito.mock(PluginMapper.class), + Mockito.mock(PluginVisibilityService.class), + Mockito.mock(McpService.class), + Mockito.mock(DocumentCollectionService.class), + Mockito.mock(ResourceAccessService.class) + ); + + service.requireWorkflow(agent(), BigInteger.valueOf(1001)); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + Mockito.verify(workflowService).getOne(queryCaptor.capture()); + Assert.assertTrue(queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")); + } + + /** + * 验证知识库绑定使用锁定读校验最新发布状态。 + * + * @throws Exception 反射调用失败 + */ + @Test + public void knowledgeBindingShouldValidateWithForUpdate() { + DocumentCollection knowledge = new DocumentCollection(); + knowledge.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + knowledge.setTenantId(BigInteger.ONE); + DocumentCollectionService knowledgeService = Mockito.mock(DocumentCollectionService.class); + Mockito.when(knowledgeService.getOne(Mockito.any(QueryWrapper.class))).thenReturn(knowledge); + AgentDependencyAccessService service = createService( + Mockito.mock(WorkflowService.class), + Mockito.mock(PluginItemService.class), + Mockito.mock(PluginMapper.class), + Mockito.mock(PluginVisibilityService.class), + Mockito.mock(McpService.class), + knowledgeService, + Mockito.mock(ResourceAccessService.class) + ); + + service.requireKnowledge(agent(), BigInteger.valueOf(2001)); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + Mockito.verify(knowledgeService).getOne(queryCaptor.capture()); + Assert.assertTrue(queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")); + } + + /** + * 验证插件绑定使用锁定读校验最新启用状态。 + * + * @throws Exception 反射调用失败 + */ + @Test + public void pluginBindingShouldValidateWithForUpdate() { + BigInteger pluginId = BigInteger.valueOf(30); + BigInteger pluginItemId = BigInteger.valueOf(3001); + PluginItem pluginItem = pluginItem(pluginId); + PluginItemService pluginItemService = Mockito.mock(PluginItemService.class); + Mockito.when(pluginItemService.getById(pluginItemId)).thenReturn(pluginItem); + Mockito.when(pluginItemService.getOne(Mockito.any(QueryWrapper.class))).thenReturn(pluginItem); + Plugin plugin = new Plugin(); + plugin.setId(pluginId); + plugin.setTenantId(1L); + PluginMapper pluginMapper = Mockito.mock(PluginMapper.class); + Mockito.when(pluginMapper.selectOneByQuery(Mockito.any(QueryWrapper.class))).thenReturn(plugin); + AgentDependencyAccessService service = createService( + Mockito.mock(WorkflowService.class), + pluginItemService, + pluginMapper, + Mockito.mock(PluginVisibilityService.class), + Mockito.mock(McpService.class), + Mockito.mock(DocumentCollectionService.class), + Mockito.mock(ResourceAccessService.class) + ); + + service.requirePluginItem(agent(), pluginItemId); + + ArgumentCaptor pluginQueryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + Mockito.verify(pluginMapper).selectOneByQuery(pluginQueryCaptor.capture()); + Assert.assertTrue(pluginQueryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")); + ArgumentCaptor itemQueryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + Mockito.verify(pluginItemService).getOne(itemQueryCaptor.capture()); + Assert.assertTrue(itemQueryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")); + } + + /** + * 验证 MCP 绑定使用锁定读校验最新启用状态。 + * + * @throws Exception 反射调用失败 + */ + @Test + public void mcpBindingShouldValidateWithForUpdate() { + Mcp mcp = new Mcp(); + mcp.setStatus(true); + mcp.setTenantId(BigInteger.ONE); + McpService mcpService = Mockito.mock(McpService.class); + Mockito.when(mcpService.getOne(Mockito.any(QueryWrapper.class))).thenReturn(mcp); + AgentDependencyAccessService service = createService( + Mockito.mock(WorkflowService.class), + Mockito.mock(PluginItemService.class), + Mockito.mock(PluginMapper.class), + Mockito.mock(PluginVisibilityService.class), + mcpService, + Mockito.mock(DocumentCollectionService.class), + Mockito.mock(ResourceAccessService.class) + ); + + service.requireMcp(agent(), BigInteger.valueOf(4001)); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + Mockito.verify(mcpService).getOne(queryCaptor.capture()); + Assert.assertTrue(queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")); + } + + /** + * 创建依赖资源校验服务。 + * + * @param workflowService 工作流服务 + * @param pluginItemService 插件工具服务 + * @param pluginMapper 插件 Mapper + * @param pluginVisibilityService 插件可见性服务 + * @param mcpService MCP 服务 + * @param documentCollectionService 知识库服务 + * @param resourceAccessService 资源权限服务 + * @return 依赖资源校验服务 + */ + private AgentDependencyAccessService createService( + WorkflowService workflowService, + PluginItemService pluginItemService, + PluginMapper pluginMapper, + PluginVisibilityService pluginVisibilityService, + McpService mcpService, + DocumentCollectionService documentCollectionService, + ResourceAccessService resourceAccessService) { + return new AgentDependencyAccessService( + Mockito.mock(ModelService.class), + workflowService, + pluginItemService, + pluginMapper, + pluginVisibilityService, + mcpService, + documentCollectionService, + Mockito.mock(AgentCategoryService.class), + Mockito.mock(CategoryPermissionService.class), + resourceAccessService + ); + } + + /** + * 创建同租户 Agent。 + * + * @return Agent + */ + private Agent agent() { + Agent agent = new Agent(); + agent.setTenantId(BigInteger.ONE); + return agent; + } + + /** + * 创建启用的插件工具。 + * + * @param pluginId 插件 ID + * @return 插件工具 + */ + private PluginItem pluginItem(BigInteger pluginId) { + PluginItem pluginItem = new PluginItem(); + pluginItem.setPluginId(pluginId); + pluginItem.setStatus(1); + return pluginItem; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImplTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImplTest.java new file mode 100644 index 00000000..4505c082 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImplTest.java @@ -0,0 +1,118 @@ +package tech.easyflow.agent.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.enums.AgentToolType; +import tech.easyflow.agent.service.AgentKnowledgeBindingService; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.agent.support.AgentBindingLockExecutor; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Map; +import java.util.List; +import java.util.function.Supplier; + +/** + * {@link AgentResourceBindingProviderImpl} 批量解绑锁顺序测试。 + */ +public class AgentResourceBindingProviderImplTest { + + /** + * 验证工作流批量解绑按 Agent ID 升序获取绑定锁。 + */ + @Test + public void unbindWorkflowShouldAcquireAgentLocksInAscendingOrder() { + AgentService agentService = Mockito.mock(AgentService.class); + AgentToolBindingService toolBindingService = Mockito.mock(AgentToolBindingService.class); + AgentKnowledgeBindingService knowledgeBindingService = + Mockito.mock(AgentKnowledgeBindingService.class); + AgentBindingLockExecutor lockExecutor = Mockito.mock(AgentBindingLockExecutor.class); + Mockito.when(toolBindingService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of( + workflowBinding(3), + workflowBinding(1), + workflowBinding(2), + workflowBinding(1) + )); + List lockOrder = new ArrayList<>(); + Mockito.doAnswer(invocation -> { + lockOrder.add(invocation.getArgument(0)); + Supplier task = invocation.getArgument(1); + return task.get(); + }).when(lockExecutor).execute(Mockito.any(BigInteger.class), Mockito.any()); + AgentResourceBindingProviderImpl provider = new AgentResourceBindingProviderImpl( + agentService, + toolBindingService, + knowledgeBindingService, + lockExecutor + ); + + provider.unbindWorkflow(BigInteger.TEN); + + Assert.assertEquals( + List.of(BigInteger.ONE, BigInteger.TWO, BigInteger.valueOf(3)), + lockOrder + ); + } + + /** + * 已发布快照中的引用必须参与资源删除影响检查。 + */ + @Test + public void listAgentsByWorkflowIdShouldIncludeSnapshotOnlyReference() { + AgentService agentService = Mockito.mock(AgentService.class); + AgentToolBindingService toolBindingService = Mockito.mock(AgentToolBindingService.class); + AgentKnowledgeBindingService knowledgeBindingService = + Mockito.mock(AgentKnowledgeBindingService.class); + AgentBindingLockExecutor lockExecutor = Mockito.mock(AgentBindingLockExecutor.class); + BigInteger agentId = BigInteger.valueOf(7); + Agent agent = new Agent(); + agent.setId(agentId); + agent.setName("已发布智能体"); + agent.setPublishedSnapshotJson(Map.of( + "toolBindings", + List.of(Map.of( + "toolType", AgentToolType.WORKFLOW.name(), + "targetId", BigInteger.TEN + )) + )); + Mockito.when(toolBindingService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of()); + Mockito.when(agentService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of(agent)); + Mockito.when(agentService.listByIds(Mockito.anyCollection())) + .thenReturn(List.of(agent)); + AgentResourceBindingProviderImpl provider = new AgentResourceBindingProviderImpl( + agentService, + toolBindingService, + knowledgeBindingService, + lockExecutor + ); + + var result = provider.listAgentsByWorkflowId(BigInteger.TEN); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals(agentId, result.get(0).getId()); + Assert.assertEquals("已发布智能体", result.get(0).getTitle()); + } + + /** + * 构造工作流工具绑定。 + * + * @param agentId Agent ID + * @return 工具绑定 + */ + private static AgentToolBinding workflowBinding(long agentId) { + AgentToolBinding binding = new AgentToolBinding(); + binding.setAgentId(BigInteger.valueOf(agentId)); + binding.setToolType(AgentToolType.WORKFLOW.name()); + binding.setTargetId(BigInteger.TEN); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/support/AgentBindingLockExecutorTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/support/AgentBindingLockExecutorTest.java new file mode 100644 index 00000000..47540f08 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/support/AgentBindingLockExecutorTest.java @@ -0,0 +1,172 @@ +package tech.easyflow.agent.support; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import tech.easyflow.common.cache.RedisLockExecutor; + +import java.math.BigInteger; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * {@link AgentBindingLockExecutor} 事务锁生命周期测试。 + */ +public class AgentBindingLockExecutorTest { + + /** + * 验证活动事务内的绑定锁延迟到事务完成后释放。 + */ + @Test + public void executeShouldReleaseLockAfterTransactionCompletion() { + RedisLockExecutor redisLockExecutor = Mockito.mock(RedisLockExecutor.class); + RedisLockExecutor.LockHandle lockHandle = Mockito.mock(RedisLockExecutor.LockHandle.class); + ScheduledExecutorService renewExecutor = Mockito.mock(ScheduledExecutorService.class); + ScheduledFuture renewTask = Mockito.mock(ScheduledFuture.class); + Mockito.when(redisLockExecutor.acquire( + Mockito.anyString(), + Mockito.any(Duration.class), + Mockito.any(Duration.class) + )).thenReturn(lockHandle); + Mockito.doReturn(renewTask).when(renewExecutor).scheduleWithFixedDelay( + Mockito.any(Runnable.class), + Mockito.anyLong(), + Mockito.anyLong(), + Mockito.eq(TimeUnit.MILLISECONDS) + ); + Mockito.when(lockHandle.renew()).thenReturn(true); + AgentBindingLockExecutor executor = new AgentBindingLockExecutor(redisLockExecutor, renewExecutor); + TransactionSynchronizationManager.initSynchronization(); + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + String result = executor.execute(BigInteger.ONE, () -> "ok"); + + Assert.assertEquals("ok", result); + Mockito.verify(lockHandle, Mockito.never()).release(); + Mockito.verify(renewTask, Mockito.never()).cancel(false); + ArgumentCaptor renewCaptor = ArgumentCaptor.forClass(Runnable.class); + Mockito.verify(renewExecutor).scheduleWithFixedDelay( + renewCaptor.capture(), + Mockito.anyLong(), + Mockito.anyLong(), + Mockito.eq(TimeUnit.MILLISECONDS) + ); + renewCaptor.getValue().run(); + Mockito.verify(lockHandle).renew(); + List synchronizations = + TransactionSynchronizationManager.getSynchronizations(); + Assert.assertEquals(1, synchronizations.size()); + synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_COMMITTED); + Mockito.verify(renewTask).cancel(false); + Mockito.verify(lockHandle).release(); + } finally { + TransactionSynchronizationManager.setActualTransactionActive(false); + TransactionSynchronizationManager.clearSynchronization(); + executor.destroy(); + } + Mockito.verify(renewExecutor).shutdownNow(); + } + + /** + * 同一事务内重复进入相同 Agent 锁时只能获取一次 Redis 锁。 + */ + @Test + public void executeShouldReuseSameAgentLockWithinTransaction() { + RedisLockExecutor redisLockExecutor = Mockito.mock(RedisLockExecutor.class); + RedisLockExecutor.LockHandle lockHandle = Mockito.mock(RedisLockExecutor.LockHandle.class); + ScheduledExecutorService renewExecutor = Mockito.mock(ScheduledExecutorService.class); + ScheduledFuture renewTask = Mockito.mock(ScheduledFuture.class); + Mockito.when(redisLockExecutor.acquire( + Mockito.anyString(), + Mockito.any(Duration.class), + Mockito.any(Duration.class) + )).thenReturn(lockHandle); + Mockito.doReturn(renewTask).when(renewExecutor).scheduleWithFixedDelay( + Mockito.any(Runnable.class), + Mockito.anyLong(), + Mockito.anyLong(), + Mockito.eq(TimeUnit.MILLISECONDS) + ); + AgentBindingLockExecutor executor = new AgentBindingLockExecutor(redisLockExecutor, renewExecutor); + TransactionSynchronizationManager.initSynchronization(); + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + String result = executor.execute( + BigInteger.ONE, + () -> executor.execute(BigInteger.ONE, () -> "nested") + ); + + Assert.assertEquals("nested", result); + Mockito.verify(redisLockExecutor, Mockito.times(1)).acquire( + Mockito.anyString(), + Mockito.any(Duration.class), + Mockito.any(Duration.class) + ); + List synchronizations = + TransactionSynchronizationManager.getSynchronizations(); + Assert.assertEquals(1, synchronizations.size()); + synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_COMMITTED); + } finally { + TransactionSynchronizationManager.unbindResourceIfPossible( + "easyflow:lock:agent:binding:" + BigInteger.ONE + ); + TransactionSynchronizationManager.setActualTransactionActive(false); + TransactionSynchronizationManager.clearSynchronization(); + executor.destroy(); + } + } + + /** + * 锁续期失败后事务提交必须被阻止。 + */ + @Test + public void renewalFailureShouldPreventTransactionCommit() { + RedisLockExecutor redisLockExecutor = Mockito.mock(RedisLockExecutor.class); + RedisLockExecutor.LockHandle lockHandle = Mockito.mock(RedisLockExecutor.LockHandle.class); + ScheduledExecutorService renewExecutor = Mockito.mock(ScheduledExecutorService.class); + ScheduledFuture renewTask = Mockito.mock(ScheduledFuture.class); + Mockito.when(redisLockExecutor.acquire( + Mockito.anyString(), + Mockito.any(Duration.class), + Mockito.any(Duration.class) + )).thenReturn(lockHandle); + ArgumentCaptor renewCaptor = ArgumentCaptor.forClass(Runnable.class); + Mockito.doReturn(renewTask).when(renewExecutor).scheduleWithFixedDelay( + renewCaptor.capture(), + Mockito.anyLong(), + Mockito.anyLong(), + Mockito.eq(TimeUnit.MILLISECONDS) + ); + Mockito.when(lockHandle.renew()).thenReturn(false); + AgentBindingLockExecutor executor = new AgentBindingLockExecutor(redisLockExecutor, renewExecutor); + TransactionSynchronizationManager.initSynchronization(); + TransactionSynchronizationManager.setActualTransactionActive(true); + try { + executor.execute(BigInteger.ONE, () -> "ok"); + renewCaptor.getValue().run(); + TransactionSynchronization synchronization = + TransactionSynchronizationManager.getSynchronizations().get(0); + + IllegalStateException exception = Assert.assertThrows( + IllegalStateException.class, + () -> synchronization.beforeCommit(false) + ); + + Assert.assertEquals("Agent 绑定锁已失效,事务禁止提交", exception.getMessage()); + synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); + } finally { + TransactionSynchronizationManager.unbindResourceIfPossible( + "easyflow:lock:agent:binding:" + BigInteger.ONE + ); + TransactionSynchronizationManager.setActualTransactionActive(false); + TransactionSynchronizationManager.clearSynchronization(); + executor.destroy(); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgeApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgeApprovalSubjectHandler.java index 9dd39b95..77ab071a 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgeApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgeApprovalSubjectHandler.java @@ -1,14 +1,11 @@ package tech.easyflow.ai.publish; import com.fasterxml.jackson.databind.ObjectMapper; -import com.mybatisflex.core.query.QueryWrapper; import org.springframework.stereotype.Component; -import tech.easyflow.ai.entity.BotDocumentCollection; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollectionCategory; import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.enums.PublishStatus; -import tech.easyflow.ai.service.BotDocumentCollectionService; import tech.easyflow.ai.service.DocumentCollectionCategoryService; import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.ModelService; @@ -37,7 +34,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle private final DocumentCollectionService documentCollectionService; private final ResourceAccessService resourceAccessService; - private final BotDocumentCollectionService botDocumentCollectionService; private final ModelService modelService; private final DocumentCollectionCategoryService documentCollectionCategoryService; private final SysDeptService sysDeptService; @@ -46,7 +42,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle public KnowledgeApprovalSubjectHandler(DocumentCollectionService documentCollectionService, ResourceAccessService resourceAccessService, ApprovalInstanceService approvalInstanceService, - BotDocumentCollectionService botDocumentCollectionService, ModelService modelService, DocumentCollectionCategoryService documentCollectionCategoryService, SysDeptService sysDeptService, @@ -55,7 +50,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle super(approvalInstanceService, objectMapper); this.documentCollectionService = documentCollectionService; this.resourceAccessService = resourceAccessService; - this.botDocumentCollectionService = botDocumentCollectionService; this.modelService = modelService; this.documentCollectionCategoryService = documentCollectionCategoryService; this.sysDeptService = sysDeptService; @@ -200,26 +194,22 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle if (!impact.isCanProceed()) { throw new BusinessException(buildWorkflowUsageBlockMessage(impact)); } - if (impact.isHasBotBindings()) { - snapshot.put("botBindings", impact.getBotBindings()); + if (impact.isHasAgentBindings()) { + snapshot.put("agentBindings", impact.getAgentBindings()); } } @Override protected void validateDelete(DocumentCollection resource, PublishStatus currentStatus) { - if (hasBotBinding(resource.getId())) { - throw new BusinessException("此知识库还关联着bot,请先取消关联!"); + OfflineImpactCheckVo impact = resourceOfflineImpactService.checkKnowledgeImpact(resource.getId()); + if (impact.isHasAgentBindings()) { + throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除"); } } @Override protected void afterOffline(BigInteger resourceId) { - resourceOfflineImpactService.unbindKnowledgeFromBots(resourceId); - } - - private boolean hasBotBinding(BigInteger knowledgeId) { - QueryWrapper queryWrapper = QueryWrapper.create().eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId); - return botDocumentCollectionService.exists(queryWrapper); + resourceOfflineImpactService.unbindKnowledgeFromAgents(resourceId); } private String buildWorkflowUsageBlockMessage(OfflineImpactCheckVo impact) { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java index efcdb947..7e2b45e5 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java @@ -1,14 +1,11 @@ package tech.easyflow.ai.publish; import com.fasterxml.jackson.databind.ObjectMapper; -import com.mybatisflex.core.query.QueryWrapper; import org.springframework.stereotype.Component; -import tech.easyflow.ai.entity.BotWorkflow; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; -import tech.easyflow.ai.service.BotWorkflowService; import tech.easyflow.ai.service.ResourceOfflineImpactService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.vo.OfflineImpactCheckVo; @@ -31,7 +28,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH private final WorkflowService workflowService; private final ResourceAccessService resourceAccessService; - private final BotWorkflowService botWorkflowService; private final ResourceOfflineImpactService resourceOfflineImpactService; private final WorkflowPluginBindingService workflowPluginBindingService; private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; @@ -39,7 +35,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH public WorkflowApprovalSubjectHandler(WorkflowService workflowService, ResourceAccessService resourceAccessService, ApprovalInstanceService approvalInstanceService, - BotWorkflowService botWorkflowService, ResourceOfflineImpactService resourceOfflineImpactService, WorkflowPluginBindingService workflowPluginBindingService, WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, @@ -47,7 +42,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH super(approvalInstanceService, objectMapper); this.workflowService = workflowService; this.resourceAccessService = resourceAccessService; - this.botWorkflowService = botWorkflowService; this.resourceOfflineImpactService = resourceOfflineImpactService; this.workflowPluginBindingService = workflowPluginBindingService; this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; @@ -178,8 +172,8 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH @Override protected void enrichOfflineSnapshot(Workflow resource, Map snapshot) { OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId()); - if (impact.isHasBotBindings()) { - snapshot.put("botBindings", impact.getBotBindings()); + if (impact.isHasAgentBindings()) { + snapshot.put("agentBindings", impact.getAgentBindings()); } if (impact.isHasPluginBindings()) { snapshot.put("pluginBindings", impact.getPluginBindings()); @@ -188,18 +182,14 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH @Override protected void validateDelete(Workflow resource, PublishStatus currentStatus) { - if (hasBotBinding(resource.getId())) { - throw new BusinessException("此工作流还关联有bot,请先取消关联后再删除!"); + OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId()); + if (impact.isHasAgentBindings()) { + throw new BusinessException("此工作流仍被智能体使用,请先取消绑定后再删除"); } } @Override protected void afterOffline(BigInteger resourceId) { - resourceOfflineImpactService.unbindWorkflowFromBots(resourceId); - } - - private boolean hasBotBinding(BigInteger workflowId) { - QueryWrapper queryWrapper = QueryWrapper.create().eq(BotWorkflow::getWorkflowId, workflowId); - return botWorkflowService.exists(queryWrapper); + resourceOfflineImpactService.unbindWorkflowFromAgents(resourceId); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceBindingProvider.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceBindingProvider.java new file mode 100644 index 00000000..07970680 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceBindingProvider.java @@ -0,0 +1,68 @@ +package tech.easyflow.ai.service; + +import tech.easyflow.ai.vo.OfflineImpactBindingVo; + +import java.math.BigInteger; +import java.util.List; + +/** + * Agent 对共享 AI 资源的绑定查询与解绑契约。 + * + *

契约定义在 AI 模块中,由 Agent 模块实现,避免共享资源生命周期反向依赖 Agent 实体。

+ */ +public interface AgentResourceBindingProvider { + + /** + * 查询绑定指定工作流的 Agent。 + * + * @param workflowId 工作流 ID + * @return Agent 摘要列表 + */ + List listAgentsByWorkflowId(BigInteger workflowId); + + /** + * 查询绑定指定知识库的 Agent。 + * + * @param knowledgeId 知识库 ID + * @return Agent 摘要列表 + */ + List listAgentsByKnowledgeId(BigInteger knowledgeId); + + /** + * 查询绑定指定插件工具的 Agent。 + * + * @param pluginItemId 插件工具 ID + * @return Agent 摘要列表 + */ + List listAgentsByPluginItemId(BigInteger pluginItemId); + + /** + * 查询绑定指定 MCP 的 Agent。 + * + * @param mcpId MCP ID + * @return Agent 摘要列表 + */ + List listAgentsByMcpId(BigInteger mcpId); + + /** + * 查询使用指定模型的 Agent。 + * + * @param modelId 模型 ID + * @return Agent 摘要列表 + */ + List listAgentsByModelId(BigInteger modelId); + + /** + * 解绑指定工作流及 Agent 发布快照中的对应绑定。 + * + * @param workflowId 工作流 ID + */ + void unbindWorkflow(BigInteger workflowId); + + /** + * 解绑指定知识库及 Agent 发布快照中的对应绑定。 + * + * @param knowledgeId 知识库 ID + */ + void unbindKnowledge(BigInteger knowledgeId); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceReferenceService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceReferenceService.java new file mode 100644 index 00000000..e9cf20e5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceReferenceService.java @@ -0,0 +1,64 @@ +package tech.easyflow.ai.service; + +import tech.easyflow.ai.vo.OfflineImpactBindingVo; + +import java.math.BigInteger; +import java.util.Collection; +import java.util.List; + +/** + * Agent 对共享 AI 资源的统一引用查询服务。 + */ +public interface AgentResourceReferenceService { + + /** + * 查询引用指定工作流的 Agent。 + * + * @param workflowId 工作流 ID + * @return Agent 摘要列表 + */ + List listAgentsByWorkflowId(BigInteger workflowId); + + /** + * 查询引用指定知识库的 Agent。 + * + * @param knowledgeId 知识库 ID + * @return Agent 摘要列表 + */ + List listAgentsByKnowledgeId(BigInteger knowledgeId); + + /** + * 校验插件工具没有被 Agent 草稿或发布快照引用。 + * + * @param pluginItemIds 插件工具 ID 集合 + */ + void assertPluginItemsUnused(Collection pluginItemIds); + + /** + * 校验 MCP 没有被 Agent 草稿或发布快照引用。 + * + * @param mcpId MCP ID + */ + void assertMcpUnused(BigInteger mcpId); + + /** + * 校验模型没有被 Agent 草稿或发布快照引用。 + * + * @param modelIds 模型 ID 集合 + */ + void assertModelsUnused(Collection modelIds); + + /** + * 从 Agent 草稿绑定和发布快照中解绑工作流。 + * + * @param workflowId 工作流 ID + */ + void unbindWorkflow(BigInteger workflowId); + + /** + * 从 Agent 草稿绑定和发布快照中解绑知识库。 + * + * @param knowledgeId 知识库 ID + */ + void unbindKnowledge(BigInteger knowledgeId); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AiResourceApprovalStateService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AiResourceApprovalStateService.java index 38a6d69c..24ec2127 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AiResourceApprovalStateService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AiResourceApprovalStateService.java @@ -1,6 +1,5 @@ package tech.easyflow.ai.service; -import tech.easyflow.ai.entity.Bot; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Workflow; @@ -42,17 +41,4 @@ public interface AiResourceApprovalStateService { */ void fillKnowledgeApprovalState(Collection collections); - /** - * 填充聊天助手审批展示状态。 - * - * @param bot 聊天助手 - */ - void fillBotApprovalState(Bot bot); - - /** - * 批量填充聊天助手审批展示状态。 - * - * @param bots 聊天助手集合 - */ - void fillBotApprovalState(Collection bots); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotApprovalStateService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotApprovalStateService.java new file mode 100644 index 00000000..0df89bbd --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotApprovalStateService.java @@ -0,0 +1,25 @@ +package tech.easyflow.ai.service; + +import tech.easyflow.ai.entity.Bot; + +import java.util.Collection; + +/** + * 旧 Bot 审批展示状态派生服务。 + */ +public interface BotApprovalStateService { + + /** + * 填充 Bot 审批展示状态。 + * + * @param bot Bot + */ + void fillApprovalState(Bot bot); + + /** + * 批量填充 Bot 审批展示状态。 + * + * @param bots Bot 集合 + */ + void fillApprovalState(Collection bots); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotPluginService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotPluginService.java index b20ed495..9a6f425f 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotPluginService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotPluginService.java @@ -3,6 +3,7 @@ package tech.easyflow.ai.service; import com.mybatisflex.core.service.IService; import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; import java.math.BigInteger; import java.util.List; @@ -22,4 +23,21 @@ public interface BotPluginService extends IService { List getBotPluginToolIds(String botId); void saveBotAndPluginTool(BigInteger botId, BigInteger[] pluginToolIds); + + /** + * 查询插件工具,并标记指定 Bot 已绑定的工具。 + * + * @param pluginId 插件 ID + * @param botId Bot ID + * @return 插件工具列表 + */ + List searchPluginTools(BigInteger pluginId, BigInteger botId); + + /** + * 查询指定 Bot 已绑定的插件工具。 + * + * @param botId Bot ID + * @return 已绑定插件工具列表 + */ + List getPluginTools(BigInteger botId); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/McpService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/McpService.java index 87010309..fa01b339 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/McpService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/McpService.java @@ -4,11 +4,11 @@ import com.easyagents.core.model.chat.tool.Tool; import com.easyagents.mcp.client.McpEnvironmentCheckResult; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.service.IService; -import tech.easyflow.ai.entity.BotMcp; import tech.easyflow.ai.entity.Mcp; import tech.easyflow.common.domain.Result; import java.io.Serializable; +import java.math.BigInteger; /** * 服务层。 @@ -24,7 +24,14 @@ public interface McpService extends IService { void removeMcp(Serializable id); - Tool toFunction(BotMcp botMcp); + /** + * 将指定 MCP 工具转换为运行时工具。 + * + * @param mcpId MCP ID + * @param mcpToolName MCP 工具名称 + * @return 运行时工具,不存在时返回 {@code null} + */ + Tool toFunction(BigInteger mcpId, String mcpToolName); Result> pageMcp(Result> page); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java index 7ce65b60..7159c7ce 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/PluginItemService.java @@ -21,10 +21,6 @@ public interface PluginItemService extends IService { boolean updatePlugin(PluginItem pluginItem); - List searchPluginToolByPluginId(BigInteger pluginId, BigInteger botId); - - List getPluginToolList(BigInteger botId); - Result pluginToolTest(String inputData, BigInteger pluginToolId); List getByPluginId(String id); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ResourceOfflineImpactService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ResourceOfflineImpactService.java index 587d488a..3da48303 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ResourceOfflineImpactService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ResourceOfflineImpactService.java @@ -26,16 +26,16 @@ public interface ResourceOfflineImpactService { OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId); /** - * 工作流下线后,静默解绑所有关联 Bot。 + * 工作流下线后,静默解绑所有关联 Agent。 * * @param workflowId 工作流 ID */ - void unbindWorkflowFromBots(BigInteger workflowId); + void unbindWorkflowFromAgents(BigInteger workflowId); /** - * 知识库下线后,静默解绑所有关联 Bot。 + * 知识库下线后,静默解绑所有关联 Agent。 * * @param knowledgeId 知识库 ID */ - void unbindKnowledgeFromBots(BigInteger knowledgeId); + void unbindKnowledgeFromAgents(BigInteger knowledgeId); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AgentResourceReferenceServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AgentResourceReferenceServiceImpl.java new file mode 100644 index 00000000..03242904 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AgentResourceReferenceServiceImpl.java @@ -0,0 +1,158 @@ +package tech.easyflow.ai.service.impl; + +import org.springframework.stereotype.Service; +import tech.easyflow.ai.service.AgentResourceBindingProvider; +import tech.easyflow.ai.service.AgentResourceReferenceService; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Agent 资源引用查询服务实现。 + */ +@Service +public class AgentResourceReferenceServiceImpl implements AgentResourceReferenceService { + + private final List providers; + + /** + * 创建 Agent 资源引用查询服务。 + * + * @param providers Agent 资源绑定提供者 + */ + public AgentResourceReferenceServiceImpl(List providers) { + this.providers = providers == null ? List.of() : List.copyOf(providers); + } + + /** + * {@inheritDoc} + */ + @Override + public List listAgentsByWorkflowId(BigInteger workflowId) { + return merge(provider -> provider.listAgentsByWorkflowId(workflowId)); + } + + /** + * {@inheritDoc} + */ + @Override + public List listAgentsByKnowledgeId(BigInteger knowledgeId) { + return merge(provider -> provider.listAgentsByKnowledgeId(knowledgeId)); + } + + /** + * {@inheritDoc} + */ + @Override + public void assertPluginItemsUnused(Collection pluginItemIds) { + if (pluginItemIds == null || pluginItemIds.isEmpty()) { + return; + } + for (BigInteger pluginItemId : pluginItemIds) { + assertUnused( + merge(provider -> provider.listAgentsByPluginItemId(pluginItemId)), + "插件工具" + ); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void assertMcpUnused(BigInteger mcpId) { + assertUnused(merge(provider -> provider.listAgentsByMcpId(mcpId)), "MCP"); + } + + /** + * {@inheritDoc} + */ + @Override + public void assertModelsUnused(Collection modelIds) { + if (modelIds == null || modelIds.isEmpty()) { + return; + } + for (BigInteger modelId : modelIds) { + assertUnused(merge(provider -> provider.listAgentsByModelId(modelId)), "模型"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void unbindWorkflow(BigInteger workflowId) { + for (AgentResourceBindingProvider provider : requireProviders()) { + provider.unbindWorkflow(workflowId); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void unbindKnowledge(BigInteger knowledgeId) { + for (AgentResourceBindingProvider provider : requireProviders()) { + provider.unbindKnowledge(knowledgeId); + } + } + + /** + * 汇总所有提供者返回的 Agent 摘要。 + * + * @param loader 单个提供者查询函数 + * @return 按 Agent ID 去重后的摘要 + */ + private List merge( + Function> loader) { + Map merged = new LinkedHashMap<>(); + for (AgentResourceBindingProvider provider : requireProviders()) { + List bindings = loader.apply(provider); + if (bindings == null) { + continue; + } + for (OfflineImpactBindingVo binding : bindings) { + if (binding != null && binding.getId() != null) { + merged.putIfAbsent(binding.getId(), binding); + } + } + } + return new ArrayList<>(merged.values()); + } + + /** + * 校验资源未被任何 Agent 引用。 + * + * @param bindings Agent 引用摘要 + * @param resourceLabel 资源名称 + */ + private void assertUnused(List bindings, String resourceLabel) { + if (bindings == null || bindings.isEmpty()) { + return; + } + String agentTitle = bindings.get(0).getTitle(); + throw new BusinessException( + resourceLabel + "仍被智能体“" + (agentTitle == null ? "未命名智能体" : agentTitle) + + "”使用,请先取消绑定或重新发布智能体后再删除" + ); + } + + /** + * 获取已注册提供者;缺失时阻止破坏性资源操作。 + * + * @return Agent 资源绑定提供者 + */ + private List requireProviders() { + if (providers.isEmpty()) { + throw new BusinessException("Agent 资源引用检查服务不可用,请稍后重试"); + } + return providers; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AiResourceApprovalStateServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AiResourceApprovalStateServiceImpl.java index bcf84f0a..32e25eec 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AiResourceApprovalStateServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AiResourceApprovalStateServiceImpl.java @@ -3,7 +3,6 @@ package tech.easyflow.ai.service.impl; import com.mybatisflex.core.query.QueryWrapper; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; -import tech.easyflow.ai.entity.Bot; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.enums.PublishStatus; @@ -88,31 +87,6 @@ public class AiResourceApprovalStateServiceImpl implements AiResourceApprovalSta ); } - /** - * {@inheritDoc} - */ - @Override - public void fillBotApprovalState(Bot bot) { - fillBotApprovalState(bot == null ? List.of() : List.of(bot)); - } - - /** - * {@inheritDoc} - */ - @Override - public void fillBotApprovalState(Collection bots) { - fillApprovalState( - bots, - ApprovalResourceType.BOT.getCode(), - Bot::getCurrentApprovalInstanceId, - bot -> PublishStatus.from(bot.getPublishStatus()), - Bot::getPublishedSnapshotJson, - Bot::setApprovalPending, - Bot::setCurrentApprovalActionType, - Bot::setDisplayPublishStatus - ); - } - /** * 统一派生审批展示状态。 * @@ -126,7 +100,7 @@ public class AiResourceApprovalStateServiceImpl implements AiResourceApprovalSta * @param displaySetter 展示状态写入器 * @param 资源类型 */ - private void fillApprovalState(Collection resources, + void fillApprovalState(Collection resources, String resourceType, Function instanceIdGetter, Function statusGetter, diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotApprovalStateServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotApprovalStateServiceImpl.java new file mode 100644 index 00000000..3d940cfa --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotApprovalStateServiceImpl.java @@ -0,0 +1,53 @@ +package tech.easyflow.ai.service.impl; + +import org.springframework.stereotype.Service; +import tech.easyflow.ai.entity.Bot; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.BotApprovalStateService; +import tech.easyflow.approval.enums.ApprovalResourceType; + +import java.util.Collection; +import java.util.List; + +/** + * 旧 Bot 审批展示状态派生服务实现。 + */ +@Service +public class BotApprovalStateServiceImpl implements BotApprovalStateService { + + private final AiResourceApprovalStateServiceImpl approvalStateService; + + /** + * 创建 Bot 审批展示状态派生服务。 + * + * @param approvalStateService 通用 AI 资源审批状态服务 + */ + public BotApprovalStateServiceImpl(AiResourceApprovalStateServiceImpl approvalStateService) { + this.approvalStateService = approvalStateService; + } + + /** + * {@inheritDoc} + */ + @Override + public void fillApprovalState(Bot bot) { + fillApprovalState(bot == null ? List.of() : List.of(bot)); + } + + /** + * {@inheritDoc} + */ + @Override + public void fillApprovalState(Collection bots) { + approvalStateService.fillApprovalState( + bots, + ApprovalResourceType.BOT.getCode(), + Bot::getCurrentApprovalInstanceId, + bot -> PublishStatus.from(bot.getPublishStatus()), + Bot::getPublishedSnapshotJson, + Bot::setApprovalPending, + Bot::setCurrentApprovalActionType, + Bot::setDisplayPublishStatus + ); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotPluginServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotPluginServiceImpl.java index a6a6a166..38b4e8b5 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotPluginServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotPluginServiceImpl.java @@ -6,7 +6,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.mapper.BotPluginMapper; +import tech.easyflow.ai.mapper.PluginItemMapper; import tech.easyflow.ai.mapper.PluginMapper; import tech.easyflow.ai.service.BotPluginService; import tech.easyflow.common.cache.RedisLockExecutor; @@ -40,6 +42,9 @@ public class BotPluginServiceImpl extends ServiceImpl searchPluginTools(BigInteger pluginId, BigInteger botId) { + List pluginItems = pluginItemMapper.selectListByQuery( + QueryWrapper.create().eq(PluginItem::getPluginId, pluginId) + ); + if (pluginItems == null || pluginItems.isEmpty() || botId == null) { + return pluginItems == null ? List.of() : pluginItems; + } + List boundToolIdList = botPluginMapper.selectListByQueryAs( + QueryWrapper.create() + .select(BOT_PLUGIN.PLUGIN_ITEM_ID) + .where(BOT_PLUGIN.BOT_ID.eq(botId)), + BigInteger.class + ); + Set boundToolIds = boundToolIdList == null + ? Set.of() + : new LinkedHashSet<>(boundToolIdList); + for (PluginItem pluginItem : pluginItems) { + pluginItem.setJoinBot(boundToolIds.contains(pluginItem.getId())); + } + return pluginItems; + } + + /** + * {@inheritDoc} + */ + @Override + public List getPluginTools(BigInteger botId) { + if (botId == null) { + return List.of(); + } + List pluginToolIds = botPluginMapper.selectListByQueryAs( + QueryWrapper.create() + .select(BOT_PLUGIN.PLUGIN_ITEM_ID) + .where(BOT_PLUGIN.BOT_ID.eq(botId)), + BigInteger.class + ); + if (pluginToolIds == null || pluginToolIds.isEmpty()) { + return List.of(); + } + return pluginItemMapper.selectListByIds(pluginToolIds); + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java index d60ba68c..c6539235 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java @@ -568,7 +568,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe queryWrapper.eq(BotMcp::getBotId, botId); List botMcpList = botMcpService.getMapper().selectListWithRelationsByQuery(queryWrapper); botMcpList.forEach(botMcp -> { - Tool tool = mcpService.toFunction(botMcp); + Tool tool = mcpService.toFunction(botMcp.getMcpId(), botMcp.getMcpToolName()); functionList.add(tool); }); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java index a9a32f6b..1bc673b8 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java @@ -540,7 +540,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl implements McpS } @Override - public Tool toFunction(BotMcp botMcp) { - Mcp mcpInfo = this.getById(botMcp.getMcpId()); + public Tool toFunction(BigInteger mcpId, String mcpToolName) { + Mcp mcpInfo = this.getById(mcpId); String configJson = mcpInfo.getConfigJson(); String mcpServerName = getFirstMcpServerName(configJson); if (StringUtil.hasText(mcpServerName)) { McpSyncClient mcpClient = mcpClientManager.getMcpClient(mcpServerName); List tools = mcpClient.listTools().tools(); for (McpSchema.Tool tool : tools) { - if (tool.name().equals(botMcp.getMcpToolName())) { + if (tool.name().equals(mcpToolName)) { Map properties = tool.inputSchema().properties(); List required = tool.inputSchema().required(); McpTool mcpTool = new McpTool(); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java index 487d7027..37c738e1 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java @@ -200,7 +200,10 @@ public class ModelServiceImpl extends ServiceImpl implements @Override public void removeByEntity(Model entity) { - QueryWrapper queryWrapper = QueryWrapper.create().eq(Model::getProviderId, entity.getProviderId()).eq(Model::getGroupName, entity.getGroupName()); + QueryWrapper queryWrapper = QueryWrapper.create() + .eq(Model::getProviderId, entity.getProviderId()) + .eq(Model::getGroupName, entity.getGroupName()) + .eq(Model::getTenantId, entity.getTenantId()); modelMapper.deleteByQuery(queryWrapper); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java index ee8e2b22..e737acef 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginItemServiceImpl.java @@ -3,7 +3,7 @@ package tech.easyflow.ai.service.impl; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; -import tech.easyflow.ai.entity.BotPlugin; +import org.springframework.transaction.annotation.Transactional; import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.Workflow; @@ -11,10 +11,10 @@ import tech.easyflow.ai.enums.PluginType; import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision; import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; -import tech.easyflow.ai.mapper.BotPluginMapper; import tech.easyflow.ai.mapper.PluginMapper; import tech.easyflow.ai.mapper.PluginItemMapper; import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.common.constant.Constants; import tech.easyflow.common.entity.LoginAccount; @@ -26,8 +26,6 @@ import javax.annotation.Resource; import java.math.BigInteger; import java.util.*; -import static tech.easyflow.ai.entity.table.BotPluginTableDef.BOT_PLUGIN; - /** * 服务层实现。 * @@ -43,19 +41,35 @@ public class PluginItemServiceImpl extends ServiceImpl searchPluginToolByPluginId(BigInteger pluginId, BigInteger botId) { - QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create() - .select() - .eq(PluginItem::getPluginId, pluginId); - List pluginItems = pluginItemMapper.selectListByQueryAs(queryAiPluginToolWrapper, PluginItem.class); - // 查询当前bot有哪些插件工具方法 - QueryWrapper queryBotPluginTools = QueryWrapper.create() - .select() - .eq(BotPlugin::getBotId, botId); - List aiBotPluginToolIds = botPluginMapper.selectListWithRelationsByQueryAs(queryBotPluginTools, BigInteger.class); - aiBotPluginToolIds.forEach(botPluginTooId -> { - pluginItems.forEach(item -> { - if (Objects.equals(botPluginTooId, item.getId())) { - item.setJoinBot(true); - } - }); - }); - return pluginItems; - } - - @Override - public List getPluginToolList(BigInteger botId) { - QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create() - .select(BOT_PLUGIN.PLUGIN_ITEM_ID) - .from(BOT_PLUGIN) - .where(BOT_PLUGIN.BOT_ID.eq(botId)); - List pluginToolIds = botPluginMapper.selectListByQueryAs(queryAiPluginToolWrapper, BigInteger.class); - if (pluginToolIds == null || pluginToolIds.isEmpty()) { - return Collections.emptyList(); - } - // 查询当前bots对应的有哪些pluginTool - return pluginItemMapper.selectListByIds(pluginToolIds); - } - @Override public Result pluginToolTest(String inputData, BigInteger pluginToolId) { PluginItem pluginItem = pluginItemMapper.selectOneById(pluginToolId); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java index b66d969c..41bef8aa 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/PluginServiceImpl.java @@ -15,10 +15,10 @@ import tech.easyflow.ai.mapper.PluginMapper; import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision; import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService; import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; -import tech.easyflow.ai.service.BotPluginService; import tech.easyflow.ai.service.PluginItemService; import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.AgentResourceReferenceService; import tech.easyflow.common.domain.Result; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.entity.LoginAccount; @@ -57,9 +57,6 @@ public class PluginServiceImpl extends ServiceImpl impleme @Resource PluginCategoryMappingMapper pluginCategoryMappingMapper; - @Resource - private BotPluginService botPluginService; - @Resource private PluginItemService pluginItemService; @Resource @@ -70,6 +67,8 @@ public class PluginServiceImpl extends ServiceImpl impleme private WorkflowPluginBindingService workflowPluginBindingService; @Resource private WorkflowPluginAvailabilityService workflowPluginAvailabilityService; + @Resource + private AgentResourceReferenceService agentResourceReferenceService; @Override public Plugin savePlugin(Plugin plugin) { @@ -87,34 +86,42 @@ public class PluginServiceImpl extends ServiceImpl impleme } @Override - @Transactional + @Transactional(rollbackFor = Exception.class) public boolean removePlugin(String id) { + Plugin plugin = pluginMapper.selectOneByQuery(QueryWrapper.create() + .eq(Plugin::getId, id) + .forUpdate()); + if (plugin == null) { + throw new BusinessException("插件不存在"); + } + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + if (loginAccount == null || loginAccount.getTenantId() == null || plugin.getTenantId() == null + || !loginAccount.getTenantId().toString().equals(plugin.getTenantId().toString())) { + throw new BusinessException("无权限删除该插件"); + } + pluginVisibilityService.assertPluginVisible( + plugin.getCreatedBy(), plugin.getId(), "无权限删除该插件"); - List pluginItems = pluginItemService.getByPluginId(id); + // 父插件行先于工具行锁定,与工具新增和 Agent 绑定保持一致锁顺序。 + List pluginItems = pluginItemService.list(QueryWrapper.create() + .eq(PluginItem::getPluginId, plugin.getId()) + .orderBy(PluginItem::getId, true) + .forUpdate()); List pluginToolIds = new ArrayList<>(); if (pluginItems != null && !pluginItems.isEmpty()) { - pluginToolIds = pluginItems.stream().map(PluginItem::getId).collect(Collectors.toList()); - QueryWrapper queryWrapper = QueryWrapper.create(); - queryWrapper.in(BotPlugin::getPluginItemId, pluginToolIds); - boolean exists = botPluginService.exists(queryWrapper); + } - if (exists){ - throw new BusinessException("插件中有工具还关联着bot,请先取消关联!"); + agentResourceReferenceService.assertPluginItemsUnused(pluginToolIds); + if (!pluginToolIds.isEmpty()) { + boolean result = pluginItemService.removeByIds(pluginToolIds); + if (!result) { + log.error("删除插件工具表结果为0"); + throw new BusinessException("删除失败,请稍后重试!"); } - } - if ( !pluginToolIds.isEmpty()) { - boolean result = pluginItemService.removeByIds(pluginToolIds); - if (!result){ - log.error("删除插件工具表结果为0"); - throw new BusinessException("删除失败,请稍后重试!"); - } - } - - int remove = pluginMapper.deleteById(id); if (remove <= 0) { log.error("删除插件结果为0"); @@ -122,7 +129,6 @@ public class PluginServiceImpl extends ServiceImpl impleme } return true; - } @Override diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java index 17160536..9822d071 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java @@ -5,65 +5,52 @@ import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.mybatisflex.core.query.QueryWrapper; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import tech.easyflow.ai.entity.Bot; -import tech.easyflow.ai.entity.BotDocumentCollection; -import tech.easyflow.ai.entity.BotWorkflow; +import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; -import tech.easyflow.ai.service.BotDocumentCollectionService; -import tech.easyflow.ai.service.BotService; -import tech.easyflow.ai.service.BotWorkflowService; +import tech.easyflow.ai.service.AgentResourceReferenceService; +import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.ResourceOfflineImpactService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.vo.OfflineImpactBindingVo; import tech.easyflow.ai.vo.OfflineImpactCheckVo; -import tech.easyflow.common.cache.RedisLockExecutor; +import tech.easyflow.common.web.exceptions.BusinessException; import java.math.BigInteger; -import java.time.Duration; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; /** - * 资源下线影响检查与 Bot 静默解绑实现。 + * 资源下线影响检查与 Agent 静默解绑实现。 */ @Service public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactService { private static final String KNOWLEDGE_NODE_TYPE = "knowledgeNode"; - private static final String BOT_BINDING_LOCK_KEY_PREFIX = "easyflow:lock:bot:binding:"; - private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2); - private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10); - private final BotWorkflowService botWorkflowService; - private final BotDocumentCollectionService botDocumentCollectionService; - private final BotService botService; private final WorkflowService workflowService; - private final RedisLockExecutor redisLockExecutor; + private final DocumentCollectionService documentCollectionService; private final WorkflowPluginDependencyService workflowPluginDependencyService; + private final AgentResourceReferenceService agentResourceReferenceService; - public ResourceOfflineImpactServiceImpl(BotWorkflowService botWorkflowService, - BotDocumentCollectionService botDocumentCollectionService, - BotService botService, - WorkflowService workflowService, - RedisLockExecutor redisLockExecutor, - WorkflowPluginDependencyService workflowPluginDependencyService) { - this.botWorkflowService = botWorkflowService; - this.botDocumentCollectionService = botDocumentCollectionService; - this.botService = botService; + /** + * 创建资源下线影响服务。 + * + * @param workflowService 工作流服务 + * @param documentCollectionService 知识库服务 + * @param workflowPluginDependencyService 工作流插件依赖服务 + * @param agentResourceReferenceService Agent 资源引用服务 + */ + public ResourceOfflineImpactServiceImpl(WorkflowService workflowService, + DocumentCollectionService documentCollectionService, + WorkflowPluginDependencyService workflowPluginDependencyService, + AgentResourceReferenceService agentResourceReferenceService) { this.workflowService = workflowService; - this.redisLockExecutor = redisLockExecutor; + this.documentCollectionService = documentCollectionService; this.workflowPluginDependencyService = workflowPluginDependencyService; + this.agentResourceReferenceService = agentResourceReferenceService; } /** @@ -71,17 +58,18 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe */ @Override public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) { - List botBindings = listBotsByWorkflowId(workflowId); - List pluginBindings = workflowPluginDependencyService.listPluginsByWorkflowId(workflowId); + List agentBindings = listAgentsByWorkflowId(workflowId); + List pluginBindings = + workflowPluginDependencyService.listPluginsByWorkflowId(workflowId); OfflineImpactCheckVo result = new OfflineImpactCheckVo(); result.setCanProceed(true); - result.setBotBindings(botBindings); - result.setHasBotBindings(!botBindings.isEmpty()); + result.setAgentBindings(agentBindings); + result.setHasAgentBindings(!agentBindings.isEmpty()); result.setPluginBindings(pluginBindings); result.setHasPluginBindings(!pluginBindings.isEmpty()); result.setWorkflowUsages(Collections.emptyList()); result.setHasWorkflowUsages(false); - result.setMessage(resolveWorkflowOfflineImpactMessage(botBindings, pluginBindings)); + result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, pluginBindings)); return result; } @@ -90,18 +78,18 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe */ @Override public OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId) { - List botBindings = listBotsByKnowledgeId(knowledgeId); + List agentBindings = listAgentsByKnowledgeId(knowledgeId); List workflowUsages = listWorkflowsUsingKnowledge(knowledgeId); OfflineImpactCheckVo result = new OfflineImpactCheckVo(); - result.setBotBindings(botBindings); - result.setHasBotBindings(!botBindings.isEmpty()); + result.setAgentBindings(agentBindings); + result.setHasAgentBindings(!agentBindings.isEmpty()); result.setWorkflowUsages(workflowUsages); result.setHasWorkflowUsages(!workflowUsages.isEmpty()); result.setCanProceed(workflowUsages.isEmpty()); result.setMessage(workflowUsages.isEmpty() - ? (botBindings.isEmpty() + ? (agentBindings.isEmpty() ? "当前知识库下线后不会影响已有绑定" - : "当前知识库下线成功后,将自动从相关聊天助手中解绑") + : "当前知识库下线成功后,将自动从相关智能体中解绑") : "当前知识库仍被工作流使用,请先调整工作流后再下线"); return result; } @@ -110,84 +98,53 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe * {@inheritDoc} */ @Override - @Transactional(rollbackFor = Exception.class) - public void unbindWorkflowFromBots(BigInteger workflowId) { - List relations = botWorkflowService.list(QueryWrapper.create() - .eq(BotWorkflow::getWorkflowId, workflowId)); - Set botIds = collectBotIds(relations, BotWorkflow::getBotId); - for (BigInteger botId : botIds) { - redisLockExecutor.executeWithLock( - BOT_BINDING_LOCK_KEY_PREFIX + botId, - LOCK_WAIT_TIMEOUT, - LOCK_LEASE_TIMEOUT, - () -> { - botWorkflowService.remove(QueryWrapper.create() - .eq(BotWorkflow::getBotId, botId) - .eq(BotWorkflow::getWorkflowId, workflowId)); - trimPublishedSnapshotBindings(botId, "workflowBindings", "workflowId", workflowId); - } - ); - } + public void unbindWorkflowFromAgents(BigInteger workflowId) { + agentResourceReferenceService.unbindWorkflow(workflowId); } /** * {@inheritDoc} */ @Override - @Transactional(rollbackFor = Exception.class) - public void unbindKnowledgeFromBots(BigInteger knowledgeId) { - List relations = botDocumentCollectionService.list(QueryWrapper.create() - .eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId)); - Set botIds = collectBotIds(relations, BotDocumentCollection::getBotId); - for (BigInteger botId : botIds) { - redisLockExecutor.executeWithLock( - BOT_BINDING_LOCK_KEY_PREFIX + botId, - LOCK_WAIT_TIMEOUT, - LOCK_LEASE_TIMEOUT, - () -> { - botDocumentCollectionService.remove(QueryWrapper.create() - .eq(BotDocumentCollection::getBotId, botId) - .eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId)); - trimPublishedSnapshotBindings(botId, "knowledgeBindings", "knowledgeId", knowledgeId); - } - ); - } + public void unbindKnowledgeFromAgents(BigInteger knowledgeId) { + agentResourceReferenceService.unbindKnowledge(knowledgeId); } - private List listBotsByWorkflowId(BigInteger workflowId) { - List relations = botWorkflowService.list(QueryWrapper.create() - .eq(BotWorkflow::getWorkflowId, workflowId)); - return listBotsByIds(collectBotIds(relations, BotWorkflow::getBotId)); + /** + * 汇总绑定指定工作流的 Agent。 + * + * @param workflowId 工作流 ID + * @return 去重后的 Agent 摘要 + */ + private List listAgentsByWorkflowId(BigInteger workflowId) { + return agentResourceReferenceService.listAgentsByWorkflowId(workflowId); } - private List listBotsByKnowledgeId(BigInteger knowledgeId) { - List relations = botDocumentCollectionService.list(QueryWrapper.create() - .eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId)); - return listBotsByIds(collectBotIds(relations, BotDocumentCollection::getBotId)); - } - - private List listBotsByIds(Set botIds) { - if (botIds.isEmpty()) { - return Collections.emptyList(); - } - List bots = botService.listByIds(botIds); - Map botMap = new HashMap<>(); - for (Bot bot : bots) { - botMap.put(bot.getId(), bot); - } - List result = new ArrayList<>(botIds.size()); - for (BigInteger botId : botIds) { - Bot bot = botMap.get(botId); - if (bot == null) { - continue; - } - result.add(toBindingVo(bot.getId(), bot.getTitle())); - } - return result; + /** + * 汇总绑定指定知识库的 Agent。 + * + * @param knowledgeId 知识库 ID + * @return 去重后的 Agent 摘要 + */ + private List listAgentsByKnowledgeId(BigInteger knowledgeId) { + return agentResourceReferenceService.listAgentsByKnowledgeId(knowledgeId); } + /** + * 查询仍在设计内容中引用指定知识库的工作流。 + * + * @param knowledgeId 知识库 ID + * @return 工作流摘要 + */ private List listWorkflowsUsingKnowledge(BigInteger knowledgeId) { - List workflows = workflowService.list(); + DocumentCollection knowledge = documentCollectionService.getById(knowledgeId); + if (knowledge == null) { + throw new BusinessException("知识库不存在,无法检查下线影响"); + } + QueryWrapper queryWrapper = QueryWrapper.create() + .select(Workflow::getId, Workflow::getTitle, Workflow::getContent) + .eq(Workflow::getTenantId, knowledge.getTenantId()); + List workflows = workflowService.list(queryWrapper); if (workflows == null || workflows.isEmpty()) { return Collections.emptyList(); } @@ -197,26 +154,43 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe continue; } if (containsKnowledgeReference(workflow.getContent(), knowledgeId)) { - result.add(toBindingVo(workflow.getId(), workflow.getTitle())); + OfflineImpactBindingVo item = new OfflineImpactBindingVo(); + item.setId(workflow.getId()); + item.setTitle(workflow.getTitle()); + result.add(item); } } return result; } - private String resolveWorkflowOfflineImpactMessage(List botBindings, + /** + * 生成工作流下线影响提示。 + * + * @param agentBindings Agent 绑定 + * @param pluginBindings 插件绑定 + * @return 提示信息 + */ + private String resolveWorkflowOfflineImpactMessage(List agentBindings, List pluginBindings) { - if (!pluginBindings.isEmpty() && !botBindings.isEmpty()) { - return "当前工作流被插件和聊天助手引用,下线后插件将不可用,聊天助手将自动解绑"; + if (!pluginBindings.isEmpty() && !agentBindings.isEmpty()) { + return "当前工作流被插件和智能体引用,下线后插件将不可用,智能体将自动解绑"; } if (!pluginBindings.isEmpty()) { return "当前工作流被插件引用,下线后相关插件将不可用"; } - if (!botBindings.isEmpty()) { - return "当前工作流下线成功后,将自动从相关聊天助手中解绑"; + if (!agentBindings.isEmpty()) { + return "当前工作流下线成功后,将自动从相关智能体中解绑"; } return "当前工作流下线后不会影响已有绑定"; } + /** + * 判断工作流内容是否引用指定知识库。 + * + * @param content 工作流内容 + * @param knowledgeId 知识库 ID + * @return 是否引用 + */ private boolean containsKnowledgeReference(String content, BigInteger knowledgeId) { if (!StringUtils.hasText(content) || knowledgeId == null) { return false; @@ -224,7 +198,7 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe try { Object parsed = JSON.parse(content); if (!(parsed instanceof JSONObject root)) { - return false; + throw new BusinessException("工作流定义格式异常,无法确认知识库下线影响"); } JSONArray nodes = root.getJSONArray("nodes"); if (nodes == null || nodes.isEmpty()) { @@ -246,73 +220,10 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe } } return false; - } catch (Exception ignored) { - return false; + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new BusinessException("工作流定义解析失败,无法确认知识库下线影响"); } } - - private void trimPublishedSnapshotBindings(BigInteger botId, - String bindingsKey, - String idKey, - BigInteger resourceId) { - Bot bot = botService.getById(botId); - if (bot == null || bot.getPublishedSnapshotJson() == null || bot.getPublishedSnapshotJson().isEmpty()) { - return; - } - Map snapshot = new LinkedHashMap<>(bot.getPublishedSnapshotJson()); - Object rawBindings = snapshot.get(bindingsKey); - if (!(rawBindings instanceof List bindings)) { - return; - } - - List> filtered = new ArrayList<>(); - boolean changed = false; - String expectedId = resourceId == null ? null : resourceId.toString(); - for (Object item : bindings) { - if (!(item instanceof Map bindingMap)) { - continue; - } - Object currentId = bindingMap.get(idKey); - if (expectedId != null && currentId != null && expectedId.equals(String.valueOf(currentId))) { - changed = true; - continue; - } - filtered.add(new LinkedHashMap<>((Map) bindingMap)); - } - if (!changed) { - return; - } - snapshot.put(bindingsKey, filtered); - Bot update = new Bot(); - update.setId(botId); - update.setPublishedSnapshotJson(snapshot); - botService.updateById(update); - } - - private Set collectBotIds(Collection relations, BotIdGetter getter) { - if (relations == null || relations.isEmpty()) { - return Collections.emptySet(); - } - Set result = new LinkedHashSet<>(); - for (T relation : relations) { - BigInteger botId = getter.getBotId(relation); - if (botId != null) { - result.add(botId); - } - } - return result; - } - - private OfflineImpactBindingVo toBindingVo(BigInteger id, String title) { - OfflineImpactBindingVo vo = new OfflineImpactBindingVo(); - vo.setId(id); - vo.setTitle(title); - return vo; - } - - @FunctionalInterface - private interface BotIdGetter { - - BigInteger getBotId(T relation); - } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java index 439c30bf..90329e02 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java @@ -10,13 +10,13 @@ public class OfflineImpactCheckVo { private boolean canProceed; - private boolean hasBotBindings; + private boolean hasAgentBindings; private boolean hasWorkflowUsages; private boolean hasPluginBindings; - private List botBindings = new ArrayList<>(); + private List agentBindings = new ArrayList<>(); private List workflowUsages = new ArrayList<>(); @@ -43,21 +43,21 @@ public class OfflineImpactCheckVo { } /** - * 是否存在 Bot 绑定。 + * 是否存在 Agent 绑定。 * - * @return 是否存在 Bot 绑定 + * @return 是否存在 Agent 绑定 */ - public boolean isHasBotBindings() { - return hasBotBindings; + public boolean isHasAgentBindings() { + return hasAgentBindings; } /** - * 设置是否存在 Bot 绑定。 + * 设置是否存在 Agent 绑定。 * - * @param hasBotBindings 是否存在 Bot 绑定 + * @param hasAgentBindings 是否存在 Agent 绑定 */ - public void setHasBotBindings(boolean hasBotBindings) { - this.hasBotBindings = hasBotBindings; + public void setHasAgentBindings(boolean hasAgentBindings) { + this.hasAgentBindings = hasAgentBindings; } /** @@ -79,21 +79,21 @@ public class OfflineImpactCheckVo { } /** - * 获取 Bot 绑定列表。 + * 获取 Agent 绑定列表。 * - * @return Bot 绑定列表 + * @return Agent 绑定列表 */ - public List getBotBindings() { - return botBindings; + public List getAgentBindings() { + return agentBindings; } /** - * 设置 Bot 绑定列表。 + * 设置 Agent 绑定列表。 * - * @param botBindings Bot 绑定列表 + * @param agentBindings Agent 绑定列表 */ - public void setBotBindings(List botBindings) { - this.botBindings = botBindings; + public void setAgentBindings(List agentBindings) { + this.agentBindings = agentBindings; } /** diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImplTest.java new file mode 100644 index 00000000..9b6f406a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImplTest.java @@ -0,0 +1,128 @@ +package tech.easyflow.ai.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; +import tech.easyflow.ai.service.AgentResourceReferenceService; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; +import tech.easyflow.ai.vo.OfflineImpactCheckVo; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Agent 资源下线影响检查测试。 + */ +public class ResourceOfflineImpactServiceImplTest { + + /** + * 验证工作流影响结果使用 Agent 绑定字段并委托 Agent 解绑。 + */ + @Test + public void shouldReportAndUnbindAgentWorkflowBindings() { + WorkflowService workflowService = mock(WorkflowService.class); + DocumentCollectionService documentCollectionService = + mock(DocumentCollectionService.class); + WorkflowPluginDependencyService pluginDependencyService = + mock(WorkflowPluginDependencyService.class); + AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class); + BigInteger workflowId = BigInteger.valueOf(10); + OfflineImpactBindingVo binding = binding(BigInteger.ONE, "测试智能体"); + when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(List.of(binding)); + when(pluginDependencyService.listPluginsByWorkflowId(workflowId)) + .thenReturn(Collections.emptyList()); + ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl( + workflowService, documentCollectionService, pluginDependencyService, referenceService); + + OfflineImpactCheckVo result = service.checkWorkflowImpact(workflowId); + service.unbindWorkflowFromAgents(workflowId); + + Assert.assertTrue(result.isHasAgentBindings()); + Assert.assertEquals(List.of(binding), result.getAgentBindings()); + Assert.assertTrue(result.getMessage().contains("智能体")); + verify(referenceService).unbindWorkflow(workflowId); + } + + /** + * 验证知识库影响结果使用 Agent 绑定字段并委托 Agent 解绑。 + */ + @Test + public void shouldReportAndUnbindAgentKnowledgeBindings() { + WorkflowService workflowService = mock(WorkflowService.class); + DocumentCollectionService documentCollectionService = + mock(DocumentCollectionService.class); + WorkflowPluginDependencyService pluginDependencyService = + mock(WorkflowPluginDependencyService.class); + AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class); + BigInteger knowledgeId = BigInteger.valueOf(20); + OfflineImpactBindingVo binding = binding(BigInteger.TWO, "知识智能体"); + DocumentCollection knowledge = new DocumentCollection(); + knowledge.setTenantId(BigInteger.ONE); + when(documentCollectionService.getById(knowledgeId)).thenReturn(knowledge); + when(referenceService.listAgentsByKnowledgeId(knowledgeId)).thenReturn(List.of(binding)); + when(workflowService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList()); + ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl( + workflowService, documentCollectionService, pluginDependencyService, referenceService); + + OfflineImpactCheckVo result = service.checkKnowledgeImpact(knowledgeId); + service.unbindKnowledgeFromAgents(knowledgeId); + + Assert.assertTrue(result.isHasAgentBindings()); + Assert.assertEquals(List.of(binding), result.getAgentBindings()); + Assert.assertTrue(result.getMessage().contains("智能体")); + verify(referenceService).unbindKnowledge(knowledgeId); + } + + /** + * 工作流定义损坏时必须阻止知识库下线,避免漏判引用关系。 + */ + @Test(expected = BusinessException.class) + public void shouldFailClosedWhenWorkflowContentIsInvalid() { + WorkflowService workflowService = mock(WorkflowService.class); + DocumentCollectionService documentCollectionService = + mock(DocumentCollectionService.class); + WorkflowPluginDependencyService pluginDependencyService = + mock(WorkflowPluginDependencyService.class); + AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class); + BigInteger knowledgeId = BigInteger.valueOf(20); + DocumentCollection knowledge = new DocumentCollection(); + knowledge.setTenantId(BigInteger.ONE); + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.TEN); + workflow.setContent("{invalid"); + when(documentCollectionService.getById(knowledgeId)).thenReturn(knowledge); + when(referenceService.listAgentsByKnowledgeId(knowledgeId)) + .thenReturn(Collections.emptyList()); + when(workflowService.list(any(QueryWrapper.class))).thenReturn(List.of(workflow)); + ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl( + workflowService, documentCollectionService, pluginDependencyService, referenceService); + + service.checkKnowledgeImpact(knowledgeId); + } + + /** + * 创建绑定摘要。 + * + * @param id 资源 ID + * @param title 标题 + * @return 绑定摘要 + */ + private OfflineImpactBindingVo binding(BigInteger id, String title) { + OfflineImpactBindingVo binding = new OfflineImpactBindingVo(); + binding.setId(id); + binding.setTitle(title); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImpl.java index 65cfc0cc..5afcf8b9 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImpl.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImpl.java @@ -21,7 +21,7 @@ import java.math.BigInteger; @Service public class ChatHistoryManageServiceImpl implements ChatHistoryManageService { - private static final String ADMIN_ASSISTANT_CODE = "AGENT"; + private static final String AGENT_ASSISTANT_CODE = "AGENT"; private final ChatSessionQueryService chatSessionQueryService; private final ChatSessionCommandService chatSessionCommandService; @@ -43,24 +43,26 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService { @Override public ChatSessionPage queryUserSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) { - return chatSessionQueryService.pageSessions(userId, assistantId, query); + return chatSessionQueryService.pageSessions(userId, assistantId, AGENT_ASSISTANT_CODE, query); } @Override public ChatSessionPage queryAdminSessions(ChatSessionFilterQuery query) { ChatSessionFilterQuery effectiveQuery = query == null ? new ChatSessionFilterQuery() : query; // 管理端聊天历史已经切换为 Agent 专属入口,类型由服务端固定,避免客户端绕过。 - effectiveQuery.setAssistantCode(ADMIN_ASSISTANT_CODE); + effectiveQuery.setAssistantCode(AGENT_ASSISTANT_CODE); return chatAnalyticalDBRepository.pageSessions(effectiveQuery); } @Override public ChatSessionSummary getUserSession(BigInteger userId, BigInteger sessionId) { ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId); - if (summary == null || summary.getIsDeleted() != null && summary.getIsDeleted() == 1) { - throw new BusinessException("会话不存在"); + if (summary == null + || Integer.valueOf(1).equals(summary.getIsDeleted()) + || !AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode())) { + throw new BusinessException("Agent 会话不存在"); } - if (!summary.getUserId().equals(userId)) { + if (summary.getUserId() == null || !summary.getUserId().equals(userId)) { throw new BusinessException("无权访问该会话"); } return summary; @@ -71,7 +73,7 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService { ChatSessionSummary summary = chatAnalyticalDBRepository.getSession(sessionId); if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted()) - || !ADMIN_ASSISTANT_CODE.equals(summary.getAssistantCode())) { + || !AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode())) { throw new BusinessException("Agent 会话不存在"); } return summary; diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImplTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImplTest.java index 3f9f566d..2e977cb4 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImplTest.java +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryManageServiceImplTest.java @@ -19,6 +19,7 @@ import tech.easyflow.chatlog.support.ChatJsonSupport; import tech.easyflow.common.analyticaldb.core.AnalyticalDBOperations; import tech.easyflow.common.web.exceptions.BusinessException; +import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; import java.math.BigInteger; @@ -28,6 +29,7 @@ import java.math.BigInteger; public class ChatHistoryManageServiceImplTest { private StubChatAnalyticalDBRepository chatAnalyticalDBRepository; + private StubChatSessionQueryHandler chatSessionQueryHandler; private ChatHistoryManageServiceImpl service; /** @@ -35,7 +37,8 @@ public class ChatHistoryManageServiceImplTest { */ @Before public void setUp() { - ChatSessionQueryService chatSessionQueryService = unusedDependency(ChatSessionQueryService.class); + chatSessionQueryHandler = new StubChatSessionQueryHandler(); + ChatSessionQueryService chatSessionQueryService = chatSessionQueryHandler.createProxy(); ChatSessionCommandService chatSessionCommandService = unusedDependency(ChatSessionCommandService.class); ChatHistoryQueryService chatHistoryQueryService = unusedDependency(ChatHistoryQueryService.class); ChatRoundOperateService chatRoundOperateService = unusedDependency(ChatRoundOperateService.class); @@ -49,6 +52,56 @@ public class ChatHistoryManageServiceImplTest { ); } + /** + * 验证用户端列表始终按 Agent 类型查询。 + */ + @Test + public void queryUserSessionsShouldForceAgentAssistantCode() { + BigInteger userId = BigInteger.valueOf(2001); + BigInteger agentId = BigInteger.valueOf(3001); + ChatPageQuery query = new ChatPageQuery(); + + service.queryUserSessions(userId, agentId, query); + + Assert.assertEquals(userId, chatSessionQueryHandler.lastUserId); + Assert.assertEquals(agentId, chatSessionQueryHandler.lastAssistantId); + Assert.assertEquals("AGENT", chatSessionQueryHandler.lastAssistantCode); + Assert.assertSame(query, chatSessionQueryHandler.lastPageQuery); + } + + /** + * 验证用户端拒绝读取归属于当前用户的旧 Bot 会话。 + */ + @Test + public void getUserSessionShouldRejectBotSession() { + BigInteger userId = BigInteger.valueOf(2002); + ChatSessionSummary summary = session(BigInteger.valueOf(3002), "BOT", 0); + summary.setUserId(userId); + chatSessionQueryHandler.sessionResult = summary; + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.getUserSession(userId, summary.getId()) + ); + + Assert.assertEquals("Agent 会话不存在", exception.getMessage()); + } + + /** + * 验证用户端可以读取归属于当前用户的 Agent 会话。 + */ + @Test + public void getUserSessionShouldReturnOwnedAgentSession() { + BigInteger userId = BigInteger.valueOf(2003); + ChatSessionSummary summary = session(BigInteger.valueOf(3003), "AGENT", 0); + summary.setUserId(userId); + chatSessionQueryHandler.sessionResult = summary; + + ChatSessionSummary result = service.getUserSession(userId, summary.getId()); + + Assert.assertSame(summary, result); + } + /** * 验证管理端列表始终覆盖客户端传入的会话类型为 Agent。 */ @@ -197,6 +250,54 @@ public class ChatHistoryManageServiceImplTest { return summary; } + /** + * 用户端会话查询依赖桩。 + */ + private static class StubChatSessionQueryHandler implements InvocationHandler { + + private BigInteger lastUserId; + private BigInteger lastAssistantId; + private String lastAssistantCode; + private ChatPageQuery lastPageQuery; + private ChatSessionSummary sessionResult; + + /** + * 创建查询服务代理。 + * + * @return 查询服务代理 + */ + private ChatSessionQueryService createProxy() { + return (ChatSessionQueryService) Proxy.newProxyInstance( + ChatSessionQueryService.class.getClassLoader(), + new Class[]{ChatSessionQueryService.class}, + this + ); + } + + /** + * 处理测试所需的查询方法。 + * + * @param proxy 代理对象 + * @param method 被调用方法 + * @param args 调用参数 + * @return 方法返回值 + */ + @Override + public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) { + if ("pageSessions".equals(method.getName()) && args != null && args.length == 4) { + lastUserId = (BigInteger) args[0]; + lastAssistantId = (BigInteger) args[1]; + lastAssistantCode = (String) args[2]; + lastPageQuery = (ChatPageQuery) args[3]; + return new ChatSessionPage(); + } + if ("getSessionSummary".equals(method.getName())) { + return sessionResult; + } + throw new AssertionError("测试路径不应调用查询方法: " + method.getName()); + } + } + /** * 仅记录管理端会话查询参数和返回值的分析库仓储桩。 */ diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java index aab62879..ec3c323e 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java @@ -27,6 +27,9 @@ import java.util.List; @Service public class SysApiKeyServiceImpl extends ServiceImpl implements SysApiKeyService { + private static final String PUBLIC_AGENT_CHAT_URI = "/public-api/agent/chat"; + private static final String LEGACY_PUBLIC_BOT_CHAT_URI = "/public-api/bot/chat"; + @Resource private SysApiKeyResourceMappingService mappingService; @Resource @@ -57,7 +60,10 @@ public class SysApiKeyServiceImpl extends ServiceImpl getCandidateRequestUris(String requestURI) { List uris = new ArrayList<>(); uris.add(requestURI); - if ("/v1/chat/completions".equals(requestURI)) { + if (PUBLIC_AGENT_CHAT_URI.equals(requestURI)) { + // 数据库权限资源暂不迁移,Agent 公共接口复用旧资源记录作为兼容别名。 + uris.add(LEGACY_PUBLIC_BOT_CHAT_URI); + } else if ("/v1/chat/completions".equals(requestURI)) { uris.add("/public-api/openai/v1/chat/completions"); } else if ("/public-api/openai/v1/chat/completions".equals(requestURI)) { uris.add("/v1/chat/completions"); diff --git a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysApiKeyServiceImplTest.java b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysApiKeyServiceImplTest.java new file mode 100644 index 00000000..5e997826 --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysApiKeyServiceImplTest.java @@ -0,0 +1,149 @@ +package tech.easyflow.system.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysApiKey; + +import java.lang.reflect.Method; +import java.util.Date; +import java.util.List; + +/** + * API Key 接口权限兼容测试。 + */ +public class SysApiKeyServiceImplTest { + + /** + * 验证不存在的 API Key 使用 HTTP 401 语义。 + */ + @Test + public void shouldReturnUnauthorizedWhenApiKeyMissing() { + TestSysApiKeyService service = new TestSysApiKeyService(null); + + BusinessException error = expectBusinessException( + () -> service.getSysApiKey("missing")); + + Assert.assertEquals(401, error.getHttpStatus()); + Assert.assertEquals(401, error.getErrorCode()); + } + + /** + * 验证状态缺失的 API Key 按禁用处理并返回 HTTP 401。 + */ + @Test + public void shouldReturnUnauthorizedWhenApiKeyStatusMissing() { + SysApiKey apiKey = new SysApiKey(); + TestSysApiKeyService service = new TestSysApiKeyService(apiKey); + + BusinessException error = expectBusinessException( + () -> service.getSysApiKey("status-missing")); + + Assert.assertEquals(401, error.getHttpStatus()); + Assert.assertEquals(401, error.getErrorCode()); + } + + /** + * 验证过期的 API Key 使用 HTTP 401 语义。 + */ + @Test + public void shouldReturnUnauthorizedWhenApiKeyExpired() { + SysApiKey apiKey = new SysApiKey(); + apiKey.setStatus(1); + apiKey.setExpiredAt(new Date(System.currentTimeMillis() - 1_000L)); + TestSysApiKeyService service = new TestSysApiKeyService(apiKey); + + BusinessException error = expectBusinessException( + () -> service.getSysApiKey("expired")); + + Assert.assertEquals(401, error.getHttpStatus()); + Assert.assertEquals(401, error.getErrorCode()); + } + + /** + * 验证 Agent 公共聊天接口可复用未迁移的旧权限资源记录。 + * + * @throws Exception 反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldResolveLegacyPermissionForPublicAgentChat() throws Exception { + SysApiKeyServiceImpl service = new SysApiKeyServiceImpl(); + Method method = SysApiKeyServiceImpl.class + .getDeclaredMethod("getCandidateRequestUris", String.class); + method.setAccessible(true); + + List candidates = + (List) method.invoke(service, "/public-api/agent/chat"); + + Assert.assertEquals( + List.of("/public-api/agent/chat", "/public-api/bot/chat"), + candidates + ); + } + + /** + * 验证旧 Bot 接口不会反向获得新 Agent 接口权限。 + * + * @throws Exception 反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldKeepLegacyBotPermissionIsolated() throws Exception { + SysApiKeyServiceImpl service = new SysApiKeyServiceImpl(); + Method method = SysApiKeyServiceImpl.class + .getDeclaredMethod("getCandidateRequestUris", String.class); + method.setAccessible(true); + + List candidates = + (List) method.invoke(service, "/public-api/bot/chat"); + + Assert.assertEquals(List.of("/public-api/bot/chat"), candidates); + } + + /** + * 执行调用并返回预期业务异常。 + * + * @param action 待执行调用 + * @return 捕获的业务异常 + */ + private BusinessException expectBusinessException(Runnable action) { + try { + action.run(); + Assert.fail("expected BusinessException"); + return null; + } catch (BusinessException error) { + return error; + } + } + + /** + * 固定返回 API Key 的测试服务。 + */ + private static final class TestSysApiKeyService + extends SysApiKeyServiceImpl { + + private final SysApiKey apiKey; + + /** + * 创建测试服务。 + * + * @param apiKey 查询时返回的 API Key + */ + private TestSysApiKeyService(SysApiKey apiKey) { + this.apiKey = apiKey; + } + + /** + * 返回预设 API Key。 + * + * @param queryWrapper 查询条件 + * @return 预设 API Key + */ + @Override + public SysApiKey getOne(QueryWrapper queryWrapper) { + return apiKey; + } + } +} diff --git a/easyflow-ui-admin/app/src/api/dashboard.ts b/easyflow-ui-admin/app/src/api/dashboard.ts index 2dc70473..009ebe35 100644 --- a/easyflow-ui-admin/app/src/api/dashboard.ts +++ b/easyflow-ui-admin/app/src/api/dashboard.ts @@ -15,7 +15,7 @@ export interface DashboardUserRankQuery extends DashboardOverviewQuery { export interface DashboardSummary { activeUserTotal: number; activeAssistantTotal: number; - botTotal: number; + agentTotal: number; chatActiveUserTotal: number; chatMessageTotal: number; chatSessionTotal: number; @@ -55,7 +55,6 @@ export interface DashboardDistributionItem { assistantId?: number | string; avgMessagePerSession?: number; avgSessionPerUser?: number; - botTotal: number; key: string; knowledgeBaseTotal: number; label: string; diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json index ab6739b7..ac09f410 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json @@ -96,11 +96,11 @@ "submitRepublishApprovalConfirm": "Republish the current workflow now?", "submitOfflineApprovalConfirm": "Take the current workflow offline?", "submitDeleteApprovalConfirm": "Delete the current workflow?", - "offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:", - "offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.", + "offlineImpactBoundAgentsIntro": "This workflow is currently bound to the following agents:", + "offlineImpactBoundAgentsFooter": "After the workflow goes offline, the system will automatically remove it from these agents.", "offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:", "offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.", - "offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from bots and mark the related plugins as unavailable.", + "offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from agents and mark the related plugins as unavailable.", "publishPendingHint": "There is already an approval in progress for this workflow.", "deletePendingHint": "There is already an approval in progress for this workflow.", "check": "Check", diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json b/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json index 1463c73d..2e35cbbc 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json @@ -7,6 +7,7 @@ "initiated": "Initiated" }, "resource": { + "agent": "Agent", "bot": "Chat Assistant", "workflow": "Workflow", "knowledge": "Knowledge Base" @@ -144,6 +145,8 @@ "knowledgeBasic": "Basic Info", "knowledgeConfig": "Retrieval Config", "botOverview": "Assistant Overview", + "agentModelConfig": "Model Config", + "agentBindings": "Capability Bindings", "botModelConfig": "Model Config", "botBindings": "Capability Bindings", "systemPrompt": "System Prompt", @@ -168,6 +171,7 @@ "notConfigured": "Not configured", "noBindings": "No bindings", "untitledKnowledge": "Untitled knowledge base", + "untitledAgent": "Untitled agent", "untitledBot": "Untitled assistant", "unnamedKnowledge": "Unnamed knowledge base", "unnamedWorkflow": "Unnamed workflow", diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json index 7199b9d5..f0653d48 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json @@ -47,8 +47,8 @@ "submitRepublishApprovalConfirm": "Republish the current knowledge base now?", "submitOfflineApprovalConfirm": "Take the current knowledge base offline?", "submitDeleteApprovalConfirm": "Delete the current knowledge base?", - "offlineImpactBoundBotsIntro": "This knowledge base is currently bound to the following bots:", - "offlineImpactBoundBotsFooter": "After the knowledge base goes offline, the system will automatically remove it from these bots.", + "offlineImpactBoundAgentsIntro": "This knowledge base is currently bound to the following agents:", + "offlineImpactBoundAgentsFooter": "After the knowledge base goes offline, the system will automatically remove it from these agents.", "offlineImpactWorkflowBlockedIntro": "This knowledge base is still used by the following workflows:", "offlineImpactWorkflowBlockedFooter": "Please update those workflow nodes before taking the knowledge base offline.", "publishPendingHint": "There is already an approval in progress for this knowledge base.", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json index dba1a5f8..1be951d2 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json @@ -96,11 +96,11 @@ "submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?", "submitOfflineApprovalConfirm": "确认下线当前工作流吗?", "submitDeleteApprovalConfirm": "确认删除当前工作流吗?", - "offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:", - "offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。", + "offlineImpactBoundAgentsIntro": "当前工作流被以下智能体绑定:", + "offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该工作流。", "offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:", "offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。", - "offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从聊天助手中解绑该工作流,同时让相关插件进入不可用状态。", + "offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从智能体中解绑该工作流,同时让相关插件进入不可用状态。", "publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "check": "检查", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json index 8e7ff8b2..452a3db0 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json @@ -7,6 +7,7 @@ "initiated": "我发起" }, "resource": { + "agent": "智能体", "bot": "聊天助手", "workflow": "工作流", "knowledge": "知识库" @@ -144,6 +145,8 @@ "knowledgeBasic": "基础信息", "knowledgeConfig": "检索配置", "botOverview": "助手概览", + "agentModelConfig": "模型配置", + "agentBindings": "能力绑定", "botModelConfig": "模型配置", "botBindings": "能力绑定", "systemPrompt": "系统提示词", @@ -168,6 +171,7 @@ "notConfigured": "未配置", "noBindings": "未绑定任何能力", "untitledKnowledge": "未命名知识库", + "untitledAgent": "未命名智能体", "untitledBot": "未命名聊天助手", "unnamedKnowledge": "未命名知识库", "unnamedWorkflow": "未命名工作流", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json index 52ea76ed..959da088 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json @@ -47,8 +47,8 @@ "submitRepublishApprovalConfirm": "确认重新发布当前知识库吗?", "submitOfflineApprovalConfirm": "确认下线当前知识库吗?", "submitDeleteApprovalConfirm": "确认删除当前知识库吗?", - "offlineImpactBoundBotsIntro": "当前知识库被以下聊天助手绑定:", - "offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该知识库。", + "offlineImpactBoundAgentsIntro": "当前知识库被以下智能体绑定:", + "offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该知识库。", "offlineImpactWorkflowBlockedIntro": "当前知识库仍被以下工作流使用:", "offlineImpactWorkflowBlockedFooter": "请先在工作流中调整相关知识库节点后再下线。", "publishPendingHint": "当前知识库已有进行中的审批,请等待处理完成。", 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 fbee8f12..8eea656a 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/list', { + return api.get>('/api/v1/agent/options', { params: { publishedOnly: true }, }); } @@ -95,10 +95,7 @@ export function getAgentSession(sessionId: number | string) { export function getPublishedKnowledges() { return api.get>( - '/api/v1/documentCollection/list', - { - params: { publishedOnly: true }, - }, + '/api/v1/agent/knowledgeOptions', ); } diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue index 2cf1fccc..b32b671b 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentDesigner.vue @@ -23,10 +23,8 @@ import { import { getAgentDetail, - getAgentModels, - getMcpPage, - getMcpTools, - getPublishedKnowledgeList, + getAgentMcpToolOptions, + getAgentResourceOptions, saveAgent, submitAgentOfflineApproval, submitAgentPublishApproval, @@ -76,7 +74,7 @@ const workflows = ref([]); const pluginTools = ref([]); const mcps = ref([]); const fetchMcpToolResource = createMcpToolLoader(async (id) => { - const res = await getMcpTools(id); + const res = await getAgentMcpToolOptions(id); return res.errorCode === 0 ? res.data : undefined; }); @@ -128,7 +126,6 @@ const offlineDisabled = computed(() => { }); onMounted(async () => { - void loadDeferredOptions(); try { await Promise.all([loadCriticalOptions(), loadAgent()]); } finally { @@ -213,11 +210,11 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) { } async function loadCriticalOptions() { - const [categoryResult, modelResult] = await Promise.allSettled([ + const [categoryResult, resourceResult] = await Promise.allSettled([ api.get('/api/v1/agentCategory/visibleList', { params: { sortKey: 'sortNo', sortType: 'asc' }, }), - getAgentModels(), + getAgentResourceOptions(), ]); if (categoryResult.status === 'fulfilled') { @@ -227,71 +224,33 @@ async function loadCriticalOptions() { raw: item, })); } - if (modelResult.status === 'fulfilled') { - models.value = (modelResult.value.data || []).map((item: any) => ({ + if (resourceResult.status === 'fulfilled') { + const resources = resourceResult.value.data; + if (resourceResult.value.errorCode !== 0 || !resources) { + return; + } + models.value = (resources.models || []).map((item: any) => ({ label: item.title || item.name, value: String(item.id), raw: item, })); - } -} - -async function loadDeferredOptions() { - const [knowledgeResult, workflowResult, pluginResult, mcpResult] = - await Promise.allSettled([ - getPublishedKnowledgeList(), - api.get('/api/v1/workflow/page', { - params: { pageNumber: 1, pageSize: 200 }, - }), - api.get('/api/v1/plugin/pageByCategory', { - params: { pageNumber: 1, pageSize: 200, category: 0 }, - }), - getMcpPage(), - ]); - - if (knowledgeResult.status === 'fulfilled') { - knowledges.value = (knowledgeResult.value.data || []).map((item: any) => ({ + knowledges.value = (resources.knowledges || []).map((item: any) => ({ label: item.title || item.name, value: String(item.id), raw: item, })); - } - if (workflowResult.status === 'fulfilled') { - workflows.value = ( - (workflowResult.value.data?.records || - workflowResult.value.data || - []) as any[] - ).map((item) => ({ + workflows.value = (resources.workflows || []).map((item: any) => ({ label: item.title || item.name, value: String(item.id), raw: item, })); + pluginTools.value = (resources.pluginTools || []).map((item: any) => ({ + label: item.name || item.title, + value: String(item.id), + raw: item, + })); + mcps.value = mapMcpOptions(resources.mcps || []); } - if (pluginResult.status === 'fulfilled') { - pluginTools.value = flattenPluginTools( - pluginResult.value.data?.records || pluginResult.value.data || [], - ); - } - if (mcpResult.status === 'fulfilled') { - mcps.value = mapMcpOptions( - mcpResult.value.data?.records || mcpResult.value.data || [], - ); - } -} - -function flattenPluginTools(list: any[]): AgentOption[] { - const result: AgentOption[] = []; - list.forEach((plugin) => { - const tools = Array.isArray(plugin.tools) ? plugin.tools : []; - tools.forEach((tool: any) => { - result.push({ - label: tool.name || tool.title, - value: String(tool.id), - raw: { ...tool, pluginName: plugin.name || plugin.title }, - }); - }); - }); - return result; } function mapMcpOptions(list: any[]): AgentOption[] { @@ -325,8 +284,7 @@ async function loadMcpToolsForOption(id: number | string) { const currentOption = mcps.value.find((item) => String(item.value) === key); const mergedResource = { ...currentOption?.raw, - ...resource, - tools: Array.isArray(resource.tools) ? resource.tools : [], + tools: Array.isArray(resource) ? resource : [], }; if (currentOption) { mcps.value = mcps.value.map((item) => @@ -334,7 +292,7 @@ async function loadMcpToolsForOption(id: number | string) { ? { ...item, label: - resource.title || resource.name || currentOption.label || 'MCP', + currentOption.label || 'MCP', raw: mergedResource, } : item, diff --git a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue index 0cd663b8..843d9496 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/AgentList.vue @@ -12,7 +12,7 @@ import {Delete, Edit, Plus, Promotion} from '@element-plus/icons-vue'; import {ElMessage, ElMessageBox, ElTag} from 'element-plus'; import {tryit} from 'radash'; -import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png'; +import defaultAgentAvatar from '#/assets/defaultUserAvatar.png'; import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue'; import PageData from '#/components/page/PageData.vue'; import PageSide from '#/components/page/PageSide.vue'; @@ -262,7 +262,7 @@ async function handleDeleteAction(row: AgentInfo) { { data: T; @@ -22,10 +26,6 @@ export function updateAgent(agent: AgentInfo) { return api.post>('/api/v1/agent/update', agent); } -export function removeAgent(id: number | string) { - return api.post('/api/v1/agent/remove', { id }); -} - export function updateAgentToolBindings( agentId: number | string, bindings: AgentToolBinding[], @@ -98,24 +98,22 @@ export function getAgentCategories() { }); } -export function getAgentModels() { - return api.get>('/api/v1/model/list', { - params: { modelType: 'chatModel', added: true }, - }); +export interface AgentResourceOptions { + knowledges: any[]; + mcps: any[]; + models: any[]; + pluginTools: any[]; + workflows: any[]; } -export function getPublishedKnowledgeList() { - return api.get>('/api/v1/documentCollection/list', { - params: { publishedOnly: true }, - }); +export function getAgentResourceOptions() { + return api.get>( + '/api/v1/agent/resourceOptions', + ); } -export function getMcpPage() { - return api.get>('/api/v1/mcp/page', { - params: { pageNumber: 1, pageSize: 200, status: 1 }, +export function getAgentMcpToolOptions(id: number | string) { + return api.get>('/api/v1/agent/mcpToolOptions', { + params: { id }, }); } - -export function getMcpTools(id: number | string) { - return api.post>('/api/v1/mcp/getMcpTools', { id }); -} 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 466135b8..a03d9a7e 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/list'"); + expect(pageSource).toContain("'/api/v1/agent/options'"); 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 b6061843..2194ad07 100644 --- a/easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue +++ b/easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue @@ -98,7 +98,7 @@ onMounted(async () => { async function fetchAgents() { agentLoading.value = true; - const [error, res] = await tryit(api.get)('/api/v1/agent/list'); + const [error, res] = await tryit(api.get)('/api/v1/agent/options'); agentLoading.value = false; if (error || res?.errorCode !== 0) { agentOptions.value = []; diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue index 7659a8a2..5913f67d 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue @@ -360,11 +360,11 @@ const submitOfflineAction = async (item: any) => { } try { await ElMessageBox.confirm( - impactRes.data?.hasBotBindings + impactRes.data?.hasAgentBindings ? buildOfflineImpactMessage( - $t('documentCollection.offlineImpactBoundBotsIntro'), - impactRes.data.botBindings, - $t('documentCollection.offlineImpactBoundBotsFooter'), + $t('documentCollection.offlineImpactBoundAgentsIntro'), + impactRes.data.agentBindings, + $t('documentCollection.offlineImpactBoundAgentsFooter'), ) : $t('documentCollection.submitOfflineApprovalConfirm'), $t('message.noticeTitle'), diff --git a/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts b/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts index 7a19e299..659153a8 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts @@ -7,10 +7,10 @@ export interface OfflineImpactBinding { export interface OfflineImpactCheck { canProceed: boolean; - hasBotBindings: boolean; + hasAgentBindings: boolean; hasPluginBindings: boolean; hasWorkflowUsages: boolean; - botBindings: OfflineImpactBinding[]; + agentBindings: OfflineImpactBinding[]; pluginBindings: OfflineImpactBinding[]; workflowUsages: OfflineImpactBinding[]; message?: string; @@ -33,7 +33,13 @@ export function buildOfflineImpactMessage( h('p', intro), h( 'ul', - items.map((item) => h('li', { key: String(item.id || item.title || '') }, resolveTitle(item))), + items.map((item) => + h( + 'li', + { key: String(item.id || item.title || '') }, + resolveTitle(item), + ), + ), ), footer ? h('p', footer) : null, ]); diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue index d93f34a3..3844722b 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue @@ -797,15 +797,15 @@ async function submitOfflineAction(row: any) { } try { const sections = []; - let offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundBotsFooter'); - if (impactRes.data?.hasBotBindings) { + let offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundAgentsFooter'); + if (impactRes.data?.hasAgentBindings) { sections.push( buildOfflineImpactMessage( - $t('aiWorkflow.offlineImpactBoundBotsIntro'), - impactRes.data.botBindings, + $t('aiWorkflow.offlineImpactBoundAgentsIntro'), + impactRes.data.agentBindings, impactRes.data?.hasPluginBindings ? undefined - : $t('aiWorkflow.offlineImpactBoundBotsFooter'), + : $t('aiWorkflow.offlineImpactBoundAgentsFooter'), ), ); } @@ -814,13 +814,13 @@ async function submitOfflineAction(row: any) { buildOfflineImpactMessage( $t('aiWorkflow.offlineImpactBoundPluginsIntro'), impactRes.data.pluginBindings, - impactRes.data?.hasBotBindings + impactRes.data?.hasAgentBindings ? undefined : $t('aiWorkflow.offlineImpactBoundPluginsFooter'), ), ); } - if (impactRes.data?.hasBotBindings && impactRes.data?.hasPluginBindings) { + if (impactRes.data?.hasAgentBindings && impactRes.data?.hasPluginBindings) { offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundMixedFooter'); } else if (impactRes.data?.hasPluginBindings) { offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundPluginsFooter'); diff --git a/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue b/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue index 065fb5dc..346188ea 100644 --- a/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue +++ b/easyflow-ui-admin/app/src/views/config/apikey/SysApiKeyModal.vue @@ -136,8 +136,11 @@ function createDefaultEntity(row: Partial = {}): Entity { } function renderPermissionLabel(item: ResourcePermission) { - if (item.requestInterface === '/public-api/bot/chat') { - return '聊天助手调用'; + if ( + item.requestInterface === '/public-api/agent/chat' || + item.requestInterface === '/public-api/bot/chat' + ) { + return '智能体调用'; } if ( item.requestInterface === '/v1/chat/completions' || diff --git a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.test.ts b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.test.ts new file mode 100644 index 00000000..0e04d586 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import pageSource from './index.vue?raw'; + +describe('管理端工作台智能体数据契约', () => { + it('使用 Agent 候选接口和名称字段', () => { + expect(pageSource).toContain("'/api/v1/agent/options'"); + expect(pageSource).not.toContain('/api/v1/bot/list'); + expect(pageSource).toContain('label: item.name'); + expect(pageSource).not.toContain('publishedOnly'); + }); + + it('使用 Agent 总数字段', () => { + expect(pageSource).toContain('summary.value.agentTotal'); + expect(pageSource).not.toContain('summary.value.botTotal'); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue index 8be3ae1a..347dd79f 100644 --- a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue +++ b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue @@ -114,7 +114,7 @@ const trendModeOptions: Array<{ label: string; value: DashboardTrendMode }> = [ const emptySummary: DashboardSummary = { activeAssistantTotal: 0, activeUserTotal: 0, - botTotal: 0, + agentTotal: 0, chatActiveUserTotal: 0, chatMessageTotal: 0, chatSessionTotal: 0, @@ -178,7 +178,7 @@ const summaryCards = computed(() => [ chatAvailable.value, ), }, - { label: '智能体总数', value: formatCount(summary.value.botTotal) }, + { label: '智能体总数', value: formatCount(summary.value.agentTotal) }, { label: '知识库总数', value: formatCount(summary.value.knowledgeBaseTotal), @@ -330,15 +330,14 @@ async function loadOverview() { async function loadAssistantOptions() { assistantOptionsLoading.value = true; try { - const bots = await requestClient.get< - Array<{ id?: number | string; title?: string }> - >('/api/v1/bot/list', { - params: { status: 1 }, - }); + const agents = + await requestClient.get>( + '/api/v1/agent/options', + ); const nextOptions: AssistantOptionItem[] = [ { label: '全部智能体', value: '' }, - ...(bots || []).map((item) => ({ - label: item.title?.trim() || '未命名智能体', + ...(agents || []).map((item) => ({ + label: item.name?.trim() || '未命名智能体', value: item.id === undefined || item.id === null ? '' : String(item.id), })), ]; diff --git a/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue b/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue index 27215376..f4e3729a 100644 --- a/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue +++ b/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue @@ -21,6 +21,7 @@ import { hasPermission } from '#/api/common/hasPermission'; import { api } from '#/api/request'; import { $t } from '#/locales'; import { router } from '#/router'; +import AgentApprovalSnapshotPreview from '#/views/system/approval/components/AgentApprovalSnapshotPreview.vue'; import BotApprovalSnapshotPreview from '#/views/system/approval/components/BotApprovalSnapshotPreview.vue'; import KnowledgeApprovalSnapshotPreview from '#/views/system/approval/components/KnowledgeApprovalSnapshotPreview.vue'; import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue'; @@ -33,6 +34,7 @@ const detail = ref(null); const approvalActionLoading = ref<'approve' | 'reject' | 'revoke' | null>(null); const resourceLabelMap: Record = { + AGENT: $t('approval.resource.agent'), BOT: $t('approval.resource.bot'), KNOWLEDGE: $t('approval.resource.knowledge'), WORKFLOW: $t('approval.resource.workflow'), @@ -91,6 +93,13 @@ const botSnapshot = computed(() => { return detail.value?.snapshotJson?.resourceSnapshot || null; }); +const agentSnapshot = computed(() => { + if (detail.value?.resourceType !== 'AGENT') { + return null; + } + return detail.value?.snapshotJson?.resourceSnapshot || null; +}); + onMounted(() => { void loadDetail(); }); @@ -498,8 +507,12 @@ function formatEventInfo(row: Record) {

{{ $t('approval.section.snapshot') }}

+ ([]); const roleOptions = ref([]); const accountOptions = ref([]); const categoryOptions = ref, SelectOption[]>>({ + AGENT: [], BOT: [], KNOWLEDGE: [], WORKFLOW: [], @@ -188,7 +189,7 @@ function buildDefaultForm(): FlowFormModel { name: '', priority: 100, remark: '', - resourceType: 'BOT', + resourceType: 'AGENT', scopes: [], status: 'ENABLED', steps: [buildDefaultStep()], @@ -236,7 +237,7 @@ async function openDialog(row: any = {}) { name: res.data?.name || '', priority: Number(res.data?.priority || 100), remark: res.data?.remark || '', - resourceType: res.data?.resourceType || 'BOT', + resourceType: res.data?.resourceType || 'AGENT', scopes: (res.data?.scopes || []).map((item: any) => ({ id: item.id, includeChildren: item.includeChildren === 1, @@ -279,8 +280,8 @@ async function ensureCategoryOptions() { if (categoryLoaded.value) { return; } - const [botRes, workflowRes, knowledgeRes] = await Promise.all([ - api.get('/api/v1/botCategory/list', { + const [agentRes, workflowRes, knowledgeRes] = await Promise.all([ + api.get('/api/v1/agentCategory/list', { params: { sortKey: 'sortNo', sortType: 'asc' }, }), api.get('/api/v1/workflowCategory/list', { @@ -291,7 +292,8 @@ async function ensureCategoryOptions() { }), ]); categoryOptions.value = { - BOT: normalizeCategoryOptions(botRes.data, 'categoryName'), + AGENT: normalizeCategoryOptions(agentRes.data, 'categoryName'), + BOT: [], KNOWLEDGE: normalizeCategoryOptions(knowledgeRes.data, 'categoryName'), WORKFLOW: normalizeCategoryOptions(workflowRes.data, 'categoryName'), }; diff --git a/easyflow-ui-admin/app/src/views/system/approval/ApprovalManage.vue b/easyflow-ui-admin/app/src/views/system/approval/ApprovalManage.vue index eb19bbc0..912bfcc4 100644 --- a/easyflow-ui-admin/app/src/views/system/approval/ApprovalManage.vue +++ b/easyflow-ui-admin/app/src/views/system/approval/ApprovalManage.vue @@ -35,6 +35,7 @@ import ApprovalFlowModal from './ApprovalFlowModal.vue'; type ApprovalTabName = 'flow' | 'initiated' | 'pending' | 'processed'; const RESOURCE_OPTIONS = [ + { label: $t('approval.resource.agent'), value: 'AGENT' }, { label: $t('approval.resource.bot'), value: 'BOT' }, { label: $t('approval.resource.workflow'), value: 'WORKFLOW' }, { label: $t('approval.resource.knowledge'), value: 'KNOWLEDGE' }, diff --git a/easyflow-ui-admin/app/src/views/system/approval/components/AgentApprovalSnapshotPreview.vue b/easyflow-ui-admin/app/src/views/system/approval/components/AgentApprovalSnapshotPreview.vue new file mode 100644 index 00000000..04d716ae --- /dev/null +++ b/easyflow-ui-admin/app/src/views/system/approval/components/AgentApprovalSnapshotPreview.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/system/sysRole/SysRoleModal.vue b/easyflow-ui-admin/app/src/views/system/sysRole/SysRoleModal.vue index a25233a2..38c6ff78 100644 --- a/easyflow-ui-admin/app/src/views/system/sysRole/SysRoleModal.vue +++ b/easyflow-ui-admin/app/src/views/system/sysRole/SysRoleModal.vue @@ -17,7 +17,13 @@ defineExpose({ openDialog, }); -type ResourceType = 'BOT' | 'KNOWLEDGE' | 'PLUGIN' | 'RESOURCE' | 'WORKFLOW'; +type ResourceType = + | 'AGENT' + | 'BOT' + | 'KNOWLEDGE' + | 'PLUGIN' + | 'RESOURCE' + | 'WORKFLOW'; interface CategoryScopeItem { categoryIds: Array; @@ -40,6 +46,7 @@ const RESOURCE_SCOPE_GROUPS: Array<{ label: string; resourceType: ResourceType; }> = [ + { resourceType: 'AGENT', label: $t('menus.ai.agents') }, { resourceType: 'BOT', label: $t('bot.chatAssistant') }, { resourceType: 'PLUGIN', label: $t('menus.ai.plugin') }, { resourceType: 'WORKFLOW', label: $t('menus.ai.workflow') }, @@ -54,6 +61,7 @@ const entity = ref(buildDefaultEntity()); const categoryScopeLoaded = ref(false); const categoryScopeEditable = ref(false); const categoryOptions = ref>({ + AGENT: [], BOT: [], KNOWLEDGE: [], PLUGIN: [], @@ -186,6 +194,9 @@ async function ensureCategoryOptions() { return; } const requests = [ + api.get('/api/v1/agentCategory/list', { + params: { sortKey: 'sortNo', sortType: 'asc' }, + }), api.get('/api/v1/botCategory/list', { params: { sortKey: 'sortNo', sortType: 'asc' }, }), @@ -201,9 +212,10 @@ async function ensureCategoryOptions() { }), ]; - const [botRes, pluginRes, workflowRes, knowledgeRes, resourceRes] = + const [agentRes, botRes, pluginRes, workflowRes, knowledgeRes, resourceRes] = await Promise.all(requests); categoryOptions.value = { + AGENT: normalizeCategoryOptions(agentRes.data, 'categoryName'), BOT: normalizeCategoryOptions(botRes.data, 'categoryName'), KNOWLEDGE: normalizeCategoryOptions(knowledgeRes.data, 'categoryName'), PLUGIN: normalizeCategoryOptions(pluginRes.data, 'name'), diff --git a/easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue b/easyflow-ui-usercenter/app/src/components/chat-timeline/ChatBubbleList.vue similarity index 99% rename from easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue rename to easyflow-ui-usercenter/app/src/components/chat-timeline/ChatBubbleList.vue index 6e863c87..a7c93638 100644 --- a/easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue +++ b/easyflow-ui-usercenter/app/src/components/chat-timeline/ChatBubbleList.vue @@ -28,7 +28,8 @@ import ShowJson from '#/components/json/ShowJson.vue'; interface Props { allowRegenerate?: boolean; allowVariantSwitch?: boolean; - bot: any; + assistant?: any; + bot?: any; messages: ChatTimeTimelineItem[]; regenerateDisabled?: boolean; switchingRoundIds?: string[]; @@ -54,7 +55,7 @@ const emit = defineEmits<{ }>(); function getAssistantAvatar() { - return props.bot.icon || defaultAssistantAvatar; + return props.assistant?.icon || props.bot?.icon || defaultAssistantAvatar; } function getUserAvatar() { return store.userInfo?.avatar || defaultUserAvatar; diff --git a/easyflow-ui-usercenter/app/src/components/chat/index.ts b/easyflow-ui-usercenter/app/src/components/chat/index.ts index aec9fd69..c14a02fe 100644 --- a/easyflow-ui-usercenter/app/src/components/chat/index.ts +++ b/easyflow-ui-usercenter/app/src/components/chat/index.ts @@ -1,3 +1,3 @@ -export { default as ChatBubbleList } from './bubbleList.vue'; +export { default as ChatBubbleList } from '../chat-timeline/ChatBubbleList.vue'; export { default as ChatContainer } from './container.vue'; export { default as ChatSender } from './sender.vue'; diff --git a/easyflow-ui-usercenter/app/src/views/chatHistory/index.vue b/easyflow-ui-usercenter/app/src/views/chatHistory/index.vue index a728ffee..5c90d104 100644 --- a/easyflow-ui-usercenter/app/src/views/chatHistory/index.vue +++ b/easyflow-ui-usercenter/app/src/views/chatHistory/index.vue @@ -28,7 +28,7 @@ import { import { tryit } from 'radash'; import { api } from '#/api/request'; -import { ChatBubbleList } from '#/components/chat'; +import ChatBubbleList from '#/components/chat-timeline/ChatBubbleList.vue'; const route = useRoute(); const router = useRouter(); @@ -58,7 +58,10 @@ const messagePage = ref({ pageNumber: 1, pageSize: 20, }); -const variantSwitchController = createChatVariantSwitchController({ +const variantSwitchController = createChatVariantSwitchController< + any, + ChatTimeTimelineItem +>({ mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records), onError: () => ElMessage.error('答案版本切换失败'), onStateChange: () => { @@ -78,7 +81,11 @@ const filteredSessions = computed(() => { const title = String(item.title || '').toLowerCase(); const preview = String(item.lastMessagePreview || '').toLowerCase(); const assistantName = String(item.assistantName || '').toLowerCase(); - return title.includes(keyword) || preview.includes(keyword) || assistantName.includes(keyword); + return ( + title.includes(keyword) || + preview.includes(keyword) || + assistantName.includes(keyword) + ); }); }); @@ -99,19 +106,20 @@ watch( messageList.value = []; return; } - if (!currentSession.value || String(currentSession.value.id) !== String(sessionId)) { + if ( + !currentSession.value || + String(currentSession.value.id) !== String(sessionId) + ) { await openSession(String(sessionId)); } }, ); async function fetchAssistants() { - const [, res] = await tryit(api.get)('/userCenter/bot/list', { - params: { status: 1 }, - }); + const [, res] = await tryit(api.get)('/userCenter/agent/list'); if (res?.errorCode === 0) { assistantList.value = (res.data || []).map((item: any) => ({ - label: item.title, + label: item.name, value: item.id, })); } @@ -135,7 +143,9 @@ async function fetchSessions() { async function openSession(sessionId: string | number) { drawerLoading.value = true; - const [, summaryRes] = await tryit(api.get)(`/userCenter/chatHistory/sessions/${sessionId}`); + const [, summaryRes] = await tryit(api.get)( + `/userCenter/chatHistory/sessions/${sessionId}`, + ); if (summaryRes?.errorCode !== 0) { drawerLoading.value = false; return; @@ -306,9 +316,12 @@ async function renameSession(session: any) { if (!value) { return; } - const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/rename`, { - title: value, - }); + const [, res] = await tryit(api.post)( + `/userCenter/chatHistory/sessions/${session.id}/rename`, + { + title: value, + }, + ); if (res?.errorCode === 0) { ElMessage.success('重命名成功'); if (currentSession.value?.id === session.id) { @@ -323,16 +336,21 @@ function deleteSession(session: any) { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', - }).then(async () => { - const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/delete`, {}); - if (res?.errorCode === 0) { - ElMessage.success('删除成功'); - if (currentSession.value?.id === session.id) { - closeDrawer(); + }) + .then(async () => { + const [, res] = await tryit(api.post)( + `/userCenter/chatHistory/sessions/${session.id}/delete`, + {}, + ); + if (res?.errorCode === 0) { + ElMessage.success('删除成功'); + if (currentSession.value?.id === session.id) { + closeDrawer(); + } + await fetchSessions(); } - await fetchSessions(); - } - }).catch(() => {}); + }) + .catch(() => {}); } function formatTime(value?: string) { @@ -350,7 +368,12 @@ function formatTime(value?: string) {