fix: 完成系统向智能体数据链路切换

- 切换工作台、聊天历史、资源候选与公共调用到 Agent

- 加固资源绑定、删除保护及发布运行并发控制

- 隔离旧 Bot 专属服务和组件并保留兼容入口
This commit is contained in:
2026-07-31 14:24:15 +08:00
parent f0aba1eddd
commit f872eac1f9
114 changed files with 5997 additions and 874 deletions

View File

@@ -35,22 +35,23 @@ import tech.easyflow.agent.runtime.document.AgentDocumentUploadView;
import tech.easyflow.agent.runtime.media.AgentMediaService; import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.runtime.media.AgentMediaUploadView; import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
import com.easyagents.agent.runtime.media.AgentMediaResource; import com.easyagents.agent.runtime.media.AgentMediaResource;
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
import tech.easyflow.agent.service.AgentApprovalStateService; import tech.easyflow.agent.service.AgentApprovalStateService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService; 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.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.controller.BaseCurdController; 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.web.jsonbody.JsonBody;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.log.annotation.LogReporterDisabled; 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 javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
@@ -60,8 +61,6 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import static tech.easyflow.agent.entity.table.AgentTableDef.AGENT;
/** /**
* Agent 管理端控制器。 * Agent 管理端控制器。
*/ */
@@ -78,10 +77,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
@Resource @Resource
private AgentPublishAppService agentPublishAppService; private AgentPublishAppService agentPublishAppService;
@Resource @Resource
private ResourceAccessService resourceAccessService;
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private AgentApprovalStateService agentApprovalStateService; private AgentApprovalStateService agentApprovalStateService;
@Resource @Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@@ -91,6 +86,10 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
private AgentDocumentService agentDocumentService; private AgentDocumentService agentDocumentService;
@Resource @Resource
private AgentComposerDraftService agentComposerDraftService; private AgentComposerDraftService agentComposerDraftService;
@Resource
private AgentOptionQueryService agentOptionQueryService;
@Resource
private AgentVisibilityQueryHelper agentVisibilityQueryHelper;
/** /**
* 创建 Agent 控制器。 * 创建 Agent 控制器。
@@ -151,20 +150,67 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) { public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) {
HttpServletRequest request = currentRequest(); HttpServletRequest request = currentRequest();
QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request); QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request);
if (!applyCategoryPermission(queryWrapper)) { agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
return Result.ok(Collections.emptyList());
}
applyPublishedOnlyFilter(queryWrapper); applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<Agent> agents = service.list(queryWrapper); List<Agent> agents = service.list(queryWrapper);
if (isPublishedOnlyRequest()) { if (isPublishedOnlyRequest()) {
agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList(); agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList();
} }
agents.forEach(this::sanitizeListItem);
agentApprovalStateService.fillAgentApprovalState(agents); agentApprovalStateService.fillAgentApprovalState(agents);
aiResourceCreatorNameSupport.fillAgentCreatorNames(agents); aiResourceCreatorNameSupport.fillAgentCreatorNames(agents);
return Result.ok(agents); return Result.ok(agents);
} }
/**
* 查询 Agent 安全选择项。
*
* @param publishedOnly 是否仅返回已发布 Agent
* @return Agent 安全选择项
*/
@GetMapping("/options")
@SaCheckPermission("/api/v1/agent/query")
public Result<List<AgentOptionView>> 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<AgentResourceOptionsView> resourceOptions() {
return Result.ok(agentOptionQueryService.listDesignerResourceOptions());
}
/**
* 查询 Agent 会话可使用的知识库安全选项。
*
* @return 知识库选项
*/
@GetMapping("/knowledgeOptions")
@SaCheckPermission("/api/v1/agent/query")
public Result<List<AgentResourceOptionsView.ResourceOption>> knowledgeOptions() {
return Result.ok(agentOptionQueryService.listKnowledgeOptions());
}
/**
* 查询指定 MCP 的安全工具列表。
*
* @param id MCP ID
* @return MCP 工具列表
*/
@GetMapping("/mcpToolOptions")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentResourceOptionsView.McpToolOption>> mcpToolOptions(
@RequestParam BigInteger id) {
return Result.ok(agentOptionQueryService.listMcpTools(id));
}
/** /**
* 运行 Agent 纯文本聊天。 * 运行 Agent 纯文本聊天。
* *
@@ -488,16 +534,13 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
} }
@Override @Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) { public Result<?> remove(Serializable id) {
for (Serializable id : ids) { throw new BusinessException("Agent 仅支持通过生命周期审批删除");
Agent agent = service.getById(String.valueOf(id)); }
if (agent != null) {
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限删除该 Agent"); @Override
} public Result<?> removeBatch(Collection<Serializable> ids) {
} throw new BusinessException("Agent 仅支持通过生命周期审批删除");
agentToolBindingService.remove(QueryWrapper.create().in("agent_id", ids));
agentKnowledgeBindingService.remove(QueryWrapper.create().in("agent_id", ids));
return super.onRemoveBefore(ids);
} }
/** /**
@@ -509,38 +552,54 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
*/ */
@Override @Override
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) { protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
if (!applyCategoryPermission(queryWrapper)) { agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
}
applyPublishedOnlyFilter(queryWrapper); applyPublishedOnlyFilter(queryWrapper);
Page<Agent> result = super.queryPage(page, queryWrapper); Page<Agent> result = service.page(page, queryWrapper);
if (isPublishedOnlyRequest()) { if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList()); result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList());
} }
result.getRecords().forEach(this::sanitizeListItem);
agentApprovalStateService.fillAgentApprovalState(result.getRecords()); agentApprovalStateService.fillAgentApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords()); aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords());
return result; 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()) { agent.setModelConfigJson(Collections.emptyMap());
queryWrapper.eq(Agent::getCreatedBy, access.getAccountId()); agent.setGenerationConfigJson(Collections.emptyMap());
return true; agent.setPromptConfigJson(Collections.emptyMap());
} agent.setMemoryConfigJson(Collections.emptyMap());
queryWrapper.and(AGENT.CREATED_BY.eq(access.getAccountId()).or(AGENT.CATEGORY_ID.in(access.getCategoryIds()))); agent.setExecutionConfigJson(Collections.emptyMap());
return true; agent.setInteractionConfigJson(Collections.emptyMap());
agent.setPublishedSnapshotJson(Collections.emptyMap());
agent.setToolBindings(null);
agent.setKnowledgeBindings(null);
} }
/**
* 为仅发布查询追加发布状态条件。
*
* @param queryWrapper Agent 查询条件
*/
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) { private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) { if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode()); queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
} }
} }
/**
* 判断当前请求是否只查询已发布 Agent。
*
* @return 是否仅查询已发布 Agent
*/
private boolean isPublishedOnlyRequest() { private boolean isPublishedOnlyRequest() {
HttpServletRequest request = currentRequest(); HttpServletRequest request = currentRequest();
if (request == null) { if (request == null) {
@@ -562,6 +621,14 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
return attributes.getRequest(); return attributes.getRequest();
} }
/**
* 将审批执行结果转换为统一响应。
*
* @param actionResult 审批动作结果
* @param approvalMessage 进入审批时的提示
* @param directMessage 直接执行时的提示
* @return 审批实例响应
*/
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult, private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage, String approvalMessage,
String directMessage) { String directMessage) {

View File

@@ -11,6 +11,7 @@ import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger; import java.math.BigInteger;
@@ -130,7 +131,17 @@ public class AgentSessionController {
return Result.ok(); return Result.ok();
} }
/**
* 获取当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录信息失效时抛出
*/
private LoginAccount currentAccount() { private LoginAccount currentAccount() {
return SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
} }
} }

View File

@@ -18,7 +18,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 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.admin.service.ai.ChatWorkspaceService;
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener; import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
@@ -82,9 +82,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Resource @Resource
private ChatRoundOperateService chatRoundOperateService; private ChatRoundOperateService chatRoundOperateService;
@Resource @Resource
private AiResourceApprovalStateService aiResourceApprovalStateService; private BotApprovalStateService botApprovalStateService;
@Resource @Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; private BotResourceCreatorNameSupport botResourceCreatorNameSupport;
@Resource @Resource
private ChatWorkspaceService chatWorkspaceService; private ChatWorkspaceService chatWorkspaceService;
@@ -240,7 +240,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
bot = botService.toPublishedView(rawBot); bot = botService.toPublishedView(rawBot);
} }
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(bot); botApprovalStateService.fillApprovalState(bot);
} }
return Result.ok(bot); return Result.ok(bot);
} }
@@ -275,7 +275,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (data.getModelId() == null) { if (data.getModelId() == null) {
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data); botApprovalStateService.fillApprovalState(data);
} }
return Result.ok(data); return Result.ok(data);
} }
@@ -286,7 +286,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (llm == null) { if (llm == null) {
data.setModelId(null); data.setModelId(null);
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data); botApprovalStateService.fillApprovalState(data);
} }
return Result.ok(data); return Result.ok(data);
} }
@@ -302,7 +302,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
} }
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data); botApprovalStateService.fillApprovalState(data);
} }
return Result.ok(data); return Result.ok(data);
} }
@@ -369,7 +369,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (isPublishedOnlyRequest()) { if (isPublishedOnlyRequest()) {
bots = bots.stream().map(botService::toPublishedView).toList(); bots = bots.stream().map(botService::toPublishedView).toList();
} }
aiResourceApprovalStateService.fillBotApprovalState(bots); botApprovalStateService.fillApprovalState(bots);
return Result.ok(bots); return Result.ok(bots);
} }
@@ -381,8 +381,8 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (isPublishedOnlyRequest()) { if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList()); result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
} }
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords()); botApprovalStateService.fillApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords()); botResourceCreatorNameSupport.fillCreatorNames(result.getRecords());
return result; return result;
} }

View File

@@ -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 插件工具绑定查询控制器。
*
* <p>保留历史接口地址,同时将 Bot 表依赖限制在 Bot 专属代码中。</p>
*/
@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<List<PluginItem>> 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<List<PluginItem>> getPluginTools(
@JsonBody(value = "botId", required = true) BigInteger botId) {
return Result.ok(botPluginService.getPluginTools(botId));
}
}

View File

@@ -14,10 +14,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; 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.permission.KnowledgeVisibilityQueryHelper;
import tech.easyflow.ai.documentimport.DocumentImportDtos; import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem; import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.PublishStatus; 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.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes; import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.DocumentChunkService; import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
@@ -68,7 +68,7 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
private final ModelService llmService; private final ModelService llmService;
@Resource @Resource
private BotDocumentCollectionService botDocumentCollectionService; private AgentKnowledgeBindingService agentKnowledgeBindingService;
@Resource @Resource
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Resource @Resource
@@ -169,11 +169,11 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
} }
QueryWrapper queryWrapper = QueryWrapper.create(); QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.in(BotDocumentCollection::getDocumentCollectionId, ids); queryWrapper.in(AgentKnowledgeBinding::getKnowledgeId, ids);
boolean exists = botDocumentCollectionService.exists(queryWrapper); boolean exists = agentKnowledgeBindingService.exists(queryWrapper);
if (exists){ if (exists){
throw new BusinessException("此知识库还关联着bot请先取消关联"); throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除");
} }
return null; return null;

View File

@@ -9,16 +9,19 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.Mcp; import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.service.BotMcpService; import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.McpService; import tech.easyflow.ai.service.McpService;
import tech.easyflow.common.domain.Result; 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.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger;
/** /**
* 控制层。 * 控制层。
@@ -34,7 +37,7 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
} }
@Resource @Resource
private BotMcpService botMcpService; private AgentResourceReferenceService agentResourceReferenceService;
@Override @Override
public Result<?> save(Mcp entity) { public Result<?> save(Mcp entity) {
return service.saveMcp(entity); return service.saveMcp(entity);
@@ -45,11 +48,29 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
return service.updateMcp(entity); return service.updateMcp(entity);
} }
/**
* 删除未被 Agent 绑定的 MCP。
*
* @param id MCP ID
* @return 删除结果
*/
@Override @Override
@Transactional @Transactional(rollbackFor = Exception.class)
public Result<?> remove(Serializable id) { 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); service.removeMcp(id);
botMcpService.remove(QueryWrapper.create().eq(BotMcp::getMcpId, id));
return Result.ok(); return Result.ok();
} }

View File

@@ -11,6 +11,7 @@ import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.table.ModelTableDef; import tech.easyflow.ai.entity.table.ModelTableDef;
import tech.easyflow.ai.mapper.ModelMapper; import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.capability.ModelCapabilityResolution; import tech.easyflow.ai.service.capability.ModelCapabilityResolution;
import tech.easyflow.common.domain.Result; 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.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree; import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.web.controller.BaseCurdController; 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.web.jsonbody.JsonBody;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -47,6 +49,8 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
@Resource @Resource
ModelMapper modelMapper; ModelMapper modelMapper;
@Resource
AgentResourceReferenceService agentResourceReferenceService;
@GetMapping("list") @GetMapping("list")
@SaCheckPermission("/api/v1/model/query") @SaCheckPermission("/api/v1/model/query")
@@ -99,7 +103,22 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
@PostMapping("/removeByEntity") @PostMapping("/removeByEntity")
@SaCheckPermission("/api/v1/model/remove") @SaCheckPermission("/api/v1/model/remove")
@Transactional(rollbackFor = Exception.class)
public Result<?> removeByEntity(@RequestBody Model entity) { public Result<?> removeByEntity(@RequestBody Model entity) {
LoginAccount account = requireAccount();
List<Model> 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); modelService.removeByEntity(entity);
return Result.ok(); return Result.ok();
} }
@@ -172,11 +191,39 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
} }
@PostMapping("removeLlmByIds") @PostMapping("removeLlmByIds")
@Transactional @SaCheckPermission("/api/v1/model/remove")
@Transactional(rollbackFor = Exception.class)
public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) { public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) {
LoginAccount account = requireAccount();
List<Serializable> ids = Collections.singletonList(id); List<Serializable> ids = Collections.singletonList(id);
QueryWrapper queryWrapper = QueryWrapper.create().in(Model::getId, ids); QueryWrapper queryWrapper = QueryWrapper.create()
service.remove(queryWrapper); .in(Model::getId, ids)
.eq(Model::getTenantId, account.getTenantId())
.orderBy(Model::getId, true)
.forUpdate();
List<Model> 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 Result.ok();
} }
/**
* 获取带租户信息的当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录状态无效时抛出
*/
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
} }

View File

@@ -12,6 +12,7 @@ import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
@@ -47,8 +48,14 @@ import static tech.easyflow.ai.entity.table.PluginTableDef.PLUGIN;
@RestController @RestController
@RequestMapping("/api/v1/plugin") @RequestMapping("/api/v1/plugin")
public class PluginController extends BaseCurdController<PluginService, Plugin> { public class PluginController extends BaseCurdController<PluginService, Plugin> {
/**
* 创建插件控制器。
*
* @param service 插件服务
*/
public PluginController(PluginService service) { public PluginController(PluginService service) {
super(service); super(service);
this.pluginService = service;
} }
@Resource @Resource
@@ -89,10 +96,15 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
return Result.ok(pluginService.updatePlugin(plugin)); return Result.ok(pluginService.updatePlugin(plugin));
} }
/**
* 删除插件。
*
* @param id 插件 ID
* @return 删除结果
*/
@PostMapping("/plugin/remove") @PostMapping("/plugin/remove")
@SaCheckPermission("/api/v1/plugin/remove") @SaCheckPermission("/api/v1/plugin/remove")
public Result<Boolean> removePlugin(@JsonBody(value = "id", required = true) String id){ public Result<Boolean> removePlugin(@JsonBody(value = "id", required = true) String id){
return Result.ok(pluginService.removePlugin(id)); return Result.ok(pluginService.removePlugin(id));
} }

View File

@@ -19,19 +19,20 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType; import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; 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.PluginService;
import tech.easyflow.ai.service.PluginItemService; 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.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -42,8 +43,10 @@ import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 控制层。 * 控制层。
@@ -55,6 +58,11 @@ import java.util.Map;
@RequestMapping("/api/v1/pluginItem") @RequestMapping("/api/v1/pluginItem")
@UsePermission(moduleName = "/api/v1/plugin") @UsePermission(moduleName = "/api/v1/plugin")
public class PluginItemController extends BaseCurdController<PluginItemService, PluginItem> { public class PluginItemController extends BaseCurdController<PluginItemService, PluginItem> {
/**
* 创建插件工具控制器。
*
* @param service 插件工具服务
*/
public PluginItemController(PluginItemService service) { public PluginItemController(PluginItemService service) {
super(service); super(service);
} }
@@ -63,10 +71,12 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
private PluginItemService pluginItemService; private PluginItemService pluginItemService;
@Resource @Resource
private BotPluginService botPluginService; private AgentResourceReferenceService agentResourceReferenceService;
@Resource @Resource
private PluginService pluginService; private PluginService pluginService;
@Resource @Resource
private PluginVisibilityService pluginVisibilityService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource @Resource
private WorkflowService workflowService; private WorkflowService workflowService;
@@ -91,25 +101,12 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return pluginItemService.searchPlugin(aiPluginToolId); return pluginItemService.searchPlugin(aiPluginToolId);
} }
@PostMapping("/toolsList")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> 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") @PostMapping("/tool/update")
@SaCheckPermission("/api/v1/plugin/save") @SaCheckPermission("/api/v1/plugin/save")
public Result<Boolean> updatePlugin(@JsonBody PluginItem pluginItem){ public Result<Boolean> updatePlugin(@JsonBody PluginItem pluginItem){
return Result.ok(pluginItemService.updatePlugin(pluginItem)); return Result.ok(pluginItemService.updatePlugin(pluginItem));
} }
@PostMapping("/tool/list")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> getPluginToolList(@JsonBody(value = "botId", required = true) BigInteger botId){
return Result.ok(pluginItemService.getPluginToolList(botId));
}
@GetMapping("/getTinyFlowData") @GetMapping("/getTinyFlowData")
@SaCheckPermission("/api/v1/plugin/query") @SaCheckPermission("/api/v1/plugin/query")
public Result<?> getTinyFlowData(BigInteger id) { public Result<?> getTinyFlowData(BigInteger id) {
@@ -275,26 +272,64 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return plugin; return plugin;
} }
/**
* 删除插件工具前锁定资源并校验 Agent 绑定。
*
* @param ids 插件工具 ID 集合
* @return 校验失败结果;允许删除时返回 {@code null}
*/
@Override @Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) { protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
Set<BigInteger> uniquePluginItemIds = new LinkedHashSet<>();
QueryWrapper queryWrapper = QueryWrapper.create(); try {
queryWrapper.in(BotPlugin::getPluginItemId, ids); for (Serializable id : ids) {
if (id == null) {
boolean exists = botPluginService.exists(queryWrapper); throw new NumberFormatException("null");
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, "工作流插件工具由系统自动维护,不支持删除");
} }
uniquePluginItemIds.add(new BigInteger(String.valueOf(id)));
}
} catch (NumberFormatException exception) {
throw new BusinessException("插件工具 ID 不合法");
}
// BaseCurdController#remove 已开启事务;锁定工具行后校验权限和 Agent 引用。
List<PluginItem> 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<BigInteger, Plugin> 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; return null;
} }
} }

View File

@@ -14,6 +14,9 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; 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.WorkflowShareResourceAccessGrantProvider;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; 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.service.AiResourceApprovalStateService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo; import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
@@ -74,7 +76,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@Resource @Resource
private SysApiKeyService apiKeyService; private SysApiKeyService apiKeyService;
@Resource @Resource
private BotWorkflowService botWorkflowService; private AgentToolBindingService agentToolBindingService;
@Resource @Resource
private ChainExecutor chainExecutor; private ChainExecutor chainExecutor;
@Resource @Resource
@@ -558,11 +560,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
Workflow workflow = requireWorkflow(String.valueOf(id)); Workflow workflow = requireWorkflow(String.valueOf(id));
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流"); resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流");
} }
QueryWrapper queryWrapper = QueryWrapper.create(); QueryWrapper queryWrapper = QueryWrapper.create()
queryWrapper.in("workflow_id", ids); .eq(AgentToolBinding::getToolType, AgentToolType.WORKFLOW.name())
boolean exists = botWorkflowService.exists(queryWrapper); .in(AgentToolBinding::getTargetId, ids);
boolean exists = agentToolBindingService.exists(queryWrapper);
if (exists) { if (exists) {
return Result.fail(1, "此工作流还关联有bot,请先取消关联后再删除"); return Result.fail(1, "此工作流仍被智能体使用,请先取消绑定后再删除");
} }
return null; return null;
} }

View File

@@ -2,7 +2,6 @@ package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
@@ -41,15 +40,6 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName); fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName);
} }
/**
* 批量填充聊天助手创建人名称。
*
* @param bots 聊天助手集合
*/
public void fillBotCreatorNames(Collection<Bot> bots) {
fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
}
/** /**
* 批量填充知识库创建人名称。 * 批量填充知识库创建人名称。
* *
@@ -94,7 +84,7 @@ public class AiResourceCreatorNameSupport {
* @param createdByNameSetter 创建人名称回填函数 * @param createdByNameSetter 创建人名称回填函数
* @param <T> 资源类型 * @param <T> 资源类型
*/ */
private <T> void fillCreatorNames( <T> void fillCreatorNames(
Collection<T> resources, Collection<T> resources,
Function<T, Number> createdByGetter, Function<T, Number> createdByGetter,
BiConsumer<T, String> createdByNameSetter BiConsumer<T, String> createdByNameSetter

View File

@@ -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<Bot> bots) {
creatorNameSupport.fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
}
}

View File

@@ -17,8 +17,6 @@ public class DashboardDistributionItemVo {
private Long activeUserTotal; private Long activeUserTotal;
private Long botTotal;
private Long workflowTotal; private Long workflowTotal;
private Long knowledgeBaseTotal; private Long knowledgeBaseTotal;
@@ -73,14 +71,6 @@ public class DashboardDistributionItemVo {
this.activeUserTotal = activeUserTotal; this.activeUserTotal = activeUserTotal;
} }
public Long getBotTotal() {
return botTotal;
}
public void setBotTotal(Long botTotal) {
this.botTotal = botTotal;
}
public Long getWorkflowTotal() { public Long getWorkflowTotal() {
return workflowTotal; return workflowTotal;
} }

View File

