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
+ *
+ * @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