fix: 完成系统向智能体数据链路切换
- 切换工作台、聊天历史、资源候选与公共调用到 Agent - 加固资源绑定、删除保护及发布运行并发控制 - 隔离旧 Bot 专属服务和组件并保留兼容入口
This commit is contained in:
@@ -35,22 +35,23 @@ import tech.easyflow.agent.runtime.document.AgentDocumentUploadView;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
|
||||
import com.easyagents.agent.runtime.media.AgentMediaResource;
|
||||
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
|
||||
import tech.easyflow.agent.service.AgentApprovalStateService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentOptionQueryService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.agent.vo.AgentOptionView;
|
||||
import tech.easyflow.agent.vo.AgentResourceOptionsView;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.log.annotation.LogReporterDisabled;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.Serializable;
|
||||
@@ -60,8 +61,6 @@ import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static tech.easyflow.agent.entity.table.AgentTableDef.AGENT;
|
||||
|
||||
/**
|
||||
* Agent 管理端控制器。
|
||||
*/
|
||||
@@ -78,10 +77,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
@Resource
|
||||
private AgentPublishAppService agentPublishAppService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
@Resource
|
||||
private AgentApprovalStateService agentApprovalStateService;
|
||||
@Resource
|
||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||
@@ -91,6 +86,10 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
private AgentDocumentService agentDocumentService;
|
||||
@Resource
|
||||
private AgentComposerDraftService agentComposerDraftService;
|
||||
@Resource
|
||||
private AgentOptionQueryService agentOptionQueryService;
|
||||
@Resource
|
||||
private AgentVisibilityQueryHelper agentVisibilityQueryHelper;
|
||||
|
||||
/**
|
||||
* 创建 Agent 控制器。
|
||||
@@ -151,20 +150,67 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) {
|
||||
HttpServletRequest request = currentRequest();
|
||||
QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request);
|
||||
if (!applyCategoryPermission(queryWrapper)) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
List<Agent> agents = service.list(queryWrapper);
|
||||
if (isPublishedOnlyRequest()) {
|
||||
agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList();
|
||||
}
|
||||
agents.forEach(this::sanitizeListItem);
|
||||
agentApprovalStateService.fillAgentApprovalState(agents);
|
||||
aiResourceCreatorNameSupport.fillAgentCreatorNames(agents);
|
||||
return Result.ok(agents);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 安全选择项。
|
||||
*
|
||||
* @param publishedOnly 是否仅返回已发布 Agent
|
||||
* @return Agent 安全选择项
|
||||
*/
|
||||
@GetMapping("/options")
|
||||
@SaCheckPermission("/api/v1/agent/query")
|
||||
public Result<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 纯文本聊天。
|
||||
*
|
||||
@@ -488,16 +534,13 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
for (Serializable id : ids) {
|
||||
Agent agent = service.getById(String.valueOf(id));
|
||||
if (agent != null) {
|
||||
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限删除该 Agent");
|
||||
public Result<?> remove(Serializable id) {
|
||||
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
|
||||
}
|
||||
}
|
||||
agentToolBindingService.remove(QueryWrapper.create().in("agent_id", ids));
|
||||
agentKnowledgeBindingService.remove(QueryWrapper.create().in("agent_id", ids));
|
||||
return super.onRemoveBefore(ids);
|
||||
|
||||
@Override
|
||||
public Result<?> removeBatch(Collection<Serializable> ids) {
|
||||
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,38 +552,54 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
*/
|
||||
@Override
|
||||
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
|
||||
if (!applyCategoryPermission(queryWrapper)) {
|
||||
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
|
||||
}
|
||||
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
Page<Agent> result = super.queryPage(page, queryWrapper);
|
||||
Page<Agent> result = service.page(page, queryWrapper);
|
||||
if (isPublishedOnlyRequest()) {
|
||||
result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList());
|
||||
}
|
||||
result.getRecords().forEach(this::sanitizeListItem);
|
||||
agentApprovalStateService.fillAgentApprovalState(result.getRecords());
|
||||
aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean applyCategoryPermission(QueryWrapper queryWrapper) {
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
|
||||
if (!access.isRestricted()) {
|
||||
return true;
|
||||
/**
|
||||
* 清理列表无需返回的配置和发布快照,避免敏感运行配置进入浏览器。
|
||||
*
|
||||
* @param agent Agent 列表项
|
||||
*/
|
||||
private void sanitizeListItem(Agent agent) {
|
||||
if (agent == null) {
|
||||
return;
|
||||
}
|
||||
if (access.getCategoryIds().isEmpty()) {
|
||||
queryWrapper.eq(Agent::getCreatedBy, access.getAccountId());
|
||||
return true;
|
||||
}
|
||||
queryWrapper.and(AGENT.CREATED_BY.eq(access.getAccountId()).or(AGENT.CATEGORY_ID.in(access.getCategoryIds())));
|
||||
return true;
|
||||
agent.setModelConfigJson(Collections.emptyMap());
|
||||
agent.setGenerationConfigJson(Collections.emptyMap());
|
||||
agent.setPromptConfigJson(Collections.emptyMap());
|
||||
agent.setMemoryConfigJson(Collections.emptyMap());
|
||||
agent.setExecutionConfigJson(Collections.emptyMap());
|
||||
agent.setInteractionConfigJson(Collections.emptyMap());
|
||||
agent.setPublishedSnapshotJson(Collections.emptyMap());
|
||||
agent.setToolBindings(null);
|
||||
agent.setKnowledgeBindings(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为仅发布查询追加发布状态条件。
|
||||
*
|
||||
* @param queryWrapper Agent 查询条件
|
||||
*/
|
||||
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前请求是否只查询已发布 Agent。
|
||||
*
|
||||
* @return 是否仅查询已发布 Agent
|
||||
*/
|
||||
private boolean isPublishedOnlyRequest() {
|
||||
HttpServletRequest request = currentRequest();
|
||||
if (request == null) {
|
||||
@@ -562,6 +621,14 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
return attributes.getRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将审批执行结果转换为统一响应。
|
||||
*
|
||||
* @param actionResult 审批动作结果
|
||||
* @param approvalMessage 进入审批时的提示
|
||||
* @param directMessage 直接执行时的提示
|
||||
* @return 审批实例响应
|
||||
*/
|
||||
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
|
||||
String approvalMessage,
|
||||
String directMessage) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.math.BigInteger;
|
||||
@@ -130,7 +131,17 @@ public class AgentSessionController {
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录信息失效时抛出
|
||||
*/
|
||||
private LoginAccount currentAccount() {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport;
|
||||
import tech.easyflow.admin.service.ai.ChatWorkspaceService;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
|
||||
@@ -82,9 +82,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
@Resource
|
||||
private ChatRoundOperateService chatRoundOperateService;
|
||||
@Resource
|
||||
private AiResourceApprovalStateService aiResourceApprovalStateService;
|
||||
private BotApprovalStateService botApprovalStateService;
|
||||
@Resource
|
||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||
private BotResourceCreatorNameSupport botResourceCreatorNameSupport;
|
||||
@Resource
|
||||
private ChatWorkspaceService chatWorkspaceService;
|
||||
|
||||
@@ -240,7 +240,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
bot = botService.toPublishedView(rawBot);
|
||||
}
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(bot);
|
||||
botApprovalStateService.fillApprovalState(bot);
|
||||
}
|
||||
return Result.ok(bot);
|
||||
}
|
||||
@@ -275,7 +275,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
|
||||
if (data.getModelId() == null) {
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(data);
|
||||
botApprovalStateService.fillApprovalState(data);
|
||||
}
|
||||
return Result.ok(data);
|
||||
}
|
||||
@@ -286,7 +286,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
if (llm == null) {
|
||||
data.setModelId(null);
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(data);
|
||||
botApprovalStateService.fillApprovalState(data);
|
||||
}
|
||||
return Result.ok(data);
|
||||
}
|
||||
@@ -302,7 +302,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
}
|
||||
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(data);
|
||||
botApprovalStateService.fillApprovalState(data);
|
||||
}
|
||||
return Result.ok(data);
|
||||
}
|
||||
@@ -369,7 +369,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
bots = bots.stream().map(botService::toPublishedView).toList();
|
||||
}
|
||||
aiResourceApprovalStateService.fillBotApprovalState(bots);
|
||||
botApprovalStateService.fillApprovalState(bots);
|
||||
return Result.ok(bots);
|
||||
}
|
||||
|
||||
@@ -381,8 +381,8 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
|
||||
}
|
||||
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords());
|
||||
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords());
|
||||
botApprovalStateService.fillApprovalState(result.getRecords());
|
||||
botResourceCreatorNameSupport.fillCreatorNames(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
|
||||
import tech.easyflow.ai.entity.BotDocumentCollection;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
@@ -27,7 +28,6 @@ import tech.easyflow.ai.vo.OfflineImpactCheckVo;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||
import tech.easyflow.ai.service.BotDocumentCollectionService;
|
||||
import tech.easyflow.ai.service.DocumentChunkService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
@@ -68,7 +68,7 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
|
||||
private final ModelService llmService;
|
||||
|
||||
@Resource
|
||||
private BotDocumentCollectionService botDocumentCollectionService;
|
||||
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
@@ -169,11 +169,11 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
|
||||
}
|
||||
|
||||
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){
|
||||
throw new BusinessException("此知识库还关联着bot,请先取消关联!");
|
||||
throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除");
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -9,16 +9,19 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.entity.BotMcp;
|
||||
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.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -34,7 +37,7 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
|
||||
}
|
||||
|
||||
@Resource
|
||||
private BotMcpService botMcpService;
|
||||
private AgentResourceReferenceService agentResourceReferenceService;
|
||||
@Override
|
||||
public Result<?> save(Mcp entity) {
|
||||
return service.saveMcp(entity);
|
||||
@@ -45,11 +48,29 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
|
||||
return service.updateMcp(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除未被 Agent 绑定的 MCP。
|
||||
*
|
||||
* @param id MCP ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Result<?> remove(Serializable id) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
// 锁定 MCP 资源行,与 Agent 绑定校验串行,避免检查后并发写入绑定。
|
||||
Mcp mcp = service.getOne(QueryWrapper.create()
|
||||
.eq(Mcp::getId, id)
|
||||
.eq(Mcp::getTenantId, account.getTenantId())
|
||||
.forUpdate());
|
||||
if (mcp == null) {
|
||||
throw new BusinessException("MCP 不存在或无权删除");
|
||||
}
|
||||
agentResourceReferenceService.assertMcpUnused(new BigInteger(String.valueOf(id)));
|
||||
service.removeMcp(id);
|
||||
botMcpService.remove(QueryWrapper.create().eq(BotMcp::getMcpId, id));
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.table.ModelTableDef;
|
||||
import tech.easyflow.ai.mapper.ModelMapper;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.capability.ModelCapabilityResolution;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
@@ -18,6 +19,7 @@ import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.tree.Tree;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
@@ -47,6 +49,8 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
||||
|
||||
@Resource
|
||||
ModelMapper modelMapper;
|
||||
@Resource
|
||||
AgentResourceReferenceService agentResourceReferenceService;
|
||||
|
||||
@GetMapping("list")
|
||||
@SaCheckPermission("/api/v1/model/query")
|
||||
@@ -99,7 +103,22 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
||||
|
||||
@PostMapping("/removeByEntity")
|
||||
@SaCheckPermission("/api/v1/model/remove")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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);
|
||||
return Result.ok();
|
||||
}
|
||||
@@ -172,11 +191,39 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
||||
}
|
||||
|
||||
@PostMapping("removeLlmByIds")
|
||||
@Transactional
|
||||
@SaCheckPermission("/api/v1/model/remove")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) {
|
||||
LoginAccount account = requireAccount();
|
||||
List<Serializable> ids = Collections.singletonList(id);
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().in(Model::getId, ids);
|
||||
service.remove(queryWrapper);
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.in(Model::getId, ids)
|
||||
.eq(Model::getTenantId, account.getTenantId())
|
||||
.orderBy(Model::getId, true)
|
||||
.forUpdate();
|
||||
List<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 当前登录账号
|
||||
* @throws BusinessException 登录状态无效时抛出
|
||||
*/
|
||||
private LoginAccount requireAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
@@ -47,8 +48,14 @@ import static tech.easyflow.ai.entity.table.PluginTableDef.PLUGIN;
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/plugin")
|
||||
public class PluginController extends BaseCurdController<PluginService, Plugin> {
|
||||
/**
|
||||
* 创建插件控制器。
|
||||
*
|
||||
* @param service 插件服务
|
||||
*/
|
||||
public PluginController(PluginService service) {
|
||||
super(service);
|
||||
this.pluginService = service;
|
||||
}
|
||||
|
||||
@Resource
|
||||
@@ -89,10 +96,15 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
|
||||
return Result.ok(pluginService.updatePlugin(plugin));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件。
|
||||
*
|
||||
* @param id 插件 ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@PostMapping("/plugin/remove")
|
||||
@SaCheckPermission("/api/v1/plugin/remove")
|
||||
public Result<Boolean> removePlugin(@JsonBody(value = "id", required = true) String id){
|
||||
|
||||
return Result.ok(pluginService.removePlugin(id));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,19 +19,20 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.BotPlugin;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PluginType;
|
||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.service.BotPluginService;
|
||||
import tech.easyflow.ai.service.PluginService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
@@ -42,8 +43,10 @@ import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -55,6 +58,11 @@ import java.util.Map;
|
||||
@RequestMapping("/api/v1/pluginItem")
|
||||
@UsePermission(moduleName = "/api/v1/plugin")
|
||||
public class PluginItemController extends BaseCurdController<PluginItemService, PluginItem> {
|
||||
/**
|
||||
* 创建插件工具控制器。
|
||||
*
|
||||
* @param service 插件工具服务
|
||||
*/
|
||||
public PluginItemController(PluginItemService service) {
|
||||
super(service);
|
||||
}
|
||||
@@ -63,10 +71,12 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
||||
private PluginItemService pluginItemService;
|
||||
|
||||
@Resource
|
||||
private BotPluginService botPluginService;
|
||||
private AgentResourceReferenceService agentResourceReferenceService;
|
||||
@Resource
|
||||
private PluginService pluginService;
|
||||
@Resource
|
||||
private PluginVisibilityService pluginVisibilityService;
|
||||
@Resource
|
||||
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@@ -91,25 +101,12 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
||||
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")
|
||||
@SaCheckPermission("/api/v1/plugin/save")
|
||||
public Result<Boolean> updatePlugin(@JsonBody PluginItem 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")
|
||||
@SaCheckPermission("/api/v1/plugin/query")
|
||||
public Result<?> getTinyFlowData(BigInteger id) {
|
||||
@@ -275,26 +272,64 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件工具前锁定资源并校验 Agent 绑定。
|
||||
*
|
||||
* @param ids 插件工具 ID 集合
|
||||
* @return 校验失败结果;允许删除时返回 {@code null}
|
||||
*/
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
queryWrapper.in(BotPlugin::getPluginItemId, ids);
|
||||
|
||||
boolean exists = botPluginService.exists(queryWrapper);
|
||||
if (exists){
|
||||
return Result.fail(1, "此工具还关联着bot,请先取消关联!");
|
||||
Set<BigInteger> uniquePluginItemIds = new LinkedHashSet<>();
|
||||
try {
|
||||
for (Serializable id : ids) {
|
||||
if (id == null) {
|
||||
throw new NumberFormatException("null");
|
||||
}
|
||||
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())) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
import tech.easyflow.agent.enums.AgentToolType;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.ai.permission.WorkflowShareResourceAccessGrantProvider;
|
||||
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
@@ -31,7 +34,6 @@ import tech.easyflow.ai.publish.WorkflowPublishAppService;
|
||||
import tech.easyflow.ai.service.AiResourceApprovalStateService;
|
||||
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.ai.service.BotWorkflowService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
@@ -74,7 +76,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
@Resource
|
||||
private SysApiKeyService apiKeyService;
|
||||
@Resource
|
||||
private BotWorkflowService botWorkflowService;
|
||||
private AgentToolBindingService agentToolBindingService;
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
@@ -558,11 +560,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
Workflow workflow = requireWorkflow(String.valueOf(id));
|
||||
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流");
|
||||
}
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
queryWrapper.in("workflow_id", ids);
|
||||
boolean exists = botWorkflowService.exists(queryWrapper);
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(AgentToolBinding::getToolType, AgentToolType.WORKFLOW.name())
|
||||
.in(AgentToolBinding::getTargetId, ids);
|
||||
boolean exists = agentToolBindingService.exists(queryWrapper);
|
||||
if (exists) {
|
||||
return Result.fail(1, "此工作流还关联有bot,请先取消关联后再删除!");
|
||||
return Result.fail(1, "此工作流仍被智能体使用,请先取消绑定后再删除");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package tech.easyflow.admin.controller.ai.support;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
@@ -41,15 +40,6 @@ public class AiResourceCreatorNameSupport {
|
||||
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 <T> 资源类型
|
||||
*/
|
||||
private <T> void fillCreatorNames(
|
||||
<T> void fillCreatorNames(
|
||||
Collection<T> resources,
|
||||
Function<T, Number> createdByGetter,
|
||||
BiConsumer<T, String> createdByNameSetter
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ public class DashboardDistributionItemVo {
|
||||
|
||||
private Long activeUserTotal;
|
||||
|
||||
private Long botTotal;
|
||||
|
||||
private Long workflowTotal;
|
||||
|
||||
private Long knowledgeBaseTotal;
|
||||
@@ -73,14 +71,6 @@ public class DashboardDistributionItemVo {
|
||||
this.activeUserTotal = activeUserTotal;
|
||||
}
|
||||
|
||||
public Long getBotTotal() {
|
||||
return botTotal;
|
||||
}
|
||||
|
||||
public void setBotTotal(Long botTotal) {
|
||||
this.botTotal = botTotal;
|
||||
}
|
||||
|
||||
public Long getWorkflowTotal() {
|
||||
return workflowTotal;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ public class DashboardSummaryVo {
|
||||
|
||||
private Long activeUserTotal;
|
||||
|
||||
private Long botTotal;
|
||||
/** 智能体总数。 */
|
||||
private Long agentTotal;
|
||||
|
||||
private Long workflowTotal;
|
||||
|
||||
@@ -39,12 +40,22 @@ public class DashboardSummaryVo {
|
||||
this.activeUserTotal = activeUserTotal;
|
||||
}
|
||||
|
||||
public Long getBotTotal() {
|
||||
return botTotal;
|
||||
/**
|
||||
* 获取智能体总数。
|
||||
*
|
||||
* @return 智能体总数
|
||||
*/
|
||||
public Long getAgentTotal() {
|
||||
return agentTotal;
|
||||
}
|
||||
|
||||
public void setBotTotal(Long botTotal) {
|
||||
this.botTotal = botTotal;
|
||||
/**
|
||||
* 设置智能体总数。
|
||||
*
|
||||
* @param agentTotal 智能体总数
|
||||
*/
|
||||
public void setAgentTotal(Long agentTotal) {
|
||||
this.agentTotal = agentTotal;
|
||||
}
|
||||
|
||||
public Long getWorkflowTotal() {
|
||||
|
||||
@@ -8,8 +8,8 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
|
||||
@@ -33,6 +33,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.entity.SysAccountRole;
|
||||
import tech.easyflow.system.entity.SysRole;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.service.SysAccountRoleService;
|
||||
@@ -81,7 +82,7 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
private ChatDashboardQueryService chatDashboardQueryService;
|
||||
|
||||
@Resource
|
||||
private BotService botService;
|
||||
private AgentService agentService;
|
||||
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
@@ -156,7 +157,7 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
|
||||
summary.setActiveUserTotal(countActiveUsers(context));
|
||||
summary.setBotTotal(countScopedTable("tb_bot", "b", false, context));
|
||||
summary.setAgentTotal(countScopedTable("tb_agent", "a", false, context));
|
||||
summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
|
||||
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
|
||||
summary.setChatMessageTotal(0L);
|
||||
@@ -863,18 +864,18 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
if (assistantId == null) {
|
||||
return null;
|
||||
}
|
||||
Bot bot = botService.getById(assistantId);
|
||||
if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) {
|
||||
throw new BusinessException("聊天助手不存在或未启用");
|
||||
Agent agent = agentService.getById(assistantId);
|
||||
if (agent == null) {
|
||||
throw new BusinessException("智能体不存在或不可见");
|
||||
}
|
||||
boolean visible = categoryPermissionService.canAccessCategory(
|
||||
loginAccount,
|
||||
"BOT",
|
||||
bot.getCreatedBy(),
|
||||
bot.getCategoryId()
|
||||
CategoryResourceType.AGENT.getCode(),
|
||||
agent.getCreatedBy(),
|
||||
agent.getCategoryId()
|
||||
);
|
||||
if (!visible) {
|
||||
throw new BusinessException("聊天助手不存在或未启用");
|
||||
throw new BusinessException("智能体不存在或不可见");
|
||||
}
|
||||
return assistantId;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import org.testng.Assert;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.service.AiResourceApprovalStateService;
|
||||
import tech.easyflow.ai.service.BotApprovalStateService;
|
||||
import tech.easyflow.ai.service.BotDocumentCollectionService;
|
||||
import tech.easyflow.ai.service.BotMessageService;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
@@ -38,7 +39,7 @@ public class BotControllerTest {
|
||||
private BotDocumentCollectionService botDocumentCollectionService;
|
||||
private BotMessageService botMessageService;
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
private AiResourceApprovalStateService aiResourceApprovalStateService;
|
||||
private BotApprovalStateService botApprovalStateService;
|
||||
private SysAccountService sysAccountService;
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,7 @@ public class BotControllerTest {
|
||||
botDocumentCollectionService = mock(BotDocumentCollectionService.class);
|
||||
botMessageService = mock(BotMessageService.class);
|
||||
categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
aiResourceApprovalStateService = mock(AiResourceApprovalStateService.class);
|
||||
botApprovalStateService = mock(BotApprovalStateService.class);
|
||||
sysAccountService = mock(SysAccountService.class);
|
||||
}
|
||||
|
||||
@@ -69,10 +70,12 @@ public class BotControllerTest {
|
||||
botMessageService
|
||||
);
|
||||
AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport();
|
||||
BotResourceCreatorNameSupport botCreatorNameSupport =
|
||||
new BotResourceCreatorNameSupport(creatorNameSupport);
|
||||
setField(creatorNameSupport, "sysAccountService", sysAccountService);
|
||||
setField(controller, "categoryPermissionService", categoryPermissionService);
|
||||
setField(controller, "aiResourceApprovalStateService", aiResourceApprovalStateService);
|
||||
setField(controller, "aiResourceCreatorNameSupport", creatorNameSupport);
|
||||
setField(controller, "botApprovalStateService", botApprovalStateService);
|
||||
setField(controller, "botResourceCreatorNameSupport", botCreatorNameSupport);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.valueOf(101));
|
||||
@@ -84,7 +87,7 @@ public class BotControllerTest {
|
||||
when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page);
|
||||
when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7))))
|
||||
.thenReturn(Map.of(BigInteger.valueOf(7), "管理员"));
|
||||
doNothing().when(aiResourceApprovalStateService).fillBotApprovalState(page.getRecords());
|
||||
doNothing().when(botApprovalStateService).fillApprovalState(page.getRecords());
|
||||
|
||||
Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||
@@ -22,6 +22,7 @@ import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.service.SysAccountRoleService;
|
||||
@@ -339,29 +340,34 @@ public class DashboardServiceImplTest {
|
||||
public void shouldQueryUserRanksWithAssistantFilter() throws Exception {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
BotService botService = mock(BotService.class);
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
SysAccountService sysAccountService = mock(SysAccountService.class);
|
||||
SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class);
|
||||
SysRoleService sysRoleService = mock(SysRoleService.class);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.TEN);
|
||||
bot.setStatus(1);
|
||||
bot.setCreatedBy(BigInteger.ONE);
|
||||
bot.setCategoryId(BigInteger.valueOf(8));
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.TEN);
|
||||
agent.setStatus(1);
|
||||
agent.setCreatedBy(BigInteger.ONE);
|
||||
agent.setCategoryId(BigInteger.valueOf(8));
|
||||
|
||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5)))
|
||||
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L)));
|
||||
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
|
||||
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
|
||||
when(agentService.getById(BigInteger.TEN)).thenReturn(agent);
|
||||
when(categoryPermissionService.canAccessCategory(
|
||||
any(LoginAccount.class),
|
||||
eq(CategoryResourceType.AGENT.getCode()),
|
||||
eq(BigInteger.ONE),
|
||||
eq(BigInteger.valueOf(8))
|
||||
))
|
||||
.thenReturn(true);
|
||||
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户")));
|
||||
when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
|
||||
|
||||
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
setField(service, "botService", botService);
|
||||
setField(service, "agentService", agentService);
|
||||
setField(service, "categoryPermissionService", categoryPermissionService);
|
||||
setField(service, "sysAccountService", sysAccountService);
|
||||
setField(service, "sysAccountRoleService", sysAccountRoleService);
|
||||
@@ -382,26 +388,59 @@ public class DashboardServiceImplTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未启用智能体会被拒绝。
|
||||
* 验证停用智能体仍可用于筛选历史统计。
|
||||
*/
|
||||
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
|
||||
public void shouldRejectDisabledAssistantFilter() {
|
||||
@Test
|
||||
public void shouldAllowDisabledAgentFilterForHistoricalStatistics() {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
BotService botService = mock(BotService.class);
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.TEN);
|
||||
bot.setStatus(0);
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.TEN);
|
||||
agent.setStatus(0);
|
||||
agent.setCreatedBy(BigInteger.ONE);
|
||||
agent.setCategoryId(BigInteger.valueOf(8));
|
||||
|
||||
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
|
||||
when(agentService.getById(BigInteger.TEN)).thenReturn(agent);
|
||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||
when(categoryPermissionService.canAccessCategory(
|
||||
any(LoginAccount.class),
|
||||
eq(CategoryResourceType.AGENT.getCode()),
|
||||
eq(BigInteger.ONE),
|
||||
eq(BigInteger.valueOf(8))
|
||||
)).thenReturn(true);
|
||||
|
||||
setFieldSilently(service, "botService", botService);
|
||||
setFieldSilently(service, "agentService", agentService);
|
||||
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
setFieldSilently(service, "categoryPermissionService", mock(CategoryPermissionService.class));
|
||||
setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
|
||||
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
|
||||
|
||||
DashboardUserRankQuery query = new DashboardUserRankQuery();
|
||||
query.setRange("7d");
|
||||
query.setAssistantId(BigInteger.TEN);
|
||||
List<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();
|
||||
query.setRange("7d");
|
||||
query.setAssistantId(BigInteger.TEN);
|
||||
@@ -411,25 +450,30 @@ public class DashboardServiceImplTest {
|
||||
/**
|
||||
* 验证当前作用域不可见的智能体会被拒绝。
|
||||
*/
|
||||
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
|
||||
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "智能体不存在或不可见")
|
||||
public void shouldRejectInvisibleAssistantFilter() {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
BotService botService = mock(BotService.class);
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.TEN);
|
||||
bot.setStatus(1);
|
||||
bot.setCreatedBy(BigInteger.ONE);
|
||||
bot.setCategoryId(BigInteger.valueOf(8));
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.TEN);
|
||||
agent.setStatus(1);
|
||||
agent.setCreatedBy(BigInteger.ONE);
|
||||
agent.setCategoryId(BigInteger.valueOf(8));
|
||||
|
||||
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
|
||||
when(agentService.getById(BigInteger.TEN)).thenReturn(agent);
|
||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
|
||||
when(categoryPermissionService.canAccessCategory(
|
||||
any(LoginAccount.class),
|
||||
eq(CategoryResourceType.AGENT.getCode()),
|
||||
eq(BigInteger.ONE),
|
||||
eq(BigInteger.valueOf(8))
|
||||
))
|
||||
.thenReturn(false);
|
||||
|
||||
setFieldSilently(service, "botService", botService);
|
||||
setFieldSilently(service, "agentService", agentService);
|
||||
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
|
||||
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<artifactId>easyflow-api-public</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-agent</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-ai</artifactId>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ public class PublicApiConfig implements WebMvcConfigurer {
|
||||
|
||||
registry.addInterceptor(publicApiInterceptor)
|
||||
.addPathPatterns("/public-api/**")
|
||||
.excludePathPatterns("/public-api/bot/chat")
|
||||
.excludePathPatterns(
|
||||
"/public-api/agent/chat",
|
||||
"/public-api/bot/chat"
|
||||
)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@
|
||||
<artifactId>easyflow-api-usercenter</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-agent</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-auth</artifactId>
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,10 @@ public enum AgentRuntimeCommandAction {
|
||||
/**
|
||||
* 审批过期并取消工具执行。
|
||||
*/
|
||||
EXPIRE
|
||||
EXPIRE,
|
||||
|
||||
/**
|
||||
* 取消指定 Agent 在目标节点上的全部运行。
|
||||
*/
|
||||
CANCEL_AGENT
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
|
||||
} else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) {
|
||||
agentRunService.expireApprovalLocal(
|
||||
command.getRequestId(), command.getResumeToken(), command.getReason());
|
||||
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) {
|
||||
agentRunService.cancelAgentLocal(command.getAgentId());
|
||||
} else {
|
||||
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
|
||||
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",
|
||||
|
||||
@@ -15,6 +15,7 @@ public class AgentRuntimeCommandMessage {
|
||||
private String reason;
|
||||
private BigInteger operatorId;
|
||||
private String userId;
|
||||
private String agentId;
|
||||
private String targetNodeId;
|
||||
private Date occurredAt;
|
||||
|
||||
@@ -74,6 +75,24 @@ public class AgentRuntimeCommandMessage {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待取消运行所属的 Agent ID。
|
||||
*
|
||||
* @return Agent ID
|
||||
*/
|
||||
public String getAgentId() {
|
||||
return agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置待取消运行所属的 Agent ID。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void setAgentId(String agentId) {
|
||||
this.agentId = agentId;
|
||||
}
|
||||
|
||||
public String getTargetNodeId() {
|
||||
return targetNodeId;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,10 @@ public class AgentRuntimeCommandProducer {
|
||||
String resumeToken,
|
||||
BigInteger operatorId,
|
||||
String userId) {
|
||||
sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId);
|
||||
sendAndWait(
|
||||
targetNodeId, requestId, resumeToken, null,
|
||||
AgentRuntimeCommandAction.APPROVE, null, operatorId, userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +91,10 @@ public class AgentRuntimeCommandProducer {
|
||||
String reason,
|
||||
BigInteger operatorId,
|
||||
String userId) {
|
||||
sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId);
|
||||
sendAndWait(
|
||||
targetNodeId, requestId, resumeToken, null,
|
||||
AgentRuntimeCommandAction.REJECT, reason, operatorId, userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,12 +109,43 @@ public class AgentRuntimeCommandProducer {
|
||||
String requestId,
|
||||
String resumeToken,
|
||||
String reason) {
|
||||
sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.EXPIRE, reason, null, null);
|
||||
sendAndWait(
|
||||
targetNodeId, requestId, resumeToken, null,
|
||||
AgentRuntimeCommandAction.EXPIRE, reason, null, null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递远程 Agent 全部运行取消命令。
|
||||
*
|
||||
* @param targetNodeId 目标节点 ID
|
||||
* @param agentId Agent ID
|
||||
* @param reason 取消原因
|
||||
*/
|
||||
public void sendCancelAgent(String targetNodeId, String agentId, String reason) {
|
||||
sendAndWait(
|
||||
targetNodeId, null, null, agentId,
|
||||
AgentRuntimeCommandAction.CANCEL_AGENT, reason, null, null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递远程运行命令并等待目标节点确认。
|
||||
*
|
||||
* @param targetNodeId 目标节点 ID
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param agentId Agent ID
|
||||
* @param action 命令动作
|
||||
* @param reason 操作原因
|
||||
* @param operatorId 操作人 ID
|
||||
* @param userId 用户 ID
|
||||
* @throws BusinessException 命令投递、处理或确认失败时抛出
|
||||
*/
|
||||
private void sendAndWait(String targetNodeId,
|
||||
String requestId,
|
||||
String resumeToken,
|
||||
String agentId,
|
||||
AgentRuntimeCommandAction action,
|
||||
String reason,
|
||||
BigInteger operatorId,
|
||||
@@ -120,6 +157,7 @@ public class AgentRuntimeCommandProducer {
|
||||
command.setCommandId(UUID.randomUUID().toString());
|
||||
command.setRequestId(requestId);
|
||||
command.setResumeToken(resumeToken);
|
||||
command.setAgentId(agentId);
|
||||
command.setAction(action);
|
||||
command.setReason(reason);
|
||||
command.setOperatorId(operatorId);
|
||||
@@ -135,8 +173,8 @@ public class AgentRuntimeCommandProducer {
|
||||
try {
|
||||
message.setBody(objectMapper.writeValueAsString(command));
|
||||
String recordId = mqProducer.send(message);
|
||||
LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, targetNodeId={}, recordId={}",
|
||||
action, requestId, targetNodeId, recordId);
|
||||
LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, agentId={}, targetNodeId={}, recordId={}",
|
||||
action, requestId, agentId, targetNodeId, recordId);
|
||||
AgentRuntimeCommandResult result = resultRegistry.waitForResult(command.getCommandId());
|
||||
if (!result.isSuccess()) {
|
||||
throw new BusinessException(result.getMessage());
|
||||
@@ -146,18 +184,29 @@ public class AgentRuntimeCommandProducer {
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, targetNodeId={}",
|
||||
action, requestId, targetNodeId, e);
|
||||
LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, agentId={}, targetNodeId={}",
|
||||
action, requestId, agentId, targetNodeId, e);
|
||||
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
|
||||
} finally {
|
||||
deleteResultQuietly(command.getCommandId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建目标节点命令主题。
|
||||
*
|
||||
* @param nodeId 节点 ID
|
||||
* @return 命令主题
|
||||
*/
|
||||
private String commandTopic(String nodeId) {
|
||||
return properties.getCommandTopicPrefix() + ":" + nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理远程命令确认结果,失败时由 Redis TTL 兜底。
|
||||
*
|
||||
* @param commandId 命令 ID
|
||||
*/
|
||||
private void deleteResultQuietly(String commandId) {
|
||||
try {
|
||||
resultRegistry.deleteResult(commandId);
|
||||
|
||||
@@ -7,6 +7,7 @@ public class AgentRuntimeRoute {
|
||||
|
||||
private String nodeId;
|
||||
private String bootId;
|
||||
private String agentId;
|
||||
|
||||
/**
|
||||
* 获取 owner 节点 ID。
|
||||
@@ -43,4 +44,22 @@ public class AgentRuntimeRoute {
|
||||
public void setBootId(String bootId) {
|
||||
this.bootId = bootId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行所属 Agent ID。
|
||||
*
|
||||
* @return Agent ID
|
||||
*/
|
||||
public String getAgentId() {
|
||||
return agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行所属 Agent ID。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void setAgentId(String agentId) {
|
||||
this.agentId = agentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Agent 运行态 Redis 路由注册表。
|
||||
@@ -21,6 +24,7 @@ public class AgentRuntimeRouteRegistry {
|
||||
private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:";
|
||||
private static final String TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:";
|
||||
private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:";
|
||||
private static final String AGENT_RUNS_PREFIX = "easyflow:agent:runtime:agent:";
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final AgentRuntimeProperties properties;
|
||||
@@ -47,10 +51,29 @@ public class AgentRuntimeRouteRegistry {
|
||||
* @param requestId 请求 ID
|
||||
*/
|
||||
public void registerRun(String requestId) {
|
||||
registerRun(requestId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册运行请求 owner 节点及所属 Agent。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void registerRun(String requestId, String agentId) {
|
||||
if (requestId == null || requestId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.opsForValue().set(requestKey(requestId), serializeRoute(currentRoute()), properties.getRouteTtl());
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
requestKey(requestId),
|
||||
serializeRoute(currentRoute(agentId)),
|
||||
properties.getRouteTtl()
|
||||
);
|
||||
if (agentId != null && !agentId.isBlank()) {
|
||||
String key = agentRunsKey(agentId);
|
||||
stringRedisTemplate.opsForSet().add(key, requestId);
|
||||
stringRedisTemplate.expire(key, properties.getRouteTtl());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +130,35 @@ public class AgentRuntimeRouteRegistry {
|
||||
return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定 Agent 当前活跃运行所在的节点。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @return 去重后的 owner 节点 ID
|
||||
*/
|
||||
public Set<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()) {
|
||||
return;
|
||||
}
|
||||
AgentRuntimeRoute route = null;
|
||||
try {
|
||||
route = findOwnerRoute(requestId);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("读取待清理的 Agent 运行路由失败: requestId={}", requestId, exception);
|
||||
}
|
||||
deleteQuietly(requestKey(requestId));
|
||||
if (route != null && route.getAgentId() != null && !route.getAgentId().isBlank()) {
|
||||
removeAgentRunIndexQuietly(route.getAgentId(), requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,10 +245,15 @@ public class AgentRuntimeRouteRegistry {
|
||||
return NODE_HEARTBEAT_PREFIX + nodeId;
|
||||
}
|
||||
|
||||
private AgentRuntimeRoute currentRoute() {
|
||||
private String agentRunsKey(String agentId) {
|
||||
return AGENT_RUNS_PREFIX + agentId;
|
||||
}
|
||||
|
||||
private AgentRuntimeRoute currentRoute(String agentId) {
|
||||
AgentRuntimeRoute route = new AgentRuntimeRoute();
|
||||
route.setNodeId(properties.getInstanceId());
|
||||
route.setBootId(properties.getBootId());
|
||||
route.setAgentId(agentId);
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -219,4 +285,19 @@ public class AgentRuntimeRouteRegistry {
|
||||
LOG.warn("清理 Agent 运行态 Redis 路由失败: key={}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Agent 反向运行索引中移除请求。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param requestId 请求 ID
|
||||
*/
|
||||
private void removeAgentRunIndexQuietly(String agentId, String requestId) {
|
||||
try {
|
||||
stringRedisTemplate.opsForSet().remove(agentRunsKey(agentId), requestId);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("清理 Agent 运行反向索引失败: agentId={}, requestId={}",
|
||||
agentId, requestId, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.update.UpdateChain;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
|
||||
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
import tech.easyflow.agent.runtime.AgentRunRegistry;
|
||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
|
||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||
@@ -22,6 +27,7 @@ import tech.easyflow.system.service.ResourceAccessService;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Agent 审批资源处理器。
|
||||
@@ -33,6 +39,11 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
private final AgentToolBindingService agentToolBindingService;
|
||||
private final AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final AgentBindingLockExecutor agentBindingLockExecutor;
|
||||
private final AgentRunRegistry agentRunRegistry;
|
||||
private final AgentHitlPendingService agentHitlPendingService;
|
||||
private final AgentRuntimeRouteRegistry agentRuntimeRouteRegistry;
|
||||
private final AgentRuntimeCommandProducer agentRuntimeCommandProducer;
|
||||
|
||||
/**
|
||||
* 创建 Agent 审批资源处理器。
|
||||
@@ -43,18 +54,33 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
* @param agentToolBindingService Agent 工具绑定服务
|
||||
* @param agentKnowledgeBindingService Agent 知识库绑定服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param agentBindingLockExecutor Agent 配置锁执行器
|
||||
* @param agentRunRegistry Agent 运行态注册表
|
||||
* @param agentHitlPendingService Agent 待审批运行服务
|
||||
* @param agentRuntimeRouteRegistry Agent 分布式运行路由注册表
|
||||
* @param agentRuntimeCommandProducer Agent 远程运行命令生产者
|
||||
*/
|
||||
public AgentApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService,
|
||||
ObjectMapper objectMapper,
|
||||
AgentService agentService,
|
||||
AgentToolBindingService agentToolBindingService,
|
||||
AgentKnowledgeBindingService agentKnowledgeBindingService,
|
||||
ResourceAccessService resourceAccessService) {
|
||||
ResourceAccessService resourceAccessService,
|
||||
AgentBindingLockExecutor agentBindingLockExecutor,
|
||||
AgentRunRegistry agentRunRegistry,
|
||||
AgentHitlPendingService agentHitlPendingService,
|
||||
AgentRuntimeRouteRegistry agentRuntimeRouteRegistry,
|
||||
AgentRuntimeCommandProducer agentRuntimeCommandProducer) {
|
||||
super(approvalInstanceService, objectMapper);
|
||||
this.agentService = agentService;
|
||||
this.agentToolBindingService = agentToolBindingService;
|
||||
this.agentKnowledgeBindingService = agentKnowledgeBindingService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.agentBindingLockExecutor = agentBindingLockExecutor;
|
||||
this.agentRunRegistry = agentRunRegistry;
|
||||
this.agentHitlPendingService = agentHitlPendingService;
|
||||
this.agentRuntimeRouteRegistry = agentRuntimeRouteRegistry;
|
||||
this.agentRuntimeCommandProducer = agentRuntimeCommandProducer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,16 +149,20 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
|
||||
@Override
|
||||
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
|
||||
agentBindingLockExecutor.execute(resourceId, () -> {
|
||||
// 生命周期操作仅更新状态字段,避免实体默认空配置覆盖 Agent 草稿配置。
|
||||
UpdateChain<Agent> updateChain = agentService.updateChain();
|
||||
updateChain.set(Agent::getPublishStatus, publishStatus.getCode());
|
||||
updateChain.set(Agent::getCurrentApprovalInstanceId, currentApprovalInstanceId);
|
||||
updateChain.eq(Agent::getId, resourceId);
|
||||
updateChain.update();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
|
||||
agentBindingLockExecutor.execute(resourceId, () -> {
|
||||
UpdateChain<Agent> updateChain = agentService.updateChain();
|
||||
updateChain.set(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode());
|
||||
updateChain.set(Agent::getPublishedSnapshotJson, resourceSnapshot);
|
||||
@@ -141,26 +171,54 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
updateChain.set(Agent::getCurrentApprovalInstanceId, null);
|
||||
updateChain.eq(Agent::getId, resourceId);
|
||||
updateChain.update();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void markResourceOffline(BigInteger resourceId) {
|
||||
agentBindingLockExecutor.execute(resourceId, () -> {
|
||||
UpdateChain<Agent> updateChain = agentService.updateChain();
|
||||
updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode());
|
||||
updateChain.set(Agent::getCurrentApprovalInstanceId, null);
|
||||
updateChain.eq(Agent::getId, resourceId);
|
||||
updateChain.update();
|
||||
cancelActiveRuns(resourceId, "Agent 已下线,待审批运行已取消");
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void removeResource(BigInteger resourceId) {
|
||||
agentBindingLockExecutor.execute(resourceId, () -> {
|
||||
cancelActiveRuns(resourceId, "Agent 已删除,待审批运行已取消");
|
||||
agentToolBindingService.remove(
|
||||
QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId));
|
||||
agentKnowledgeBindingService.remove(
|
||||
QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId));
|
||||
agentService.removeById(resourceId);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeRemove(BigInteger resourceId) {
|
||||
agentToolBindingService.remove(QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId));
|
||||
agentKnowledgeBindingService.remove(QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId));
|
||||
/**
|
||||
* 取消指定 Agent 的集群运行态和持久化待审批请求。
|
||||
*
|
||||
* @param resourceId Agent ID
|
||||
* @param reason 取消原因
|
||||
*/
|
||||
private void cancelActiveRuns(BigInteger resourceId, String reason) {
|
||||
String agentId = resourceId.toString();
|
||||
Set<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
|
||||
|
||||
@@ -5,6 +5,7 @@ import tech.easyflow.ai.publish.AiResourceLifecycleService;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
@@ -57,15 +58,27 @@ public class AgentPublishAppService {
|
||||
return submit(id, ApprovalActionType.DELETE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 Agent 生命周期审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @param actionType 审批动作
|
||||
* @return 审批动作结果
|
||||
* @throws BusinessException 资源 ID 或登录信息无效时抛出
|
||||
*/
|
||||
private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) {
|
||||
if (id == null) {
|
||||
throw new BusinessException("Agent 审批时资源ID不能为空");
|
||||
}
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return aiResourceLifecycleService.submitAction(
|
||||
ApprovalResourceType.AGENT.getCode(),
|
||||
id,
|
||||
actionType.getCode(),
|
||||
SaTokenUtil.getLoginAccount().getId()
|
||||
account.getId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -71,7 +72,10 @@ public class AgentRunRegistry {
|
||||
}
|
||||
owners.put(context.requestId(), context.owner());
|
||||
if (routeRegistry != null) {
|
||||
routeRegistry.registerRun(context.requestId());
|
||||
routeRegistry.registerRun(
|
||||
context.requestId(),
|
||||
context.owner() == null ? null : context.owner().agentId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +136,28 @@ public class AgentRunRegistry {
|
||||
remove(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前节点上指定 Agent 的全部活跃运行。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void cancelAgent(String agentId) {
|
||||
if (agentId == null || agentId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录等待审批的恢复令牌。
|
||||
*
|
||||
|
||||
@@ -39,6 +39,7 @@ import tech.easyflow.agent.runtime.document.AgentDocumentContext;
|
||||
import tech.easyflow.agent.runtime.document.AgentDocumentContextSelector;
|
||||
import tech.easyflow.agent.runtime.document.AgentDocumentService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
@@ -99,6 +100,10 @@ public class AgentRunService {
|
||||
@Resource
|
||||
private AgentRunRegistry agentRunRegistry;
|
||||
@Resource
|
||||
private AgentBindingLockExecutor agentBindingLockExecutor;
|
||||
@Resource
|
||||
private AgentRunStartGuard agentRunStartGuard;
|
||||
@Resource
|
||||
private AgentRuntimeRouteRegistry agentRuntimeRouteRegistry;
|
||||
@Resource
|
||||
private AgentRuntimeCommandProducer agentRuntimeCommandProducer;
|
||||
@@ -183,6 +188,75 @@ public class AgentRunService {
|
||||
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过公共 API 启动已发布 Agent 的纯文本聊天。
|
||||
*
|
||||
* @param chatRequest 聊天请求
|
||||
* @param apiAccount API Key 对应的隔离调用身份
|
||||
* @return SSE Emitter
|
||||
*/
|
||||
public SseEmitter chatPublic(AgentChatRequest chatRequest, LoginAccount apiAccount) {
|
||||
validateChatRequest(chatRequest);
|
||||
if (apiAccount == null || apiAccount.getId() == null) {
|
||||
throw new BusinessException("API 调用身份不能为空");
|
||||
}
|
||||
if (chatRequest.getImageUploadIds() != null && !chatRequest.getImageUploadIds().isEmpty()) {
|
||||
throw new BusinessException("公共 Agent API 暂不支持图片附件");
|
||||
}
|
||||
if (chatRequest.getDocumentUploadIds() != null && !chatRequest.getDocumentUploadIds().isEmpty()) {
|
||||
throw new BusinessException("公共 Agent API 暂不支持文档附件");
|
||||
}
|
||||
if (chatRequest.getCapabilities() != null && !chatRequest.getCapabilities().isEmpty()) {
|
||||
throw new BusinessException("公共 Agent API 暂不支持临时能力");
|
||||
}
|
||||
Agent liveAgent = agentService.getById(chatRequest.getAgentId());
|
||||
if (liveAgent == null || !Objects.equals(liveAgent.getTenantId(), apiAccount.getTenantId())) {
|
||||
throw new BusinessException("Agent 不存在或不可用");
|
||||
}
|
||||
assertAgentRunnable(liveAgent);
|
||||
BigInteger sessionId = chatRequest.getSessionId() == null
|
||||
? BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId())
|
||||
: chatRequest.getSessionId();
|
||||
ChatSessionSummary existingSession =
|
||||
resolveExistingSession(apiAccount, sessionId, chatRequest.getAgentId());
|
||||
Agent agent = agentService.getPublishedView(chatRequest.getAgentId());
|
||||
assertPublicHitlUnsupported(agent);
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
String traceId = UUID.randomUUID().toString();
|
||||
String titlePrompt = effectivePrompt(chatRequest.getPrompt(), false, false);
|
||||
ChatRuntimeContext chatContext = buildChatRuntimeContext(
|
||||
agent, sessionId, titlePrompt, apiAccount, ASSISTANT_CODE, ChatChannel.PUBLIC_API, true);
|
||||
applyFormalSessionTitle(chatContext, titlePrompt, existingSession);
|
||||
return run(agent, chatRequest.getPrompt(), Collections.emptyList(), Collections.emptyList(),
|
||||
apiAccount, requestId, traceId, sessionId.toString(),
|
||||
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共 API 当前没有审批恢复入口,因此拒绝包含 HITL 工具的 Agent。
|
||||
*
|
||||
* @param agent 已发布 Agent 运行视图
|
||||
*/
|
||||
private void assertPublicHitlUnsupported(Agent agent) {
|
||||
if (agent == null || agent.getToolBindings() == null) {
|
||||
return;
|
||||
}
|
||||
for (AgentToolBinding binding : agent.getToolBindings()) {
|
||||
if (binding == null || !Boolean.TRUE.equals(binding.getEnabled())) {
|
||||
continue;
|
||||
}
|
||||
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
|
||||
throw new BusinessException("公共 Agent API 暂不支持需要执行确认的工具");
|
||||
}
|
||||
Object approvalRequired = binding.getResourceSnapshot() == null
|
||||
? null : binding.getResourceSnapshot().get("approvalRequired");
|
||||
if ("MCP".equalsIgnoreCase(binding.getToolType())
|
||||
&& Boolean.parseBoolean(String.valueOf(approvalRequired))) {
|
||||
throw new BusinessException("公共 Agent API 暂不支持需要执行确认的 MCP");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Agent 草稿态纯文本试用。
|
||||
*
|
||||
@@ -615,6 +689,23 @@ public class AgentRunService {
|
||||
return resolvedRequestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Agent 生命周期锁内启动正式运行,避免与下线或删除并发穿透。
|
||||
*
|
||||
* @param agent Agent 运行视图
|
||||
* @param userMessage 用户消息
|
||||
* @param documentContext 文档上下文
|
||||
* @param account 当前账号
|
||||
* @param requestId 请求 ID
|
||||
* @param traceId 链路 ID
|
||||
* @param runtimeSessionId 运行会话 ID
|
||||
* @param assistantCode 助手类型
|
||||
* @param chatContext 聊天上下文
|
||||
* @param chatSseEmitter SSE 发射器
|
||||
* @param persistChatlog 是否持久化聊天日志
|
||||
* @param runtimeSessionStore 运行会话存储
|
||||
* @param initialLockHandle 会话运行锁
|
||||
*/
|
||||
private void startRuntime(Agent agent,
|
||||
AgentMessage userMessage,
|
||||
AgentDocumentContext documentContext,
|
||||
@@ -628,6 +719,73 @@ public class AgentRunService {
|
||||
boolean persistChatlog,
|
||||
AgentSessionStore runtimeSessionStore,
|
||||
AgentRunLock.Handle initialLockHandle) {
|
||||
if (!persistChatlog || agent == null || agent.getId() == null) {
|
||||
startRuntimeLocked(
|
||||
agent, userMessage, documentContext, account, requestId, traceId,
|
||||
runtimeSessionId, assistantCode, chatContext, chatSseEmitter,
|
||||
persistChatlog, runtimeSessionStore, initialLockHandle
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
agentBindingLockExecutor.execute(agent.getId(), () -> {
|
||||
agentRunStartGuard.assertRunnable(agent.getId());
|
||||
startRuntimeLocked(
|
||||
agent, userMessage, documentContext, account, requestId, traceId,
|
||||
runtimeSessionId, assistantCode, chatContext, chatSseEmitter,
|
||||
true, runtimeSessionStore, initialLockHandle
|
||||
);
|
||||
return null;
|
||||
});
|
||||
} catch (Exception exception) {
|
||||
AgentRunRegistry.AgentRunContext runContext = agentRunRegistry.get(requestId);
|
||||
if (runContext != null) {
|
||||
runContext.cancel();
|
||||
agentRunRegistry.remove(requestId);
|
||||
} else if (initialLockHandle != null) {
|
||||
initialLockHandle.release();
|
||||
}
|
||||
handleRuntimeError(
|
||||
exception,
|
||||
requestId,
|
||||
chatSseEmitter,
|
||||
chatContext,
|
||||
new AtomicBoolean(false),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化、注册并订阅单次 Agent 运行。
|
||||
*
|
||||
* @param agent Agent 运行视图
|
||||
* @param userMessage 用户消息
|
||||
* @param documentContext 文档上下文
|
||||
* @param account 当前账号
|
||||
* @param requestId 请求 ID
|
||||
* @param traceId 链路 ID
|
||||
* @param runtimeSessionId 运行会话 ID
|
||||
* @param assistantCode 助手类型
|
||||
* @param chatContext 聊天上下文
|
||||
* @param chatSseEmitter SSE 发射器
|
||||
* @param persistChatlog 是否持久化聊天日志
|
||||
* @param runtimeSessionStore 运行会话存储
|
||||
* @param initialLockHandle 会话运行锁
|
||||
*/
|
||||
private void startRuntimeLocked(Agent agent,
|
||||
AgentMessage userMessage,
|
||||
AgentDocumentContext documentContext,
|
||||
LoginAccount account,
|
||||
String requestId,
|
||||
String traceId,
|
||||
String runtimeSessionId,
|
||||
String assistantCode,
|
||||
ChatRuntimeContext chatContext,
|
||||
ChatSseEmitter chatSseEmitter,
|
||||
boolean persistChatlog,
|
||||
AgentSessionStore runtimeSessionStore,
|
||||
AgentRunLock.Handle initialLockHandle) {
|
||||
AtomicBoolean finished = new AtomicBoolean(false);
|
||||
StringBuilder answer = new StringBuilder();
|
||||
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
|
||||
@@ -705,6 +863,18 @@ public class AgentRunService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前节点上指定 Agent 的全部运行。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void cancelAgentLocal(String agentId) {
|
||||
if (agentId == null || agentId.isBlank()) {
|
||||
throw new BusinessException("Agent ID 不能为空");
|
||||
}
|
||||
agentRunRegistry.cancelAgent(agentId);
|
||||
}
|
||||
|
||||
private void bindAgentSession(Agent agent, String runtimeSessionId, ChatRuntimeContext chatContext) {
|
||||
if (easyFlowAgentSessionStore == null || runtimeSessionId == null || runtimeSessionId.isBlank()) {
|
||||
return;
|
||||
@@ -1268,8 +1438,31 @@ public class AgentRunService {
|
||||
String prompt,
|
||||
LoginAccount account,
|
||||
String assistantCode) {
|
||||
return buildChatRuntimeContext(
|
||||
agent, sessionId, prompt, account, assistantCode, ChatChannel.ADMIN, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建指定渠道的 Agent 聊天上下文。
|
||||
*
|
||||
* @param agent Agent 运行视图
|
||||
* @param sessionId 会话 ID
|
||||
* @param prompt 用户输入
|
||||
* @param account 调用身份
|
||||
* @param assistantCode 助手类型编码
|
||||
* @param channel 调用渠道
|
||||
* @param anonymous 是否匿名调用
|
||||
* @return 聊天运行上下文
|
||||
*/
|
||||
private ChatRuntimeContext buildChatRuntimeContext(Agent agent,
|
||||
BigInteger sessionId,
|
||||
String prompt,
|
||||
LoginAccount account,
|
||||
String assistantCode,
|
||||
ChatChannel channel,
|
||||
boolean anonymous) {
|
||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||
context.setChannel(ChatChannel.ADMIN);
|
||||
context.setChannel(channel);
|
||||
context.setSessionId(sessionId);
|
||||
context.setTenantId(account.getTenantId());
|
||||
context.setDeptId(account.getDeptId());
|
||||
@@ -1280,6 +1473,7 @@ public class AgentRunService {
|
||||
context.setAssistantCode(assistantCode);
|
||||
context.setAssistantName(agent.getName());
|
||||
context.setSessionTitle(toSessionTitle(prompt));
|
||||
context.setAnonymous(anonymous);
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1491,10 +1685,23 @@ public class AgentRunService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录信息失效时抛出
|
||||
*/
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
try {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
} catch (Exception e) {
|
||||
if (e instanceof BusinessException businessException) {
|
||||
throw businessException;
|
||||
}
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 已下线或不可继续会话");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,14 @@ public interface AgentHitlPendingService {
|
||||
*/
|
||||
void cancelByRequestId(String requestId, String reason);
|
||||
|
||||
/**
|
||||
* 取消指定 Agent 的全部待审批运行。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param reason 取消原因
|
||||
*/
|
||||
void cancelByAgentId(BigInteger agentId, String reason);
|
||||
|
||||
/**
|
||||
* 删除指定聊天会话的 pending。
|
||||
*
|
||||
|
||||
@@ -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
|
||||
public void deleteByChatSessionId(BigInteger chatSessionId) {
|
||||
if (chatSessionId == null) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,10 @@ import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.agent.mapper.AgentKnowledgeBindingMapper;
|
||||
import tech.easyflow.agent.mapper.AgentMapper;
|
||||
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
@@ -22,9 +21,13 @@ import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Agent 知识库绑定服务实现。
|
||||
@@ -38,9 +41,11 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
|
||||
@Resource
|
||||
private AgentMapper agentMapper;
|
||||
@Resource
|
||||
private DocumentCollectionService documentCollectionService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private AgentBindingLockExecutor agentBindingLockExecutor;
|
||||
@Resource
|
||||
private AgentDependencyAccessService agentDependencyAccessService;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
@@ -48,19 +53,21 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<AgentKnowledgeBinding> replaceBindings(BigInteger agentId, List<AgentKnowledgeBinding> bindings) {
|
||||
Agent agent = requireAgent(agentId);
|
||||
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||
return agentBindingLockExecutor.execute(agentId, () -> {
|
||||
Agent agent = requireAgentForUpdate(agentId);
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||
validateBindings(agent, bindings);
|
||||
remove(QueryWrapper.create().where("agent_id = ?", agentId));
|
||||
if (bindings == null || bindings.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
for (int i = 0; i < bindings.size(); i++) {
|
||||
AgentKnowledgeBinding binding = bindings.get(i);
|
||||
validateBinding(binding);
|
||||
applyBindingDefaults(agent, binding, i);
|
||||
applyBindingDefaults(agent, bindings.get(i), i);
|
||||
}
|
||||
saveBatch(bindings);
|
||||
return listEnabled(agentId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,26 +81,70 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
|
||||
.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) {
|
||||
throw new BusinessException("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) {
|
||||
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());
|
||||
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) {
|
||||
LoginAccount account = requireCurrentLoginAccount();
|
||||
Date now = new Date();
|
||||
@@ -113,10 +164,23 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
|
||||
binding.setModifiedBy(account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录信息失效时抛出
|
||||
*/
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
try {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
} catch (Exception e) {
|
||||
if (e instanceof BusinessException businessException) {
|
||||
throw businessException;
|
||||
}
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package tech.easyflow.agent.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -10,9 +11,12 @@ import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
import tech.easyflow.agent.mapper.AgentMapper;
|
||||
import tech.easyflow.agent.runtime.AgentRuntimeCompiler;
|
||||
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||
import tech.easyflow.ai.entity.*;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.*;
|
||||
@@ -58,6 +62,12 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@Resource
|
||||
private AgentDependencyAccessService agentDependencyAccessService;
|
||||
@Resource
|
||||
private AgentBindingLockExecutor agentBindingLockExecutor;
|
||||
@Resource
|
||||
private AgentRuntimeCompiler agentRuntimeCompiler;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
@@ -77,8 +87,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Agent saveDraft(Agent agent) {
|
||||
validateDraft(agent);
|
||||
applyDraftDefaults(agent);
|
||||
validateDraft(agent);
|
||||
save(agent);
|
||||
return getDetail(agent.getId());
|
||||
}
|
||||
@@ -92,12 +102,16 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
if (agent == null || agent.getId() == null) {
|
||||
throw new BusinessException("Agent ID 不能为空");
|
||||
}
|
||||
Agent existing = requireAgent(agent.getId());
|
||||
resourceAccessService.assertAccess(CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||
return agentBindingLockExecutor.execute(agent.getId(), () -> {
|
||||
Agent existing = requireAgentForUpdate(agent.getId());
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||
agent.setTenantId(existing.getTenantId());
|
||||
validateDraft(agent);
|
||||
applyDraftUpdate(existing, agent);
|
||||
updateById(existing);
|
||||
return getDetail(existing.getId());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,8 +131,27 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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<>();
|
||||
snapshot.put("id", detail.getId());
|
||||
snapshot.put("tenantId", detail.getTenantId());
|
||||
@@ -136,8 +169,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
snapshot.put("executionConfigJson", detail.getExecutionConfigJson());
|
||||
snapshot.put("interactionConfigJson", detail.getInteractionConfigJson());
|
||||
snapshot.put("visibilityScope", detail.getVisibilityScope());
|
||||
snapshot.put("toolBindings", snapshotToolBindings(detail.getToolBindings()));
|
||||
snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail.getKnowledgeBindings()));
|
||||
snapshot.put("toolBindings", snapshotToolBindings(detail, detail.getToolBindings()));
|
||||
snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail, detail.getKnowledgeBindings()));
|
||||
snapshot.put("basicSummary", basicSummary(detail));
|
||||
snapshot.put("modelSummary", modelSummary(detail.getModelId()));
|
||||
snapshot.put("parameterSummary", parameterSummary(detail));
|
||||
@@ -145,6 +178,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings()));
|
||||
snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings()));
|
||||
snapshot.put("snapshotAt", new Date());
|
||||
// 发布前完整编译一次,提前暴露工具运行名冲突和运行定义错误。
|
||||
agentRuntimeCompiler.compile(fromSnapshot(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -179,6 +214,22 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询并锁定 Agent 数据行。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return Agent
|
||||
*/
|
||||
private Agent requireAgentForUpdate(BigInteger id) {
|
||||
Agent agent = getOne(QueryWrapper.create()
|
||||
.eq(Agent::getId, id)
|
||||
.forUpdate());
|
||||
if (agent == null) {
|
||||
throw new BusinessException("Agent 不存在");
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private void validateDraft(Agent agent) {
|
||||
if (agent == null) {
|
||||
throw new BusinessException("Agent 不能为空");
|
||||
@@ -186,13 +237,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
if (agent.getName() == null || agent.getName().isBlank()) {
|
||||
throw new BusinessException("Agent 名称不能为空");
|
||||
}
|
||||
if (agent.getModelId() == null) {
|
||||
throw new BusinessException("Agent 模型不能为空");
|
||||
}
|
||||
Model model = modelService.getModelInstance(agent.getModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("Agent 模型不存在");
|
||||
}
|
||||
agentDependencyAccessService.requireModel(agent, agent.getModelId());
|
||||
agentDependencyAccessService.validateCategory(agent);
|
||||
agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name());
|
||||
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
|
||||
agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson()));
|
||||
@@ -334,57 +380,47 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
return summary;
|
||||
}
|
||||
|
||||
private List<AgentToolBinding> snapshotToolBindings(List<AgentToolBinding> bindings) {
|
||||
private List<AgentToolBinding> snapshotToolBindings(Agent agent, List<AgentToolBinding> bindings) {
|
||||
if (bindings == null) {
|
||||
return List.of();
|
||||
}
|
||||
return bindings.stream().map(binding -> {
|
||||
AgentToolBinding snapshot = objectMapper.convertValue(binding, AgentToolBinding.class);
|
||||
snapshot.setResourceSnapshot(toolResourceSnapshot(agent, binding));
|
||||
snapshot.setResourceSummary(toolSummary(binding));
|
||||
snapshot.setResourceSnapshot(toolResourceSnapshot(binding));
|
||||
return snapshot;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private Map<String, Object> toolResourceSnapshot(AgentToolBinding binding) {
|
||||
private Map<String, Object> toolResourceSnapshot(Agent agent, AgentToolBinding binding) {
|
||||
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
|
||||
Workflow workflow = workflowService.getPublishedById(binding.getTargetId());
|
||||
if (workflow == null || !PublishStatus.from(workflow.getPublishStatus()).isExternallyVisible()) {
|
||||
throw new BusinessException("绑定工作流不存在或未发布");
|
||||
}
|
||||
Workflow workflow = agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId());
|
||||
return objectMapper.convertValue(workflow, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
|
||||
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
|
||||
if (pluginItem == null) {
|
||||
throw new BusinessException("绑定插件不存在");
|
||||
}
|
||||
PluginItem pluginItem = agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId());
|
||||
return objectMapper.convertValue(pluginItem, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
Mcp mcp = mcpService.getById(binding.getTargetId());
|
||||
if (mcp == null) {
|
||||
throw new BusinessException("绑定 MCP 不存在");
|
||||
}
|
||||
Mcp mcp = agentDependencyAccessService.requireMcp(agent, binding.getTargetId());
|
||||
return objectMapper.convertValue(mcp, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(List<AgentKnowledgeBinding> bindings) {
|
||||
private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(
|
||||
Agent agent, List<AgentKnowledgeBinding> bindings) {
|
||||
if (bindings == null) {
|
||||
return List.of();
|
||||
}
|
||||
return bindings.stream().map(binding -> {
|
||||
AgentKnowledgeBinding snapshot = objectMapper.convertValue(binding, AgentKnowledgeBinding.class);
|
||||
snapshot.setResourceSnapshot(knowledgeResourceSnapshot(agent, binding));
|
||||
snapshot.setResourceSummary(knowledgeSummary(binding));
|
||||
snapshot.setResourceSnapshot(knowledgeResourceSnapshot(binding));
|
||||
return snapshot;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private Map<String, Object> knowledgeResourceSnapshot(AgentKnowledgeBinding binding) {
|
||||
DocumentCollection knowledge = documentCollectionService.getPublishedById(binding.getKnowledgeId());
|
||||
if (knowledge == null || !PublishStatus.from(knowledge.getPublishStatus()).isExternallyVisible()) {
|
||||
throw new BusinessException("绑定知识库不存在或未发布");
|
||||
}
|
||||
private Map<String, Object> knowledgeResourceSnapshot(Agent agent, AgentKnowledgeBinding binding) {
|
||||
DocumentCollection knowledge =
|
||||
agentDependencyAccessService.requireKnowledge(agent, binding.getKnowledgeId());
|
||||
return objectMapper.convertValue(knowledge, new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
@@ -408,10 +444,23 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录信息失效时抛出
|
||||
*/
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
try {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
} catch (Exception e) {
|
||||
if (e instanceof BusinessException businessException) {
|
||||
throw businessException;
|
||||
}
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,9 @@ import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
import tech.easyflow.agent.enums.AgentToolType;
|
||||
import tech.easyflow.agent.mapper.AgentMapper;
|
||||
import tech.easyflow.agent.mapper.AgentToolBindingMapper;
|
||||
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.McpService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
@@ -26,9 +21,13 @@ import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Agent 工具绑定服务实现。
|
||||
@@ -40,13 +39,11 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
|
||||
@Resource
|
||||
private AgentMapper agentMapper;
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@Resource
|
||||
private PluginItemService pluginItemService;
|
||||
@Resource
|
||||
private McpService mcpService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private AgentBindingLockExecutor agentBindingLockExecutor;
|
||||
@Resource
|
||||
private AgentDependencyAccessService agentDependencyAccessService;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
@@ -54,19 +51,21 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<AgentToolBinding> replaceBindings(BigInteger agentId, List<AgentToolBinding> bindings) {
|
||||
Agent agent = requireAgent(agentId);
|
||||
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||
return agentBindingLockExecutor.execute(agentId, () -> {
|
||||
Agent agent = requireAgentForUpdate(agentId);
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||
validateBindings(agent, bindings);
|
||||
remove(QueryWrapper.create().where("agent_id = ?", agentId));
|
||||
if (bindings == null || bindings.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
for (int i = 0; i < bindings.size(); i++) {
|
||||
AgentToolBinding binding = bindings.get(i);
|
||||
validateBinding(binding);
|
||||
applyBindingDefaults(agent, binding, i);
|
||||
applyBindingDefaults(agent, bindings.get(i), i);
|
||||
}
|
||||
saveBatch(bindings);
|
||||
return listEnabled(agentId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,40 +79,89 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
|
||||
.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) {
|
||||
throw new BusinessException("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) {
|
||||
throw new BusinessException("工具绑定参数不完整");
|
||||
}
|
||||
AgentToolType type = AgentToolType.from(binding.getToolType());
|
||||
if (type == AgentToolType.WORKFLOW) {
|
||||
Workflow workflow = workflowService.getById(binding.getTargetId());
|
||||
if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
throw new BusinessException("绑定工作流不存在或未发布");
|
||||
}
|
||||
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, "无权限绑定该工作流");
|
||||
agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId());
|
||||
return;
|
||||
}
|
||||
if (type == AgentToolType.PLUGIN) {
|
||||
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
|
||||
if (pluginItem == null || pluginItem.getStatus() == null || pluginItem.getStatus() != 1) {
|
||||
throw new BusinessException("绑定插件不存在或未启用");
|
||||
}
|
||||
agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId());
|
||||
return;
|
||||
}
|
||||
Mcp mcp = mcpService.getById(binding.getTargetId());
|
||||
if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) {
|
||||
throw new BusinessException("绑定 MCP 不存在或未启用");
|
||||
}
|
||||
agentDependencyAccessService.requireMcp(agent, binding.getTargetId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按稳定顺序校验绑定并锁定关联资源,降低并发替换产生数据库死锁的概率。
|
||||
*
|
||||
* @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) {
|
||||
LoginAccount account = requireCurrentLoginAccount();
|
||||
Date now = new Date();
|
||||
@@ -129,10 +177,23 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
|
||||
binding.setModifiedBy(account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录信息失效时抛出
|
||||
*/
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
try {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
} catch (Exception e) {
|
||||
if (e instanceof BusinessException businessException) {
|
||||
throw businessException;
|
||||
}
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,34 @@ public class AgentRuntimeCommandConsumerTest {
|
||||
Assert.assertEquals("cmd-expire", resultRegistry.lastSuccessCommandId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Agent 集群取消命令只取消目标节点的对应 Agent 运行。
|
||||
*
|
||||
* @throws Exception 消息序列化异常
|
||||
*/
|
||||
@Test
|
||||
public void consumerShouldHandleCancelAgentCommand() throws Exception {
|
||||
AgentRuntimeProperties properties = new AgentRuntimeProperties();
|
||||
properties.setInstanceId("node-a");
|
||||
RecordingAgentRunService service = new RecordingAgentRunService();
|
||||
RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry();
|
||||
AgentRuntimeCommandConsumer consumer = new AgentRuntimeCommandConsumer(
|
||||
new ObjectMapper(),
|
||||
properties,
|
||||
new MQProperties(),
|
||||
service,
|
||||
resultRegistry
|
||||
);
|
||||
AgentRuntimeCommandMessage command = command("cmd-cancel", "node-a");
|
||||
command.setAction(AgentRuntimeCommandAction.CANCEL_AGENT);
|
||||
command.setAgentId("1001");
|
||||
|
||||
consumer.handle(List.of(message(command)));
|
||||
|
||||
Assert.assertEquals("1001", service.lastCancelledAgentId);
|
||||
Assert.assertEquals("cmd-cancel", resultRegistry.lastSuccessCommandId);
|
||||
}
|
||||
|
||||
private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) {
|
||||
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
|
||||
command.setCommandId(commandId);
|
||||
@@ -138,6 +166,7 @@ public class AgentRuntimeCommandConsumerTest {
|
||||
private int expireCount;
|
||||
private String lastRequestId;
|
||||
private String lastReason;
|
||||
private String lastCancelledAgentId;
|
||||
|
||||
@Override
|
||||
public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
|
||||
@@ -151,6 +180,11 @@ public class AgentRuntimeCommandConsumerTest {
|
||||
lastRequestId = requestId;
|
||||
lastReason = reason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelAgentLocal(String agentId) {
|
||||
lastCancelledAgentId = agentId;
|
||||
}
|
||||
}
|
||||
|
||||
private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry {
|
||||
|
||||
@@ -6,11 +6,13 @@ import org.junit.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@link AgentRuntimeRouteRegistry} 回归测试。
|
||||
@@ -40,12 +42,49 @@ public class AgentRuntimeRouteRegistryTest {
|
||||
"easyflow:agent:runtime:resume-token:token-1", "request-1", Duration.ofHours(24));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正式运行会写入 Agent 反向索引,并可解析全部 owner 节点。
|
||||
*/
|
||||
@Test
|
||||
public void agentRunIndexShouldTrackOwnerNodes() {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<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 路由。
|
||||
*/
|
||||
@Test
|
||||
public void removeShouldDeleteRunAndTokenRoutes() {
|
||||
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"));
|
||||
|
||||
registry.removeRun("request-1");
|
||||
|
||||
@@ -5,17 +5,24 @@ import com.mybatisflex.core.update.UpdateChain;
|
||||
import com.mybatisflex.core.util.LambdaGetter;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
|
||||
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.runtime.AgentRunRegistry;
|
||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -82,27 +89,50 @@ public class AgentApprovalSubjectHandlerTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批删除 Agent 前必须同步清理工具绑定和知识库绑定,避免留下孤儿数据。
|
||||
* 审批删除 Agent 时必须在同一配置锁内取消运行并清理关联数据。
|
||||
*/
|
||||
@Test
|
||||
public void beforeRemoveShouldCleanAgentBindings() {
|
||||
public void removeResourceShouldCancelRunsAndCleanBindings() {
|
||||
AtomicInteger toolRemoveCalls = new AtomicInteger();
|
||||
AtomicInteger knowledgeRemoveCalls = new AtomicInteger();
|
||||
AgentToolBindingService toolBindingService = proxy(AgentToolBindingService.class, toolRemoveCalls);
|
||||
AgentKnowledgeBindingService knowledgeBindingService = proxy(AgentKnowledgeBindingService.class, knowledgeRemoveCalls);
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
AgentRunRegistry runRegistry = mock(AgentRunRegistry.class);
|
||||
AgentHitlPendingService pendingService = mock(AgentHitlPendingService.class);
|
||||
AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class);
|
||||
AgentRuntimeCommandProducer commandProducer = mock(AgentRuntimeCommandProducer.class);
|
||||
when(routeRegistry.findOwnerNodesByAgent("1001")).thenReturn(Set.of("node-a", "node-b"));
|
||||
when(routeRegistry.currentNodeId()).thenReturn("node-a");
|
||||
AgentApprovalSubjectHandler handler = new AgentApprovalSubjectHandler(
|
||||
null,
|
||||
new ObjectMapper(),
|
||||
null,
|
||||
agentService,
|
||||
toolBindingService,
|
||||
knowledgeBindingService,
|
||||
null
|
||||
null,
|
||||
immediateLockExecutor(),
|
||||
runRegistry,
|
||||
pendingService,
|
||||
routeRegistry,
|
||||
commandProducer
|
||||
);
|
||||
|
||||
handler.beforeRemove(BigInteger.valueOf(1001));
|
||||
handler.removeResource(BigInteger.valueOf(1001));
|
||||
|
||||
Assert.assertEquals(1, toolRemoveCalls.get());
|
||||
Assert.assertEquals(1, knowledgeRemoveCalls.get());
|
||||
verify(pendingService).cancelByAgentId(
|
||||
BigInteger.valueOf(1001),
|
||||
"Agent 已删除,待审批运行已取消"
|
||||
);
|
||||
verify(runRegistry).cancelAgent("1001");
|
||||
verify(commandProducer).sendCancelAgent(
|
||||
"node-b",
|
||||
"1001",
|
||||
"Agent 已删除,待审批运行已取消"
|
||||
);
|
||||
verify(agentService).removeById(BigInteger.valueOf(1001));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,16 +142,36 @@ public class AgentApprovalSubjectHandlerTest {
|
||||
* @return Agent 审批资源处理器
|
||||
*/
|
||||
private static AgentApprovalSubjectHandler handler(AgentService agentService) {
|
||||
AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class);
|
||||
when(routeRegistry.findOwnerNodesByAgent("1001")).thenReturn(Set.of());
|
||||
return new AgentApprovalSubjectHandler(
|
||||
null,
|
||||
new ObjectMapper(),
|
||||
agentService,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
immediateLockExecutor(),
|
||||
mock(AgentRunRegistry.class),
|
||||
mock(AgentHitlPendingService.class),
|
||||
routeRegistry,
|
||||
mock(AgentRuntimeCommandProducer.class)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建同步执行任务的 Agent 配置锁测试桩。
|
||||
*
|
||||
* @return Agent 配置锁执行器
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static AgentBindingLockExecutor immediateLockExecutor() {
|
||||
AgentBindingLockExecutor executor = mock(AgentBindingLockExecutor.class);
|
||||
when(executor.execute(any(BigInteger.class), any(Supplier.class)))
|
||||
.thenAnswer(invocation -> ((Supplier<?>) invocation.getArgument(1)).get());
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备字段更新对象。
|
||||
*
|
||||
|
||||
@@ -1246,6 +1246,11 @@ public class AgentRunServiceDraftAndHitlTest {
|
||||
cancelByRequestIdCount++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelByAgentId(BigInteger agentId, String reason) {
|
||||
// 测试桩无需处理。
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByChatSessionId(BigInteger chatSessionId) {
|
||||
// 测试桩无需处理。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package tech.easyflow.ai.publish;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.BotDocumentCollection;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.DocumentCollectionCategory;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.BotDocumentCollectionService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionCategoryService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
@@ -37,7 +34,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
|
||||
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final BotDocumentCollectionService botDocumentCollectionService;
|
||||
private final ModelService modelService;
|
||||
private final DocumentCollectionCategoryService documentCollectionCategoryService;
|
||||
private final SysDeptService sysDeptService;
|
||||
@@ -46,7 +42,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
|
||||
public KnowledgeApprovalSubjectHandler(DocumentCollectionService documentCollectionService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
ApprovalInstanceService approvalInstanceService,
|
||||
BotDocumentCollectionService botDocumentCollectionService,
|
||||
ModelService modelService,
|
||||
DocumentCollectionCategoryService documentCollectionCategoryService,
|
||||
SysDeptService sysDeptService,
|
||||
@@ -55,7 +50,6 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
|
||||
super(approvalInstanceService, objectMapper);
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.botDocumentCollectionService = botDocumentCollectionService;
|
||||
this.modelService = modelService;
|
||||
this.documentCollectionCategoryService = documentCollectionCategoryService;
|
||||
this.sysDeptService = sysDeptService;
|
||||
@@ -200,26 +194,22 @@ public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycle
|
||||
if (!impact.isCanProceed()) {
|
||||
throw new BusinessException(buildWorkflowUsageBlockMessage(impact));
|
||||
}
|
||||
if (impact.isHasBotBindings()) {
|
||||
snapshot.put("botBindings", impact.getBotBindings());
|
||||
if (impact.isHasAgentBindings()) {
|
||||
snapshot.put("agentBindings", impact.getAgentBindings());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void validateDelete(DocumentCollection resource, PublishStatus currentStatus) {
|
||||
if (hasBotBinding(resource.getId())) {
|
||||
throw new BusinessException("此知识库还关联着bot,请先取消关联!");
|
||||
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkKnowledgeImpact(resource.getId());
|
||||
if (impact.isHasAgentBindings()) {
|
||||
throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterOffline(BigInteger resourceId) {
|
||||
resourceOfflineImpactService.unbindKnowledgeFromBots(resourceId);
|
||||
}
|
||||
|
||||
private boolean hasBotBinding(BigInteger knowledgeId) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId);
|
||||
return botDocumentCollectionService.exists(queryWrapper);
|
||||
resourceOfflineImpactService.unbindKnowledgeFromAgents(resourceId);
|
||||
}
|
||||
|
||||
private String buildWorkflowUsageBlockMessage(OfflineImpactCheckVo impact) {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package tech.easyflow.ai.publish;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.BotWorkflow;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
|
||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.service.BotWorkflowService;
|
||||
import tech.easyflow.ai.service.ResourceOfflineImpactService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
|
||||
@@ -31,7 +28,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
|
||||
private final WorkflowService workflowService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final BotWorkflowService botWorkflowService;
|
||||
private final ResourceOfflineImpactService resourceOfflineImpactService;
|
||||
private final WorkflowPluginBindingService workflowPluginBindingService;
|
||||
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
||||
@@ -39,7 +35,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
public WorkflowApprovalSubjectHandler(WorkflowService workflowService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
ApprovalInstanceService approvalInstanceService,
|
||||
BotWorkflowService botWorkflowService,
|
||||
ResourceOfflineImpactService resourceOfflineImpactService,
|
||||
WorkflowPluginBindingService workflowPluginBindingService,
|
||||
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
|
||||
@@ -47,7 +42,6 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
super(approvalInstanceService, objectMapper);
|
||||
this.workflowService = workflowService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.botWorkflowService = botWorkflowService;
|
||||
this.resourceOfflineImpactService = resourceOfflineImpactService;
|
||||
this.workflowPluginBindingService = workflowPluginBindingService;
|
||||
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
|
||||
@@ -178,8 +172,8 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
@Override
|
||||
protected void enrichOfflineSnapshot(Workflow resource, Map<String, Object> snapshot) {
|
||||
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
|
||||
if (impact.isHasBotBindings()) {
|
||||
snapshot.put("botBindings", impact.getBotBindings());
|
||||
if (impact.isHasAgentBindings()) {
|
||||
snapshot.put("agentBindings", impact.getAgentBindings());
|
||||
}
|
||||
if (impact.isHasPluginBindings()) {
|
||||
snapshot.put("pluginBindings", impact.getPluginBindings());
|
||||
@@ -188,18 +182,14 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
|
||||
@Override
|
||||
protected void validateDelete(Workflow resource, PublishStatus currentStatus) {
|
||||
if (hasBotBinding(resource.getId())) {
|
||||
throw new BusinessException("此工作流还关联有bot,请先取消关联后再删除!");
|
||||
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
|
||||
if (impact.isHasAgentBindings()) {
|
||||
throw new BusinessException("此工作流仍被智能体使用,请先取消绑定后再删除");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterOffline(BigInteger resourceId) {
|
||||
resourceOfflineImpactService.unbindWorkflowFromBots(resourceId);
|
||||
}
|
||||
|
||||
private boolean hasBotBinding(BigInteger workflowId) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotWorkflow::getWorkflowId, workflowId);
|
||||
return botWorkflowService.exists(queryWrapper);
|
||||
resourceOfflineImpactService.unbindWorkflowFromAgents(resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package tech.easyflow.ai.service;
|
||||
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
|
||||
@@ -42,17 +41,4 @@ public interface AiResourceApprovalStateService {
|
||||
*/
|
||||
void fillKnowledgeApprovalState(Collection<DocumentCollection> collections);
|
||||
|
||||
/**
|
||||
* 填充聊天助手审批展示状态。
|
||||
*
|
||||
* @param bot 聊天助手
|
||||
*/
|
||||
void fillBotApprovalState(Bot bot);
|
||||
|
||||
/**
|
||||
* 批量填充聊天助手审批展示状态。
|
||||
*
|
||||
* @param bots 聊天助手集合
|
||||
*/
|
||||
void fillBotApprovalState(Collection<Bot> bots);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package tech.easyflow.ai.service;
|
||||
import com.mybatisflex.core.service.IService;
|
||||
import tech.easyflow.ai.entity.BotPlugin;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
@@ -22,4 +23,21 @@ public interface BotPluginService extends IService<BotPlugin> {
|
||||
List<BigInteger> getBotPluginToolIds(String botId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import com.easyagents.core.model.chat.tool.Tool;
|
||||
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.service.IService;
|
||||
import tech.easyflow.ai.entity.BotMcp;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 服务层。
|
||||
@@ -24,7 +24,14 @@ public interface McpService extends IService<Mcp> {
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@ public interface PluginItemService extends IService<PluginItem> {
|
||||
|
||||
boolean updatePlugin(PluginItem pluginItem);
|
||||
|
||||
List<PluginItem> searchPluginToolByPluginId(BigInteger pluginId, BigInteger botId);
|
||||
|
||||
List<PluginItem> getPluginToolList(BigInteger botId);
|
||||
|
||||
Result pluginToolTest(String inputData, BigInteger pluginToolId);
|
||||
|
||||
List<PluginItem> getByPluginId(String id);
|
||||
|
||||
@@ -26,16 +26,16 @@ public interface ResourceOfflineImpactService {
|
||||
OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId);
|
||||
|
||||
/**
|
||||
* 工作流下线后,静默解绑所有关联 Bot。
|
||||
* 工作流下线后,静默解绑所有关联 Agent。
|
||||
*
|
||||
* @param workflowId 工作流 ID
|
||||
*/
|
||||
void unbindWorkflowFromBots(BigInteger workflowId);
|
||||
void unbindWorkflowFromAgents(BigInteger workflowId);
|
||||
|
||||
/**
|
||||
* 知识库下线后,静默解绑所有关联 Bot。
|
||||
* 知识库下线后,静默解绑所有关联 Agent。
|
||||
*
|
||||
* @param knowledgeId 知识库 ID
|
||||
*/
|
||||
void unbindKnowledgeFromBots(BigInteger knowledgeId);
|
||||
void unbindKnowledgeFromAgents(BigInteger knowledgeId);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package tech.easyflow.ai.service.impl;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
@@ -88,31 +87,6 @@ public class AiResourceApprovalStateServiceImpl implements AiResourceApprovalSta
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void fillBotApprovalState(Bot bot) {
|
||||
fillBotApprovalState(bot == null ? List.of() : List.of(bot));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void fillBotApprovalState(Collection<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 <T> 资源类型
|
||||
*/
|
||||
private <T> void fillApprovalState(Collection<T> resources,
|
||||
<T> void fillApprovalState(Collection<T> resources,
|
||||
String resourceType,
|
||||
Function<T, BigInteger> instanceIdGetter,
|
||||
Function<T, PublishStatus> statusGetter,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.ai.entity.BotPlugin;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.mapper.BotPluginMapper;
|
||||
import tech.easyflow.ai.mapper.PluginItemMapper;
|
||||
import tech.easyflow.ai.mapper.PluginMapper;
|
||||
import tech.easyflow.ai.service.BotPluginService;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
@@ -40,6 +42,9 @@ public class BotPluginServiceImpl extends ServiceImpl<BotPluginMapper, BotPlugin
|
||||
@Resource
|
||||
private PluginMapper pluginMapper;
|
||||
|
||||
@Resource
|
||||
private PluginItemMapper pluginItemMapper;
|
||||
|
||||
@Resource
|
||||
private RedisLockExecutor redisLockExecutor;
|
||||
|
||||
@@ -97,4 +102,50 @@ public class BotPluginServiceImpl extends ServiceImpl<BotPluginMapper, BotPlugin
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,7 +568,7 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
queryWrapper.eq(BotMcp::getBotId, botId);
|
||||
List<BotMcp> botMcpList = botMcpService.getMapper().selectListWithRelationsByQuery(queryWrapper);
|
||||
botMcpList.forEach(botMcp -> {
|
||||
Tool tool = mcpService.toFunction(botMcp);
|
||||
Tool tool = mcpService.toFunction(botMcp.getMcpId(), botMcp.getMcpToolName());
|
||||
functionList.add(tool);
|
||||
});
|
||||
|
||||
|
||||
@@ -540,7 +540,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
||||
public boolean updateById(DocumentCollection entity) {
|
||||
DocumentCollection documentCollection = getById(entity.getId());
|
||||
if (documentCollection == null) {
|
||||
throw new BusinessException("bot 不存在");
|
||||
throw new BusinessException("知识库不存在");
|
||||
}
|
||||
|
||||
CustomBeanUtils.copyPropertiesIgnoreNull(entity, documentCollection);
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.ai.easyagents.tool.McpTool;
|
||||
import tech.easyflow.ai.entity.BotMcp;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.ai.mapper.McpMapper;
|
||||
import tech.easyflow.ai.mcp.McpTransportType;
|
||||
@@ -29,6 +28,7 @@ import tech.easyflow.common.util.StringUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
@@ -163,15 +163,15 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
|
||||
}
|
||||
|
||||
@Override
|
||||
public Tool toFunction(BotMcp botMcp) {
|
||||
Mcp mcpInfo = this.getById(botMcp.getMcpId());
|
||||
public Tool toFunction(BigInteger mcpId, String mcpToolName) {
|
||||
Mcp mcpInfo = this.getById(mcpId);
|
||||
String configJson = mcpInfo.getConfigJson();
|
||||
String mcpServerName = getFirstMcpServerName(configJson);
|
||||
if (StringUtil.hasText(mcpServerName)) {
|
||||
McpSyncClient mcpClient = mcpClientManager.getMcpClient(mcpServerName);
|
||||
List<McpSchema.Tool> tools = mcpClient.listTools().tools();
|
||||
for (McpSchema.Tool tool : tools) {
|
||||
if (tool.name().equals(botMcp.getMcpToolName())) {
|
||||
if (tool.name().equals(mcpToolName)) {
|
||||
Map<String, Object> properties = tool.inputSchema().properties();
|
||||
List<String> required = tool.inputSchema().required();
|
||||
McpTool mcpTool = new McpTool();
|
||||
|
||||
@@ -200,7 +200,10 @@ public class ModelServiceImpl extends ServiceImpl<ModelMapper, Model> implements
|
||||
|
||||
@Override
|
||||
public void removeByEntity(Model entity) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(Model::getProviderId, entity.getProviderId()).eq(Model::getGroupName, entity.getGroupName());
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(Model::getProviderId, entity.getProviderId())
|
||||
.eq(Model::getGroupName, entity.getGroupName())
|
||||
.eq(Model::getTenantId, entity.getTenantId());
|
||||
modelMapper.deleteByQuery(queryWrapper);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package tech.easyflow.ai.service.impl;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.ai.entity.BotPlugin;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
@@ -11,10 +11,10 @@ import tech.easyflow.ai.enums.PluginType;
|
||||
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision;
|
||||
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
|
||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.mapper.BotPluginMapper;
|
||||
import tech.easyflow.ai.mapper.PluginMapper;
|
||||
import tech.easyflow.ai.mapper.PluginItemMapper;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
@@ -26,8 +26,6 @@ import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
|
||||
import static tech.easyflow.ai.entity.table.BotPluginTableDef.BOT_PLUGIN;
|
||||
|
||||
/**
|
||||
* 服务层实现。
|
||||
*
|
||||
@@ -43,19 +41,35 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
|
||||
@Resource
|
||||
private PluginMapper pluginMapper;
|
||||
|
||||
@Resource
|
||||
private BotPluginMapper botPluginMapper;
|
||||
@Resource
|
||||
private WorkflowPluginAvailabilityService workflowPluginAvailabilityService;
|
||||
@Resource
|
||||
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@Resource
|
||||
private PluginVisibilityService pluginVisibilityService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean savePluginTool(PluginItem pluginItem) {
|
||||
Plugin plugin = pluginMapper.selectOneById(pluginItem.getPluginId());
|
||||
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
|
||||
if (pluginItem == null || pluginItem.getPluginId() == null) {
|
||||
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("工作流插件工具由系统自动维护,不支持手动新增");
|
||||
}
|
||||
pluginItem.setCreated(new Date());
|
||||
@@ -100,41 +114,6 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
|
||||
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
|
||||
public Result<?> pluginToolTest(String inputData, BigInteger pluginToolId) {
|
||||
PluginItem pluginItem = pluginItemMapper.selectOneById(pluginToolId);
|
||||
|
||||
@@ -15,10 +15,10 @@ import tech.easyflow.ai.mapper.PluginMapper;
|
||||
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision;
|
||||
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
|
||||
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
|
||||
import tech.easyflow.ai.service.BotPluginService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.PluginService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
@@ -57,9 +57,6 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
@Resource
|
||||
PluginCategoryMappingMapper pluginCategoryMappingMapper;
|
||||
|
||||
@Resource
|
||||
private BotPluginService botPluginService;
|
||||
|
||||
@Resource
|
||||
private PluginItemService pluginItemService;
|
||||
@Resource
|
||||
@@ -70,6 +67,8 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
private WorkflowPluginBindingService workflowPluginBindingService;
|
||||
@Resource
|
||||
private WorkflowPluginAvailabilityService workflowPluginAvailabilityService;
|
||||
@Resource
|
||||
private AgentResourceReferenceService agentResourceReferenceService;
|
||||
|
||||
@Override
|
||||
public Plugin savePlugin(Plugin plugin) {
|
||||
@@ -87,25 +86,34 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean removePlugin(String id) {
|
||||
Plugin plugin = pluginMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq(Plugin::getId, id)
|
||||
.forUpdate());
|
||||
if (plugin == null) {
|
||||
throw new BusinessException("插件不存在");
|
||||
}
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
if (loginAccount == null || loginAccount.getTenantId() == null || plugin.getTenantId() == null
|
||||
|| !loginAccount.getTenantId().toString().equals(plugin.getTenantId().toString())) {
|
||||
throw new BusinessException("无权限删除该插件");
|
||||
}
|
||||
pluginVisibilityService.assertPluginVisible(
|
||||
plugin.getCreatedBy(), plugin.getId(), "无权限删除该插件");
|
||||
|
||||
List<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<>();
|
||||
|
||||
if (pluginItems != null && !pluginItems.isEmpty()) {
|
||||
|
||||
pluginToolIds = pluginItems.stream().map(PluginItem::getId).collect(Collectors.toList());
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
queryWrapper.in(BotPlugin::getPluginItemId, pluginToolIds);
|
||||
boolean exists = botPluginService.exists(queryWrapper);
|
||||
|
||||
if (exists){
|
||||
throw new BusinessException("插件中有工具还关联着bot,请先取消关联!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
agentResourceReferenceService.assertPluginItemsUnused(pluginToolIds);
|
||||
if (!pluginToolIds.isEmpty()) {
|
||||
boolean result = pluginItemService.removeByIds(pluginToolIds);
|
||||
if (!result) {
|
||||
@@ -114,7 +122,6 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
int remove = pluginMapper.deleteById(id);
|
||||
if (remove <= 0) {
|
||||
log.error("删除插件结果为0");
|
||||
@@ -122,7 +129,6 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -5,65 +5,52 @@ import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.entity.BotDocumentCollection;
|
||||
import tech.easyflow.ai.entity.BotWorkflow;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
|
||||
import tech.easyflow.ai.service.BotDocumentCollectionService;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
import tech.easyflow.ai.service.BotWorkflowService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.ResourceOfflineImpactService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
|
||||
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 资源下线影响检查与 Bot 静默解绑实现。
|
||||
* 资源下线影响检查与 Agent 静默解绑实现。
|
||||
*/
|
||||
@Service
|
||||
public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactService {
|
||||
|
||||
private static final String KNOWLEDGE_NODE_TYPE = "knowledgeNode";
|
||||
private static final String BOT_BINDING_LOCK_KEY_PREFIX = "easyflow:lock:bot:binding:";
|
||||
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private final BotWorkflowService botWorkflowService;
|
||||
private final BotDocumentCollectionService botDocumentCollectionService;
|
||||
private final BotService botService;
|
||||
private final WorkflowService workflowService;
|
||||
private final RedisLockExecutor redisLockExecutor;
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final WorkflowPluginDependencyService workflowPluginDependencyService;
|
||||
private final AgentResourceReferenceService agentResourceReferenceService;
|
||||
|
||||
public ResourceOfflineImpactServiceImpl(BotWorkflowService botWorkflowService,
|
||||
BotDocumentCollectionService botDocumentCollectionService,
|
||||
BotService botService,
|
||||
WorkflowService workflowService,
|
||||
RedisLockExecutor redisLockExecutor,
|
||||
WorkflowPluginDependencyService workflowPluginDependencyService) {
|
||||
this.botWorkflowService = botWorkflowService;
|
||||
this.botDocumentCollectionService = botDocumentCollectionService;
|
||||
this.botService = botService;
|
||||
/**
|
||||
* 创建资源下线影响服务。
|
||||
*
|
||||
* @param workflowService 工作流服务
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param workflowPluginDependencyService 工作流插件依赖服务
|
||||
* @param agentResourceReferenceService Agent 资源引用服务
|
||||
*/
|
||||
public ResourceOfflineImpactServiceImpl(WorkflowService workflowService,
|
||||
DocumentCollectionService documentCollectionService,
|
||||
WorkflowPluginDependencyService workflowPluginDependencyService,
|
||||
AgentResourceReferenceService agentResourceReferenceService) {
|
||||
this.workflowService = workflowService;
|
||||
this.redisLockExecutor = redisLockExecutor;
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.workflowPluginDependencyService = workflowPluginDependencyService;
|
||||
this.agentResourceReferenceService = agentResourceReferenceService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,17 +58,18 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
*/
|
||||
@Override
|
||||
public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) {
|
||||
List<OfflineImpactBindingVo> botBindings = listBotsByWorkflowId(workflowId);
|
||||
List<OfflineImpactBindingVo> pluginBindings = workflowPluginDependencyService.listPluginsByWorkflowId(workflowId);
|
||||
List<OfflineImpactBindingVo> agentBindings = listAgentsByWorkflowId(workflowId);
|
||||
List<OfflineImpactBindingVo> pluginBindings =
|
||||
workflowPluginDependencyService.listPluginsByWorkflowId(workflowId);
|
||||
OfflineImpactCheckVo result = new OfflineImpactCheckVo();
|
||||
result.setCanProceed(true);
|
||||
result.setBotBindings(botBindings);
|
||||
result.setHasBotBindings(!botBindings.isEmpty());
|
||||
result.setAgentBindings(agentBindings);
|
||||
result.setHasAgentBindings(!agentBindings.isEmpty());
|
||||
result.setPluginBindings(pluginBindings);
|
||||
result.setHasPluginBindings(!pluginBindings.isEmpty());
|
||||
result.setWorkflowUsages(Collections.emptyList());
|
||||
result.setHasWorkflowUsages(false);
|
||||
result.setMessage(resolveWorkflowOfflineImpactMessage(botBindings, pluginBindings));
|
||||
result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, pluginBindings));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -90,18 +78,18 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
*/
|
||||
@Override
|
||||
public OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId) {
|
||||
List<OfflineImpactBindingVo> botBindings = listBotsByKnowledgeId(knowledgeId);
|
||||
List<OfflineImpactBindingVo> agentBindings = listAgentsByKnowledgeId(knowledgeId);
|
||||
List<OfflineImpactBindingVo> workflowUsages = listWorkflowsUsingKnowledge(knowledgeId);
|
||||
OfflineImpactCheckVo result = new OfflineImpactCheckVo();
|
||||
result.setBotBindings(botBindings);
|
||||
result.setHasBotBindings(!botBindings.isEmpty());
|
||||
result.setAgentBindings(agentBindings);
|
||||
result.setHasAgentBindings(!agentBindings.isEmpty());
|
||||
result.setWorkflowUsages(workflowUsages);
|
||||
result.setHasWorkflowUsages(!workflowUsages.isEmpty());
|
||||
result.setCanProceed(workflowUsages.isEmpty());
|
||||
result.setMessage(workflowUsages.isEmpty()
|
||||
? (botBindings.isEmpty()
|
||||
? (agentBindings.isEmpty()
|
||||
? "当前知识库下线后不会影响已有绑定"
|
||||
: "当前知识库下线成功后,将自动从相关聊天助手中解绑")
|
||||
: "当前知识库下线成功后,将自动从相关智能体中解绑")
|
||||
: "当前知识库仍被工作流使用,请先调整工作流后再下线");
|
||||
return result;
|
||||
}
|
||||
@@ -110,84 +98,53 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void unbindWorkflowFromBots(BigInteger workflowId) {
|
||||
List<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);
|
||||
}
|
||||
);
|
||||
}
|
||||
public void unbindWorkflowFromAgents(BigInteger workflowId) {
|
||||
agentResourceReferenceService.unbindWorkflow(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void unbindKnowledgeFromBots(BigInteger 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
public void unbindKnowledgeFromAgents(BigInteger knowledgeId) {
|
||||
agentResourceReferenceService.unbindKnowledge(knowledgeId);
|
||||
}
|
||||
|
||||
private List<OfflineImpactBindingVo> listBotsByWorkflowId(BigInteger workflowId) {
|
||||
List<BotWorkflow> relations = botWorkflowService.list(QueryWrapper.create()
|
||||
.eq(BotWorkflow::getWorkflowId, workflowId));
|
||||
return listBotsByIds(collectBotIds(relations, BotWorkflow::getBotId));
|
||||
/**
|
||||
* 汇总绑定指定工作流的 Agent。
|
||||
*
|
||||
* @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()
|
||||
.eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId));
|
||||
return listBotsByIds(collectBotIds(relations, BotDocumentCollection::getBotId));
|
||||
}
|
||||
|
||||
private List<OfflineImpactBindingVo> listBotsByIds(Set<BigInteger> botIds) {
|
||||
if (botIds.isEmpty()) {
|
||||
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;
|
||||
/**
|
||||
* 汇总绑定指定知识库的 Agent。
|
||||
*
|
||||
* @param knowledgeId 知识库 ID
|
||||
* @return 去重后的 Agent 摘要
|
||||
*/
|
||||
private List<OfflineImpactBindingVo> listAgentsByKnowledgeId(BigInteger knowledgeId) {
|
||||
return agentResourceReferenceService.listAgentsByKnowledgeId(knowledgeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询仍在设计内容中引用指定知识库的工作流。
|
||||
*
|
||||
* @param knowledgeId 知识库 ID
|
||||
* @return 工作流摘要
|
||||
*/
|
||||
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()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -197,26 +154,43 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
continue;
|
||||
}
|
||||
if (containsKnowledgeReference(workflow.getContent(), knowledgeId)) {
|
||||
result.add(toBindingVo(workflow.getId(), workflow.getTitle()));
|
||||
OfflineImpactBindingVo item = new OfflineImpactBindingVo();
|
||||
item.setId(workflow.getId());
|
||||
item.setTitle(workflow.getTitle());
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String resolveWorkflowOfflineImpactMessage(List<OfflineImpactBindingVo> botBindings,
|
||||
/**
|
||||
* 生成工作流下线影响提示。
|
||||
*
|
||||
* @param agentBindings Agent 绑定
|
||||
* @param pluginBindings 插件绑定
|
||||
* @return 提示信息
|
||||
*/
|
||||
private String resolveWorkflowOfflineImpactMessage(List<OfflineImpactBindingVo> agentBindings,
|
||||
List<OfflineImpactBindingVo> pluginBindings) {
|
||||
if (!pluginBindings.isEmpty() && !botBindings.isEmpty()) {
|
||||
return "当前工作流被插件和聊天助手引用,下线后插件将不可用,聊天助手将自动解绑";
|
||||
if (!pluginBindings.isEmpty() && !agentBindings.isEmpty()) {
|
||||
return "当前工作流被插件和智能体引用,下线后插件将不可用,智能体将自动解绑";
|
||||
}
|
||||
if (!pluginBindings.isEmpty()) {
|
||||
return "当前工作流被插件引用,下线后相关插件将不可用";
|
||||
}
|
||||
if (!botBindings.isEmpty()) {
|
||||
return "当前工作流下线成功后,将自动从相关聊天助手中解绑";
|
||||
if (!agentBindings.isEmpty()) {
|
||||
return "当前工作流下线成功后,将自动从相关智能体中解绑";
|
||||
}
|
||||
return "当前工作流下线后不会影响已有绑定";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断工作流内容是否引用指定知识库。
|
||||
*
|
||||
* @param content 工作流内容
|
||||
* @param knowledgeId 知识库 ID
|
||||
* @return 是否引用
|
||||
*/
|
||||
private boolean containsKnowledgeReference(String content, BigInteger knowledgeId) {
|
||||
if (!StringUtils.hasText(content) || knowledgeId == null) {
|
||||
return false;
|
||||
@@ -224,7 +198,7 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
try {
|
||||
Object parsed = JSON.parse(content);
|
||||
if (!(parsed instanceof JSONObject root)) {
|
||||
return false;
|
||||
throw new BusinessException("工作流定义格式异常,无法确认知识库下线影响");
|
||||
}
|
||||
JSONArray nodes = root.getJSONArray("nodes");
|
||||
if (nodes == null || nodes.isEmpty()) {
|
||||
@@ -246,73 +220,10 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("工作流定义解析失败,无法确认知识库下线影响");
|
||||
}
|
||||
}
|
||||
|
||||
private void trimPublishedSnapshotBindings(BigInteger botId,
|
||||
String bindingsKey,
|
||||
String idKey,
|
||||
BigInteger resourceId) {
|
||||
Bot bot = botService.getById(botId);
|
||||
if (bot == null || bot.getPublishedSnapshotJson() == null || bot.getPublishedSnapshotJson().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ public class OfflineImpactCheckVo {
|
||||
|
||||
private boolean canProceed;
|
||||
|
||||
private boolean hasBotBindings;
|
||||
private boolean hasAgentBindings;
|
||||
|
||||
private boolean hasWorkflowUsages;
|
||||
|
||||
private boolean hasPluginBindings;
|
||||
|
||||
private List<OfflineImpactBindingVo> botBindings = new ArrayList<>();
|
||||
private List<OfflineImpactBindingVo> agentBindings = new ArrayList<>();
|
||||
|
||||
private List<OfflineImpactBindingVo> workflowUsages = new ArrayList<>();
|
||||
|
||||
@@ -43,21 +43,21 @@ public class OfflineImpactCheckVo {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否存在 Bot 绑定。
|
||||
* 是否存在 Agent 绑定。
|
||||
*
|
||||
* @return 是否存在 Bot 绑定
|
||||
* @return 是否存在 Agent 绑定
|
||||
*/
|
||||
public boolean isHasBotBindings() {
|
||||
return hasBotBindings;
|
||||
public boolean isHasAgentBindings() {
|
||||
return hasAgentBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否存在 Bot 绑定。
|
||||
* 设置是否存在 Agent 绑定。
|
||||
*
|
||||
* @param hasBotBindings 是否存在 Bot 绑定
|
||||
* @param hasAgentBindings 是否存在 Agent 绑定
|
||||
*/
|
||||
public void setHasBotBindings(boolean hasBotBindings) {
|
||||
this.hasBotBindings = hasBotBindings;
|
||||
public void setHasAgentBindings(boolean hasAgentBindings) {
|
||||
this.hasAgentBindings = hasAgentBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,21 +79,21 @@ public class OfflineImpactCheckVo {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Bot 绑定列表。
|
||||
* 获取 Agent 绑定列表。
|
||||
*
|
||||
* @return Bot 绑定列表
|
||||
* @return Agent 绑定列表
|
||||
*/
|
||||
public List<OfflineImpactBindingVo> getBotBindings() {
|
||||
return botBindings;
|
||||
public List<OfflineImpactBindingVo> getAgentBindings() {
|
||||
return agentBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Bot 绑定列表。
|
||||
* 设置 Agent 绑定列表。
|
||||
*
|
||||
* @param botBindings Bot 绑定列表
|
||||
* @param agentBindings Agent 绑定列表
|
||||
*/
|
||||
public void setBotBindings(List<OfflineImpactBindingVo> botBindings) {
|
||||
this.botBindings = botBindings;
|
||||
public void setAgentBindings(List<OfflineImpactBindingVo> agentBindings) {
|
||||
this.agentBindings = agentBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import java.math.BigInteger;
|
||||
@Service
|
||||
public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
||||
|
||||
private static final String ADMIN_ASSISTANT_CODE = "AGENT";
|
||||
private static final String AGENT_ASSISTANT_CODE = "AGENT";
|
||||
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatSessionCommandService chatSessionCommandService;
|
||||
@@ -43,24 +43,26 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
||||
|
||||
@Override
|
||||
public ChatSessionPage queryUserSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
|
||||
return chatSessionQueryService.pageSessions(userId, assistantId, query);
|
||||
return chatSessionQueryService.pageSessions(userId, assistantId, AGENT_ASSISTANT_CODE, query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatSessionPage queryAdminSessions(ChatSessionFilterQuery query) {
|
||||
ChatSessionFilterQuery effectiveQuery = query == null ? new ChatSessionFilterQuery() : query;
|
||||
// 管理端聊天历史已经切换为 Agent 专属入口,类型由服务端固定,避免客户端绕过。
|
||||
effectiveQuery.setAssistantCode(ADMIN_ASSISTANT_CODE);
|
||||
effectiveQuery.setAssistantCode(AGENT_ASSISTANT_CODE);
|
||||
return chatAnalyticalDBRepository.pageSessions(effectiveQuery);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatSessionSummary getUserSession(BigInteger userId, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || summary.getIsDeleted() != null && summary.getIsDeleted() == 1) {
|
||||
throw new BusinessException("会话不存在");
|
||||
if (summary == null
|
||||
|| Integer.valueOf(1).equals(summary.getIsDeleted())
|
||||
|| !AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
if (!summary.getUserId().equals(userId)) {
|
||||
if (summary.getUserId() == null || !summary.getUserId().equals(userId)) {
|
||||
throw new BusinessException("无权访问该会话");
|
||||
}
|
||||
return summary;
|
||||
@@ -71,7 +73,7 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
||||
ChatSessionSummary summary = chatAnalyticalDBRepository.getSession(sessionId);
|
||||
if (summary == null
|
||||
|| Integer.valueOf(1).equals(summary.getIsDeleted())
|
||||
|| !ADMIN_ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
|| !AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
return summary;
|
||||
|
||||
@@ -19,6 +19,7 @@ import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.common.analyticaldb.core.AnalyticalDBOperations;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
|
||||
@@ -28,6 +29,7 @@ import java.math.BigInteger;
|
||||
public class ChatHistoryManageServiceImplTest {
|
||||
|
||||
private StubChatAnalyticalDBRepository chatAnalyticalDBRepository;
|
||||
private StubChatSessionQueryHandler chatSessionQueryHandler;
|
||||
private ChatHistoryManageServiceImpl service;
|
||||
|
||||
/**
|
||||
@@ -35,7 +37,8 @@ public class ChatHistoryManageServiceImplTest {
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
ChatSessionQueryService chatSessionQueryService = unusedDependency(ChatSessionQueryService.class);
|
||||
chatSessionQueryHandler = new StubChatSessionQueryHandler();
|
||||
ChatSessionQueryService chatSessionQueryService = chatSessionQueryHandler.createProxy();
|
||||
ChatSessionCommandService chatSessionCommandService = unusedDependency(ChatSessionCommandService.class);
|
||||
ChatHistoryQueryService chatHistoryQueryService = unusedDependency(ChatHistoryQueryService.class);
|
||||
ChatRoundOperateService chatRoundOperateService = unusedDependency(ChatRoundOperateService.class);
|
||||
@@ -49,6 +52,56 @@ public class ChatHistoryManageServiceImplTest {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户端列表始终按 Agent 类型查询。
|
||||
*/
|
||||
@Test
|
||||
public void queryUserSessionsShouldForceAgentAssistantCode() {
|
||||
BigInteger userId = BigInteger.valueOf(2001);
|
||||
BigInteger agentId = BigInteger.valueOf(3001);
|
||||
ChatPageQuery query = new ChatPageQuery();
|
||||
|
||||
service.queryUserSessions(userId, agentId, query);
|
||||
|
||||
Assert.assertEquals(userId, chatSessionQueryHandler.lastUserId);
|
||||
Assert.assertEquals(agentId, chatSessionQueryHandler.lastAssistantId);
|
||||
Assert.assertEquals("AGENT", chatSessionQueryHandler.lastAssistantCode);
|
||||
Assert.assertSame(query, chatSessionQueryHandler.lastPageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户端拒绝读取归属于当前用户的旧 Bot 会话。
|
||||
*/
|
||||
@Test
|
||||
public void getUserSessionShouldRejectBotSession() {
|
||||
BigInteger userId = BigInteger.valueOf(2002);
|
||||
ChatSessionSummary summary = session(BigInteger.valueOf(3002), "BOT", 0);
|
||||
summary.setUserId(userId);
|
||||
chatSessionQueryHandler.sessionResult = summary;
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> service.getUserSession(userId, summary.getId())
|
||||
);
|
||||
|
||||
Assert.assertEquals("Agent 会话不存在", exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户端可以读取归属于当前用户的 Agent 会话。
|
||||
*/
|
||||
@Test
|
||||
public void getUserSessionShouldReturnOwnedAgentSession() {
|
||||
BigInteger userId = BigInteger.valueOf(2003);
|
||||
ChatSessionSummary summary = session(BigInteger.valueOf(3003), "AGENT", 0);
|
||||
summary.setUserId(userId);
|
||||
chatSessionQueryHandler.sessionResult = summary;
|
||||
|
||||
ChatSessionSummary result = service.getUserSession(userId, summary.getId());
|
||||
|
||||
Assert.assertSame(summary, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证管理端列表始终覆盖客户端传入的会话类型为 Agent。
|
||||
*/
|
||||
@@ -197,6 +250,54 @@ public class ChatHistoryManageServiceImplTest {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户端会话查询依赖桩。
|
||||
*/
|
||||
private static class StubChatSessionQueryHandler implements InvocationHandler {
|
||||
|
||||
private BigInteger lastUserId;
|
||||
private BigInteger lastAssistantId;
|
||||
private String lastAssistantCode;
|
||||
private ChatPageQuery lastPageQuery;
|
||||
private ChatSessionSummary sessionResult;
|
||||
|
||||
/**
|
||||
* 创建查询服务代理。
|
||||
*
|
||||
* @return 查询服务代理
|
||||
*/
|
||||
private ChatSessionQueryService createProxy() {
|
||||
return (ChatSessionQueryService) Proxy.newProxyInstance(
|
||||
ChatSessionQueryService.class.getClassLoader(),
|
||||
new Class<?>[]{ChatSessionQueryService.class},
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理测试所需的查询方法。
|
||||
*
|
||||
* @param proxy 代理对象
|
||||
* @param method 被调用方法
|
||||
* @param args 调用参数
|
||||
* @return 方法返回值
|
||||
*/
|
||||
@Override
|
||||
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
|
||||
if ("pageSessions".equals(method.getName()) && args != null && args.length == 4) {
|
||||
lastUserId = (BigInteger) args[0];
|
||||
lastAssistantId = (BigInteger) args[1];
|
||||
lastAssistantCode = (String) args[2];
|
||||
lastPageQuery = (ChatPageQuery) args[3];
|
||||
return new ChatSessionPage();
|
||||
}
|
||||
if ("getSessionSummary".equals(method.getName())) {
|
||||
return sessionResult;
|
||||
}
|
||||
throw new AssertionError("测试路径不应调用查询方法: " + method.getName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅记录管理端会话查询参数和返回值的分析库仓储桩。
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,9 @@ import java.util.List;
|
||||
@Service
|
||||
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
|
||||
private SysApiKeyResourceMappingService mappingService;
|
||||
@Resource
|
||||
@@ -57,7 +60,10 @@ public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey
|
||||
private List<String> getCandidateRequestUris(String requestURI) {
|
||||
List<String> uris = new ArrayList<>();
|
||||
uris.add(requestURI);
|
||||
if ("/v1/chat/completions".equals(requestURI)) {
|
||||
if (PUBLIC_AGENT_CHAT_URI.equals(requestURI)) {
|
||||
// 数据库权限资源暂不迁移,Agent 公共接口复用旧资源记录作为兼容别名。
|
||||
uris.add(LEGACY_PUBLIC_BOT_CHAT_URI);
|
||||
} else if ("/v1/chat/completions".equals(requestURI)) {
|
||||
uris.add("/public-api/openai/v1/chat/completions");
|
||||
} else if ("/public-api/openai/v1/chat/completions".equals(requestURI)) {
|
||||
uris.add("/v1/chat/completions");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface DashboardUserRankQuery extends DashboardOverviewQuery {
|
||||
export interface DashboardSummary {
|
||||
activeUserTotal: number;
|
||||
activeAssistantTotal: number;
|
||||
botTotal: number;
|
||||
agentTotal: number;
|
||||
chatActiveUserTotal: number;
|
||||
chatMessageTotal: number;
|
||||
chatSessionTotal: number;
|
||||
@@ -55,7 +55,6 @@ export interface DashboardDistributionItem {
|
||||
assistantId?: number | string;
|
||||
avgMessagePerSession?: number;
|
||||
avgSessionPerUser?: number;
|
||||
botTotal: number;
|
||||
key: string;
|
||||
knowledgeBaseTotal: number;
|
||||
label: string;
|
||||
|
||||
@@ -96,11 +96,11 @@
|
||||
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
|
||||
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
|
||||
"submitDeleteApprovalConfirm": "Delete the current workflow?",
|
||||
"offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:",
|
||||
"offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.",
|
||||
"offlineImpactBoundAgentsIntro": "This workflow is currently bound to the following agents:",
|
||||
"offlineImpactBoundAgentsFooter": "After the workflow goes offline, the system will automatically remove it from these agents.",
|
||||
"offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:",
|
||||
"offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.",
|
||||
"offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from bots and mark the related plugins as unavailable.",
|
||||
"offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from agents and mark the related plugins as unavailable.",
|
||||
"publishPendingHint": "There is already an approval in progress for this workflow.",
|
||||
"deletePendingHint": "There is already an approval in progress for this workflow.",
|
||||
"check": "Check",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"initiated": "Initiated"
|
||||
},
|
||||
"resource": {
|
||||
"agent": "Agent",
|
||||
"bot": "Chat Assistant",
|
||||
"workflow": "Workflow",
|
||||
"knowledge": "Knowledge Base"
|
||||
@@ -144,6 +145,8 @@
|
||||
"knowledgeBasic": "Basic Info",
|
||||
"knowledgeConfig": "Retrieval Config",
|
||||
"botOverview": "Assistant Overview",
|
||||
"agentModelConfig": "Model Config",
|
||||
"agentBindings": "Capability Bindings",
|
||||
"botModelConfig": "Model Config",
|
||||
"botBindings": "Capability Bindings",
|
||||
"systemPrompt": "System Prompt",
|
||||
@@ -168,6 +171,7 @@
|
||||
"notConfigured": "Not configured",
|
||||
"noBindings": "No bindings",
|
||||
"untitledKnowledge": "Untitled knowledge base",
|
||||
"untitledAgent": "Untitled agent",
|
||||
"untitledBot": "Untitled assistant",
|
||||
"unnamedKnowledge": "Unnamed knowledge base",
|
||||
"unnamedWorkflow": "Unnamed workflow",
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
"submitRepublishApprovalConfirm": "Republish the current knowledge base now?",
|
||||
"submitOfflineApprovalConfirm": "Take the current knowledge base offline?",
|
||||
"submitDeleteApprovalConfirm": "Delete the current knowledge base?",
|
||||
"offlineImpactBoundBotsIntro": "This knowledge base is currently bound to the following bots:",
|
||||
"offlineImpactBoundBotsFooter": "After the knowledge base goes offline, the system will automatically remove it from these bots.",
|
||||
"offlineImpactBoundAgentsIntro": "This knowledge base is currently bound to the following agents:",
|
||||
"offlineImpactBoundAgentsFooter": "After the knowledge base goes offline, the system will automatically remove it from these agents.",
|
||||
"offlineImpactWorkflowBlockedIntro": "This knowledge base is still used by the following workflows:",
|
||||
"offlineImpactWorkflowBlockedFooter": "Please update those workflow nodes before taking the knowledge base offline.",
|
||||
"publishPendingHint": "There is already an approval in progress for this knowledge base.",
|
||||
|
||||
@@ -96,11 +96,11 @@
|
||||
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
|
||||
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
|
||||
"submitDeleteApprovalConfirm": "确认删除当前工作流吗?",
|
||||
"offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:",
|
||||
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。",
|
||||
"offlineImpactBoundAgentsIntro": "当前工作流被以下智能体绑定:",
|
||||
"offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该工作流。",
|
||||
"offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:",
|
||||
"offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。",
|
||||
"offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从聊天助手中解绑该工作流,同时让相关插件进入不可用状态。",
|
||||
"offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从智能体中解绑该工作流,同时让相关插件进入不可用状态。",
|
||||
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
|
||||
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
|
||||
"check": "检查",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"initiated": "我发起"
|
||||
},
|
||||
"resource": {
|
||||
"agent": "智能体",
|
||||
"bot": "聊天助手",
|
||||
"workflow": "工作流",
|
||||
"knowledge": "知识库"
|
||||
@@ -144,6 +145,8 @@
|
||||
"knowledgeBasic": "基础信息",
|
||||
"knowledgeConfig": "检索配置",
|
||||
"botOverview": "助手概览",
|
||||
"agentModelConfig": "模型配置",
|
||||
"agentBindings": "能力绑定",
|
||||
"botModelConfig": "模型配置",
|
||||
"botBindings": "能力绑定",
|
||||
"systemPrompt": "系统提示词",
|
||||
@@ -168,6 +171,7 @@
|
||||
"notConfigured": "未配置",
|
||||
"noBindings": "未绑定任何能力",
|
||||
"untitledKnowledge": "未命名知识库",
|
||||
"untitledAgent": "未命名智能体",
|
||||
"untitledBot": "未命名聊天助手",
|
||||
"unnamedKnowledge": "未命名知识库",
|
||||
"unnamedWorkflow": "未命名工作流",
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
"submitRepublishApprovalConfirm": "确认重新发布当前知识库吗?",
|
||||
"submitOfflineApprovalConfirm": "确认下线当前知识库吗?",
|
||||
"submitDeleteApprovalConfirm": "确认删除当前知识库吗?",
|
||||
"offlineImpactBoundBotsIntro": "当前知识库被以下聊天助手绑定:",
|
||||
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该知识库。",
|
||||
"offlineImpactBoundAgentsIntro": "当前知识库被以下智能体绑定:",
|
||||
"offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该知识库。",
|
||||
"offlineImpactWorkflowBlockedIntro": "当前知识库仍被以下工作流使用:",
|
||||
"offlineImpactWorkflowBlockedFooter": "请先在工作流中调整相关知识库节点后再下线。",
|
||||
"publishPendingHint": "当前知识库已有进行中的审批,请等待处理完成。",
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface AgentChatCapabilityPayload {
|
||||
}
|
||||
|
||||
export function getPublishedAgents() {
|
||||
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/list', {
|
||||
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/options', {
|
||||
params: { publishedOnly: true },
|
||||
});
|
||||
}
|
||||
@@ -95,10 +95,7 @@ export function getAgentSession(sessionId: number | string) {
|
||||
|
||||
export function getPublishedKnowledges() {
|
||||
return api.get<RequestResult<AgentChatKnowledgeView[]>>(
|
||||
'/api/v1/documentCollection/list',
|
||||
{
|
||||
params: { publishedOnly: true },
|
||||
},
|
||||
'/api/v1/agent/knowledgeOptions',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,8 @@ import {
|
||||
|
||||
import {
|
||||
getAgentDetail,
|
||||
getAgentModels,
|
||||
getMcpPage,
|
||||
getMcpTools,
|
||||
getPublishedKnowledgeList,
|
||||
getAgentMcpToolOptions,
|
||||
getAgentResourceOptions,
|
||||
saveAgent,
|
||||
submitAgentOfflineApproval,
|
||||
submitAgentPublishApproval,
|
||||
@@ -76,7 +74,7 @@ const workflows = ref<AgentOption[]>([]);
|
||||
const pluginTools = ref<AgentOption[]>([]);
|
||||
const mcps = ref<AgentOption[]>([]);
|
||||
const fetchMcpToolResource = createMcpToolLoader(async (id) => {
|
||||
const res = await getMcpTools(id);
|
||||
const res = await getAgentMcpToolOptions(id);
|
||||
return res.errorCode === 0 ? res.data : undefined;
|
||||
});
|
||||
|
||||
@@ -128,7 +126,6 @@ const offlineDisabled = computed(() => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
void loadDeferredOptions();
|
||||
try {
|
||||
await Promise.all([loadCriticalOptions(), loadAgent()]);
|
||||
} finally {
|
||||
@@ -213,11 +210,11 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
|
||||
}
|
||||
|
||||
async function loadCriticalOptions() {
|
||||
const [categoryResult, modelResult] = await Promise.allSettled([
|
||||
const [categoryResult, resourceResult] = await Promise.allSettled([
|
||||
api.get('/api/v1/agentCategory/visibleList', {
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
}),
|
||||
getAgentModels(),
|
||||
getAgentResourceOptions(),
|
||||
]);
|
||||
|
||||
if (categoryResult.status === 'fulfilled') {
|
||||
@@ -227,71 +224,33 @@ async function loadCriticalOptions() {
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
if (modelResult.status === 'fulfilled') {
|
||||
models.value = (modelResult.value.data || []).map((item: any) => ({
|
||||
if (resourceResult.status === 'fulfilled') {
|
||||
const resources = resourceResult.value.data;
|
||||
if (resourceResult.value.errorCode !== 0 || !resources) {
|
||||
return;
|
||||
}
|
||||
models.value = (resources.models || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeferredOptions() {
|
||||
const [knowledgeResult, workflowResult, pluginResult, mcpResult] =
|
||||
await Promise.allSettled([
|
||||
getPublishedKnowledgeList(),
|
||||
api.get('/api/v1/workflow/page', {
|
||||
params: { pageNumber: 1, pageSize: 200 },
|
||||
}),
|
||||
api.get('/api/v1/plugin/pageByCategory', {
|
||||
params: { pageNumber: 1, pageSize: 200, category: 0 },
|
||||
}),
|
||||
getMcpPage(),
|
||||
]);
|
||||
|
||||
if (knowledgeResult.status === 'fulfilled') {
|
||||
knowledges.value = (knowledgeResult.value.data || []).map((item: any) => ({
|
||||
knowledges.value = (resources.knowledges || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
if (workflowResult.status === 'fulfilled') {
|
||||
workflows.value = (
|
||||
(workflowResult.value.data?.records ||
|
||||
workflowResult.value.data ||
|
||||
[]) as any[]
|
||||
).map((item) => ({
|
||||
workflows.value = (resources.workflows || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
pluginTools.value = (resources.pluginTools || []).map((item: any) => ({
|
||||
label: item.name || item.title,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
mcps.value = mapMcpOptions(resources.mcps || []);
|
||||
}
|
||||
if (pluginResult.status === 'fulfilled') {
|
||||
pluginTools.value = flattenPluginTools(
|
||||
pluginResult.value.data?.records || pluginResult.value.data || [],
|
||||
);
|
||||
}
|
||||
if (mcpResult.status === 'fulfilled') {
|
||||
mcps.value = mapMcpOptions(
|
||||
mcpResult.value.data?.records || mcpResult.value.data || [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function flattenPluginTools(list: any[]): AgentOption[] {
|
||||
const result: AgentOption[] = [];
|
||||
list.forEach((plugin) => {
|
||||
const tools = Array.isArray(plugin.tools) ? plugin.tools : [];
|
||||
tools.forEach((tool: any) => {
|
||||
result.push({
|
||||
label: tool.name || tool.title,
|
||||
value: String(tool.id),
|
||||
raw: { ...tool, pluginName: plugin.name || plugin.title },
|
||||
});
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapMcpOptions(list: any[]): AgentOption[] {
|
||||
@@ -325,8 +284,7 @@ async function loadMcpToolsForOption(id: number | string) {
|
||||
const currentOption = mcps.value.find((item) => String(item.value) === key);
|
||||
const mergedResource = {
|
||||
...currentOption?.raw,
|
||||
...resource,
|
||||
tools: Array.isArray(resource.tools) ? resource.tools : [],
|
||||
tools: Array.isArray(resource) ? resource : [],
|
||||
};
|
||||
if (currentOption) {
|
||||
mcps.value = mcps.value.map((item) =>
|
||||
@@ -334,7 +292,7 @@ async function loadMcpToolsForOption(id: number | string) {
|
||||
? {
|
||||
...item,
|
||||
label:
|
||||
resource.title || resource.name || currentOption.label || 'MCP',
|
||||
currentOption.label || 'MCP',
|
||||
raw: mergedResource,
|
||||
}
|
||||
: item,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {Delete, Edit, Plus, Promotion} from '@element-plus/icons-vue';
|
||||
import {ElMessage, ElMessageBox, ElTag} from 'element-plus';
|
||||
import {tryit} from 'radash';
|
||||
|
||||
import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
||||
import defaultAgentAvatar from '#/assets/defaultUserAvatar.png';
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import PageSide from '#/components/page/PageSide.vue';
|
||||
@@ -262,7 +262,7 @@ async function handleDeleteAction(row: AgentInfo) {
|
||||
<CardList
|
||||
title-field="name"
|
||||
icon-field="avatar"
|
||||
:default-icon="defaultAvatar"
|
||||
:default-icon="defaultAgentAvatar"
|
||||
:data="pageList"
|
||||
:primary-action="primaryAction"
|
||||
:actions="actions"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from './types';
|
||||
import type {
|
||||
AgentInfo,
|
||||
AgentKnowledgeBinding,
|
||||
AgentToolBinding,
|
||||
} from './types';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
@@ -22,10 +26,6 @@ export function updateAgent(agent: AgentInfo) {
|
||||
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(
|
||||
agentId: number | string,
|
||||
bindings: AgentToolBinding[],
|
||||
@@ -98,24 +98,22 @@ export function getAgentCategories() {
|
||||
});
|
||||
}
|
||||
|
||||
export function getAgentModels() {
|
||||
return api.get<RequestResult<any[]>>('/api/v1/model/list', {
|
||||
params: { modelType: 'chatModel', added: true },
|
||||
});
|
||||
export interface AgentResourceOptions {
|
||||
knowledges: any[];
|
||||
mcps: any[];
|
||||
models: any[];
|
||||
pluginTools: any[];
|
||||
workflows: any[];
|
||||
}
|
||||
|
||||
export function getPublishedKnowledgeList() {
|
||||
return api.get<RequestResult<any[]>>('/api/v1/documentCollection/list', {
|
||||
params: { publishedOnly: true },
|
||||
});
|
||||
export function getAgentResourceOptions() {
|
||||
return api.get<RequestResult<AgentResourceOptions>>(
|
||||
'/api/v1/agent/resourceOptions',
|
||||
);
|
||||
}
|
||||
|
||||
export function getMcpPage() {
|
||||
return api.get<RequestResult<any>>('/api/v1/mcp/page', {
|
||||
params: { pageNumber: 1, pageSize: 200, status: 1 },
|
||||
export function getAgentMcpToolOptions(id: number | string) {
|
||||
return api.get<RequestResult<any[]>>('/api/v1/agent/mcpToolOptions', {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMcpTools(id: number | string) {
|
||||
return api.post<RequestResult<any>>('/api/v1/mcp/getMcpTools', { id });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import pageSource from './index.vue?raw';
|
||||
|
||||
describe('管理端智能体聊天历史契约', () => {
|
||||
it('使用智能体候选接口并保留 assistantId 查询参数', () => {
|
||||
expect(pageSource).toContain("'/api/v1/agent/list'");
|
||||
expect(pageSource).toContain("'/api/v1/agent/options'");
|
||||
expect(pageSource).not.toContain('/api/v1/bot/list');
|
||||
expect(pageSource).toContain('label: item.name');
|
||||
expect(pageSource).toContain('assistantId: query.value.assistantId');
|
||||
|
||||
@@ -98,7 +98,7 @@ onMounted(async () => {
|
||||
|
||||
async function fetchAgents() {
|
||||
agentLoading.value = true;
|
||||
const [error, res] = await tryit(api.get)('/api/v1/agent/list');
|
||||
const [error, res] = await tryit(api.get)('/api/v1/agent/options');
|
||||
agentLoading.value = false;
|
||||
if (error || res?.errorCode !== 0) {
|
||||
agentOptions.value = [];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user