@@ -9,7 +9,8 @@ public class DashboardSummaryVo {
private Long activeUserTotal; private Long activeUserTotal;
private Long botTotal; /** 智能体总数。 */
private Long agentTotal;
private Long workflowTotal; private Long workflowTotal;
@@ -39,12 +40,22 @@ public class DashboardSummaryVo {
this.activeUserTotal = activeUserTotal; 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() { public Long getWorkflowTotal() {

View File

@@ -8,8 +8,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Bot; import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.service.BotService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo; import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; 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.SysAccount;
import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysAccountRoleService;
@@ -81,7 +82,7 @@ public class DashboardServiceImpl implements DashboardService {
private ChatDashboardQueryService chatDashboardQueryService; private ChatDashboardQueryService chatDashboardQueryService;
@Resource @Resource
private BotService botService; private AgentService agentService;
@Resource @Resource
private CategoryPermissionService categoryPermissionService; private CategoryPermissionService categoryPermissionService;
@@ -156,7 +157,7 @@ public class DashboardServiceImpl implements DashboardService {
DashboardSummaryVo summary = new DashboardSummaryVo(); DashboardSummaryVo summary = new DashboardSummaryVo();
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context)); summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
summary.setActiveUserTotal(countActiveUsers(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.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context)); summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
summary.setChatMessageTotal(0L); summary.setChatMessageTotal(0L);
@@ -863,18 +864,18 @@ public class DashboardServiceImpl implements DashboardService {
if (assistantId == null) { if (assistantId == null) {
return null; return null;
} }
Bot bot = botService.getById(assistantId); Agent agent = agentService.getById(assistantId);
if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) { if (agent == null) {
throw new BusinessException("聊天助手不存在或未启用"); throw new BusinessException("智能体不存在或不可见");
} }
boolean visible = categoryPermissionService.canAccessCategory( boolean visible = categoryPermissionService.canAccessCategory(
loginAccount, loginAccount,
"BOT", CategoryResourceType.AGENT.getCode(),
bot.getCreatedBy(), agent.getCreatedBy(),
bot.getCategoryId() agent.getCategoryId()
); );
if (!visible) { if (!visible) {
throw new BusinessException("聊天助手不存在或未启用"); throw new BusinessException("智能体不存在或不可见");
} }
return assistantId; return assistantId;
} }

View File

@@ -6,8 +6,9 @@ import org.testng.Assert;
import org.testng.annotations.BeforeMethod; import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; 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.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.BotDocumentCollectionService;
import tech.easyflow.ai.service.BotMessageService; import tech.easyflow.ai.service.BotMessageService;
import tech.easyflow.ai.service.BotService; import tech.easyflow.ai.service.BotService;
@@ -38,7 +39,7 @@ public class BotControllerTest {
private BotDocumentCollectionService botDocumentCollectionService; private BotDocumentCollectionService botDocumentCollectionService;
private BotMessageService botMessageService; private BotMessageService botMessageService;
private CategoryPermissionService categoryPermissionService; private CategoryPermissionService categoryPermissionService;
private AiResourceApprovalStateService aiResourceApprovalStateService; private BotApprovalStateService botApprovalStateService;
private SysAccountService sysAccountService; private SysAccountService sysAccountService;
/** /**
@@ -52,7 +53,7 @@ public class BotControllerTest {
botDocumentCollectionService = mock(BotDocumentCollectionService.class); botDocumentCollectionService = mock(BotDocumentCollectionService.class);
botMessageService = mock(BotMessageService.class); botMessageService = mock(BotMessageService.class);
categoryPermissionService = mock(CategoryPermissionService.class); categoryPermissionService = mock(CategoryPermissionService.class);
aiResourceApprovalStateService = mock(AiResourceApprovalStateService.class); botApprovalStateService = mock(BotApprovalStateService.class);
sysAccountService = mock(SysAccountService.class); sysAccountService = mock(SysAccountService.class);
} }
@@ -69,10 +70,12 @@ public class BotControllerTest {
botMessageService botMessageService
); );
AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport(); AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport();
BotResourceCreatorNameSupport botCreatorNameSupport =
new BotResourceCreatorNameSupport(creatorNameSupport);
setField(creatorNameSupport, "sysAccountService", sysAccountService); setField(creatorNameSupport, "sysAccountService", sysAccountService);
setField(controller, "categoryPermissionService", categoryPermissionService); setField(controller, "categoryPermissionService", categoryPermissionService);
setField(controller, "aiResourceApprovalStateService", aiResourceApprovalStateService); setField(controller, "botApprovalStateService", botApprovalStateService);
setField(controller, "aiResourceCreatorNameSupport", creatorNameSupport); setField(controller, "botResourceCreatorNameSupport", botCreatorNameSupport);
Bot bot = new Bot(); Bot bot = new Bot();
bot.setId(BigInteger.valueOf(101)); bot.setId(BigInteger.valueOf(101));
@@ -84,7 +87,7 @@ public class BotControllerTest {
when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page); when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page);
when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7)))) when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7))))
.thenReturn(Map.of(BigInteger.valueOf(7), "管理员")); .thenReturn(Map.of(BigInteger.valueOf(7), "管理员"));
doNothing().when(aiResourceApprovalStateService).fillBotApprovalState(page.getRecords()); doNothing().when(botApprovalStateService).fillApprovalState(page.getRecords());
Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create()); Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create());

View File

@@ -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<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
controller.remove(BigInteger.TEN);
}
ArgumentCaptor<QueryWrapper> 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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
controller.onRemoveBefore(List.of(BigInteger.TWO, BigInteger.ONE));
}
ArgumentCaptor<QueryWrapper> 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);
}
}
}

View File

@@ -4,8 +4,8 @@ import com.mybatisflex.core.query.QueryWrapper;
import org.apache.poi.ss.usermodel.WorkbookFactory; import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.testng.Assert; import org.testng.Assert;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Bot; import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.service.BotService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; 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.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysAccountRoleService;
@@ -339,29 +340,34 @@ public class DashboardServiceImplTest {
public void shouldQueryUserRanksWithAssistantFilter() throws Exception { public void shouldQueryUserRanksWithAssistantFilter() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl(); DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
BotService botService = mock(BotService.class); AgentService agentService = mock(AgentService.class);
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysAccountService sysAccountService = mock(SysAccountService.class); SysAccountService sysAccountService = mock(SysAccountService.class);
SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class); SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class);
SysRoleService sysRoleService = mock(SysRoleService.class); SysRoleService sysRoleService = mock(SysRoleService.class);
Bot bot = new Bot(); Agent agent = new Agent();
bot.setId(BigInteger.TEN); agent.setId(BigInteger.TEN);
bot.setStatus(1); agent.setStatus(1);
bot.setCreatedBy(BigInteger.ONE); agent.setCreatedBy(BigInteger.ONE);
bot.setCategoryId(BigInteger.valueOf(8)); agent.setCategoryId(BigInteger.valueOf(8));
when(chatDashboardQueryService.available()).thenReturn(true); when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5))) when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L))); .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L)));
when(botService.getById(BigInteger.TEN)).thenReturn(bot); when(agentService.getById(BigInteger.TEN)).thenReturn(agent);
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(true); .thenReturn(true);
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户"))); when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户")));
when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList()); when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
setField(service, "chatDashboardQueryService", chatDashboardQueryService); setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "botService", botService); setField(service, "agentService", agentService);
setField(service, "categoryPermissionService", categoryPermissionService); setField(service, "categoryPermissionService", categoryPermissionService);
setField(service, "sysAccountService", sysAccountService); setField(service, "sysAccountService", sysAccountService);
setField(service, "sysAccountRoleService", sysAccountRoleService); setField(service, "sysAccountRoleService", sysAccountRoleService);
@@ -382,26 +388,59 @@ public class DashboardServiceImplTest {
} }
/** /**
* 验证未启用智能体会被拒绝 * 验证用智能体仍可用于筛选历史统计
*/ */
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用") @Test
public void shouldRejectDisabledAssistantFilter() { public void shouldAllowDisabledAgentFilterForHistoricalStatistics() {
DashboardServiceImpl service = new DashboardServiceImpl(); DashboardServiceImpl service = new DashboardServiceImpl();
BotService botService = mock(BotService.class); AgentService agentService = mock(AgentService.class);
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
Bot bot = new Bot(); Agent agent = new Agent();
bot.setId(BigInteger.TEN); agent.setId(BigInteger.TEN);
bot.setStatus(0); 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(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, "chatDashboardQueryService", chatDashboardQueryService);
setFieldSilently(service, "categoryPermissionService", mock(CategoryPermissionService.class)); setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class)); setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
query.setAssistantId(BigInteger.TEN);
List<DashboardUserRankItemVo> 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(); DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d"); query.setRange("7d");
query.setAssistantId(BigInteger.TEN); query.setAssistantId(BigInteger.TEN);
@@ -411,25 +450,30 @@ public class DashboardServiceImplTest {
/** /**
* 验证当前作用域不可见的智能体会被拒绝。 * 验证当前作用域不可见的智能体会被拒绝。
*/ */
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用") @Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "智能体不存在或不可见")
public void shouldRejectInvisibleAssistantFilter() { public void shouldRejectInvisibleAssistantFilter() {
DashboardServiceImpl service = new DashboardServiceImpl(); DashboardServiceImpl service = new DashboardServiceImpl();
BotService botService = mock(BotService.class); AgentService agentService = mock(AgentService.class);
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class); CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
Bot bot = new Bot(); Agent agent = new Agent();
bot.setId(BigInteger.TEN); agent.setId(BigInteger.TEN);
bot.setStatus(1); agent.setStatus(1);
bot.setCreatedBy(BigInteger.ONE); agent.setCreatedBy(BigInteger.ONE);
bot.setCategoryId(BigInteger.valueOf(8)); 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(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); .thenReturn(false);
setFieldSilently(service, "botService", botService); setFieldSilently(service, "agentService", agentService);
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService); setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
setFieldSilently(service, "categoryPermissionService", categoryPermissionService); setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class)); setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));

View File

@@ -12,6 +12,10 @@
<artifactId>easyflow-api-public</artifactId> <artifactId>easyflow-api-public</artifactId>
<dependencies> <dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId> <artifactId>easyflow-module-ai</artifactId>

View File

@@ -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;
}
}

View File

@@ -16,7 +16,10 @@ public class PublicApiConfig implements WebMvcConfigurer {
registry.addInterceptor(publicApiInterceptor) registry.addInterceptor(publicApiInterceptor)
.addPathPatterns("/public-api/**") .addPathPatterns("/public-api/**")
.excludePathPatterns("/public-api/bot/chat") .excludePathPatterns(
"/public-api/agent/chat",
"/public-api/bot/chat"
)
; ;
} }
} }

View File

@@ -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<String> 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 <T> 接口类型
* @return 代理实例
*/
private <T> T proxy(Class<T> 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<String> calls;
private final SseEmitter emitter = new SseEmitter();
private LoginAccount account;
/**
* 创建运行服务桩。
*
* @param calls 调用顺序记录
*/
private RecordingAgentRunService(List<String> 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;
}
}
}

View File

@@ -12,6 +12,10 @@
<artifactId>easyflow-api-usercenter</artifactId> <artifactId>easyflow-api-usercenter</artifactId>
<dependencies> <dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-auth</artifactId> <artifactId>easyflow-module-auth</artifactId>

View File

@@ -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<UcAgentListItemVo>> 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<Agent> 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()
);
}
}

View File

@@ -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;
}
}

View File

@@ -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<UcAgentListItemVo> 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 <T> 接口类型
* @return 代理实例
*/
private <T> T proxy(Class<T> 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;
}
}
}

View File

@@ -18,5 +18,10 @@ public enum AgentRuntimeCommandAction {
/** /**
* 审批过期并取消工具执行。 * 审批过期并取消工具执行。
*/ */
EXPIRE EXPIRE,
/**
* 取消指定 Agent 在目标节点上的全部运行。
*/
CANCEL_AGENT
} }

View File

@@ -93,6 +93,8 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
} else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) { } else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) {
agentRunService.expireApprovalLocal( agentRunService.expireApprovalLocal(
command.getRequestId(), command.getResumeToken(), command.getReason()); command.getRequestId(), command.getResumeToken(), command.getReason());
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) {
agentRunService.cancelAgentLocal(command.getAgentId());
} else { } else {
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令")); markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}", LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",

View File

@@ -15,6 +15,7 @@ public class AgentRuntimeCommandMessage {
private String reason; private String reason;
private BigInteger operatorId; private BigInteger operatorId;
private String userId; private String userId;
private String agentId;
private String targetNodeId; private String targetNodeId;
private Date occurredAt; private Date occurredAt;
@@ -74,6 +75,24 @@ public class AgentRuntimeCommandMessage {
this.userId = userId; 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() { public String getTargetNodeId() {
return targetNodeId; return targetNodeId;
} }

View File

@@ -69,7 +69,10 @@ public class AgentRuntimeCommandProducer {
String resumeToken, String resumeToken,
BigInteger operatorId, BigInteger operatorId,
String userId) { 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, String reason,
BigInteger operatorId, BigInteger operatorId,
String userId) { 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 requestId,
String resumeToken, String resumeToken,
String reason) { 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, private void sendAndWait(String targetNodeId,
String requestId, String requestId,
String resumeToken, String resumeToken,
String agentId,
AgentRuntimeCommandAction action, AgentRuntimeCommandAction action,
String reason, String reason,
BigInteger operatorId, BigInteger operatorId,
@@ -120,6 +157,7 @@ public class AgentRuntimeCommandProducer {
command.setCommandId(UUID.randomUUID().toString()); command.setCommandId(UUID.randomUUID().toString());
command.setRequestId(requestId); command.setRequestId(requestId);
command.setResumeToken(resumeToken); command.setResumeToken(resumeToken);
command.setAgentId(agentId);
command.setAction(action); command.setAction(action);
command.setReason(reason); command.setReason(reason);
command.setOperatorId(operatorId); command.setOperatorId(operatorId);
@@ -135,8 +173,8 @@ public class AgentRuntimeCommandProducer {
try { try {
message.setBody(objectMapper.writeValueAsString(command)); message.setBody(objectMapper.writeValueAsString(command));
String recordId = mqProducer.send(message); String recordId = mqProducer.send(message);
LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, targetNodeId={}, recordId={}", LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, agentId={}, targetNodeId={}, recordId={}",
action, requestId, targetNodeId, recordId); action, requestId, agentId, targetNodeId, recordId);
AgentRuntimeCommandResult result = resultRegistry.waitForResult(command.getCommandId()); AgentRuntimeCommandResult result = resultRegistry.waitForResult(command.getCommandId());
if (!result.isSuccess()) { if (!result.isSuccess()) {
throw new BusinessException(result.getMessage()); throw new BusinessException(result.getMessage());
@@ -146,18 +184,29 @@ public class AgentRuntimeCommandProducer {
} catch (BusinessException e) { } catch (BusinessException e) {
throw e; throw e;
} catch (RuntimeException e) { } catch (RuntimeException e) {
LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, targetNodeId={}", LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, agentId={}, targetNodeId={}",
action, requestId, targetNodeId, e); action, requestId, agentId, targetNodeId, e);
throw new BusinessException("Agent 运行节点不可用,请重新发起对话"); throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
} finally { } finally {
deleteResultQuietly(command.getCommandId()); deleteResultQuietly(command.getCommandId());
} }
} }
/**
* 构建目标节点命令主题。
*
* @param nodeId 节点 ID
* @return 命令主题
*/
private String commandTopic(String nodeId) { private String commandTopic(String nodeId) {
return properties.getCommandTopicPrefix() + ":" + nodeId; return properties.getCommandTopicPrefix() + ":" + nodeId;
} }
/**
* 清理远程命令确认结果,失败时由 Redis TTL 兜底。
*
* @param commandId 命令 ID
*/
private void deleteResultQuietly(String commandId) { private void deleteResultQuietly(String commandId) {
try { try {
resultRegistry.deleteResult(commandId); resultRegistry.deleteResult(commandId);

View File

@@ -7,6 +7,7 @@ public class AgentRuntimeRoute {
private String nodeId; private String nodeId;
private String bootId; private String bootId;
private String agentId;
/** /**
* 获取 owner 节点 ID。 * 获取 owner 节点 ID。
@@ -43,4 +44,22 @@ public class AgentRuntimeRoute {
public void setBootId(String bootId) { public void setBootId(String bootId) {
this.bootId = 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;
}
} }

View File

@@ -9,6 +9,9 @@ import org.springframework.stereotype.Component;
import tech.easyflow.agent.config.AgentRuntimeProperties; import tech.easyflow.agent.config.AgentRuntimeProperties;
import java.time.Duration; import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/** /**
* Agent 运行态 Redis 路由注册表。 * Agent 运行态 Redis 路由注册表。
@@ -21,6 +24,7 @@ public class AgentRuntimeRouteRegistry {
private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:"; 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 TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:";
private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:"; 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 StringRedisTemplate stringRedisTemplate;
private final AgentRuntimeProperties properties; private final AgentRuntimeProperties properties;
@@ -47,10 +51,29 @@ public class AgentRuntimeRouteRegistry {
* @param requestId 请求 ID * @param requestId 请求 ID
*/ */
public void registerRun(String requestId) { 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()) { if (requestId == null || requestId.isBlank()) {
return; 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)); return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken));
} }
/**
* 查询指定 Agent 当前活跃运行所在的节点。
*
* @param agentId Agent ID
* @return 去重后的 owner 节点 ID
*/
public Set<String> findOwnerNodesByAgent(String agentId) {
if (agentId == null || agentId.isBlank()) {
return Collections.emptySet();
}
Set<String> requestIds = stringRedisTemplate.opsForSet().members(agentRunsKey(agentId));
if (requestIds == null || requestIds.isEmpty()) {
return Collections.emptySet();
}
Set<String> 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()) { if (requestId == null || requestId.isBlank()) {
return; return;
} }
AgentRuntimeRoute route = null;
try {
route = findOwnerRoute(requestId);
} catch (RuntimeException exception) {
LOG.warn("读取待清理的 Agent 运行路由失败: requestId={}", requestId, exception);
}
deleteQuietly(requestKey(requestId)); 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; 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(); AgentRuntimeRoute route = new AgentRuntimeRoute();
route.setNodeId(properties.getInstanceId()); route.setNodeId(properties.getInstanceId());
route.setBootId(properties.getBootId()); route.setBootId(properties.getBootId());
route.setAgentId(agentId);
return route; return route;
} }
@@ -219,4 +285,19 @@ public class AgentRuntimeRouteRegistry {
LOG.warn("清理 Agent 运行态 Redis 路由失败: key={}", key, e); 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);
}
}
} }

View File

@@ -4,12 +4,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.update.UpdateChain; import com.mybatisflex.core.update.UpdateChain;
import org.springframework.stereotype.Component; 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.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding; 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.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService; import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler; import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
import tech.easyflow.approval.enums.ApprovalResourceType; import tech.easyflow.approval.enums.ApprovalResourceType;
@@ -22,6 +27,7 @@ import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Date; import java.util.Date;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* Agent 审批资源处理器。 * Agent 审批资源处理器。
@@ -33,6 +39,11 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
private final AgentToolBindingService agentToolBindingService; private final AgentToolBindingService agentToolBindingService;
private final AgentKnowledgeBindingService agentKnowledgeBindingService; private final AgentKnowledgeBindingService agentKnowledgeBindingService;
private final ResourceAccessService resourceAccessService; 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 审批资源处理器。 * 创建 Agent 审批资源处理器。
@@ -43,18 +54,33 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
* @param agentToolBindingService Agent 工具绑定服务 * @param agentToolBindingService Agent 工具绑定服务
* @param agentKnowledgeBindingService Agent 知识库绑定服务 * @param agentKnowledgeBindingService Agent 知识库绑定服务
* @param resourceAccessService 资源访问服务 * @param resourceAccessService 资源访问服务
* @param agentBindingLockExecutor Agent 配置锁执行器
* @param agentRunRegistry Agent 运行态注册表
* @param agentHitlPendingService Agent 待审批运行服务
* @param agentRuntimeRouteRegistry Agent 分布式运行路由注册表
* @param agentRuntimeCommandProducer Agent 远程运行命令生产者
*/ */
public AgentApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService, public AgentApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService,
ObjectMapper objectMapper, ObjectMapper objectMapper,
AgentService agentService, AgentService agentService,
AgentToolBindingService agentToolBindingService, AgentToolBindingService agentToolBindingService,
AgentKnowledgeBindingService agentKnowledgeBindingService, AgentKnowledgeBindingService agentKnowledgeBindingService,
ResourceAccessService resourceAccessService) { ResourceAccessService resourceAccessService,
AgentBindingLockExecutor agentBindingLockExecutor,
AgentRunRegistry agentRunRegistry,
AgentHitlPendingService agentHitlPendingService,
AgentRuntimeRouteRegistry agentRuntimeRouteRegistry,
AgentRuntimeCommandProducer agentRuntimeCommandProducer) {
super(approvalInstanceService, objectMapper); super(approvalInstanceService, objectMapper);
this.agentService = agentService; this.agentService = agentService;
this.agentToolBindingService = agentToolBindingService; this.agentToolBindingService = agentToolBindingService;
this.agentKnowledgeBindingService = agentKnowledgeBindingService; this.agentKnowledgeBindingService = agentKnowledgeBindingService;
this.resourceAccessService = resourceAccessService; 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 @Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) { protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
// 生命周期操作仅更新状态字段,避免实体默认空配置覆盖 Agent 草稿配置。 agentBindingLockExecutor.execute(resourceId, () -> {
UpdateChain<Agent> updateChain = agentService.updateChain(); // 生命周期操作仅更新状态字段,避免实体默认空配置覆盖 Agent 草稿配置。
updateChain.set(Agent::getPublishStatus, publishStatus.getCode()); UpdateChain<Agent> updateChain = agentService.updateChain();
updateChain.set(Agent::getCurrentApprovalInstanceId, currentApprovalInstanceId); updateChain.set(Agent::getPublishStatus, publishStatus.getCode());
updateChain.eq(Agent::getId, resourceId); updateChain.set(Agent::getCurrentApprovalInstanceId, currentApprovalInstanceId);
updateChain.update(); updateChain.eq(Agent::getId, resourceId);
updateChain.update();
return null;
});
} }
@Override @Override
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) { protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
UpdateChain<Agent> updateChain = agentService.updateChain(); agentBindingLockExecutor.execute(resourceId, () -> {
updateChain.set(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode()); UpdateChain<Agent> updateChain = agentService.updateChain();
updateChain.set(Agent::getPublishedSnapshotJson, resourceSnapshot); updateChain.set(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode());
updateChain.set(Agent::getPublishedAt, new Date()); updateChain.set(Agent::getPublishedSnapshotJson, resourceSnapshot);
updateChain.set(Agent::getPublishedBy, operatorId); updateChain.set(Agent::getPublishedAt, new Date());
updateChain.set(Agent::getCurrentApprovalInstanceId, null); updateChain.set(Agent::getPublishedBy, operatorId);
updateChain.eq(Agent::getId, resourceId); updateChain.set(Agent::getCurrentApprovalInstanceId, null);
updateChain.update(); updateChain.eq(Agent::getId, resourceId);
updateChain.update();
return null;
});
} }
@Override @Override
protected void markResourceOffline(BigInteger resourceId) { protected void markResourceOffline(BigInteger resourceId) {
UpdateChain<Agent> updateChain = agentService.updateChain(); agentBindingLockExecutor.execute(resourceId, () -> {
updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode()); UpdateChain<Agent> updateChain = agentService.updateChain();
updateChain.set(Agent::getCurrentApprovalInstanceId, null); updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode());
updateChain.eq(Agent::getId, resourceId); updateChain.set(Agent::getCurrentApprovalInstanceId, null);
updateChain.update(); updateChain.eq(Agent::getId, resourceId);
updateChain.update();
cancelActiveRuns(resourceId, "Agent 已下线,待审批运行已取消");
return null;
});
} }
@Override @Override
protected void removeResource(BigInteger resourceId) { 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) { * 取消指定 Agent 的集群运行态和持久化待审批请求。
agentToolBindingService.remove(QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId)); *
agentKnowledgeBindingService.remove(QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId)); * @param resourceId Agent ID
* @param reason 取消原因
*/
private void cancelActiveRuns(BigInteger resourceId, String reason) {
String agentId = resourceId.toString();
Set<String> 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 @Override

View File

@@ -5,6 +5,7 @@ import tech.easyflow.ai.publish.AiResourceLifecycleService;
import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.enums.ApprovalActionType; import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType; import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -57,15 +58,27 @@ public class AgentPublishAppService {
return submit(id, ApprovalActionType.DELETE); return submit(id, ApprovalActionType.DELETE);
} }
/**
* 提交 Agent 生命周期审批。
*
* @param id Agent ID
* @param actionType 审批动作
* @return 审批动作结果
* @throws BusinessException 资源 ID 或登录信息无效时抛出
*/
private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) { private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) {
if (id == null) { if (id == null) {
throw new BusinessException("Agent 审批时资源ID不能为空"); throw new BusinessException("Agent 审批时资源ID不能为空");
} }
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return aiResourceLifecycleService.submitAction( return aiResourceLifecycleService.submitAction(
ApprovalResourceType.AGENT.getCode(), ApprovalResourceType.AGENT.getCode(),
id, id,
actionType.getCode(), actionType.getCode(),
SaTokenUtil.getLoginAccount().getId() account.getId()
); );
} }
} }

View File

@@ -16,6 +16,7 @@ import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.core.runtime.ChatAssistantAccumulator; import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.util.ArrayList;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -71,7 +72,10 @@ public class AgentRunRegistry {
} }
owners.put(context.requestId(), context.owner()); owners.put(context.requestId(), context.owner());
if (routeRegistry != null) { 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); remove(requestId);
} }
/**
* 取消当前节点上指定 Agent 的全部活跃运行。
*
* @param agentId Agent ID
*/
public void cancelAgent(String agentId) {
if (agentId == null || agentId.isBlank()) {
return;
}
for (Map.Entry<String, RunOwner> 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());
}
}
/** /**
* 记录等待审批的恢复令牌。 * 记录等待审批的恢复令牌。
* *

View File

@@ -39,6 +39,7 @@ import tech.easyflow.agent.runtime.document.AgentDocumentContext;
import tech.easyflow.agent.runtime.document.AgentDocumentContextSelector; import tech.easyflow.agent.runtime.document.AgentDocumentContextSelector;
import tech.easyflow.agent.runtime.document.AgentDocumentService; import tech.easyflow.agent.runtime.document.AgentDocumentService;
import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Mcp; import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.PluginItem;
@@ -99,6 +100,10 @@ public class AgentRunService {
@Resource @Resource
private AgentRunRegistry agentRunRegistry; private AgentRunRegistry agentRunRegistry;
@Resource @Resource
private AgentBindingLockExecutor agentBindingLockExecutor;
@Resource
private AgentRunStartGuard agentRunStartGuard;
@Resource
private AgentRuntimeRouteRegistry agentRuntimeRouteRegistry; private AgentRuntimeRouteRegistry agentRuntimeRouteRegistry;
@Resource @Resource
private AgentRuntimeCommandProducer agentRuntimeCommandProducer; private AgentRuntimeCommandProducer agentRuntimeCommandProducer;
@@ -183,6 +188,75 @@ public class AgentRunService {
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore); 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 草稿态纯文本试用。 * 启动 Agent 草稿态纯文本试用。
* *
@@ -615,6 +689,23 @@ public class AgentRunService {
return resolvedRequestId; 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, private void startRuntime(Agent agent,
AgentMessage userMessage, AgentMessage userMessage,
AgentDocumentContext documentContext, AgentDocumentContext documentContext,
@@ -628,6 +719,73 @@ public class AgentRunService {
boolean persistChatlog, boolean persistChatlog,
AgentSessionStore runtimeSessionStore, AgentSessionStore runtimeSessionStore,
AgentRunLock.Handle initialLockHandle) { 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); AtomicBoolean finished = new AtomicBoolean(false);
StringBuilder answer = new StringBuilder(); StringBuilder answer = new StringBuilder();
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator(); 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) { private void bindAgentSession(Agent agent, String runtimeSessionId, ChatRuntimeContext chatContext) {
if (easyFlowAgentSessionStore == null || runtimeSessionId == null || runtimeSessionId.isBlank()) { if (easyFlowAgentSessionStore == null || runtimeSessionId == null || runtimeSessionId.isBlank()) {
return; return;
@@ -1268,8 +1438,31 @@ public class AgentRunService {
String prompt, String prompt,
LoginAccount account, LoginAccount account,
String assistantCode) { 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(); ChatRuntimeContext context = new ChatRuntimeContext();
context.setChannel(ChatChannel.ADMIN); context.setChannel(channel);
context.setSessionId(sessionId); context.setSessionId(sessionId);
context.setTenantId(account.getTenantId()); context.setTenantId(account.getTenantId());
context.setDeptId(account.getDeptId()); context.setDeptId(account.getDeptId());
@@ -1280,6 +1473,7 @@ public class AgentRunService {
context.setAssistantCode(assistantCode); context.setAssistantCode(assistantCode);
context.setAssistantName(agent.getName()); context.setAssistantName(agent.getName());
context.setSessionTitle(toSessionTitle(prompt)); context.setSessionTitle(toSessionTitle(prompt));
context.setAnonymous(anonymous);
return context; return context;
} }
@@ -1491,10 +1685,23 @@ public class AgentRunService {
} }
} }
/**
* 获取当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录信息失效时抛出
*/
private LoginAccount requireCurrentLoginAccount() { private LoginAccount requireCurrentLoginAccount() {
try { 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) { } catch (Exception e) {
if (e instanceof BusinessException businessException) {
throw businessException;
}
throw new BusinessException("当前登录状态失效,请重新登录后再试"); throw new BusinessException("当前登录状态失效,请重新登录后再试");
} }
} }

View File

@@ -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 已下线或不可继续会话");
}
}
}

View File

@@ -48,6 +48,14 @@ public interface AgentHitlPendingService {
*/ */
void cancelByRequestId(String requestId, String reason); void cancelByRequestId(String requestId, String reason);
/**
* 取消指定 Agent 的全部待审批运行。
*
* @param agentId Agent ID
* @param reason 取消原因
*/
void cancelByAgentId(BigInteger agentId, String reason);
/** /**
* 删除指定聊天会话的 pending。 * 删除指定聊天会话的 pending。
* *

View File

@@ -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<AgentHitlPending> 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 @Override
public void deleteByChatSessionId(BigInteger chatSessionId) { public void deleteByChatSessionId(BigInteger chatSessionId) {
if (chatSessionId == null) { if (chatSessionId == null) {

View File

@@ -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<BigInteger> 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));
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,378 @@
package tech.easyflow.agent.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.agent.vo.AgentResourceOptionsView;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Agent 与设计器依赖资源的安全选项查询服务。
*/
@Service
public class AgentOptionQueryService {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};
private final AgentService agentService;
private final ModelService modelService;
private final DocumentCollectionService documentCollectionService;
private final WorkflowService workflowService;
private final PluginService pluginService;
private final PluginItemService pluginItemService;
private final PluginVisibilityService pluginVisibilityService;
private final McpService mcpService;
private final AgentVisibilityQueryHelper agentVisibilityQueryHelper;
private final ResourceAccessService resourceAccessService;
private final ObjectMapper objectMapper;
/**
* 创建 Agent 安全选项查询服务。
*
* @param agentService Agent 服务
* @param modelService 模型服务
* @param documentCollectionService 知识库服务
* @param workflowService 工作流服务
* @param pluginService 插件服务
* @param pluginItemService 插件工具服务
* @param pluginVisibilityService 插件可见性服务
* @param mcpService MCP 服务
* @param agentVisibilityQueryHelper Agent 可见性查询助手
* @param resourceAccessService 资源访问服务
* @param objectMapper JSON 映射器
*/
public AgentOptionQueryService(AgentService agentService,
ModelService modelService,
DocumentCollectionService documentCollectionService,
WorkflowService workflowService,
PluginService pluginService,
PluginItemService pluginItemService,
PluginVisibilityService pluginVisibilityService,
McpService mcpService,
AgentVisibilityQueryHelper agentVisibilityQueryHelper,
ResourceAccessService resourceAccessService,
ObjectMapper objectMapper) {
this.agentService = agentService;
this.modelService = modelService;
this.documentCollectionService = documentCollectionService;
this.workflowService = workflowService;
this.pluginService = pluginService;
this.pluginItemService = pluginItemService;
this.pluginVisibilityService = pluginVisibilityService;
this.mcpService = mcpService;
this.agentVisibilityQueryHelper = agentVisibilityQueryHelper;
this.resourceAccessService = resourceAccessService;
this.objectMapper = objectMapper;
}
/**
* 查询当前账号可见的 Agent 安全选项。
*
* @param publishedOnly 是否仅查询已发布 Agent
* @return Agent 选项
*/
public List<AgentOptionView> listAgentOptions(boolean publishedOnly) {
LoginAccount account = requireAccount();
QueryWrapper wrapper = QueryWrapper.create();
agentVisibilityQueryHelper.applyReadableAccess(wrapper);
wrapper.orderBy(Agent::getModified, false);
if (publishedOnly) {
wrapper.eq(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.eq(Agent::getStatus, 1);
}
List<AgentOptionView> result = new ArrayList<>();
ResourceAction action = publishedOnly ? ResourceAction.USE : ResourceAction.READ;
for (Agent agent : agentService.list(wrapper)) {
if (!resourceAccessService.canAccess(CategoryResourceType.AGENT, agent, action)) {
continue;
}
result.add(toAgentOption(agent, publishedOnly));
}
return result;
}
/**
* 查询 Agent 设计器所需的安全资源选项。
*
* @return 资源选项集合
*/
public AgentResourceOptionsView listDesignerResourceOptions() {
LoginAccount account = requireAccount();
return new AgentResourceOptionsView(
listModelOptions(account),
listKnowledgeOptions(account),
listWorkflowOptions(account),
listPluginToolOptions(account),
listMcpOptions(account)
);
}
/**
* 查询当前账号可用于 Agent 会话的知识库安全选项。
*
* @return 知识库选项
*/
public List<AgentResourceOptionsView.ResourceOption> listKnowledgeOptions() {
return listKnowledgeOptions(requireAccount());
}
/**
* 查询指定 MCP 的安全工具列表。
*
* @param mcpId MCP ID
* @return MCP 工具选项
*/
public List<AgentResourceOptionsView.McpToolOption> listMcpTools(BigInteger mcpId) {
LoginAccount account = requireAccount();
Mcp mcp = mcpService.getOne(QueryWrapper.create()
.eq(Mcp::getId, mcpId)
.eq(Mcp::getTenantId, account.getTenantId())
.eq(Mcp::getStatus, true));
if (mcp == null) {
throw new BusinessException("MCP 不存在或不可用");
}
Mcp detail = mcpService.getMcpTools(mcpId.toString());
if (detail == null || detail.getTools() == null) {
return Collections.emptyList();
}
List<AgentResourceOptionsView.McpToolOption> result = new ArrayList<>();
for (Object tool : detail.getTools()) {
Map<String, Object> value = objectMapper.convertValue(tool, MAP_TYPE);
result.add(new AgentResourceOptionsView.McpToolOption(
text(value.get("name")),
text(value.get("description"))
));
}
return result;
}
/**
* 查询当前租户可用于 Agent 的模型选项。
*
* @param account 当前登录账号
* @return 模型选项
*/
private List<AgentResourceOptionsView.ModelOption> listModelOptions(LoginAccount account) {
Model query = new Model();
query.setTenantId(account.getTenantId());
query.setModelType(Model.MODEL_TYPES[0]);
return modelService.listSelectableModels(query, false, "id", "desc").stream()
.filter(model -> Objects.equals(model.getTenantId(), account.getTenantId()))
.map(model -> new AgentResourceOptionsView.ModelOption(
model.getId(),
model.getTitle(),
model.getContextWindowTokens(),
model.getMaxOutputTokens()
))
.toList();
}
/**
* 查询当前账号可使用的已发布知识库。
*
* @param account 当前登录账号
* @return 知识库选项
*/
private List<AgentResourceOptionsView.ResourceOption> listKnowledgeOptions(LoginAccount account) {
return documentCollectionService.list(QueryWrapper.create()
.eq(DocumentCollection::getTenantId, account.getTenantId())
.eq(DocumentCollection::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.orderBy(DocumentCollection::getModified, false))
.stream()
.filter(item -> resourceAccessService.canAccess(
CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
.map(item -> new AgentResourceOptionsView.ResourceOption(
item.getId(), item.getTitle(), item.getDescription(), null))
.toList();
}
/**
* 查询当前账号可使用的已发布工作流。
*
* @param account 当前登录账号
* @return 工作流选项
*/
private List<AgentResourceOptionsView.ResourceOption> listWorkflowOptions(LoginAccount account) {
return workflowService.list(QueryWrapper.create()
.eq(Workflow::getTenantId, account.getTenantId())
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.eq(Workflow::getStatus, 1)
.orderBy(Workflow::getModified, false))
.stream()
.filter(item -> resourceAccessService.canAccess(
CategoryResourceType.WORKFLOW, item, ResourceAction.USE))
.map(item -> new AgentResourceOptionsView.ResourceOption(
item.getId(), item.getTitle(), item.getDescription(), item.getEnglishName()))
.toList();
}
/**
* 查询当前账号可使用的插件工具。
*
* @param account 当前登录账号
* @return 插件工具选项
*/
private List<AgentResourceOptionsView.PluginToolOption> listPluginToolOptions(LoginAccount account) {
List<Plugin> plugins = pluginService.list(QueryWrapper.create()
.eq(Plugin::getTenantId, account.getTenantId()))
.stream()
.filter(plugin -> pluginVisibilityService.canAccessPlugin(
plugin.getCreatedBy(), plugin.getId()))
.toList();
if (plugins.isEmpty()) {
return Collections.emptyList();
}
Map<BigInteger, String> pluginNames = new LinkedHashMap<>();
plugins.forEach(plugin -> pluginNames.put(plugin.getId(), plugin.getName()));
return pluginItemService.list(QueryWrapper.create()
.in(PluginItem::getPluginId, pluginNames.keySet())
.eq(PluginItem::getStatus, 1)
.orderBy(PluginItem::getId, false))
.stream()
.map(item -> new AgentResourceOptionsView.PluginToolOption(
item.getId(),
item.getName(),
item.getDescription(),
item.getEnglishName(),
pluginNames.get(item.getPluginId())
))
.toList();
}
/**
* 查询当前租户可使用的 MCP。
*
* @param account 当前登录账号
* @return MCP 选项
*/
private List<AgentResourceOptionsView.McpOption> listMcpOptions(LoginAccount account) {
return mcpService.list(QueryWrapper.create()
.eq(Mcp::getTenantId, account.getTenantId())
.eq(Mcp::getStatus, true)
.orderBy(Mcp::getModified, false))
.stream()
.map(item -> new AgentResourceOptionsView.McpOption(
item.getId(),
item.getTitle(),
item.getDescription(),
item.getApprovalRequired()
))
.toList();
}
/**
* 将 Agent 转换为不包含运行配置的安全选项。
*
* @param agent Agent 数据
* @param publishedOnly 是否读取发布快照中的展示信息
* @return Agent 安全选项
*/
private AgentOptionView toAgentOption(Agent agent, boolean publishedOnly) {
Map<String, Object> snapshot = publishedOnly ? agent.getPublishedSnapshotJson() : Map.of();
Map<String, Object> basic = snapshot == null ? Map.of() : map(snapshot.get("basicSummary"));
Map<String, Object> model = snapshot == null ? Map.of() : map(snapshot.get("modelSummary"));
Map<String, Object> interaction = publishedOnly
? map(snapshot == null ? null : snapshot.get("interactionConfigJson"))
: agent.getInteractionConfigJson();
return new AgentOptionView(
agent.getId(),
firstText(text(basic.get("name")), agent.getName()),
firstText(text(basic.get("description")), agent.getDescription()),
firstText(text(basic.get("avatar")), agent.getAvatar()),
interaction,
publishedOnly ? booleanValue(model.get("supportImage")) : null
);
}
/**
* 将对象安全转换为字符串键 Map。
*
* @param value 待转换值
* @return Map非 Map 值返回空 Map
*/
private Map<String, Object> map(Object value) {
if (!(value instanceof Map<?, ?> raw)) {
return Collections.emptyMap();
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
/**
* 转换可空布尔值。
*
* @param value 原始值
* @return 布尔值或 null
*/
private Boolean booleanValue(Object value) {
return value == null ? null : Boolean.parseBoolean(String.valueOf(value));
}
/**
* 返回首个非空文本。
*
* @param value 首选文本
* @param fallback 备用文本
* @return 最终文本
*/
private String firstText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
/**
* 将对象转换为可空文本。
*
* @param value 原始值
* @return 文本或 null
*/
private String text(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 获取带租户信息的当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录状态无效时抛出
*/
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
}

View File

@@ -8,11 +8,10 @@ import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.mapper.AgentKnowledgeBindingMapper; import tech.easyflow.agent.mapper.AgentKnowledgeBindingMapper;
import tech.easyflow.agent.mapper.AgentMapper; import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.service.AgentDependencyAccessService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes; import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -22,9 +21,13 @@ import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* Agent 知识库绑定服务实现。 * Agent 知识库绑定服务实现。
@@ -38,9 +41,11 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
@Resource @Resource
private AgentMapper agentMapper; private AgentMapper agentMapper;
@Resource @Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Resource
private AgentBindingLockExecutor agentBindingLockExecutor;
@Resource
private AgentDependencyAccessService agentDependencyAccessService;
/** /**
* {@inheritDoc} * {@inheritDoc}
@@ -48,19 +53,21 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public List<AgentKnowledgeBinding> replaceBindings(BigInteger agentId, List<AgentKnowledgeBinding> bindings) { public List<AgentKnowledgeBinding> replaceBindings(BigInteger agentId, List<AgentKnowledgeBinding> bindings) {
Agent agent = requireAgent(agentId); return agentBindingLockExecutor.execute(agentId, () -> {
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent"); Agent agent = requireAgentForUpdate(agentId);
remove(QueryWrapper.create().where("agent_id = ?", agentId)); resourceAccessService.assertAccess(
if (bindings == null || bindings.isEmpty()) { CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
return Collections.emptyList(); validateBindings(agent, bindings);
} remove(QueryWrapper.create().where("agent_id = ?", agentId));
for (int i = 0; i < bindings.size(); i++) { if (bindings == null || bindings.isEmpty()) {
AgentKnowledgeBinding binding = bindings.get(i); return Collections.emptyList();
validateBinding(binding); }
applyBindingDefaults(agent, binding, i); for (int i = 0; i < bindings.size(); i++) {
} applyBindingDefaults(agent, bindings.get(i), i);
saveBatch(bindings); }
return listEnabled(agentId); saveBatch(bindings);
return listEnabled(agentId);
});
} }
/** /**
@@ -74,26 +81,70 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
.orderBy("sort_no asc, id asc")); .orderBy("sort_no asc, id asc"));
} }
private Agent requireAgent(BigInteger agentId) { /**
Agent agent = agentMapper.selectOneById(agentId); * 锁定并加载待修改的 Agent。
*
* @param agentId Agent ID
* @return 已锁定 Agent
* @throws BusinessException Agent 不存在时抛出
*/
private Agent requireAgentForUpdate(BigInteger agentId) {
Agent agent = agentMapper.selectOneByQuery(QueryWrapper.create()
.eq(Agent::getId, agentId)
.forUpdate());
if (agent == null) { if (agent == null) {
throw new BusinessException("Agent 不存在"); throw new BusinessException("Agent 不存在");
} }
return agent; return agent;
} }
private void validateBinding(AgentKnowledgeBinding binding) { /**
* 校验知识库绑定并锁定目标知识库到当前事务结束。
*
* @param agent 当前 Agent
* @param binding 知识库绑定
* @throws BusinessException 绑定参数无效或知识库不可用时抛出
*/
private void validateBinding(Agent agent, AgentKnowledgeBinding binding) {
if (binding == null || binding.getKnowledgeId() == null) { if (binding == null || binding.getKnowledgeId() == null) {
throw new BusinessException("知识库绑定参数不完整"); throw new BusinessException("知识库绑定参数不完整");
} }
DocumentCollection knowledge = documentCollectionService.getById(binding.getKnowledgeId());
if (knowledge == null || PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("绑定知识库不存在或未发布");
}
KnowledgeRetrievalModes.parse(binding.getRetrievalMode()); KnowledgeRetrievalModes.parse(binding.getRetrievalMode());
resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE, "无权限绑定该知识库"); agentDependencyAccessService.requireKnowledge(agent, binding.getKnowledgeId());
} }
/**
* 按知识库 ID 的稳定顺序校验绑定并锁定关联资源。
*
* @param agent 当前 Agent
* @param bindings 知识库绑定
*/
private void validateBindings(Agent agent, List<AgentKnowledgeBinding> bindings) {
if (bindings == null || bindings.isEmpty()) {
return;
}
Set<BigInteger> knowledgeIds = new LinkedHashSet<>();
for (AgentKnowledgeBinding binding : bindings) {
if (binding != null && binding.getKnowledgeId() != null
&& !knowledgeIds.add(binding.getKnowledgeId())) {
throw new BusinessException("同一知识库不能重复绑定");
}
}
List<AgentKnowledgeBinding> validationOrder = new ArrayList<>(bindings);
validationOrder.sort(Comparator.comparing(binding ->
binding == null || binding.getKnowledgeId() == null
? BigInteger.ZERO
: binding.getKnowledgeId()));
validationOrder.forEach(binding -> validateBinding(agent, binding));
}
/**
* 写入知识库绑定的归属、审计与排序默认值。
*
* @param agent 当前 Agent
* @param binding 知识库绑定
* @param index 绑定顺序
*/
private void applyBindingDefaults(Agent agent, AgentKnowledgeBinding binding, int index) { private void applyBindingDefaults(Agent agent, AgentKnowledgeBinding binding, int index) {
LoginAccount account = requireCurrentLoginAccount(); LoginAccount account = requireCurrentLoginAccount();
Date now = new Date(); Date now = new Date();
@@ -113,10 +164,23 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
binding.setModifiedBy(account.getId()); binding.setModifiedBy(account.getId());
} }
/**
* 获取当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录信息失效时抛出
*/
private LoginAccount requireCurrentLoginAccount() { private LoginAccount requireCurrentLoginAccount() {
try { try {
return SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
} catch (Exception e) { } catch (Exception e) {
if (e instanceof BusinessException businessException) {
throw businessException;
}
throw new BusinessException("当前登录状态失效,请重新登录后再试"); throw new BusinessException("当前登录状态失效,请重新登录后再试");
} }
} }

View File

@@ -0,0 +1,444 @@
package tech.easyflow.agent.service.impl;
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.entity.AgentKnowledgeBinding;
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 tech.easyflow.ai.service.AgentResourceBindingProvider;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Agent 对共享 AI 资源的绑定查询与解绑实现。
*/
@Service
public class AgentResourceBindingProviderImpl implements AgentResourceBindingProvider {
private final AgentService agentService;
private final AgentToolBindingService agentToolBindingService;
private final AgentKnowledgeBindingService agentKnowledgeBindingService;
private final AgentBindingLockExecutor agentBindingLockExecutor;
/**
* 创建 Agent 资源绑定提供者。
*
* @param agentService Agent 服务
* @param agentToolBindingService Agent 工具绑定服务
* @param agentKnowledgeBindingService Agent 知识库绑定服务
* @param agentBindingLockExecutor Agent 绑定锁执行器
*/
public AgentResourceBindingProviderImpl(AgentService agentService,
AgentToolBindingService agentToolBindingService,
AgentKnowledgeBindingService agentKnowledgeBindingService,
AgentBindingLockExecutor agentBindingLockExecutor) {
this.agentService = agentService;
this.agentToolBindingService = agentToolBindingService;
this.agentKnowledgeBindingService = agentKnowledgeBindingService;
this.agentBindingLockExecutor = agentBindingLockExecutor;
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listAgentsByWorkflowId(BigInteger workflowId) {
return listAgents(collectToolResourceAgentIds(AgentToolType.WORKFLOW, workflowId, false));
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listAgentsByKnowledgeId(BigInteger knowledgeId) {
return listAgents(collectKnowledgeAgentIds(knowledgeId, false));
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listAgentsByPluginItemId(BigInteger pluginItemId) {
return listAgents(collectToolResourceAgentIds(AgentToolType.PLUGIN, pluginItemId, false));
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listAgentsByMcpId(BigInteger mcpId) {
return listAgents(collectToolResourceAgentIds(AgentToolType.MCP, mcpId, false));
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listAgentsByModelId(BigInteger modelId) {
Set<BigInteger> agentIds = collectAgentIdsFromAgents(agentService.list(QueryWrapper.create()
.select(Agent::getId)
.eq(Agent::getModelId, modelId)));
for (Agent agent : listPublishedSnapshotAgents()) {
if (sameId(agent.getPublishedSnapshotJson().get("modelId"), modelId)) {
agentIds.add(agent.getId());
}
}
return listAgents(agentIds);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindWorkflow(BigInteger workflowId) {
Set<BigInteger> agentIds = collectToolResourceAgentIds(
AgentToolType.WORKFLOW, workflowId, true);
for (BigInteger agentId : sortedAgentIds(agentIds)) {
agentBindingLockExecutor.execute(agentId, () -> {
agentToolBindingService.remove(QueryWrapper.create()
.eq(AgentToolBinding::getAgentId, agentId)
.eq(AgentToolBinding::getToolType, AgentToolType.WORKFLOW.name())
.eq(AgentToolBinding::getTargetId, workflowId));
trimPublishedSnapshot(
agentId,
workflowId,
AgentToolType.WORKFLOW.name(),
"toolBindings",
"toolSummaries"
);
return null;
});
}
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindKnowledge(BigInteger knowledgeId) {
Set<BigInteger> agentIds = collectKnowledgeAgentIds(knowledgeId, true);
for (BigInteger agentId : sortedAgentIds(agentIds)) {
agentBindingLockExecutor.execute(agentId, () -> {
agentKnowledgeBindingService.remove(QueryWrapper.create()
.eq(AgentKnowledgeBinding::getAgentId, agentId)
.eq(AgentKnowledgeBinding::getKnowledgeId, knowledgeId));
trimPublishedSnapshot(
agentId,
knowledgeId,
null,
"knowledgeBindings",
"knowledgeSummaries"
);
return null;
});
}
}
/**
* 查询 Agent 摘要并保持绑定中首次出现的顺序。
*
* @param agentIds Agent ID 集合
* @return Agent 摘要列表
*/
private List<OfflineImpactBindingVo> listAgents(Set<BigInteger> agentIds) {
if (agentIds.isEmpty()) {
return Collections.emptyList();
}
Map<BigInteger, Agent> agentMap = new LinkedHashMap<>();
for (Agent agent : agentService.listByIds(agentIds)) {
agentMap.put(agent.getId(), agent);
}
List<OfflineImpactBindingVo> result = new ArrayList<>(agentIds.size());
for (BigInteger agentId : agentIds) {
Agent agent = agentMap.get(agentId);
OfflineImpactBindingVo item = new OfflineImpactBindingVo();
item.setId(agentId);
item.setTitle(agent == null ? "已删除智能体(悬空绑定)" : agent.getName());
result.add(item);
}
return result;
}
/**
* 收集草稿绑定和已发布快照中引用指定工具资源的 Agent ID。
*
* @param toolType 工具类型
* @param resourceId 资源 ID
* @param lockBindings 是否锁定实时绑定行
* @return Agent ID 集合
*/
private Set<BigInteger> collectToolResourceAgentIds(AgentToolType toolType,
BigInteger resourceId,
boolean lockBindings) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(AgentToolBinding::getToolType, toolType.name())
.eq(AgentToolBinding::getTargetId, resourceId);
if (lockBindings) {
wrapper.forUpdate();
}
Set<BigInteger> agentIds = collectAgentIdsFromToolBindings(
agentToolBindingService.list(wrapper));
for (Agent agent : listPublishedSnapshotAgents()) {
if (snapshotContainsToolResource(agent.getPublishedSnapshotJson(), toolType, resourceId)) {
agentIds.add(agent.getId());
}
}
return agentIds;
}
/**
* 收集草稿绑定和已发布快照中引用指定知识库的 Agent ID。
*
* @param knowledgeId 知识库 ID
* @param lockBindings 是否锁定实时绑定行
* @return Agent ID 集合
*/
private Set<BigInteger> collectKnowledgeAgentIds(BigInteger knowledgeId, boolean lockBindings) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(AgentKnowledgeBinding::getKnowledgeId, knowledgeId);
if (lockBindings) {
wrapper.forUpdate();
}
Set<BigInteger> agentIds = collectAgentIdsFromKnowledgeBindings(
agentKnowledgeBindingService.list(wrapper));
for (Agent agent : listPublishedSnapshotAgents()) {
if (snapshotContainsKnowledge(agent.getPublishedSnapshotJson(), knowledgeId)) {
agentIds.add(agent.getId());
}
}
return agentIds;
}
/**
* 查询带发布快照的 Agent 最小字段。
*
* @return Agent 发布快照记录
*/
private List<Agent> listPublishedSnapshotAgents() {
return agentService.list(QueryWrapper.create()
.select(
Agent::getId,
Agent::getName,
Agent::getModelId,
Agent::getPublishedSnapshotJson
)
.isNotNull(Agent::getPublishedSnapshotJson));
}
/**
* 判断发布快照是否引用指定工具资源。
*
* @param snapshot 发布快照
* @param toolType 工具类型
* @param resourceId 资源 ID
* @return 是否引用
*/
private boolean snapshotContainsToolResource(Map<String, Object> snapshot,
AgentToolType toolType,
BigInteger resourceId) {
return snapshotListContains(snapshot, "toolBindings", resourceId, toolType.name())
|| snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name());
}
/**
* 判断发布快照是否引用指定知识库。
*
* @param snapshot 发布快照
* @param knowledgeId 知识库 ID
* @return 是否引用
*/
private boolean snapshotContainsKnowledge(Map<String, Object> snapshot, BigInteger knowledgeId) {
return snapshotListContains(snapshot, "knowledgeBindings", knowledgeId, null)
|| snapshotListContains(snapshot, "knowledgeSummaries", knowledgeId, null);
}
/**
* 判断快照列表中是否存在指定资源。
*
* @param snapshot 发布快照
* @param key 列表字段
* @param resourceId 资源 ID
* @param toolType 工具类型;知识库为空
* @return 是否存在
*/
private boolean snapshotListContains(Map<String, Object> snapshot,
String key,
BigInteger resourceId,
String toolType) {
if (snapshot == null || snapshot.isEmpty()) {
return false;
}
Object value = snapshot.get(key);
if (!(value instanceof List<?> items)) {
return false;
}
return items.stream().anyMatch(item -> matchesResourceBinding(item, resourceId, toolType));
}
/**
* 收集工具绑定中的 Agent ID。
*
* @param bindings 工具绑定
* @return 去重后的 Agent ID
*/
private Set<BigInteger> collectAgentIdsFromToolBindings(List<AgentToolBinding> bindings) {
Set<BigInteger> result = new LinkedHashSet<>();
if (bindings == null) {
return result;
}
for (AgentToolBinding binding : bindings) {
if (binding != null && binding.getAgentId() != null) {
result.add(binding.getAgentId());
}
}
return result;
}
/**
* 收集知识库绑定中的 Agent ID。
*
* @param bindings 知识库绑定
* @return 去重后的 Agent ID
*/
private Set<BigInteger> collectAgentIdsFromKnowledgeBindings(List<AgentKnowledgeBinding> bindings) {
Set<BigInteger> result = new LinkedHashSet<>();
if (bindings == null) {
return result;
}
for (AgentKnowledgeBinding binding : bindings) {
if (binding != null && binding.getAgentId() != null) {
result.add(binding.getAgentId());
}
}
return result;
}
/**
* 收集 Agent 实体中的 ID。
*
* @param agents Agent 列表
* @return Agent ID 集合
*/
private Set<BigInteger> collectAgentIdsFromAgents(List<Agent> agents) {
Set<BigInteger> result = new LinkedHashSet<>();
if (agents == null) {
return result;
}
for (Agent agent : agents) {
if (agent != null && agent.getId() != null) {
result.add(agent.getId());
}
}
return result;
}
/**
* 按 Agent ID 升序返回锁定顺序,避免并发批量解绑以相反顺序持锁。
*
* @param agentIds Agent ID 集合
* @return 稳定排序后的 Agent ID
*/
private List<BigInteger> sortedAgentIds(Set<BigInteger> agentIds) {
if (agentIds == null || agentIds.isEmpty()) {
return Collections.emptyList();
}
return agentIds.stream()
.sorted(Comparator.naturalOrder())
.toList();
}
/**
* 从 Agent 发布快照中移除指定资源绑定。
*
* @param agentId Agent ID
* @param resourceId 资源 ID
* @param toolType 工具类型;知识库绑定时为空
* @param bindingsKeys 快照绑定字段
*/
private void trimPublishedSnapshot(BigInteger agentId,
BigInteger resourceId,
String toolType,
String... bindingsKeys) {
Agent agent = agentService.getOne(QueryWrapper.create()
.eq(Agent::getId, agentId)
.forUpdate());
if (agent == null || agent.getPublishedSnapshotJson() == null || agent.getPublishedSnapshotJson().isEmpty()) {
return;
}
Map<String, Object> snapshot = new LinkedHashMap<>(agent.getPublishedSnapshotJson());
boolean changed = false;
for (String bindingsKey : bindingsKeys) {
Object rawBindings = snapshot.get(bindingsKey);
if (!(rawBindings instanceof List<?> bindings)) {
continue;
}
List<Object> filtered = new ArrayList<>(bindings.size());
for (Object item : bindings) {
if (matchesResourceBinding(item, resourceId, toolType)) {
changed = true;
continue;
}
filtered.add(item);
}
snapshot.put(bindingsKey, filtered);
}
if (!changed) {
return;
}
// 仅更新发布快照,避免并发草稿编辑被旧实体中的其他字段覆盖。
agentService.updateChain()
.set(Agent::getPublishedSnapshotJson, snapshot)
.eq(Agent::getId, agentId)
.update();
}
/**
* 判断快照项是否指向指定资源。
*
* @param item 快照项
* @param resourceId 资源 ID
* @param toolType 工具类型;知识库绑定时为空
* @return 是否匹配
*/
private boolean matchesResourceBinding(Object item, BigInteger resourceId, String toolType) {
if (!(item instanceof Map<?, ?> binding)) {
return false;
}
Object currentId = toolType == null ? binding.get("knowledgeId") : binding.get("targetId");
if (!Objects.equals(String.valueOf(currentId), String.valueOf(resourceId))) {
return false;
}
return toolType == null || toolType.equalsIgnoreCase(String.valueOf(binding.get("toolType")));
}
/**
* 比较快照中的资源 ID 与数据库 ID。
*
* @param snapshotId 快照 ID
* @param resourceId 数据库 ID
* @return 是否相同
*/
private boolean sameId(Object snapshotId, BigInteger resourceId) {
return snapshotId != null
&& resourceId != null
&& Objects.equals(String.valueOf(snapshotId), resourceId.toString());
}
}

View File

@@ -2,6 +2,7 @@ package tech.easyflow.agent.service.impl;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl; import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -10,9 +11,12 @@ import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding; import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.mapper.AgentMapper; import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.runtime.AgentRuntimeCompiler;
import tech.easyflow.agent.service.AgentDependencyAccessService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService; import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.entity.*; import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.*; import tech.easyflow.ai.service.*;
@@ -58,6 +62,12 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Resource @Resource
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@Resource
private AgentDependencyAccessService agentDependencyAccessService;
@Resource
private AgentBindingLockExecutor agentBindingLockExecutor;
@Resource
private AgentRuntimeCompiler agentRuntimeCompiler;
/** /**
* {@inheritDoc} * {@inheritDoc}
@@ -77,8 +87,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Agent saveDraft(Agent agent) { public Agent saveDraft(Agent agent) {
validateDraft(agent);
applyDraftDefaults(agent); applyDraftDefaults(agent);
validateDraft(agent);
save(agent); save(agent);
return getDetail(agent.getId()); return getDetail(agent.getId());
} }
@@ -92,12 +102,16 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
if (agent == null || agent.getId() == null) { if (agent == null || agent.getId() == null) {
throw new BusinessException("Agent ID 不能为空"); throw new BusinessException("Agent ID 不能为空");
} }
Agent existing = requireAgent(agent.getId()); return agentBindingLockExecutor.execute(agent.getId(), () -> {
resourceAccessService.assertAccess(CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent"); Agent existing = requireAgentForUpdate(agent.getId());
validateDraft(agent); resourceAccessService.assertAccess(
applyDraftUpdate(existing, agent); CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
updateById(existing); agent.setTenantId(existing.getTenantId());
return getDetail(existing.getId()); validateDraft(agent);
applyDraftUpdate(existing, agent);
updateById(existing);
return getDetail(existing.getId());
});
} }
/** /**
@@ -117,8 +131,27 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
* {@inheritDoc} * {@inheritDoc}
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> buildPublishSnapshot(Agent agent) { public Map<String, Object> buildPublishSnapshot(Agent agent) {
Agent detail = getDetail(agent.getId()); if (agent == null || agent.getId() == null) {
throw new BusinessException("Agent ID 不能为空");
}
return agentBindingLockExecutor.execute(agent.getId(), () -> buildPublishSnapshotLocked(agent.getId()));
}
/**
* 在 Agent 锁和数据库行锁内构建并校验发布快照。
*
* @param agentId Agent ID
* @return 发布快照
*/
private Map<String, Object> buildPublishSnapshotLocked(BigInteger agentId) {
Agent detail = requireAgentForUpdate(agentId);
resourceAccessService.assertAccess(
CategoryResourceType.AGENT, detail, ResourceAction.MANAGE, "无权限管理该 Agent");
detail.setToolBindings(agentToolBindingService.listEnabled(agentId));
detail.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(agentId));
validateDraft(detail);
Map<String, Object> snapshot = new LinkedHashMap<>(); Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", detail.getId()); snapshot.put("id", detail.getId());
snapshot.put("tenantId", detail.getTenantId()); snapshot.put("tenantId", detail.getTenantId());
@@ -136,8 +169,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
snapshot.put("executionConfigJson", detail.getExecutionConfigJson()); snapshot.put("executionConfigJson", detail.getExecutionConfigJson());
snapshot.put("interactionConfigJson", detail.getInteractionConfigJson()); snapshot.put("interactionConfigJson", detail.getInteractionConfigJson());
snapshot.put("visibilityScope", detail.getVisibilityScope()); snapshot.put("visibilityScope", detail.getVisibilityScope());
snapshot.put("toolBindings", snapshotToolBindings(detail.getToolBindings())); snapshot.put("toolBindings", snapshotToolBindings(detail, detail.getToolBindings()));
snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail.getKnowledgeBindings())); snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail, detail.getKnowledgeBindings()));
snapshot.put("basicSummary", basicSummary(detail)); snapshot.put("basicSummary", basicSummary(detail));
snapshot.put("modelSummary", modelSummary(detail.getModelId())); snapshot.put("modelSummary", modelSummary(detail.getModelId()));
snapshot.put("parameterSummary", parameterSummary(detail)); snapshot.put("parameterSummary", parameterSummary(detail));
@@ -145,6 +178,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings())); snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings()));
snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings())); snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings()));
snapshot.put("snapshotAt", new Date()); snapshot.put("snapshotAt", new Date());
// 发布前完整编译一次,提前暴露工具运行名冲突和运行定义错误。
agentRuntimeCompiler.compile(fromSnapshot(snapshot));
return snapshot; return snapshot;
} }
@@ -179,6 +214,22 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
return agent; return agent;
} }
/**
* 查询并锁定 Agent 数据行。
*
* @param id Agent ID
* @return Agent
*/
private Agent requireAgentForUpdate(BigInteger id) {
Agent agent = getOne(QueryWrapper.create()
.eq(Agent::getId, id)
.forUpdate());
if (agent == null) {
throw new BusinessException("Agent 不存在");
}
return agent;
}
private void validateDraft(Agent agent) { private void validateDraft(Agent agent) {
if (agent == null) { if (agent == null) {
throw new BusinessException("Agent 不能为空"); throw new BusinessException("Agent 不能为空");
@@ -186,13 +237,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
if (agent.getName() == null || agent.getName().isBlank()) { if (agent.getName() == null || agent.getName().isBlank()) {
throw new BusinessException("Agent 名称不能为空"); throw new BusinessException("Agent 名称不能为空");
} }
if (agent.getModelId() == null) { agentDependencyAccessService.requireModel(agent, agent.getModelId());
throw new BusinessException("Agent 模型不能为空"); agentDependencyAccessService.validateCategory(agent);
}
Model model = modelService.getModelInstance(agent.getModelId());
if (model == null) {
throw new BusinessException("Agent 模型不存在");
}
agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name()); agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name());
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson())); agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson()));
@@ -334,57 +380,47 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
return summary; return summary;
} }
private List<AgentToolBinding> snapshotToolBindings(List<AgentToolBinding> bindings) { private List<AgentToolBinding> snapshotToolBindings(Agent agent, List<AgentToolBinding> bindings) {
if (bindings == null) { if (bindings == null) {
return List.of(); return List.of();
} }
return bindings.stream().map(binding -> { return bindings.stream().map(binding -> {
AgentToolBinding snapshot = objectMapper.convertValue(binding, AgentToolBinding.class); AgentToolBinding snapshot = objectMapper.convertValue(binding, AgentToolBinding.class);
snapshot.setResourceSnapshot(toolResourceSnapshot(agent, binding));
snapshot.setResourceSummary(toolSummary(binding)); snapshot.setResourceSummary(toolSummary(binding));
snapshot.setResourceSnapshot(toolResourceSnapshot(binding));
return snapshot; return snapshot;
}).toList(); }).toList();
} }
private Map<String, Object> toolResourceSnapshot(AgentToolBinding binding) { private Map<String, Object> toolResourceSnapshot(Agent agent, AgentToolBinding binding) {
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) { if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
Workflow workflow = workflowService.getPublishedById(binding.getTargetId()); Workflow workflow = agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId());
if (workflow == null || !PublishStatus.from(workflow.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("绑定工作流不存在或未发布");
}
return objectMapper.convertValue(workflow, new TypeReference<Map<String, Object>>() {}); return objectMapper.convertValue(workflow, new TypeReference<Map<String, Object>>() {});
} }
if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) { if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId()); PluginItem pluginItem = agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId());
if (pluginItem == null) {
throw new BusinessException("绑定插件不存在");
}
return objectMapper.convertValue(pluginItem, new TypeReference<Map<String, Object>>() {}); return objectMapper.convertValue(pluginItem, new TypeReference<Map<String, Object>>() {});
} }
Mcp mcp = mcpService.getById(binding.getTargetId()); Mcp mcp = agentDependencyAccessService.requireMcp(agent, binding.getTargetId());
if (mcp == null) {
throw new BusinessException("绑定 MCP 不存在");
}
return objectMapper.convertValue(mcp, new TypeReference<Map<String, Object>>() {}); return objectMapper.convertValue(mcp, new TypeReference<Map<String, Object>>() {});
} }
private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(List<AgentKnowledgeBinding> bindings) { private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(
Agent agent, List<AgentKnowledgeBinding> bindings) {
if (bindings == null) { if (bindings == null) {
return List.of(); return List.of();
} }
return bindings.stream().map(binding -> { return bindings.stream().map(binding -> {
AgentKnowledgeBinding snapshot = objectMapper.convertValue(binding, AgentKnowledgeBinding.class); AgentKnowledgeBinding snapshot = objectMapper.convertValue(binding, AgentKnowledgeBinding.class);
snapshot.setResourceSnapshot(knowledgeResourceSnapshot(agent, binding));
snapshot.setResourceSummary(knowledgeSummary(binding)); snapshot.setResourceSummary(knowledgeSummary(binding));
snapshot.setResourceSnapshot(knowledgeResourceSnapshot(binding));
return snapshot; return snapshot;
}).toList(); }).toList();
} }
private Map<String, Object> knowledgeResourceSnapshot(AgentKnowledgeBinding binding) { private Map<String, Object> knowledgeResourceSnapshot(Agent agent, AgentKnowledgeBinding binding) {
DocumentCollection knowledge = documentCollectionService.getPublishedById(binding.getKnowledgeId()); DocumentCollection knowledge =
if (knowledge == null || !PublishStatus.from(knowledge.getPublishStatus()).isExternallyVisible()) { agentDependencyAccessService.requireKnowledge(agent, binding.getKnowledgeId());
throw new BusinessException("绑定知识库不存在或未发布");
}
return objectMapper.convertValue(knowledge, new TypeReference<Map<String, Object>>() {}); return objectMapper.convertValue(knowledge, new TypeReference<Map<String, Object>>() {});
} }
@@ -408,10 +444,23 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
return summary; return summary;
} }
/**
* 获取当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录信息失效时抛出
*/
private LoginAccount requireCurrentLoginAccount() { private LoginAccount requireCurrentLoginAccount() {
try { 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) { } catch (Exception e) {
if (e instanceof BusinessException businessException) {
throw businessException;
}
throw new BusinessException("当前登录状态失效,请重新登录后再试"); throw new BusinessException("当前登录状态失效,请重新登录后再试");
} }
} }

View File

@@ -9,14 +9,9 @@ import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.mapper.AgentMapper; import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.mapper.AgentToolBindingMapper; import tech.easyflow.agent.mapper.AgentToolBindingMapper;
import tech.easyflow.agent.service.AgentDependencyAccessService;
import tech.easyflow.agent.service.AgentToolBindingService; import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.ai.entity.Mcp; import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -26,9 +21,13 @@ import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* Agent 工具绑定服务实现。 * Agent 工具绑定服务实现。
@@ -40,13 +39,11 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
@Resource @Resource
private AgentMapper agentMapper; private AgentMapper agentMapper;
@Resource @Resource
private WorkflowService workflowService;
@Resource
private PluginItemService pluginItemService;
@Resource
private McpService mcpService;
@Resource
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Resource
private AgentBindingLockExecutor agentBindingLockExecutor;
@Resource
private AgentDependencyAccessService agentDependencyAccessService;
/** /**
* {@inheritDoc} * {@inheritDoc}
@@ -54,19 +51,21 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public List<AgentToolBinding> replaceBindings(BigInteger agentId, List<AgentToolBinding> bindings) { public List<AgentToolBinding> replaceBindings(BigInteger agentId, List<AgentToolBinding> bindings) {
Agent agent = requireAgent(agentId); return agentBindingLockExecutor.execute(agentId, () -> {
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent"); Agent agent = requireAgentForUpdate(agentId);
remove(QueryWrapper.create().where("agent_id = ?", agentId)); resourceAccessService.assertAccess(
if (bindings == null || bindings.isEmpty()) { CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
return Collections.emptyList(); validateBindings(agent, bindings);
} remove(QueryWrapper.create().where("agent_id = ?", agentId));
for (int i = 0; i < bindings.size(); i++) { if (bindings == null || bindings.isEmpty()) {
AgentToolBinding binding = bindings.get(i); return Collections.emptyList();
validateBinding(binding); }
applyBindingDefaults(agent, binding, i); for (int i = 0; i < bindings.size(); i++) {
} applyBindingDefaults(agent, bindings.get(i), i);
saveBatch(bindings); }
return listEnabled(agentId); saveBatch(bindings);
return listEnabled(agentId);
});
} }
/** /**
@@ -80,40 +79,89 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
.orderBy("sort_no asc, id asc")); .orderBy("sort_no asc, id asc"));
} }
private Agent requireAgent(BigInteger agentId) { /**
Agent agent = agentMapper.selectOneById(agentId); * 锁定并加载待修改的 Agent。
*
* @param agentId Agent ID
* @return 已锁定 Agent
* @throws BusinessException Agent 不存在时抛出
*/
private Agent requireAgentForUpdate(BigInteger agentId) {
Agent agent = agentMapper.selectOneByQuery(QueryWrapper.create()
.eq(Agent::getId, agentId)
.forUpdate());
if (agent == null) { if (agent == null) {
throw new BusinessException("Agent 不存在"); throw new BusinessException("Agent 不存在");
} }
return agent; return agent;
} }
private void validateBinding(AgentToolBinding binding) { /**
* 校验单个工具绑定,并锁定目标资源到当前事务结束。
*
* @param agent 当前 Agent
* @param binding 工具绑定
* @throws BusinessException 绑定参数无效或目标资源不可用时抛出
*/
private void validateBinding(Agent agent, AgentToolBinding binding) {
if (binding == null || binding.getTargetId() == null || binding.getToolType() == null) { if (binding == null || binding.getTargetId() == null || binding.getToolType() == null) {
throw new BusinessException("工具绑定参数不完整"); throw new BusinessException("工具绑定参数不完整");
} }
AgentToolType type = AgentToolType.from(binding.getToolType()); AgentToolType type = AgentToolType.from(binding.getToolType());
if (type == AgentToolType.WORKFLOW) { if (type == AgentToolType.WORKFLOW) {
Workflow workflow = workflowService.getById(binding.getTargetId()); agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId());
if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("绑定工作流不存在或未发布");
}
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, "无权限绑定该工作流");
return; return;
} }
if (type == AgentToolType.PLUGIN) { if (type == AgentToolType.PLUGIN) {
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId()); agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId());
if (pluginItem == null || pluginItem.getStatus() == null || pluginItem.getStatus() != 1) {
throw new BusinessException("绑定插件不存在或未启用");
}
return; return;
} }
Mcp mcp = mcpService.getById(binding.getTargetId()); agentDependencyAccessService.requireMcp(agent, binding.getTargetId());
if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) {
throw new BusinessException("绑定 MCP 不存在或未启用");
}
} }
/**
* 按稳定顺序校验绑定并锁定关联资源,降低并发替换产生数据库死锁的概率。
*
* @param agent 当前 Agent
* @param bindings 工具绑定
*/
private void validateBindings(Agent agent, List<AgentToolBinding> bindings) {
if (bindings == null || bindings.isEmpty()) {
return;
}
Set<String> resourceKeys = new LinkedHashSet<>();
Set<String> declaredToolNames = new LinkedHashSet<>();
for (AgentToolBinding binding : bindings) {
if (binding == null || binding.getToolType() == null || binding.getTargetId() == null) {
continue;
}
String resourceKey = AgentToolType.from(binding.getToolType()).name() + ":" + binding.getTargetId();
if (!resourceKeys.add(resourceKey)) {
throw new BusinessException("同一工具资源不能重复绑定");
}
String toolName = binding.getToolName();
if (toolName != null && !toolName.isBlank() && !declaredToolNames.add(toolName.trim())) {
throw new BusinessException("Agent 工具运行名冲突:" + toolName.trim() + ",请调整工具名称");
}
}
List<AgentToolBinding> validationOrder = new ArrayList<>(bindings);
validationOrder.sort(Comparator
.comparing((AgentToolBinding binding) ->
binding == null || binding.getToolType() == null ? "" : binding.getToolType())
.thenComparing(binding ->
binding == null || binding.getTargetId() == null
? BigInteger.ZERO
: binding.getTargetId()));
validationOrder.forEach(binding -> validateBinding(agent, binding));
}
/**
* 写入工具绑定的归属、审计与排序默认值。
*
* @param agent 当前 Agent
* @param binding 工具绑定
* @param index 绑定顺序
*/
private void applyBindingDefaults(Agent agent, AgentToolBinding binding, int index) { private void applyBindingDefaults(Agent agent, AgentToolBinding binding, int index) {
LoginAccount account = requireCurrentLoginAccount(); LoginAccount account = requireCurrentLoginAccount();
Date now = new Date(); Date now = new Date();
@@ -129,10 +177,23 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
binding.setModifiedBy(account.getId()); binding.setModifiedBy(account.getId());
} }
/**
* 获取当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录信息失效时抛出
*/
private LoginAccount requireCurrentLoginAccount() { private LoginAccount requireCurrentLoginAccount() {
try { try {
return SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
} catch (Exception e) { } catch (Exception e) {
if (e instanceof BusinessException businessException) {
throw businessException;
}
throw new BusinessException("当前登录状态失效,请重新登录后再试"); throw new BusinessException("当前登录状态失效,请重新登录后再试");
} }
} }

View File

@@ -0,0 +1,198 @@
package tech.easyflow.agent.support;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
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.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
/**
* 在同一 Agent 维度串行执行绑定变更,并将锁持有到数据库事务结束。
*/
@Component
public class AgentBindingLockExecutor {
private static final Logger LOG = LoggerFactory.getLogger(AgentBindingLockExecutor.class);
private static final String LOCK_KEY_PREFIX = "easyflow:lock:agent:binding:";
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(30);
private final RedisLockExecutor redisLockExecutor;
private final ScheduledExecutorService renewExecutor;
/**
* 创建 Agent 绑定锁执行器。
*
* @param redisLockExecutor Redis 分布式锁执行器
*/
@Autowired
public AgentBindingLockExecutor(RedisLockExecutor redisLockExecutor) {
this(
redisLockExecutor,
Executors.newSingleThreadScheduledExecutor(new AgentBindingLockRenewThreadFactory())
);
}
/**
* 创建使用指定续期执行器的 Agent 绑定锁执行器。
*
* @param redisLockExecutor Redis 分布式锁执行器
* @param renewExecutor 锁续期执行器
*/
AgentBindingLockExecutor(RedisLockExecutor redisLockExecutor,
ScheduledExecutorService renewExecutor) {
this.redisLockExecutor = redisLockExecutor;
this.renewExecutor = renewExecutor;
}
/**
* 在 Agent 绑定锁内执行任务。
*
* <p>存在活动事务时,锁会在事务完成后释放,避免提交前出现并发写入窗口。</p>
*
* @param agentId Agent ID
* @param task 绑定变更任务
* @param <T> 返回类型
* @return 任务结果
*/
public <T> T execute(BigInteger agentId, Supplier<T> 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;
}
}
}

View File

@@ -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<String, Object> interactionConfigJson,
Boolean supportImage
) {
}

View File

@@ -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<ModelOption> models,
List<ResourceOption> knowledges,
List<ResourceOption> workflows,
List<PluginToolOption> pluginTools,
List<McpOption> 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) {
}
}

View File

@@ -113,6 +113,34 @@ public class AgentRuntimeCommandConsumerTest {
Assert.assertEquals("cmd-expire", resultRegistry.lastSuccessCommandId); 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) { private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) {
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage(); AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
command.setCommandId(commandId); command.setCommandId(commandId);
@@ -138,6 +166,7 @@ public class AgentRuntimeCommandConsumerTest {
private int expireCount; private int expireCount;
private String lastRequestId; private String lastRequestId;
private String lastReason; private String lastReason;
private String lastCancelledAgentId;
@Override @Override
public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) { public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
@@ -151,6 +180,11 @@ public class AgentRuntimeCommandConsumerTest {
lastRequestId = requestId; lastRequestId = requestId;
lastReason = reason; lastReason = reason;
} }
@Override
public void cancelAgentLocal(String agentId) {
lastCancelledAgentId = agentId;
}
} }
private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry { private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry {

View File

@@ -6,11 +6,13 @@ import org.junit.Test;
import org.mockito.ArgumentMatchers; import org.mockito.ArgumentMatchers;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.ValueOperations;
import tech.easyflow.agent.config.AgentRuntimeProperties; import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import java.time.Duration; import java.time.Duration;
import java.util.Set;
/** /**
* {@link AgentRuntimeRouteRegistry} 回归测试。 * {@link AgentRuntimeRouteRegistry} 回归测试。
@@ -40,12 +42,49 @@ public class AgentRuntimeRouteRegistryTest {
"easyflow:agent:runtime:resume-token:token-1", "request-1", Duration.ofHours(24)); "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<String, String> valueOperations = Mockito.mock(ValueOperations.class);
@SuppressWarnings("unchecked")
SetOperations<String, String> 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 路由。 * 验证运行结束时清理 Redis 路由。
*/ */
@Test @Test
public void removeShouldDeleteRunAndTokenRoutes() { public void removeShouldDeleteRunAndTokenRoutes() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class); StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties("node-a")); AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties("node-a"));
registry.removeRun("request-1"); registry.removeRun("request-1");

View File

@@ -5,17 +5,24 @@ import com.mybatisflex.core.update.UpdateChain;
import com.mybatisflex.core.util.LambdaGetter; import com.mybatisflex.core.util.LambdaGetter;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; 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.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.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentToolBindingService; import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.agent.support.AgentBindingLockExecutor;
import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.enums.PublishStatus;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -82,27 +89,50 @@ public class AgentApprovalSubjectHandlerTest {
} }
/** /**
* 审批删除 Agent 必须同步清理工具绑定和知识库绑定,避免留下孤儿数据。 * 审批删除 Agent 必须在同一配置锁内取消运行并清理关联数据。
*/ */
@Test @Test
public void beforeRemoveShouldCleanAgentBindings() { public void removeResourceShouldCancelRunsAndCleanBindings() {
AtomicInteger toolRemoveCalls = new AtomicInteger(); AtomicInteger toolRemoveCalls = new AtomicInteger();
AtomicInteger knowledgeRemoveCalls = new AtomicInteger(); AtomicInteger knowledgeRemoveCalls = new AtomicInteger();
AgentToolBindingService toolBindingService = proxy(AgentToolBindingService.class, toolRemoveCalls); AgentToolBindingService toolBindingService = proxy(AgentToolBindingService.class, toolRemoveCalls);
AgentKnowledgeBindingService knowledgeBindingService = proxy(AgentKnowledgeBindingService.class, knowledgeRemoveCalls); 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( AgentApprovalSubjectHandler handler = new AgentApprovalSubjectHandler(
null, null,
new ObjectMapper(), new ObjectMapper(),
null, agentService,
toolBindingService, toolBindingService,
knowledgeBindingService, 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, toolRemoveCalls.get());
Assert.assertEquals(1, knowledgeRemoveCalls.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 审批资源处理器 * @return Agent 审批资源处理器
*/ */
private static AgentApprovalSubjectHandler handler(AgentService agentService) { private static AgentApprovalSubjectHandler handler(AgentService agentService) {
AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class);
when(routeRegistry.findOwnerNodesByAgent("1001")).thenReturn(Set.of());
return new AgentApprovalSubjectHandler( return new AgentApprovalSubjectHandler(
null, null,
new ObjectMapper(), new ObjectMapper(),
agentService, agentService,
null, null,
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;
}
/** /**
* 准备字段更新对象。 * 准备字段更新对象。
* *

View File

@@ -1246,6 +1246,11 @@ public class AgentRunServiceDraftAndHitlTest {
cancelByRequestIdCount++; cancelByRequestIdCount++;
} }
@Override
public void cancelByAgentId(BigInteger agentId, String reason) {
// 测试桩无需处理。
}
@Override @Override
public void deleteByChatSessionId(BigInteger chatSessionId) { public void deleteByChatSessionId(BigInteger chatSessionId) {
// 测试桩无需处理。 // 测试桩无需处理。

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<QueryWrapper> 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<QueryWrapper> 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<QueryWrapper> pluginQueryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
Mockito.verify(pluginMapper).selectOneByQuery(pluginQueryCaptor.capture());
Assert.assertTrue(pluginQueryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE"));
ArgumentCaptor<QueryWrapper> 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<QueryWrapper> 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;
}
}

View File

@@ -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<BigInteger> 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;
}
}

View File

@@ -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<Runnable> 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<TransactionSynchronization> 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<TransactionSynchronization> 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<Runnable> 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();
}
}
}

View File

@@ -1,14 +1,11 @@
package tech.easyflow.ai.publish; package tech.easyflow.ai.publish;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentCollectionCategory; import tech.easyflow.ai.entity.DocumentCollectionCategory;
import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.DocumentCollectionCategoryService; import tech.easyflow.ai.service.DocumentCollectionCategoryService;
import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
@@ -37,7 +34,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
private final DocumentCollectionService documentCollectionService; private final DocumentCollectionService documentCollectionService;
private final ResourceAccessService resourceAccessService; private final ResourceAccessService resourceAccessService;
private final BotDocumentCollectionService botDocumentCollectionService;
private final ModelService modelService; private final ModelService modelService;
private final DocumentCollectionCategoryService documentCollectionCategoryService; private final DocumentCollectionCategoryService documentCollectionCategoryService;
private final SysDeptService sysDeptService; private final SysDeptService sysDeptService;
@@ -46,7 +42,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
public KnowledgeApprovalSubjectHandler(DocumentCollectionService documentCollectionService, public KnowledgeApprovalSubjectHandler(DocumentCollectionService documentCollectionService,
ResourceAccessService resourceAccessService, ResourceAccessService resourceAccessService,
ApprovalInstanceService approvalInstanceService, ApprovalInstanceService approvalInstanceService,
BotDocumentCollectionService botDocumentCollectionService,
ModelService modelService, ModelService modelService,
DocumentCollectionCategoryService documentCollectionCategoryService, DocumentCollectionCategoryService documentCollectionCategoryService,
SysDeptService sysDeptService, SysDeptService sysDeptService,
@@ -55,7 +50,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
super(approvalInstanceService, objectMapper); super(approvalInstanceService, objectMapper);
this.documentCollectionService = documentCollectionService; this.documentCollectionService = documentCollectionService;
this.resourceAccessService = resourceAccessService; this.resourceAccessService = resourceAccessService;
this.botDocumentCollectionService = botDocumentCollectionService;
this.modelService = modelService; this.modelService = modelService;
this.documentCollectionCategoryService = documentCollectionCategoryService; this.documentCollectionCategoryService = documentCollectionCategoryService;
this.sysDeptService = sysDeptService; this.sysDeptService = sysDeptService;
@@ -200,26 +194,22 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
if (!impact.isCanProceed()) { if (!impact.isCanProceed()) {
throw new BusinessException(buildWorkflowUsageBlockMessage(impact)); throw new BusinessException(buildWorkflowUsageBlockMessage(impact));
} }
if (impact.isHasBotBindings()) { if (impact.isHasAgentBindings()) {
snapshot.put("botBindings", impact.getBotBindings()); snapshot.put("agentBindings", impact.getAgentBindings());
} }
} }
@Override @Override
protected void validateDelete(DocumentCollection resource, PublishStatus currentStatus) { protected void validateDelete(DocumentCollection resource, PublishStatus currentStatus) {
if (hasBotBinding(resource.getId())) { OfflineImpactCheckVo impact = resourceOfflineImpactService.checkKnowledgeImpact(resource.getId());
throw new BusinessException("此知识库还关联着bot请先取消关联"); if (impact.isHasAgentBindings()) {
throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除");
} }
} }
@Override @Override
protected void afterOffline(BigInteger resourceId) { protected void afterOffline(BigInteger resourceId) {
resourceOfflineImpactService.unbindKnowledgeFromBots(resourceId); resourceOfflineImpactService.unbindKnowledgeFromAgents(resourceId);
}
private boolean hasBotBinding(BigInteger knowledgeId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId);
return botDocumentCollectionService.exists(queryWrapper);
} }
private String buildWorkflowUsageBlockMessage(OfflineImpactCheckVo impact) { private String buildWorkflowUsageBlockMessage(OfflineImpactCheckVo impact) {

View File

@@ -1,14 +1,11 @@
package tech.easyflow.ai.publish; package tech.easyflow.ai.publish;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; 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.ResourceOfflineImpactService;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo; import tech.easyflow.ai.vo.OfflineImpactCheckVo;
@@ -31,7 +28,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
private final WorkflowService workflowService; private final WorkflowService workflowService;
private final ResourceAccessService resourceAccessService; private final ResourceAccessService resourceAccessService;
private final BotWorkflowService botWorkflowService;
private final ResourceOfflineImpactService resourceOfflineImpactService; private final ResourceOfflineImpactService resourceOfflineImpactService;
private final WorkflowPluginBindingService workflowPluginBindingService; private final WorkflowPluginBindingService workflowPluginBindingService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@@ -39,7 +35,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
public WorkflowApprovalSubjectHandler(WorkflowService workflowService, public WorkflowApprovalSubjectHandler(WorkflowService workflowService,
ResourceAccessService resourceAccessService, ResourceAccessService resourceAccessService,
ApprovalInstanceService approvalInstanceService, ApprovalInstanceService approvalInstanceService,
BotWorkflowService botWorkflowService,
ResourceOfflineImpactService resourceOfflineImpactService, ResourceOfflineImpactService resourceOfflineImpactService,
WorkflowPluginBindingService workflowPluginBindingService, WorkflowPluginBindingService workflowPluginBindingService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
@@ -47,7 +42,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
super(approvalInstanceService, objectMapper); super(approvalInstanceService, objectMapper);
this.workflowService = workflowService; this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService; this.resourceAccessService = resourceAccessService;
this.botWorkflowService = botWorkflowService;
this.resourceOfflineImpactService = resourceOfflineImpactService; this.resourceOfflineImpactService = resourceOfflineImpactService;
this.workflowPluginBindingService = workflowPluginBindingService; this.workflowPluginBindingService = workflowPluginBindingService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
@@ -178,8 +172,8 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
@Override @Override
protected void enrichOfflineSnapshot(Workflow resource, Map<String, Object> snapshot) { protected void enrichOfflineSnapshot(Workflow resource, Map<String, Object> snapshot) {
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId()); OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
if (impact.isHasBotBindings()) { if (impact.isHasAgentBindings()) {
snapshot.put("botBindings", impact.getBotBindings()); snapshot.put("agentBindings", impact.getAgentBindings());
} }
if (impact.isHasPluginBindings()) { if (impact.isHasPluginBindings()) {
snapshot.put("pluginBindings", impact.getPluginBindings()); snapshot.put("pluginBindings", impact.getPluginBindings());
@@ -188,18 +182,14 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
@Override @Override
protected void validateDelete(Workflow resource, PublishStatus currentStatus) { protected void validateDelete(Workflow resource, PublishStatus currentStatus) {
if (hasBotBinding(resource.getId())) { OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
throw new BusinessException("此工作流还关联有bot请先取消关联后再删除"); if (impact.isHasAgentBindings()) {
throw new BusinessException("此工作流仍被智能体使用,请先取消绑定后再删除");
} }
} }
@Override @Override
protected void afterOffline(BigInteger resourceId) { protected void afterOffline(BigInteger resourceId) {
resourceOfflineImpactService.unbindWorkflowFromBots(resourceId); resourceOfflineImpactService.unbindWorkflowFromAgents(resourceId);
}
private boolean hasBotBinding(BigInteger workflowId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotWorkflow::getWorkflowId, workflowId);
return botWorkflowService.exists(queryWrapper);
} }
} }

View File

@@ -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 资源的绑定查询与解绑契约。
*
* <p>契约定义在 AI 模块中,由 Agent 模块实现,避免共享资源生命周期反向依赖 Agent 实体。</p>
*/
public interface AgentResourceBindingProvider {
/**
* 查询绑定指定工作流的 Agent。
*
* @param workflowId 工作流 ID
* @return Agent 摘要列表
*/
List<OfflineImpactBindingVo> listAgentsByWorkflowId(BigInteger workflowId);
/**
* 查询绑定指定知识库的 Agent。
*
* @param knowledgeId 知识库 ID
* @return Agent 摘要列表
*/
List<OfflineImpactBindingVo> listAgentsByKnowledgeId(BigInteger knowledgeId);
/**
* 查询绑定指定插件工具的 Agent。
*
* @param pluginItemId 插件工具 ID
* @return Agent 摘要列表
*/
List<OfflineImpactBindingVo> listAgentsByPluginItemId(BigInteger pluginItemId);
/**
* 查询绑定指定 MCP 的 Agent。
*
* @param mcpId MCP ID
* @return Agent 摘要列表
*/
List<OfflineImpactBindingVo> listAgentsByMcpId(BigInteger mcpId);
/**
* 查询使用指定模型的 Agent。
*
* @param modelId 模型 ID
* @return Agent 摘要列表
*/
List<OfflineImpactBindingVo> listAgentsByModelId(BigInteger modelId);
/**
* 解绑指定工作流及 Agent 发布快照中的对应绑定。
*
* @param workflowId 工作流 ID
*/
void unbindWorkflow(BigInteger workflowId);
/**
* 解绑指定知识库及 Agent 发布快照中的对应绑定。
*
* @param knowledgeId 知识库 ID
*/
void unbindKnowledge(BigInteger knowledgeId);
}

View File

@@ -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<OfflineImpactBindingVo> listAgentsByWorkflowId(BigInteger workflowId);
/**
* 查询引用指定知识库的 Agent。
*
* @param knowledgeId 知识库 ID
* @return Agent 摘要列表
*/
List<OfflineImpactBindingVo> listAgentsByKnowledgeId(BigInteger knowledgeId);
/**
* 校验插件工具没有被 Agent 草稿或发布快照引用。
*
* @param pluginItemIds 插件工具 ID 集合
*/
void assertPluginItemsUnused(Collection<BigInteger> pluginItemIds);
/**
* 校验 MCP 没有被 Agent 草稿或发布快照引用。
*
* @param mcpId MCP ID
*/
void assertMcpUnused(BigInteger mcpId);
/**
* 校验模型没有被 Agent 草稿或发布快照引用。
*
* @param modelIds 模型 ID 集合
*/
void assertModelsUnused(Collection<BigInteger> modelIds);
/**
* 从 Agent 草稿绑定和发布快照中解绑工作流。
*
* @param workflowId 工作流 ID
*/
void unbindWorkflow(BigInteger workflowId);
/**
* 从 Agent 草稿绑定和发布快照中解绑知识库。
*
* @param knowledgeId 知识库 ID
*/
void unbindKnowledge(BigInteger knowledgeId);
}

View File

@@ -1,6 +1,5 @@
package tech.easyflow.ai.service; package tech.easyflow.ai.service;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
@@ -42,17 +41,4 @@ public interface AiResourceApprovalStateService {
*/ */
void fillKnowledgeApprovalState(Collection<DocumentCollection> collections); void fillKnowledgeApprovalState(Collection<DocumentCollection> collections);
/**
* 填充聊天助手审批展示状态。
*
* @param bot 聊天助手
*/
void fillBotApprovalState(Bot bot);
/**
* 批量填充聊天助手审批展示状态。
*
* @param bots 聊天助手集合
*/
void fillBotApprovalState(Collection<Bot> bots);
} }

View File

@@ -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<Bot> bots);
}

View File

@@ -3,6 +3,7 @@ package tech.easyflow.ai.service;
import com.mybatisflex.core.service.IService; import com.mybatisflex.core.service.IService;
import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List; import java.util.List;
@@ -22,4 +23,21 @@ public interface BotPluginService extends IService<BotPlugin> {
List<BigInteger> getBotPluginToolIds(String botId); List<BigInteger> getBotPluginToolIds(String botId);
void saveBotAndPluginTool(BigInteger botId, BigInteger[] pluginToolIds); void saveBotAndPluginTool(BigInteger botId, BigInteger[] pluginToolIds);
/**
* 查询插件工具,并标记指定 Bot 已绑定的工具。
*
* @param pluginId 插件 ID
* @param botId Bot ID
* @return 插件工具列表
*/
List<PluginItem> searchPluginTools(BigInteger pluginId, BigInteger botId);
/**
* 查询指定 Bot 已绑定的插件工具。
*
* @param botId Bot ID
* @return 已绑定插件工具列表
*/
List<PluginItem> getPluginTools(BigInteger botId);
} }

View File

@@ -4,11 +4,11 @@ import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.mcp.client.McpEnvironmentCheckResult; import com.easyagents.mcp.client.McpEnvironmentCheckResult;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.service.IService; import com.mybatisflex.core.service.IService;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.Mcp; import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger;
/** /**
* 服务层。 * 服务层。
@@ -24,7 +24,14 @@ public interface McpService extends IService<Mcp> {
void removeMcp(Serializable id); 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<Page<Mcp>> pageMcp(Result<Page<Mcp>> page); Result<Page<Mcp>> pageMcp(Result<Page<Mcp>> page);

View File

@@ -21,10 +21,6 @@ public interface PluginItemService extends IService<PluginItem> {
boolean updatePlugin(PluginItem pluginItem); boolean updatePlugin(PluginItem pluginItem);
List<PluginItem> searchPluginToolByPluginId(BigInteger pluginId, BigInteger botId);
List<PluginItem> getPluginToolList(BigInteger botId);
Result pluginToolTest(String inputData, BigInteger pluginToolId); Result pluginToolTest(String inputData, BigInteger pluginToolId);
List<PluginItem> getByPluginId(String id); List<PluginItem> getByPluginId(String id);

View File

@@ -26,16 +26,16 @@ public interface ResourceOfflineImpactService {
OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId); OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId);
/** /**
* 工作流下线后,静默解绑所有关联 Bot。 * 工作流下线后,静默解绑所有关联 Agent。
* *
* @param workflowId 工作流 ID * @param workflowId 工作流 ID
*/ */
void unbindWorkflowFromBots(BigInteger workflowId); void unbindWorkflowFromAgents(BigInteger workflowId);
/** /**
* 知识库下线后,静默解绑所有关联 Bot。 * 知识库下线后,静默解绑所有关联 Agent。
* *
* @param knowledgeId 知识库 ID * @param knowledgeId 知识库 ID
*/ */
void unbindKnowledgeFromBots(BigInteger knowledgeId); void unbindKnowledgeFromAgents(BigInteger knowledgeId);
} }

View File

@@ -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<AgentResourceBindingProvider> providers;
/**
* 创建 Agent 资源引用查询服务。
*
* @param providers Agent 资源绑定提供者
*/
public AgentResourceReferenceServiceImpl(List<AgentResourceBindingProvider> providers) {
this.providers = providers == null ? List.of() : List.copyOf(providers);
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listAgentsByWorkflowId(BigInteger workflowId) {
return merge(provider -> provider.listAgentsByWorkflowId(workflowId));
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listAgentsByKnowledgeId(BigInteger knowledgeId) {
return merge(provider -> provider.listAgentsByKnowledgeId(knowledgeId));
}
/**
* {@inheritDoc}
*/
@Override
public void assertPluginItemsUnused(Collection<BigInteger> 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<BigInteger> 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<OfflineImpactBindingVo> merge(
Function<AgentResourceBindingProvider, List<OfflineImpactBindingVo>> loader) {
Map<BigInteger, OfflineImpactBindingVo> merged = new LinkedHashMap<>();
for (AgentResourceBindingProvider provider : requireProviders()) {
List<OfflineImpactBindingVo> 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<OfflineImpactBindingVo> 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<AgentResourceBindingProvider> requireProviders() {
if (providers.isEmpty()) {
throw new BusinessException("Agent 资源引用检查服务不可用,请稍后重试");
}
return providers;
}
}

View File

@@ -3,7 +3,6 @@ package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus; 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<Bot> 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 displaySetter 展示状态写入器
* @param <T> 资源类型 * @param <T> 资源类型
*/ */
private <T> void fillApprovalState(Collection<T> resources, <T> void fillApprovalState(Collection<T> resources,
String resourceType, String resourceType,
Function<T, BigInteger> instanceIdGetter, Function<T, BigInteger> instanceIdGetter,
Function<T, PublishStatus> statusGetter, Function<T, PublishStatus> statusGetter,

View File

@@ -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<Bot> bots) {
approvalStateService.fillApprovalState(
bots,
ApprovalResourceType.BOT.getCode(),
Bot::getCurrentApprovalInstanceId,
bot -> PublishStatus.from(bot.getPublishStatus()),
Bot::getPublishedSnapshotJson,
Bot::setApprovalPending,
Bot::setCurrentApprovalActionType,
Bot::setDisplayPublishStatus
);
}
}

View File

@@ -6,7 +6,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.mapper.BotPluginMapper; import tech.easyflow.ai.mapper.BotPluginMapper;
import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.mapper.PluginMapper; import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.service.BotPluginService; import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.common.cache.RedisLockExecutor; import tech.easyflow.common.cache.RedisLockExecutor;
@@ -40,6 +42,9 @@ public class BotPluginServiceImpl extends ServiceImpl<BotPluginMapper, BotPlugin
@Resource @Resource
private PluginMapper pluginMapper; private PluginMapper pluginMapper;
@Resource
private PluginItemMapper pluginItemMapper;
@Resource @Resource
private RedisLockExecutor redisLockExecutor; private RedisLockExecutor redisLockExecutor;
@@ -97,4 +102,50 @@ public class BotPluginServiceImpl extends ServiceImpl<BotPluginMapper, BotPlugin
this.saveBatch(list); this.saveBatch(list);
}); });
} }
/**
* {@inheritDoc}
*/
@Override
public List<PluginItem> searchPluginTools(BigInteger pluginId, BigInteger botId) {
List<PluginItem> pluginItems = pluginItemMapper.selectListByQuery(
QueryWrapper.create().eq(PluginItem::getPluginId, pluginId)
);
if (pluginItems == null || pluginItems.isEmpty() || botId == null) {
return pluginItems == null ? List.of() : pluginItems;
}
List<BigInteger> boundToolIdList = botPluginMapper.selectListByQueryAs(
QueryWrapper.create()
.select(BOT_PLUGIN.PLUGIN_ITEM_ID)
.where(BOT_PLUGIN.BOT_ID.eq(botId)),
BigInteger.class
);
Set<BigInteger> boundToolIds = boundToolIdList == null
? Set.of()
: new LinkedHashSet<>(boundToolIdList);
for (PluginItem pluginItem : pluginItems) {
pluginItem.setJoinBot(boundToolIds.contains(pluginItem.getId()));
}
return pluginItems;
}
/**
* {@inheritDoc}
*/
@Override
public List<PluginItem> getPluginTools(BigInteger botId) {
if (botId == null) {
return List.of();
}
List<BigInteger> 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);
}
} }

View File

@@ -568,7 +568,7 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
queryWrapper.eq(BotMcp::getBotId, botId); queryWrapper.eq(BotMcp::getBotId, botId);
List<BotMcp> botMcpList = botMcpService.getMapper().selectListWithRelationsByQuery(queryWrapper); List<BotMcp> botMcpList = botMcpService.getMapper().selectListWithRelationsByQuery(queryWrapper);
botMcpList.forEach(botMcp -> { botMcpList.forEach(botMcp -> {
Tool tool = mcpService.toFunction(botMcp); Tool tool = mcpService.toFunction(botMcp.getMcpId(), botMcp.getMcpToolName());
functionList.add(tool); functionList.add(tool);
}); });

View File

@@ -540,7 +540,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
public boolean updateById(DocumentCollection entity) { public boolean updateById(DocumentCollection entity) {
DocumentCollection documentCollection = getById(entity.getId()); DocumentCollection documentCollection = getById(entity.getId());
if (documentCollection == null) { if (documentCollection == null) {
throw new BusinessException("bot 不存在"); throw new BusinessException("知识库不存在");
} }
CustomBeanUtils.copyPropertiesIgnoreNull(entity, documentCollection); CustomBeanUtils.copyPropertiesIgnoreNull(entity, documentCollection);

View File

@@ -15,7 +15,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import tech.easyflow.ai.easyagents.tool.McpTool; import tech.easyflow.ai.easyagents.tool.McpTool;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.Mcp; import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.mapper.McpMapper; import tech.easyflow.ai.mapper.McpMapper;
import tech.easyflow.ai.mcp.McpTransportType; import tech.easyflow.ai.mcp.McpTransportType;
@@ -29,6 +28,7 @@ import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger;
import java.util.*; import java.util.*;
/** /**
@@ -163,15 +163,15 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
} }
@Override @Override
public Tool toFunction(BotMcp botMcp) { public Tool toFunction(BigInteger mcpId, String mcpToolName) {
Mcp mcpInfo = this.getById(botMcp.getMcpId()); Mcp mcpInfo = this.getById(mcpId);
String configJson = mcpInfo.getConfigJson(); String configJson = mcpInfo.getConfigJson();
String mcpServerName = getFirstMcpServerName(configJson); String mcpServerName = getFirstMcpServerName(configJson);
if (StringUtil.hasText(mcpServerName)) { if (StringUtil.hasText(mcpServerName)) {
McpSyncClient mcpClient = mcpClientManager.getMcpClient(mcpServerName); McpSyncClient mcpClient = mcpClientManager.getMcpClient(mcpServerName);
List<McpSchema.Tool> tools = mcpClient.listTools().tools(); List<McpSchema.Tool> tools = mcpClient.listTools().tools();
for (McpSchema.Tool tool : tools) { for (McpSchema.Tool tool : tools) {
if (tool.name().equals(botMcp.getMcpToolName())) { if (tool.name().equals(mcpToolName)) {
Map<String, Object> properties = tool.inputSchema().properties(); Map<String, Object> properties = tool.inputSchema().properties();
List<String> required = tool.inputSchema().required(); List<String> required = tool.inputSchema().required();
McpTool mcpTool = new McpTool(); McpTool mcpTool = new McpTool();

View File

@@ -200,7 +200,10 @@ public class ModelServiceImpl extends ServiceImpl<ModelMapper, Model> implements
@Override @Override
public void removeByEntity(Model entity) { 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); modelMapper.deleteByQuery(queryWrapper);
} }

View File

@@ -3,7 +3,7 @@ package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl; import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service; 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.Plugin;
import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow; 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.WorkflowPluginAvailabilityDecision;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService; import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; 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.PluginMapper;
import tech.easyflow.ai.mapper.PluginItemMapper; import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.service.PluginItemService; import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
@@ -26,8 +26,6 @@ import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.*; import java.util.*;
import static tech.easyflow.ai.entity.table.BotPluginTableDef.BOT_PLUGIN;
/** /**
* 服务层实现。 * 服务层实现。
* *
@@ -43,19 +41,35 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
@Resource @Resource
private PluginMapper pluginMapper; private PluginMapper pluginMapper;
@Resource
private BotPluginMapper botPluginMapper;
@Resource @Resource
private WorkflowPluginAvailabilityService workflowPluginAvailabilityService; private WorkflowPluginAvailabilityService workflowPluginAvailabilityService;
@Resource @Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource @Resource
private WorkflowService workflowService; private WorkflowService workflowService;
@Resource
private PluginVisibilityService pluginVisibilityService;
@Override @Override
@Transactional(rollbackFor = Exception.class)
public boolean savePluginTool(PluginItem pluginItem) { public boolean savePluginTool(PluginItem pluginItem) {
Plugin plugin = pluginMapper.selectOneById(pluginItem.getPluginId()); if (pluginItem == null || pluginItem.getPluginId() == null) {
if (plugin != null && PluginType.isWorkflow(plugin.getType())) { throw new BusinessException("插件工具参数不完整");
}
Plugin plugin = pluginMapper.selectOneByQuery(QueryWrapper.create()
.eq(Plugin::getId, pluginItem.getPluginId())
.forUpdate());
if (plugin == null) {
throw new BusinessException("插件不存在");
}
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null || plugin.getTenantId() == null
|| !account.getTenantId().toString().equals(plugin.getTenantId().toString())) {
throw new BusinessException("无权限新增该插件工具");
}
pluginVisibilityService.assertPluginVisible(
plugin.getCreatedBy(), plugin.getId(), "无权限新增该插件工具");
if (PluginType.isWorkflow(plugin.getType())) {
throw new BusinessException("工作流插件工具由系统自动维护,不支持手动新增"); throw new BusinessException("工作流插件工具由系统自动维护,不支持手动新增");
} }
pluginItem.setCreated(new Date()); pluginItem.setCreated(new Date());
@@ -100,41 +114,6 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
return true; return true;
} }
@Override
public List<PluginItem> searchPluginToolByPluginId(BigInteger pluginId, BigInteger botId) {
QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create()
.select()
.eq(PluginItem::getPluginId, pluginId);
List<PluginItem> pluginItems = pluginItemMapper.selectListByQueryAs(queryAiPluginToolWrapper, PluginItem.class);
// 查询当前bot有哪些插件工具方法
QueryWrapper queryBotPluginTools = QueryWrapper.create()
.select()
.eq(BotPlugin::getBotId, botId);
List<BigInteger> 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<PluginItem> getPluginToolList(BigInteger botId) {
QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create()
.select(BOT_PLUGIN.PLUGIN_ITEM_ID)
.from(BOT_PLUGIN)
.where(BOT_PLUGIN.BOT_ID.eq(botId));
List<BigInteger> pluginToolIds = botPluginMapper.selectListByQueryAs(queryAiPluginToolWrapper, BigInteger.class);
if (pluginToolIds == null || pluginToolIds.isEmpty()) {
return Collections.emptyList();
}
// 查询当前bots对应的有哪些pluginTool
return pluginItemMapper.selectListByIds(pluginToolIds);
}
@Override @Override
public Result<?> pluginToolTest(String inputData, BigInteger pluginToolId) { public Result<?> pluginToolTest(String inputData, BigInteger pluginToolId) {
PluginItem pluginItem = pluginItemMapper.selectOneById(pluginToolId); PluginItem pluginItem = pluginItemMapper.selectOneById(pluginToolId);

View File

@@ -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.WorkflowPluginAvailabilityDecision;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService; import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; 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.PluginItemService;
import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
@@ -57,9 +57,6 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
@Resource @Resource
PluginCategoryMappingMapper pluginCategoryMappingMapper; PluginCategoryMappingMapper pluginCategoryMappingMapper;
@Resource
private BotPluginService botPluginService;
@Resource @Resource
private PluginItemService pluginItemService; private PluginItemService pluginItemService;
@Resource @Resource
@@ -70,6 +67,8 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
private WorkflowPluginBindingService workflowPluginBindingService; private WorkflowPluginBindingService workflowPluginBindingService;
@Resource @Resource
private WorkflowPluginAvailabilityService workflowPluginAvailabilityService; private WorkflowPluginAvailabilityService workflowPluginAvailabilityService;
@Resource
private AgentResourceReferenceService agentResourceReferenceService;
@Override @Override
public Plugin savePlugin(Plugin plugin) { public Plugin savePlugin(Plugin plugin) {
@@ -87,34 +86,42 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
} }
@Override @Override
@Transactional @Transactional(rollbackFor = Exception.class)
public boolean removePlugin(String id) { 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<PluginItem> pluginItems = pluginItemService.getByPluginId(id); // 父插件行先于工具行锁定,与工具新增和 Agent 绑定保持一致锁顺序。
List<PluginItem> pluginItems = pluginItemService.list(QueryWrapper.create()
.eq(PluginItem::getPluginId, plugin.getId())
.orderBy(PluginItem::getId, true)
.forUpdate());
List<BigInteger> pluginToolIds = new ArrayList<>(); List<BigInteger> pluginToolIds = new ArrayList<>();
if (pluginItems != null && !pluginItems.isEmpty()) { if (pluginItems != null && !pluginItems.isEmpty()) {
pluginToolIds = pluginItems.stream().map(PluginItem::getId).collect(Collectors.toList()); 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){ agentResourceReferenceService.assertPluginItemsUnused(pluginToolIds);
throw new BusinessException("插件中有工具还关联着bot请先取消关联"); 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); int remove = pluginMapper.deleteById(id);
if (remove <= 0) { if (remove <= 0) {
log.error("删除插件结果为0"); log.error("删除插件结果为0");
@@ -122,7 +129,6 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
} }
return true; return true;
} }
@Override @Override

View File

@@ -5,65 +5,52 @@ import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Bot; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.service.BotDocumentCollectionService; import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.BotService; import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ResourceOfflineImpactService; import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactBindingVo; import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import tech.easyflow.ai.vo.OfflineImpactCheckVo; 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.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** /**
* 资源下线影响检查与 Bot 静默解绑实现。 * 资源下线影响检查与 Agent 静默解绑实现。
*/ */
@Service @Service
public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactService { public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactService {
private static final String KNOWLEDGE_NODE_TYPE = "knowledgeNode"; 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 WorkflowService workflowService;
private final RedisLockExecutor redisLockExecutor; private final DocumentCollectionService documentCollectionService;
private final WorkflowPluginDependencyService workflowPluginDependencyService; private final WorkflowPluginDependencyService workflowPluginDependencyService;
private final AgentResourceReferenceService agentResourceReferenceService;
public ResourceOfflineImpactServiceImpl(BotWorkflowService botWorkflowService, /**
BotDocumentCollectionService botDocumentCollectionService, * 创建资源下线影响服务。
BotService botService, *
WorkflowService workflowService, * @param workflowService 工作流服务
RedisLockExecutor redisLockExecutor, * @param documentCollectionService 知识库服务
WorkflowPluginDependencyService workflowPluginDependencyService) { * @param workflowPluginDependencyService 工作流插件依赖服务
this.botWorkflowService = botWorkflowService; * @param agentResourceReferenceService Agent 资源引用服务
this.botDocumentCollectionService = botDocumentCollectionService; */
this.botService = botService; public ResourceOfflineImpactServiceImpl(WorkflowService workflowService,
DocumentCollectionService documentCollectionService,
WorkflowPluginDependencyService workflowPluginDependencyService,
AgentResourceReferenceService agentResourceReferenceService) {
this.workflowService = workflowService; this.workflowService = workflowService;
this.redisLockExecutor = redisLockExecutor; this.documentCollectionService = documentCollectionService;
this.workflowPluginDependencyService = workflowPluginDependencyService; this.workflowPluginDependencyService = workflowPluginDependencyService;
this.agentResourceReferenceService = agentResourceReferenceService;
} }
/** /**
@@ -71,17 +58,18 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
*/ */
@Override @Override
public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) { public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) {
List<OfflineImpactBindingVo> botBindings = listBotsByWorkflowId(workflowId); List<OfflineImpactBindingVo> agentBindings = listAgentsByWorkflowId(workflowId);
List<OfflineImpactBindingVo> pluginBindings = workflowPluginDependencyService.listPluginsByWorkflowId(workflowId); List<OfflineImpactBindingVo> pluginBindings =
workflowPluginDependencyService.listPluginsByWorkflowId(workflowId);
OfflineImpactCheckVo result = new OfflineImpactCheckVo(); OfflineImpactCheckVo result = new OfflineImpactCheckVo();
result.setCanProceed(true); result.setCanProceed(true);
result.setBotBindings(botBindings); result.setAgentBindings(agentBindings);
result.setHasBotBindings(!botBindings.isEmpty()); result.setHasAgentBindings(!agentBindings.isEmpty());
result.setPluginBindings(pluginBindings); result.setPluginBindings(pluginBindings);
result.setHasPluginBindings(!pluginBindings.isEmpty()); result.setHasPluginBindings(!pluginBindings.isEmpty());
result.setWorkflowUsages(Collections.emptyList()); result.setWorkflowUsages(Collections.emptyList());
result.setHasWorkflowUsages(false); result.setHasWorkflowUsages(false);
result.setMessage(resolveWorkflowOfflineImpactMessage(botBindings, pluginBindings)); result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, pluginBindings));
return result; return result;
} }
@@ -90,18 +78,18 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
*/ */
@Override @Override
public OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId) { public OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId) {
List<OfflineImpactBindingVo> botBindings = listBotsByKnowledgeId(knowledgeId); List<OfflineImpactBindingVo> agentBindings = listAgentsByKnowledgeId(knowledgeId);
List<OfflineImpactBindingVo> workflowUsages = listWorkflowsUsingKnowledge(knowledgeId); List<OfflineImpactBindingVo> workflowUsages = listWorkflowsUsingKnowledge(knowledgeId);
OfflineImpactCheckVo result = new OfflineImpactCheckVo(); OfflineImpactCheckVo result = new OfflineImpactCheckVo();
result.setBotBindings(botBindings); result.setAgentBindings(agentBindings);
result.setHasBotBindings(!botBindings.isEmpty()); result.setHasAgentBindings(!agentBindings.isEmpty());
result.setWorkflowUsages(workflowUsages); result.setWorkflowUsages(workflowUsages);
result.setHasWorkflowUsages(!workflowUsages.isEmpty()); result.setHasWorkflowUsages(!workflowUsages.isEmpty());
result.setCanProceed(workflowUsages.isEmpty()); result.setCanProceed(workflowUsages.isEmpty());
result.setMessage(workflowUsages.isEmpty() result.setMessage(workflowUsages.isEmpty()
? (botBindings.isEmpty() ? (agentBindings.isEmpty()
? "当前知识库下线后不会影响已有绑定" ? "当前知识库下线后不会影响已有绑定"
: "当前知识库下线成功后,将自动从相关聊天助手中解绑") : "当前知识库下线成功后,将自动从相关智能体中解绑")
: "当前知识库仍被工作流使用,请先调整工作流后再下线"); : "当前知识库仍被工作流使用,请先调整工作流后再下线");
return result; return result;
} }
@@ -110,84 +98,53 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
* {@inheritDoc} * {@inheritDoc}
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class) public void unbindWorkflowFromAgents(BigInteger workflowId) {
public void unbindWorkflowFromBots(BigInteger workflowId) { agentResourceReferenceService.unbindWorkflow(workflowId);
List<BotWorkflow> relations = botWorkflowService.list(QueryWrapper.create()
.eq(BotWorkflow::getWorkflowId, workflowId));
Set<BigInteger> 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);
}
);
}
} }
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class) public void unbindKnowledgeFromAgents(BigInteger knowledgeId) {
public void unbindKnowledgeFromBots(BigInteger knowledgeId) { agentResourceReferenceService.unbindKnowledge(knowledgeId);
List<BotDocumentCollection> relations = botDocumentCollectionService.list(QueryWrapper.create()
.eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId));
Set<BigInteger> 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);
}
);
}
} }
private List<OfflineImpactBindingVo> listBotsByWorkflowId(BigInteger workflowId) { /**
List<BotWorkflow> relations = botWorkflowService.list(QueryWrapper.create() * 汇总绑定指定工作流的 Agent。
.eq(BotWorkflow::getWorkflowId, workflowId)); *
return listBotsByIds(collectBotIds(relations, BotWorkflow::getBotId)); * @param workflowId 工作流 ID
* @return 去重后的 Agent 摘要
*/
private List<OfflineImpactBindingVo> listAgentsByWorkflowId(BigInteger workflowId) {
return agentResourceReferenceService.listAgentsByWorkflowId(workflowId);
} }
private List<OfflineImpactBindingVo> listBotsByKnowledgeId(BigInteger knowledgeId) { /**
List<BotDocumentCollection> relations = botDocumentCollectionService.list(QueryWrapper.create() * 汇总绑定指定知识库的 Agent。
.eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId)); *
return listBotsByIds(collectBotIds(relations, BotDocumentCollection::getBotId)); * @param knowledgeId 知识库 ID
} * @return 去重后的 Agent 摘要
*/
private List<OfflineImpactBindingVo> listBotsByIds(Set<BigInteger> botIds) { private List<OfflineImpactBindingVo> listAgentsByKnowledgeId(BigInteger knowledgeId) {
if (botIds.isEmpty()) { return agentResourceReferenceService.listAgentsByKnowledgeId(knowledgeId);
return Collections.emptyList();
}
List<Bot> bots = botService.listByIds(botIds);
Map<BigInteger, Bot> botMap = new HashMap<>();
for (Bot bot : bots) {
botMap.put(bot.getId(), bot);
}
List<OfflineImpactBindingVo> 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;
} }
/**
* 查询仍在设计内容中引用指定知识库的工作流。
*
* @param knowledgeId 知识库 ID
* @return 工作流摘要
*/
private List<OfflineImpactBindingVo> listWorkflowsUsingKnowledge(BigInteger knowledgeId) { private List<OfflineImpactBindingVo> listWorkflowsUsingKnowledge(BigInteger knowledgeId) {
List<Workflow> 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<Workflow> workflows = workflowService.list(queryWrapper);
if (workflows == null || workflows.isEmpty()) { if (workflows == null || workflows.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
} }
@@ -197,26 +154,43 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
continue; continue;
} }
if (containsKnowledgeReference(workflow.getContent(), knowledgeId)) { 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; return result;
} }
private String resolveWorkflowOfflineImpactMessage(List<OfflineImpactBindingVo> botBindings, /**
* 生成工作流下线影响提示。
*
* @param agentBindings Agent 绑定
* @param pluginBindings 插件绑定
* @return 提示信息
*/
private String resolveWorkflowOfflineImpactMessage(List<OfflineImpactBindingVo> agentBindings,
List<OfflineImpactBindingVo> pluginBindings) { List<OfflineImpactBindingVo> pluginBindings) {
if (!pluginBindings.isEmpty() && !botBindings.isEmpty()) { if (!pluginBindings.isEmpty() && !agentBindings.isEmpty()) {
return "当前工作流被插件和聊天助手引用,下线后插件将不可用,聊天助手将自动解绑"; return "当前工作流被插件和智能体引用,下线后插件将不可用,智能体将自动解绑";
} }
if (!pluginBindings.isEmpty()) { if (!pluginBindings.isEmpty()) {
return "当前工作流被插件引用,下线后相关插件将不可用"; return "当前工作流被插件引用,下线后相关插件将不可用";
} }
if (!botBindings.isEmpty()) { if (!agentBindings.isEmpty()) {
return "当前工作流下线成功后,将自动从相关聊天助手中解绑"; return "当前工作流下线成功后,将自动从相关智能体中解绑";
} }
return "当前工作流下线后不会影响已有绑定"; return "当前工作流下线后不会影响已有绑定";
} }
/**
* 判断工作流内容是否引用指定知识库。
*
* @param content 工作流内容
* @param knowledgeId 知识库 ID
* @return 是否引用
*/
private boolean containsKnowledgeReference(String content, BigInteger knowledgeId) { private boolean containsKnowledgeReference(String content, BigInteger knowledgeId) {
if (!StringUtils.hasText(content) || knowledgeId == null) { if (!StringUtils.hasText(content) || knowledgeId == null) {
return false; return false;
@@ -224,7 +198,7 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
try { try {
Object parsed = JSON.parse(content); Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) { if (!(parsed instanceof JSONObject root)) {
return false; throw new BusinessException("工作流定义格式异常,无法确认知识库下线影响");
} }
JSONArray nodes = root.getJSONArray("nodes"); JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null || nodes.isEmpty()) { if (nodes == null || nodes.isEmpty()) {
@@ -246,73 +220,10 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
} }
} }
return false; return false;
} catch (Exception ignored) { } catch (BusinessException exception) {
return false; 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<String, Object> snapshot = new LinkedHashMap<>(bot.getPublishedSnapshotJson());
Object rawBindings = snapshot.get(bindingsKey);
if (!(rawBindings instanceof List<?> bindings)) {
return;
}
List<Map<String, Object>> filtered = new ArrayList<>();
boolean changed = false;
String expectedId = resourceId == null ? null : resourceId.toString();
for (Object item : bindings) {
if (!(item instanceof Map<?, ?> bindingMap)) {
continue;
}
Object currentId = bindingMap.get(idKey);
if (expectedId != null && currentId != null && expectedId.equals(String.valueOf(currentId))) {
changed = true;
continue;
}
filtered.add(new LinkedHashMap<>((Map<String, Object>) bindingMap));
}
if (!changed) {
return;
}
snapshot.put(bindingsKey, filtered);
Bot update = new Bot();
update.setId(botId);
update.setPublishedSnapshotJson(snapshot);
botService.updateById(update);
}
private <T> Set<BigInteger> collectBotIds(Collection<T> relations, BotIdGetter<T> getter) {
if (relations == null || relations.isEmpty()) {
return Collections.emptySet();
}
Set<BigInteger> result = new LinkedHashSet<>();
for (T relation : relations) {
BigInteger botId = getter.getBotId(relation);
if (botId != null) {
result.add(botId);
}
}
return result;
}
private OfflineImpactBindingVo toBindingVo(BigInteger id, String title) {
OfflineImpactBindingVo vo = new OfflineImpactBindingVo();
vo.setId(id);
vo.setTitle(title);
return vo;
}
@FunctionalInterface
private interface BotIdGetter<T> {
BigInteger getBotId(T relation);
}
} }

View File

@@ -10,13 +10,13 @@ public class OfflineImpactCheckVo {
private boolean canProceed; private boolean canProceed;
private boolean hasBotBindings; private boolean hasAgentBindings;
private boolean hasWorkflowUsages; private boolean hasWorkflowUsages;
private boolean hasPluginBindings; private boolean hasPluginBindings;
private List<OfflineImpactBindingVo> botBindings = new ArrayList<>(); private List<OfflineImpactBindingVo> agentBindings = new ArrayList<>();
private List<OfflineImpactBindingVo> workflowUsages = new ArrayList<>(); private List<OfflineImpactBindingVo> workflowUsages = new ArrayList<>();
@@ -43,21 +43,21 @@ public class OfflineImpactCheckVo {
} }
/** /**
* 是否存在 Bot 绑定。 * 是否存在 Agent 绑定。
* *
* @return 是否存在 Bot 绑定 * @return 是否存在 Agent 绑定
*/ */
public boolean isHasBotBindings() { public boolean isHasAgentBindings() {
return hasBotBindings; return hasAgentBindings;
} }
/** /**
* 设置是否存在 Bot 绑定。 * 设置是否存在 Agent 绑定。
* *
* @param hasBotBindings 是否存在 Bot 绑定 * @param hasAgentBindings 是否存在 Agent 绑定
*/ */
public void setHasBotBindings(boolean hasBotBindings) { public void setHasAgentBindings(boolean hasAgentBindings) {
this.hasBotBindings = hasBotBindings; this.hasAgentBindings = hasAgentBindings;
} }
/** /**
@@ -79,21 +79,21 @@ public class OfflineImpactCheckVo {
} }
/** /**
* 获取 Bot 绑定列表。 * 获取 Agent 绑定列表。
* *
* @return Bot 绑定列表 * @return Agent 绑定列表
*/ */
public List<OfflineImpactBindingVo> getBotBindings() { public List<OfflineImpactBindingVo> getAgentBindings() {
return botBindings; return agentBindings;
} }
/** /**
* 设置 Bot 绑定列表。 * 设置 Agent 绑定列表。
* *
* @param botBindings Bot 绑定列表 * @param agentBindings Agent 绑定列表
*/ */
public void setBotBindings(List<OfflineImpactBindingVo> botBindings) { public void setAgentBindings(List<OfflineImpactBindingVo> agentBindings) {
this.botBindings = botBindings; this.agentBindings = agentBindings;
} }
/** /**

View File

@@ -0,0 +1,128 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Agent 资源下线影响检查测试。
*/
public class ResourceOfflineImpactServiceImplTest {
/**
* 验证工作流影响结果使用 Agent 绑定字段并委托 Agent 解绑。
*/
@Test
public void shouldReportAndUnbindAgentWorkflowBindings() {
WorkflowService workflowService = mock(WorkflowService.class);
DocumentCollectionService documentCollectionService =
mock(DocumentCollectionService.class);
WorkflowPluginDependencyService pluginDependencyService =
mock(WorkflowPluginDependencyService.class);
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
BigInteger workflowId = BigInteger.valueOf(10);
OfflineImpactBindingVo binding = binding(BigInteger.ONE, "测试智能体");
when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(List.of(binding));
when(pluginDependencyService.listPluginsByWorkflowId(workflowId))
.thenReturn(Collections.emptyList());
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
workflowService, documentCollectionService, pluginDependencyService, referenceService);
OfflineImpactCheckVo result = service.checkWorkflowImpact(workflowId);
service.unbindWorkflowFromAgents(workflowId);
Assert.assertTrue(result.isHasAgentBindings());
Assert.assertEquals(List.of(binding), result.getAgentBindings());
Assert.assertTrue(result.getMessage().contains("智能体"));
verify(referenceService).unbindWorkflow(workflowId);
}
/**
* 验证知识库影响结果使用 Agent 绑定字段并委托 Agent 解绑。
*/
@Test
public void shouldReportAndUnbindAgentKnowledgeBindings() {
WorkflowService workflowService = mock(WorkflowService.class);
DocumentCollectionService documentCollectionService =
mock(DocumentCollectionService.class);
WorkflowPluginDependencyService pluginDependencyService =
mock(WorkflowPluginDependencyService.class);
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
BigInteger knowledgeId = BigInteger.valueOf(20);
OfflineImpactBindingVo binding = binding(BigInteger.TWO, "知识智能体");
DocumentCollection knowledge = new DocumentCollection();
knowledge.setTenantId(BigInteger.ONE);
when(documentCollectionService.getById(knowledgeId)).thenReturn(knowledge);
when(referenceService.listAgentsByKnowledgeId(knowledgeId)).thenReturn(List.of(binding));
when(workflowService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
workflowService, documentCollectionService, pluginDependencyService, referenceService);
OfflineImpactCheckVo result = service.checkKnowledgeImpact(knowledgeId);
service.unbindKnowledgeFromAgents(knowledgeId);
Assert.assertTrue(result.isHasAgentBindings());
Assert.assertEquals(List.of(binding), result.getAgentBindings());
Assert.assertTrue(result.getMessage().contains("智能体"));
verify(referenceService).unbindKnowledge(knowledgeId);
}
/**
* 工作流定义损坏时必须阻止知识库下线,避免漏判引用关系。
*/
@Test(expected = BusinessException.class)
public void shouldFailClosedWhenWorkflowContentIsInvalid() {
WorkflowService workflowService = mock(WorkflowService.class);
DocumentCollectionService documentCollectionService =
mock(DocumentCollectionService.class);
WorkflowPluginDependencyService pluginDependencyService =
mock(WorkflowPluginDependencyService.class);
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
BigInteger knowledgeId = BigInteger.valueOf(20);
DocumentCollection knowledge = new DocumentCollection();
knowledge.setTenantId(BigInteger.ONE);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.TEN);
workflow.setContent("{invalid");
when(documentCollectionService.getById(knowledgeId)).thenReturn(knowledge);
when(referenceService.listAgentsByKnowledgeId(knowledgeId))
.thenReturn(Collections.emptyList());
when(workflowService.list(any(QueryWrapper.class))).thenReturn(List.of(workflow));
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
workflowService, documentCollectionService, pluginDependencyService, referenceService);
service.checkKnowledgeImpact(knowledgeId);
}
/**
* 创建绑定摘要。
*
* @param id 资源 ID
* @param title 标题
* @return 绑定摘要
*/
private OfflineImpactBindingVo binding(BigInteger id, String title) {
OfflineImpactBindingVo binding = new OfflineImpactBindingVo();
binding.setId(id);
binding.setTitle(title);
return binding;
}
}

View File

@@ -21,7 +21,7 @@ import java.math.BigInteger;
@Service @Service
public class ChatHistoryManageServiceImpl implements ChatHistoryManageService { public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
private static final String ADMIN_ASSISTANT_CODE = "AGENT"; private static final String AGENT_ASSISTANT_CODE = "AGENT";
private final ChatSessionQueryService chatSessionQueryService; private final ChatSessionQueryService chatSessionQueryService;
private final ChatSessionCommandService chatSessionCommandService; private final ChatSessionCommandService chatSessionCommandService;
@@ -43,24 +43,26 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
@Override @Override
public ChatSessionPage queryUserSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) { public ChatSessionPage queryUserSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
return chatSessionQueryService.pageSessions(userId, assistantId, query); return chatSessionQueryService.pageSessions(userId, assistantId, AGENT_ASSISTANT_CODE, query);
} }
@Override @Override
public ChatSessionPage queryAdminSessions(ChatSessionFilterQuery query) { public ChatSessionPage queryAdminSessions(ChatSessionFilterQuery query) {
ChatSessionFilterQuery effectiveQuery = query == null ? new ChatSessionFilterQuery() : query; ChatSessionFilterQuery effectiveQuery = query == null ? new ChatSessionFilterQuery() : query;
// 管理端聊天历史已经切换为 Agent 专属入口,类型由服务端固定,避免客户端绕过。 // 管理端聊天历史已经切换为 Agent 专属入口,类型由服务端固定,避免客户端绕过。
effectiveQuery.setAssistantCode(ADMIN_ASSISTANT_CODE); effectiveQuery.setAssistantCode(AGENT_ASSISTANT_CODE);
return chatAnalyticalDBRepository.pageSessions(effectiveQuery); return chatAnalyticalDBRepository.pageSessions(effectiveQuery);
} }
@Override @Override
public ChatSessionSummary getUserSession(BigInteger userId, BigInteger sessionId) { public ChatSessionSummary getUserSession(BigInteger userId, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId); ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || summary.getIsDeleted() != null && summary.getIsDeleted() == 1) { if (summary == null
throw new BusinessException("会话不存在"); || Integer.valueOf(1).equals(summary.getIsDeleted())
|| !AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode())) {
throw new BusinessException("Agent 会话不存在");
} }
if (!summary.getUserId().equals(userId)) { if (summary.getUserId() == null || !summary.getUserId().equals(userId)) {
throw new BusinessException("无权访问该会话"); throw new BusinessException("无权访问该会话");
} }
return summary; return summary;
@@ -71,7 +73,7 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
ChatSessionSummary summary = chatAnalyticalDBRepository.getSession(sessionId); ChatSessionSummary summary = chatAnalyticalDBRepository.getSession(sessionId);
if (summary == null if (summary == null
|| Integer.valueOf(1).equals(summary.getIsDeleted()) || Integer.valueOf(1).equals(summary.getIsDeleted())
|| !ADMIN_ASSISTANT_CODE.equals(summary.getAssistantCode())) { || !AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode())) {
throw new BusinessException("Agent 会话不存在"); throw new BusinessException("Agent 会话不存在");
} }
return summary; return summary;

View File

@@ -19,6 +19,7 @@ import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.analyticaldb.core.AnalyticalDBOperations; import tech.easyflow.common.analyticaldb.core.AnalyticalDBOperations;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
import java.math.BigInteger; import java.math.BigInteger;
@@ -28,6 +29,7 @@ import java.math.BigInteger;
public class ChatHistoryManageServiceImplTest { public class ChatHistoryManageServiceImplTest {
private StubChatAnalyticalDBRepository chatAnalyticalDBRepository; private StubChatAnalyticalDBRepository chatAnalyticalDBRepository;
private StubChatSessionQueryHandler chatSessionQueryHandler;
private ChatHistoryManageServiceImpl service; private ChatHistoryManageServiceImpl service;
/** /**
@@ -35,7 +37,8 @@ public class ChatHistoryManageServiceImplTest {
*/ */
@Before @Before
public void setUp() { public void setUp() {
ChatSessionQueryService chatSessionQueryService = unusedDependency(ChatSessionQueryService.class); chatSessionQueryHandler = new StubChatSessionQueryHandler();
ChatSessionQueryService chatSessionQueryService = chatSessionQueryHandler.createProxy();
ChatSessionCommandService chatSessionCommandService = unusedDependency(ChatSessionCommandService.class); ChatSessionCommandService chatSessionCommandService = unusedDependency(ChatSessionCommandService.class);
ChatHistoryQueryService chatHistoryQueryService = unusedDependency(ChatHistoryQueryService.class); ChatHistoryQueryService chatHistoryQueryService = unusedDependency(ChatHistoryQueryService.class);
ChatRoundOperateService chatRoundOperateService = unusedDependency(ChatRoundOperateService.class); ChatRoundOperateService chatRoundOperateService = unusedDependency(ChatRoundOperateService.class);
@@ -49,6 +52,56 @@ public class ChatHistoryManageServiceImplTest {
); );
} }
/**
* 验证用户端列表始终按 Agent 类型查询。
*/
@Test
public void queryUserSessionsShouldForceAgentAssistantCode() {
BigInteger userId = BigInteger.valueOf(2001);
BigInteger agentId = BigInteger.valueOf(3001);
ChatPageQuery query = new ChatPageQuery();
service.queryUserSessions(userId, agentId, query);
Assert.assertEquals(userId, chatSessionQueryHandler.lastUserId);
Assert.assertEquals(agentId, chatSessionQueryHandler.lastAssistantId);
Assert.assertEquals("AGENT", chatSessionQueryHandler.lastAssistantCode);
Assert.assertSame(query, chatSessionQueryHandler.lastPageQuery);
}
/**
* 验证用户端拒绝读取归属于当前用户的旧 Bot 会话。
*/
@Test
public void getUserSessionShouldRejectBotSession() {
BigInteger userId = BigInteger.valueOf(2002);
ChatSessionSummary summary = session(BigInteger.valueOf(3002), "BOT", 0);
summary.setUserId(userId);
chatSessionQueryHandler.sessionResult = summary;
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> service.getUserSession(userId, summary.getId())
);
Assert.assertEquals("Agent 会话不存在", exception.getMessage());
}
/**
* 验证用户端可以读取归属于当前用户的 Agent 会话。
*/
@Test
public void getUserSessionShouldReturnOwnedAgentSession() {
BigInteger userId = BigInteger.valueOf(2003);
ChatSessionSummary summary = session(BigInteger.valueOf(3003), "AGENT", 0);
summary.setUserId(userId);
chatSessionQueryHandler.sessionResult = summary;
ChatSessionSummary result = service.getUserSession(userId, summary.getId());
Assert.assertSame(summary, result);
}
/** /**
* 验证管理端列表始终覆盖客户端传入的会话类型为 Agent。 * 验证管理端列表始终覆盖客户端传入的会话类型为 Agent。
*/ */
@@ -197,6 +250,54 @@ public class ChatHistoryManageServiceImplTest {
return summary; return summary;
} }
/**
* 用户端会话查询依赖桩。
*/
private static class StubChatSessionQueryHandler implements InvocationHandler {
private BigInteger lastUserId;
private BigInteger lastAssistantId;
private String lastAssistantCode;
private ChatPageQuery lastPageQuery;
private ChatSessionSummary sessionResult;
/**
* 创建查询服务代理。
*
* @return 查询服务代理
*/
private ChatSessionQueryService createProxy() {
return (ChatSessionQueryService) Proxy.newProxyInstance(
ChatSessionQueryService.class.getClassLoader(),
new Class<?>[]{ChatSessionQueryService.class},
this
);
}
/**
* 处理测试所需的查询方法。
*
* @param proxy 代理对象
* @param method 被调用方法
* @param args 调用参数
* @return 方法返回值
*/
@Override
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
if ("pageSessions".equals(method.getName()) && args != null && args.length == 4) {
lastUserId = (BigInteger) args[0];
lastAssistantId = (BigInteger) args[1];
lastAssistantCode = (String) args[2];
lastPageQuery = (ChatPageQuery) args[3];
return new ChatSessionPage();
}
if ("getSessionSummary".equals(method.getName())) {
return sessionResult;
}
throw new AssertionError("测试路径不应调用查询方法: " + method.getName());
}
}
/** /**
* 仅记录管理端会话查询参数和返回值的分析库仓储桩。 * 仅记录管理端会话查询参数和返回值的分析库仓储桩。
*/ */

View File

@@ -27,6 +27,9 @@ import java.util.List;
@Service @Service
public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey> implements SysApiKeyService { public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey> implements SysApiKeyService {
private static final String PUBLIC_AGENT_CHAT_URI = "/public-api/agent/chat";
private static final String LEGACY_PUBLIC_BOT_CHAT_URI = "/public-api/bot/chat";
@Resource @Resource
private SysApiKeyResourceMappingService mappingService; private SysApiKeyResourceMappingService mappingService;
@Resource @Resource
@@ -57,7 +60,10 @@ public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey
private List<String> getCandidateRequestUris(String requestURI) { private List<String> getCandidateRequestUris(String requestURI) {
List<String> uris = new ArrayList<>(); List<String> uris = new ArrayList<>();
uris.add(requestURI); uris.add(requestURI);
if ("/v1/chat/completions".equals(requestURI)) { if (PUBLIC_AGENT_CHAT_URI.equals(requestURI)) {
// 数据库权限资源暂不迁移Agent 公共接口复用旧资源记录作为兼容别名。
uris.add(LEGACY_PUBLIC_BOT_CHAT_URI);
} else if ("/v1/chat/completions".equals(requestURI)) {
uris.add("/public-api/openai/v1/chat/completions"); uris.add("/public-api/openai/v1/chat/completions");
} else if ("/public-api/openai/v1/chat/completions".equals(requestURI)) { } else if ("/public-api/openai/v1/chat/completions".equals(requestURI)) {
uris.add("/v1/chat/completions"); uris.add("/v1/chat/completions");

View File

@@ -0,0 +1,149 @@
package tech.easyflow.system.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysApiKey;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.List;
/**
* API Key 接口权限兼容测试。
*/
public class SysApiKeyServiceImplTest {
/**
* 验证不存在的 API Key 使用 HTTP 401 语义。
*/
@Test
public void shouldReturnUnauthorizedWhenApiKeyMissing() {
TestSysApiKeyService service = new TestSysApiKeyService(null);
BusinessException error = expectBusinessException(
() -> service.getSysApiKey("missing"));
Assert.assertEquals(401, error.getHttpStatus());
Assert.assertEquals(401, error.getErrorCode());
}
/**
* 验证状态缺失的 API Key 按禁用处理并返回 HTTP 401。
*/
@Test
public void shouldReturnUnauthorizedWhenApiKeyStatusMissing() {
SysApiKey apiKey = new SysApiKey();
TestSysApiKeyService service = new TestSysApiKeyService(apiKey);
BusinessException error = expectBusinessException(
() -> service.getSysApiKey("status-missing"));
Assert.assertEquals(401, error.getHttpStatus());
Assert.assertEquals(401, error.getErrorCode());
}
/**
* 验证过期的 API Key 使用 HTTP 401 语义。
*/
@Test
public void shouldReturnUnauthorizedWhenApiKeyExpired() {
SysApiKey apiKey = new SysApiKey();
apiKey.setStatus(1);
apiKey.setExpiredAt(new Date(System.currentTimeMillis() - 1_000L));
TestSysApiKeyService service = new TestSysApiKeyService(apiKey);
BusinessException error = expectBusinessException(
() -> service.getSysApiKey("expired"));
Assert.assertEquals(401, error.getHttpStatus());
Assert.assertEquals(401, error.getErrorCode());
}
/**
* 验证 Agent 公共聊天接口可复用未迁移的旧权限资源记录。
*
* @throws Exception 反射调用失败
*/
@Test
@SuppressWarnings("unchecked")
public void shouldResolveLegacyPermissionForPublicAgentChat() throws Exception {
SysApiKeyServiceImpl service = new SysApiKeyServiceImpl();
Method method = SysApiKeyServiceImpl.class
.getDeclaredMethod("getCandidateRequestUris", String.class);
method.setAccessible(true);
List<String> candidates =
(List<String>) method.invoke(service, "/public-api/agent/chat");
Assert.assertEquals(
List.of("/public-api/agent/chat", "/public-api/bot/chat"),
candidates
);
}
/**
* 验证旧 Bot 接口不会反向获得新 Agent 接口权限。
*
* @throws Exception 反射调用失败
*/
@Test
@SuppressWarnings("unchecked")
public void shouldKeepLegacyBotPermissionIsolated() throws Exception {
SysApiKeyServiceImpl service = new SysApiKeyServiceImpl();
Method method = SysApiKeyServiceImpl.class
.getDeclaredMethod("getCandidateRequestUris", String.class);
method.setAccessible(true);
List<String> candidates =
(List<String>) method.invoke(service, "/public-api/bot/chat");
Assert.assertEquals(List.of("/public-api/bot/chat"), candidates);
}
/**
* 执行调用并返回预期业务异常。
*
* @param action 待执行调用
* @return 捕获的业务异常
*/
private BusinessException expectBusinessException(Runnable action) {
try {
action.run();
Assert.fail("expected BusinessException");
return null;
} catch (BusinessException error) {
return error;
}
}
/**
* 固定返回 API Key 的测试服务。
*/
private static final class TestSysApiKeyService
extends SysApiKeyServiceImpl {
private final SysApiKey apiKey;
/**
* 创建测试服务。
*
* @param apiKey 查询时返回的 API Key
*/
private TestSysApiKeyService(SysApiKey apiKey) {
this.apiKey = apiKey;
}
/**
* 返回预设 API Key。
*
* @param queryWrapper 查询条件
* @return 预设 API Key
*/
@Override
public SysApiKey getOne(QueryWrapper queryWrapper) {
return apiKey;
}
}
}

View File

@@ -15,7 +15,7 @@ export interface DashboardUserRankQuery extends DashboardOverviewQuery {
export interface DashboardSummary { export interface DashboardSummary {
activeUserTotal: number; activeUserTotal: number;
activeAssistantTotal: number; activeAssistantTotal: number;
botTotal: number; agentTotal: number;
chatActiveUserTotal: number; chatActiveUserTotal: number;
chatMessageTotal: number; chatMessageTotal: number;
chatSessionTotal: number; chatSessionTotal: number;
@@ -55,7 +55,6 @@ export interface DashboardDistributionItem {
assistantId?: number | string; assistantId?: number | string;
avgMessagePerSession?: number; avgMessagePerSession?: number;
avgSessionPerUser?: number; avgSessionPerUser?: number;
botTotal: number;
key: string; key: string;
knowledgeBaseTotal: number; knowledgeBaseTotal: number;
label: string; label: string;

View File

@@ -96,11 +96,11 @@
"submitRepublishApprovalConfirm": "Republish the current workflow now?", "submitRepublishApprovalConfirm": "Republish the current workflow now?",
"submitOfflineApprovalConfirm": "Take the current workflow offline?", "submitOfflineApprovalConfirm": "Take the current workflow offline?",
"submitDeleteApprovalConfirm": "Delete the current workflow?", "submitDeleteApprovalConfirm": "Delete the current workflow?",
"offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:", "offlineImpactBoundAgentsIntro": "This workflow is currently bound to the following agents:",
"offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.", "offlineImpactBoundAgentsFooter": "After the workflow goes offline, the system will automatically remove it from these agents.",
"offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:", "offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:",
"offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.", "offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.",
"offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from bots and mark the related plugins as unavailable.", "offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from agents and mark the related plugins as unavailable.",
"publishPendingHint": "There is already an approval in progress for this workflow.", "publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.", "deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check", "check": "Check",

View File

@@ -7,6 +7,7 @@
"initiated": "Initiated" "initiated": "Initiated"
}, },
"resource": { "resource": {
"agent": "Agent",
"bot": "Chat Assistant", "bot": "Chat Assistant",
"workflow": "Workflow", "workflow": "Workflow",
"knowledge": "Knowledge Base" "knowledge": "Knowledge Base"
@@ -144,6 +145,8 @@
"knowledgeBasic": "Basic Info", "knowledgeBasic": "Basic Info",
"knowledgeConfig": "Retrieval Config", "knowledgeConfig": "Retrieval Config",
"botOverview": "Assistant Overview", "botOverview": "Assistant Overview",
"agentModelConfig": "Model Config",
"agentBindings": "Capability Bindings",
"botModelConfig": "Model Config", "botModelConfig": "Model Config",
"botBindings": "Capability Bindings", "botBindings": "Capability Bindings",
"systemPrompt": "System Prompt", "systemPrompt": "System Prompt",
@@ -168,6 +171,7 @@
"notConfigured": "Not configured", "notConfigured": "Not configured",
"noBindings": "No bindings", "noBindings": "No bindings",
"untitledKnowledge": "Untitled knowledge base", "untitledKnowledge": "Untitled knowledge base",
"untitledAgent": "Untitled agent",
"untitledBot": "Untitled assistant", "untitledBot": "Untitled assistant",
"unnamedKnowledge": "Unnamed knowledge base", "unnamedKnowledge": "Unnamed knowledge base",
"unnamedWorkflow": "Unnamed workflow", "unnamedWorkflow": "Unnamed workflow",

View File

@@ -47,8 +47,8 @@
"submitRepublishApprovalConfirm": "Republish the current knowledge base now?", "submitRepublishApprovalConfirm": "Republish the current knowledge base now?",
"submitOfflineApprovalConfirm": "Take the current knowledge base offline?", "submitOfflineApprovalConfirm": "Take the current knowledge base offline?",
"submitDeleteApprovalConfirm": "Delete the current knowledge base?", "submitDeleteApprovalConfirm": "Delete the current knowledge base?",
"offlineImpactBoundBotsIntro": "This knowledge base is currently bound to the following bots:", "offlineImpactBoundAgentsIntro": "This knowledge base is currently bound to the following agents:",
"offlineImpactBoundBotsFooter": "After the knowledge base goes offline, the system will automatically remove it from these bots.", "offlineImpactBoundAgentsFooter": "After the knowledge base goes offline, the system will automatically remove it from these agents.",
"offlineImpactWorkflowBlockedIntro": "This knowledge base is still used by the following workflows:", "offlineImpactWorkflowBlockedIntro": "This knowledge base is still used by the following workflows:",
"offlineImpactWorkflowBlockedFooter": "Please update those workflow nodes before taking the knowledge base offline.", "offlineImpactWorkflowBlockedFooter": "Please update those workflow nodes before taking the knowledge base offline.",
"publishPendingHint": "There is already an approval in progress for this knowledge base.", "publishPendingHint": "There is already an approval in progress for this knowledge base.",

View File

@@ -96,11 +96,11 @@
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?", "submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?", "submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
"submitDeleteApprovalConfirm": "确认删除当前工作流吗?", "submitDeleteApprovalConfirm": "确认删除当前工作流吗?",
"offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:", "offlineImpactBoundAgentsIntro": "当前工作流被以下智能体绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。", "offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该工作流。",
"offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:", "offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:",
"offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。", "offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。",
"offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从聊天助手中解绑该工作流,同时让相关插件进入不可用状态。", "offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从智能体中解绑该工作流,同时让相关插件进入不可用状态。",
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查", "check": "检查",

View File

@@ -7,6 +7,7 @@
"initiated": "我发起" "initiated": "我发起"
}, },
"resource": { "resource": {
"agent": "智能体",
"bot": "聊天助手", "bot": "聊天助手",
"workflow": "工作流", "workflow": "工作流",
"knowledge": "知识库" "knowledge": "知识库"
@@ -144,6 +145,8 @@
"knowledgeBasic": "基础信息", "knowledgeBasic": "基础信息",
"knowledgeConfig": "检索配置", "knowledgeConfig": "检索配置",
"botOverview": "助手概览", "botOverview": "助手概览",
"agentModelConfig": "模型配置",
"agentBindings": "能力绑定",
"botModelConfig": "模型配置", "botModelConfig": "模型配置",
"botBindings": "能力绑定", "botBindings": "能力绑定",
"systemPrompt": "系统提示词", "systemPrompt": "系统提示词",
@@ -168,6 +171,7 @@
"notConfigured": "未配置", "notConfigured": "未配置",
"noBindings": "未绑定任何能力", "noBindings": "未绑定任何能力",
"untitledKnowledge": "未命名知识库", "untitledKnowledge": "未命名知识库",
"untitledAgent": "未命名智能体",
"untitledBot": "未命名聊天助手", "untitledBot": "未命名聊天助手",
"unnamedKnowledge": "未命名知识库", "unnamedKnowledge": "未命名知识库",
"unnamedWorkflow": "未命名工作流", "unnamedWorkflow": "未命名工作流",

View File

@@ -47,8 +47,8 @@
"submitRepublishApprovalConfirm": "确认重新发布当前知识库吗?", "submitRepublishApprovalConfirm": "确认重新发布当前知识库吗?",
"submitOfflineApprovalConfirm": "确认下线当前知识库吗?", "submitOfflineApprovalConfirm": "确认下线当前知识库吗?",
"submitDeleteApprovalConfirm": "确认删除当前知识库吗?", "submitDeleteApprovalConfirm": "确认删除当前知识库吗?",
"offlineImpactBoundBotsIntro": "当前知识库被以下聊天助手绑定:", "offlineImpactBoundAgentsIntro": "当前知识库被以下智能体绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该知识库。", "offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该知识库。",
"offlineImpactWorkflowBlockedIntro": "当前知识库仍被以下工作流使用:", "offlineImpactWorkflowBlockedIntro": "当前知识库仍被以下工作流使用:",
"offlineImpactWorkflowBlockedFooter": "请先在工作流中调整相关知识库节点后再下线。", "offlineImpactWorkflowBlockedFooter": "请先在工作流中调整相关知识库节点后再下线。",
"publishPendingHint": "当前知识库已有进行中的审批,请等待处理完成。", "publishPendingHint": "当前知识库已有进行中的审批,请等待处理完成。",

View File

@@ -78,7 +78,7 @@ export interface AgentChatCapabilityPayload {
} }
export function getPublishedAgents() { export function getPublishedAgents() {
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/list', { return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/options', {
params: { publishedOnly: true }, params: { publishedOnly: true },
}); });
} }
@@ -95,10 +95,7 @@ export function getAgentSession(sessionId: number | string) {
export function getPublishedKnowledges() { export function getPublishedKnowledges() {
return api.get<RequestResult<AgentChatKnowledgeView[]>>( return api.get<RequestResult<AgentChatKnowledgeView[]>>(
'/api/v1/documentCollection/list', '/api/v1/agent/knowledgeOptions',
{
params: { publishedOnly: true },
},
); );
} }

View File

@@ -23,10 +23,8 @@ import {
import { import {
getAgentDetail, getAgentDetail,
getAgentModels, getAgentMcpToolOptions,
getMcpPage, getAgentResourceOptions,
getMcpTools,
getPublishedKnowledgeList,
saveAgent, saveAgent,
submitAgentOfflineApproval, submitAgentOfflineApproval,
submitAgentPublishApproval, submitAgentPublishApproval,
@@ -76,7 +74,7 @@ const workflows = ref<AgentOption[]>([]);
const pluginTools = ref<AgentOption[]>([]); const pluginTools = ref<AgentOption[]>([]);
const mcps = ref<AgentOption[]>([]); const mcps = ref<AgentOption[]>([]);
const fetchMcpToolResource = createMcpToolLoader(async (id) => { const fetchMcpToolResource = createMcpToolLoader(async (id) => {
const res = await getMcpTools(id); const res = await getAgentMcpToolOptions(id);
return res.errorCode === 0 ? res.data : undefined; return res.errorCode === 0 ? res.data : undefined;
}); });
@@ -128,7 +126,6 @@ const offlineDisabled = computed(() => {
}); });
onMounted(async () => { onMounted(async () => {
void loadDeferredOptions();
try { try {
await Promise.all([loadCriticalOptions(), loadAgent()]); await Promise.all([loadCriticalOptions(), loadAgent()]);
} finally { } finally {
@@ -213,11 +210,11 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
} }
async function loadCriticalOptions() { async function loadCriticalOptions() {
const [categoryResult, modelResult] = await Promise.allSettled([ const [categoryResult, resourceResult] = await Promise.allSettled([
api.get('/api/v1/agentCategory/visibleList', { api.get('/api/v1/agentCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' }, params: { sortKey: 'sortNo', sortType: 'asc' },
}), }),
getAgentModels(), getAgentResourceOptions(),
]); ]);
if (categoryResult.status === 'fulfilled') { if (categoryResult.status === 'fulfilled') {
@@ -227,71 +224,33 @@ async function loadCriticalOptions() {
raw: item, raw: item,
})); }));
} }
if (modelResult.status === 'fulfilled') { if (resourceResult.status === 'fulfilled') {
models.value = (modelResult.value.data || []).map((item: any) => ({ const resources = resourceResult.value.data;
if (resourceResult.value.errorCode !== 0 || !resources) {
return;
}
models.value = (resources.models || []).map((item: any) => ({
label: item.title || item.name, label: item.title || item.name,
value: String(item.id), value: String(item.id),
raw: item, raw: item,
})); }));
} knowledges.value = (resources.knowledges || []).map((item: any) => ({
}
async function loadDeferredOptions() {
const [knowledgeResult, workflowResult, pluginResult, mcpResult] =
await Promise.allSettled([
getPublishedKnowledgeList(),
api.get('/api/v1/workflow/page', {
params: { pageNumber: 1, pageSize: 200 },
}),
api.get('/api/v1/plugin/pageByCategory', {
params: { pageNumber: 1, pageSize: 200, category: 0 },
}),
getMcpPage(),
]);
if (knowledgeResult.status === 'fulfilled') {
knowledges.value = (knowledgeResult.value.data || []).map((item: any) => ({
label: item.title || item.name, label: item.title || item.name,
value: String(item.id), value: String(item.id),
raw: item, raw: item,
})); }));
} workflows.value = (resources.workflows || []).map((item: any) => ({
if (workflowResult.status === 'fulfilled') {
workflows.value = (
(workflowResult.value.data?.records ||
workflowResult.value.data ||
[]) as any[]
).map((item) => ({
label: item.title || item.name, label: item.title || item.name,
value: String(item.id), value: String(item.id),
raw: item, raw: item,
})); }));
pluginTools.value = (resources.pluginTools || []).map((item: any) => ({
label: item.name || item.title,
value: String(item.id),
raw: item,
}));
mcps.value = mapMcpOptions(resources.mcps || []);
} }
if (pluginResult.status === 'fulfilled') {
pluginTools.value = flattenPluginTools(
pluginResult.value.data?.records || pluginResult.value.data || [],
);
}
if (mcpResult.status === 'fulfilled') {
mcps.value = mapMcpOptions(
mcpResult.value.data?.records || mcpResult.value.data || [],
);
}
}
function flattenPluginTools(list: any[]): AgentOption[] {
const result: AgentOption[] = [];
list.forEach((plugin) => {
const tools = Array.isArray(plugin.tools) ? plugin.tools : [];
tools.forEach((tool: any) => {
result.push({
label: tool.name || tool.title,
value: String(tool.id),
raw: { ...tool, pluginName: plugin.name || plugin.title },
});
});
});
return result;
} }
function mapMcpOptions(list: any[]): AgentOption[] { function mapMcpOptions(list: any[]): AgentOption[] {
@@ -325,8 +284,7 @@ async function loadMcpToolsForOption(id: number | string) {
const currentOption = mcps.value.find((item) => String(item.value) === key); const currentOption = mcps.value.find((item) => String(item.value) === key);
const mergedResource = { const mergedResource = {
...currentOption?.raw, ...currentOption?.raw,
...resource, tools: Array.isArray(resource) ? resource : [],
tools: Array.isArray(resource.tools) ? resource.tools : [],
}; };
if (currentOption) { if (currentOption) {
mcps.value = mcps.value.map((item) => mcps.value = mcps.value.map((item) =>
@@ -334,7 +292,7 @@ async function loadMcpToolsForOption(id: number | string) {
? { ? {
...item, ...item,
label: label:
resource.title || resource.name || currentOption.label || 'MCP', currentOption.label || 'MCP',
raw: mergedResource, raw: mergedResource,
} }
: item, : item,

View File

@@ -12,7 +12,7 @@ import {Delete, Edit, Plus, Promotion} from '@element-plus/icons-vue';
import {ElMessage, ElMessageBox, ElTag} from 'element-plus'; import {ElMessage, ElMessageBox, ElTag} from 'element-plus';
import {tryit} from 'radash'; import {tryit} from 'radash';
import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png'; import defaultAgentAvatar from '#/assets/defaultUserAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue'; import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue'; import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue'; import PageSide from '#/components/page/PageSide.vue';
@@ -262,7 +262,7 @@ async function handleDeleteAction(row: AgentInfo) {
<CardList <CardList
title-field="name" title-field="name"
icon-field="avatar" icon-field="avatar"
:default-icon="defaultAvatar" :default-icon="defaultAgentAvatar"
:data="pageList" :data="pageList"
:primary-action="primaryAction" :primary-action="primaryAction"
:actions="actions" :actions="actions"

View File

@@ -1,6 +1,10 @@
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from './types'; import type {
AgentInfo,
AgentKnowledgeBinding,
AgentToolBinding,
} from './types';
import {api} from '#/api/request'; import { api } from '#/api/request';
export interface RequestResult<T = any> { export interface RequestResult<T = any> {
data: T; data: T;
@@ -22,10 +26,6 @@ export function updateAgent(agent: AgentInfo) {
return api.post<RequestResult<AgentInfo>>('/api/v1/agent/update', agent); return api.post<RequestResult<AgentInfo>>('/api/v1/agent/update', agent);
} }
export function removeAgent(id: number | string) {
return api.post<RequestResult>('/api/v1/agent/remove', { id });
}
export function updateAgentToolBindings( export function updateAgentToolBindings(
agentId: number | string, agentId: number | string,
bindings: AgentToolBinding[], bindings: AgentToolBinding[],
@@ -98,24 +98,22 @@ export function getAgentCategories() {
}); });
} }
export function getAgentModels() { export interface AgentResourceOptions {
return api.get<RequestResult<any[]>>('/api/v1/model/list', { knowledges: any[];
params: { modelType: 'chatModel', added: true }, mcps: any[];
}); models: any[];
pluginTools: any[];
workflows: any[];
} }
export function getPublishedKnowledgeList() { export function getAgentResourceOptions() {
return api.get<RequestResult<any[]>>('/api/v1/documentCollection/list', { return api.get<RequestResult<AgentResourceOptions>>(
params: { publishedOnly: true }, '/api/v1/agent/resourceOptions',
}); );
} }
export function getMcpPage() { export function getAgentMcpToolOptions(id: number | string) {
return api.get<RequestResult<any>>('/api/v1/mcp/page', { return api.get<RequestResult<any[]>>('/api/v1/agent/mcpToolOptions', {
params: { pageNumber: 1, pageSize: 200, status: 1 }, params: { id },
}); });
} }
export function getMcpTools(id: number | string) {
return api.post<RequestResult<any>>('/api/v1/mcp/getMcpTools', { id });
}

View File

@@ -6,7 +6,7 @@ import pageSource from './index.vue?raw';
describe('管理端智能体聊天历史契约', () => { describe('管理端智能体聊天历史契约', () => {
it('使用智能体候选接口并保留 assistantId 查询参数', () => { it('使用智能体候选接口并保留 assistantId 查询参数', () => {
expect(pageSource).toContain("'/api/v1/agent/list'"); expect(pageSource).toContain("'/api/v1/agent/options'");
expect(pageSource).not.toContain('/api/v1/bot/list'); expect(pageSource).not.toContain('/api/v1/bot/list');
expect(pageSource).toContain('label: item.name'); expect(pageSource).toContain('label: item.name');
expect(pageSource).toContain('assistantId: query.value.assistantId'); expect(pageSource).toContain('assistantId: query.value.assistantId');

View File

@@ -98,7 +98,7 @@ onMounted(async () => {
async function fetchAgents() { async function fetchAgents() {
agentLoading.value = true; agentLoading.value = true;
const [error, res] = await tryit(api.get)('/api/v1/agent/list'); const [error, res] = await tryit(api.get)('/api/v1/agent/options');
agentLoading.value = false; agentLoading.value = false;
if (error || res?.errorCode !== 0) { if (error || res?.errorCode !== 0) {
agentOptions.value = []; agentOptions.value = [];

Some files were not shown because too many files have changed in this diff Show More