feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -24,6 +24,7 @@ import tech.easyflow.common.ai.plugin.PluginParamConverter;
|
||||
import tech.easyflow.common.filestorage.FileStorageManager;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.util.SpringContextUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import com.easyagents.flow.core.util.IoBulkhead;
|
||||
|
||||
import java.io.*;
|
||||
@@ -32,6 +33,8 @@ import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class PluginTool extends BaseTool {
|
||||
|
||||
@@ -43,6 +46,8 @@ public class PluginTool extends BaseTool {
|
||||
private transient PluginItem pluginItemSnapshot;
|
||||
private transient Plugin pluginSnapshot;
|
||||
private static final Logger logger = LoggerFactory.getLogger(PluginTool.class);
|
||||
private static final Pattern INPUT_REFERENCE =
|
||||
Pattern.compile("^\\$\\{input:([A-Za-z0-9_.-]+)}$");
|
||||
|
||||
public PluginTool() {
|
||||
|
||||
@@ -175,18 +180,18 @@ public class PluginTool extends BaseTool {
|
||||
List<Map<String, Object>> headers = getDataList(plugin.getHeaders());
|
||||
Map<String, Object> headersMap = new HashMap<>();
|
||||
for (Map<String, Object> header : headers) {
|
||||
headersMap.put((String) header.get("label"), header.get("value"));
|
||||
headersMap.put((String) header.get("label"), resolveInputReference(header.get("value")));
|
||||
}
|
||||
List<PluginParam> params = new ArrayList<>();
|
||||
|
||||
String authType = plugin.getAuthType();
|
||||
if (!StrUtil.isEmpty(authType) && "apiKey".equals(plugin.getAuthType())){
|
||||
if ("headers".equals(plugin.getPosition())){
|
||||
headersMap.put(plugin.getTokenKey(), plugin.getTokenValue());
|
||||
headersMap.put(plugin.getTokenKey(), resolveInputReference(plugin.getTokenValue()));
|
||||
} else {
|
||||
PluginParam pluginParam = new PluginParam();
|
||||
pluginParam.setName(plugin.getTokenKey());
|
||||
pluginParam.setDefaultValue(plugin.getTokenValue());
|
||||
pluginParam.setDefaultValue(resolveInputReference(plugin.getTokenValue()));
|
||||
pluginParam.setEnabled(true);
|
||||
pluginParam.setRequired(true);
|
||||
pluginParam.setMethod("query");
|
||||
@@ -407,6 +412,29 @@ public class PluginTool extends BaseTool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在实际调用前解析服务端插件输入引用,避免发布快照持久化明文凭据。
|
||||
*
|
||||
* @param rawValue 快照中的字段值
|
||||
* @return 原值或服务端解析后的凭据
|
||||
* @throws BusinessException 引用未配置时抛出
|
||||
*/
|
||||
private Object resolveInputReference(Object rawValue) {
|
||||
if (!(rawValue instanceof String text)) {
|
||||
return rawValue;
|
||||
}
|
||||
Matcher matcher = INPUT_REFERENCE.matcher(text.trim());
|
||||
if (!matcher.matches()) {
|
||||
return rawValue;
|
||||
}
|
||||
String key = matcher.group(1);
|
||||
String value = System.getProperty("plugin.input." + key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new BusinessException("插件输入变量未解析:" + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void processParamWithChildren(Map<String, Object> paramDef, Map<String, Object> argsMap, List<PluginParam> params) {
|
||||
boolean enabled = (boolean) paramDef.get("enabled");
|
||||
if (!enabled){
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
|
||||
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
|
||||
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
@@ -35,6 +36,8 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
||||
private WorkflowExecResultService workflowExecResultService;
|
||||
@Resource
|
||||
private WorkflowExecutionAuditProducer auditProducer;
|
||||
@Resource
|
||||
private FrozenWorkflowDefinitionRegistry frozenWorkflowDefinitionRegistry;
|
||||
|
||||
@Override
|
||||
public void onEvent(Event event, Chain chain) {
|
||||
@@ -245,6 +248,9 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
||||
return null;
|
||||
}
|
||||
String definitionId = definition.getId();
|
||||
if (frozenWorkflowDefinitionRegistry.isFrozen(definitionId)) {
|
||||
return frozenWorkflowDefinitionRegistry.getWorkflow(definitionId);
|
||||
}
|
||||
String workflowId = PublishedWorkflowDefinitionIds.unwrap(definitionId);
|
||||
try {
|
||||
java.math.BigInteger id = new java.math.BigInteger(workflowId);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.node.ConfirmNode;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.node.WorkflowNode;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 构建并校验 Agent 与 Skill 使用的 Workflow 冻结快照。
|
||||
*
|
||||
* <p>Agent Tool 当前按同步调用执行,因此发布投影只能接受不会依赖子工作流热读、
|
||||
* 也不会在内部等待人工确认的定义。独立 Workflow 的发布能力不受此组件限制。</p>
|
||||
*/
|
||||
@Component
|
||||
public class AgentWorkflowSnapshotFactory {
|
||||
|
||||
private final ChainParser chainParser;
|
||||
private final WorkflowDatacenterContentService contentService;
|
||||
|
||||
/**
|
||||
* 创建 Workflow 冻结快照工厂。
|
||||
*
|
||||
* @param chainParser 工作流定义解析器
|
||||
* @param contentService 数据中枢内容准备服务
|
||||
*/
|
||||
public AgentWorkflowSnapshotFactory(ChainParser chainParser,
|
||||
WorkflowDatacenterContentService contentService) {
|
||||
this.chainParser = chainParser;
|
||||
this.contentService = contentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编译并校验一份 Agent 可执行的 Workflow 定义。
|
||||
*
|
||||
* @param workflow 包含完整 content 的 Workflow
|
||||
* @return 已准备内容和解析后的定义
|
||||
* @throws BusinessException 快照不完整或包含同步 Tool 不支持的节点时抛出
|
||||
*/
|
||||
public PreparedWorkflow prepare(Workflow workflow) {
|
||||
if (workflow == null || workflow.getId() == null
|
||||
|| workflow.getContent() == null || workflow.getContent().isBlank()) {
|
||||
throw new BusinessException(409, 4092, "绑定工作流快照不完整,请重新发布工作流");
|
||||
}
|
||||
String preparedContent = contentService.prepareContent(workflow.getContent());
|
||||
ChainDefinition definition;
|
||||
try {
|
||||
definition = chainParser.parse(preparedContent);
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BusinessException(409, 4092, "绑定工作流定义无效,请修复后重新发布", exception);
|
||||
}
|
||||
if (definition.getNodes() != null
|
||||
&& definition.getNodes().stream().anyMatch(WorkflowNode.class::isInstance)) {
|
||||
throw new BusinessException(409, 4092, "Agent 或 Skill 绑定的工作流暂不支持子工作流节点");
|
||||
}
|
||||
if (definition.getNodes() != null
|
||||
&& definition.getNodes().stream().anyMatch(ConfirmNode.class::isInstance)) {
|
||||
throw new BusinessException(409, 4092, "Agent 或 Skill 绑定的工作流暂不支持内部确认节点");
|
||||
}
|
||||
return new PreparedWorkflow(preparedContent, definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段白名单 Workflow 冻结快照。
|
||||
*
|
||||
* @param workflow 已发布 Workflow
|
||||
* @return 仅包含 Runtime 所需字段的快照
|
||||
* @throws BusinessException Workflow 不兼容同步 Agent Tool 时抛出
|
||||
*/
|
||||
public Map<String, Object> snapshot(Workflow workflow) {
|
||||
PreparedWorkflow prepared = prepare(workflow);
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("id", workflow.getId());
|
||||
snapshot.put("title", workflow.getTitle());
|
||||
snapshot.put("description", workflow.getDescription());
|
||||
snapshot.put("englishName", workflow.getEnglishName());
|
||||
snapshot.put("revision", workflow.getRevision());
|
||||
snapshot.put("content", prepared.content());
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 可执行 Workflow 的准备结果。
|
||||
*
|
||||
* @param content 已完成服务端占位处理的定义内容
|
||||
* @param definition 解析后的工作流定义
|
||||
*/
|
||||
public record PreparedWorkflow(String content, ChainDefinition definition) {
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,18 @@ public class ChainDefinitionRepositoryImpl implements ChainDefinitionRepository
|
||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||
@Resource
|
||||
private WorkflowDefinitionCache workflowDefinitionCache;
|
||||
@Resource
|
||||
private FrozenWorkflowDefinitionRegistry frozenWorkflowDefinitionRegistry;
|
||||
|
||||
@Override
|
||||
public ChainDefinition getChainDefinitionById(String id) {
|
||||
ChainDefinition frozen = frozenWorkflowDefinitionRegistry.get(id);
|
||||
if (frozen != null) {
|
||||
return frozen;
|
||||
}
|
||||
if (frozenWorkflowDefinitionRegistry.isFrozen(id)) {
|
||||
throw new IllegalStateException("Frozen workflow definition is not registered: " + id);
|
||||
}
|
||||
return workflowDefinitionCache.get(id, () -> loadAndCompile(id));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 当前进程内的已发布 Agent 工作流冻结定义注册表。
|
||||
*
|
||||
* <p>正式 Agent 编译时使用发布快照中的工作流内容生成内容寻址定义,执行阶段只读取该定义,
|
||||
* 不再按工作流 ID 回查当前发布版本。注册表按访问顺序有界保留,Agent 再次编译时可无损重建。</p>
|
||||
*/
|
||||
@Component
|
||||
public class FrozenWorkflowDefinitionRegistry {
|
||||
|
||||
private static final String PREFIX = "agent-frozen:";
|
||||
private static final int MAX_ENTRIES = 512;
|
||||
|
||||
private final AgentWorkflowSnapshotFactory snapshotFactory;
|
||||
private final Map<String, ChainDefinition> definitions =
|
||||
new LinkedHashMap<>(32, 0.75F, true);
|
||||
private final Map<String, Workflow> workflows =
|
||||
new LinkedHashMap<>(32, 0.75F, true);
|
||||
|
||||
/**
|
||||
* 创建冻结定义注册表。
|
||||
*
|
||||
* @param snapshotFactory Agent Workflow 冻结快照工厂
|
||||
*/
|
||||
public FrozenWorkflowDefinitionRegistry(AgentWorkflowSnapshotFactory snapshotFactory) {
|
||||
this.snapshotFactory = snapshotFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一份工作流快照并返回内容寻址定义 ID。
|
||||
*
|
||||
* @param workflow 包含完整 content 的工作流快照
|
||||
* @return 冻结定义 ID
|
||||
* @throws tech.easyflow.common.web.exceptions.BusinessException 工作流快照不完整或不兼容时抛出
|
||||
*/
|
||||
public String register(Workflow workflow) {
|
||||
AgentWorkflowSnapshotFactory.PreparedWorkflow prepared = snapshotFactory.prepare(workflow);
|
||||
String preparedContent = prepared.content();
|
||||
String id = PREFIX + workflow.getId() + ":" + sha256(preparedContent);
|
||||
synchronized (definitions) {
|
||||
if (definitions.containsKey(id)) {
|
||||
definitions.get(id);
|
||||
return id;
|
||||
}
|
||||
ChainDefinition definition = prepared.definition();
|
||||
definition.setId(id);
|
||||
definition.setName(workflow.getEnglishName());
|
||||
definition.setDescription(workflow.getDescription());
|
||||
definitions.put(id, definition);
|
||||
workflows.put(id, workflow);
|
||||
while (definitions.size() > MAX_ENTRIES) {
|
||||
String eldest = definitions.keySet().iterator().next();
|
||||
definitions.remove(eldest);
|
||||
workflows.remove(eldest);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册冻结定义。
|
||||
*
|
||||
* @param definitionId 定义 ID
|
||||
* @return 冻结定义;不存在时返回 null
|
||||
*/
|
||||
public ChainDefinition get(String definitionId) {
|
||||
synchronized (definitions) {
|
||||
return definitions.get(definitionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取冻结定义对应的工作流快照,用于执行审计展示。
|
||||
*
|
||||
* @param definitionId 冻结定义 ID
|
||||
* @return 工作流快照;不存在时返回 null
|
||||
*/
|
||||
public Workflow getWorkflow(String definitionId) {
|
||||
synchronized (definitions) {
|
||||
return workflows.get(definitionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断定义 ID 是否属于冻结 Agent 工作流。
|
||||
*
|
||||
* @param definitionId 定义 ID
|
||||
* @return 是否冻结定义
|
||||
*/
|
||||
public boolean isFrozen(String definitionId) {
|
||||
return definitionId != null && definitionId.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
private String sha256(String content) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(content.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package tech.easyflow.ai.mcp;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 构建可持久化的 MCP 受控连接快照。
|
||||
*
|
||||
* <p>连接拓扑可冻结,凭据值只能使用 {@code ${input:key}} 服务端引用,
|
||||
* 避免发布与审批快照复制已经解析的令牌、Header 或查询参数。</p>
|
||||
*/
|
||||
@Component
|
||||
public class McpConnectionSnapshotFactory {
|
||||
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { };
|
||||
private static final Pattern INPUT_REFERENCE =
|
||||
Pattern.compile("^\\$\\{input:[A-Za-z0-9_.-]+}$");
|
||||
private static final Pattern SENSITIVE_NAME = Pattern.compile(
|
||||
".*(token|secret|password|passwd|api[_-]?key|authorization|cookie|credential|private[_-]?key).*",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
private static final Set<String> PUBLIC_HEADERS = Set.of(
|
||||
"accept", "accept-language", "content-type", "user-agent");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建快照工厂。
|
||||
*
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public McpConnectionSnapshotFactory(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段白名单 MCP 连接快照并校验凭据引用。
|
||||
*
|
||||
* @param mcp MCP 资源
|
||||
* @return 仅供服务端 Runtime 使用的连接快照
|
||||
* @throws BusinessException 配置包含明文凭据或格式无效时抛出
|
||||
*/
|
||||
public Map<String, Object> snapshot(Mcp mcp) {
|
||||
if (mcp == null || mcp.getId() == null) {
|
||||
throw new BusinessException("MCP 资源不能为空");
|
||||
}
|
||||
validateCredentialReferences(mcp.getConfigJson());
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("id", mcp.getId());
|
||||
snapshot.put("title", mcp.getTitle());
|
||||
snapshot.put("description", mcp.getDescription());
|
||||
snapshot.put("transportType", mcp.getTransportType());
|
||||
snapshot.put("approvalRequired", Boolean.TRUE.equals(mcp.getApprovalRequired()));
|
||||
snapshot.put("configJson", mcp.getConfigJson());
|
||||
snapshot.put("configHash", sha256(mcp.getConfigJson()));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private void validateCredentialReferences(String configJson) {
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
throw new BusinessException("MCP 配置 JSON 不能为空");
|
||||
}
|
||||
Map<String, Object> config;
|
||||
try {
|
||||
config = objectMapper.readValue(configJson, MAP_TYPE);
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("MCP 配置 JSON 格式错误");
|
||||
}
|
||||
Map<String, Object> servers = map(config.get("mcpServers"), "mcpServers");
|
||||
for (Map.Entry<String, Object> entry : servers.entrySet()) {
|
||||
Map<String, Object> server = map(entry.getValue(), "MCP 服务 " + entry.getKey());
|
||||
validateMap(server.get("headers"), "headers", true);
|
||||
validateMap(server.get("queryParams"), "queryParams", true);
|
||||
validateMap(server.get("env"), "env", false);
|
||||
validateUrl(server.get("url"));
|
||||
validateArgs(server.get("args"));
|
||||
validateNestedSensitiveValues(server, "mcpServers." + entry.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMap(Object value, String field, boolean requireReferenceForAll) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> values = map(value, field);
|
||||
for (Map.Entry<String, Object> entry : values.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String text = entry.getValue() == null ? "" : String.valueOf(entry.getValue()).trim();
|
||||
boolean publicHeader = "headers".equals(field)
|
||||
&& PUBLIC_HEADERS.contains(key.toLowerCase(Locale.ROOT));
|
||||
boolean requiresReference = (requireReferenceForAll && !publicHeader)
|
||||
|| SENSITIVE_NAME.matcher(key).matches();
|
||||
if (requiresReference && !text.isEmpty() && !INPUT_REFERENCE.matcher(text).matches()) {
|
||||
throw new BusinessException("MCP " + field + " 中的凭据必须使用 ${input:key} 引用:" + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUrl(Object value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
String lower = String.valueOf(value).toLowerCase(Locale.ROOT);
|
||||
if (lower.matches(".*[?&](token|secret|password|api[_-]?key|authorization)=[^&$][^&]*.*")
|
||||
|| lower.matches("^[a-z][a-z0-9+.-]*://[^/@]+:[^/@]+@.*")) {
|
||||
throw new BusinessException("MCP URL 不能包含明文凭据,请使用 ${input:key} 引用");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateArgs(Object value) {
|
||||
if (!(value instanceof List<?> args)) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < args.size(); index++) {
|
||||
Object raw = args.get(index);
|
||||
String arg = raw == null ? "" : String.valueOf(raw);
|
||||
if (!SENSITIVE_NAME.matcher(arg).matches()) {
|
||||
continue;
|
||||
}
|
||||
int separator = arg.indexOf('=');
|
||||
if (separator >= 0 && INPUT_REFERENCE.matcher(arg.substring(separator + 1).trim()).matches()) {
|
||||
continue;
|
||||
}
|
||||
if (separator < 0 && index + 1 < args.size()
|
||||
&& INPUT_REFERENCE.matcher(String.valueOf(args.get(index + 1)).trim()).matches()) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
throw new BusinessException("MCP 启动参数不能包含明文凭据,请使用 ${input:key} 引用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归检查扩展配置,防止未知或嵌套敏感字段绕过固定字段校验。
|
||||
*
|
||||
* @param value 当前配置值
|
||||
* @param path 配置路径
|
||||
*/
|
||||
private void validateNestedSensitiveValues(Object value, String path) {
|
||||
if (value instanceof Map<?, ?> values) {
|
||||
for (Map.Entry<?, ?> entry : values.entrySet()) {
|
||||
String key = String.valueOf(entry.getKey());
|
||||
Object nested = entry.getValue();
|
||||
String nestedPath = path + "." + key;
|
||||
if (SENSITIVE_NAME.matcher(key).matches()
|
||||
&& (nested == null || !INPUT_REFERENCE.matcher(String.valueOf(nested).trim()).matches())) {
|
||||
throw new BusinessException("MCP 敏感配置必须使用 ${input:key} 引用:" + nestedPath);
|
||||
}
|
||||
if (!SENSITIVE_NAME.matcher(key).matches()) {
|
||||
validateNestedSensitiveValues(nested, nestedPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value instanceof List<?> values) {
|
||||
for (int index = 0; index < values.size(); index++) {
|
||||
validateNestedSensitiveValues(values.get(index), path + "[" + index + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> map(Object value, String field) {
|
||||
if (!(value instanceof Map<?, ?> raw)) {
|
||||
throw new BusinessException("MCP 配置字段必须是对象:" + field);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
raw.forEach((key, item) -> result.put(String.valueOf(key), item));
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package tech.easyflow.ai.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||
import com.easyagents.agent.runtime.mcp.McpTransportType;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 将 EasyFlow MCP 配置映射为无业务状态的运行时连接声明。
|
||||
*/
|
||||
@Component
|
||||
public class McpRuntimeSpecFactory {
|
||||
|
||||
private static final Pattern INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { };
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建 MCP 运行声明工厂。
|
||||
*
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public McpRuntimeSpecFactory(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 MCP 运行连接声明。
|
||||
*
|
||||
* @param mcp MCP 资源
|
||||
* @param requireUniqueServer 是否要求配置中只有一个服务
|
||||
* @return MCP 运行连接声明
|
||||
* @throws BusinessException 配置为空、格式错误、多服务或输入变量未解析时抛出
|
||||
*/
|
||||
public McpSpec build(Mcp mcp, boolean requireUniqueServer) {
|
||||
if (mcp == null || mcp.getId() == null) {
|
||||
throw new BusinessException("MCP 资源不能为空");
|
||||
}
|
||||
Map<String, Object> config = parseConfig(mcp.getConfigJson());
|
||||
Map<String, Object> servers = mapValue(config, "mcpServers");
|
||||
if (servers.isEmpty()) {
|
||||
throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务");
|
||||
}
|
||||
if (requireUniqueServer && servers.size() != 1) {
|
||||
throw new BusinessException(409, 4092, "MCP 配置必须且只能包含一个服务,请拆分后重试");
|
||||
}
|
||||
Map.Entry<String, Object> server = servers.entrySet().iterator().next();
|
||||
if (!(server.getValue() instanceof Map<?, ?> rawServer)) {
|
||||
throw new BusinessException("MCP 服务配置必须是对象:" + server.getKey());
|
||||
}
|
||||
Map<String, Object> serverConfig = new LinkedHashMap<>();
|
||||
rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value));
|
||||
|
||||
McpSpec spec = new McpSpec();
|
||||
spec.setName("mcp_" + safeSegment(mcp.getId().toString()));
|
||||
spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle()));
|
||||
spec.setTransportType(McpTransportType.from(firstNonBlank(
|
||||
mcp.getTransportType(), stringValue(serverConfig, "transport", null))));
|
||||
spec.setCommand(resolveInput(stringValue(serverConfig, "command", null)));
|
||||
spec.setArgs(resolveInputs(stringListValue(serverConfig, "args")));
|
||||
spec.setEnv(resolveInputMap(stringMapValue(serverConfig, "env")));
|
||||
spec.setUrl(resolveInput(stringValue(serverConfig, "url", null)));
|
||||
spec.setHeaders(resolveInputMap(stringMapValue(serverConfig, "headers")));
|
||||
spec.setQueryParams(resolveInputMap(stringMapValue(serverConfig, "queryParams")));
|
||||
Duration timeout = durationValue(serverConfig, "timeout");
|
||||
if (timeout != null) {
|
||||
spec.setTimeout(timeout);
|
||||
}
|
||||
Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout");
|
||||
if (initializationTimeout != null) {
|
||||
spec.setInitializationTimeout(initializationTimeout);
|
||||
}
|
||||
spec.getMetadata().put("mcpId", mcp.getId().toString());
|
||||
spec.getMetadata().put("mcpTitle", mcp.getTitle());
|
||||
spec.getMetadata().put("serverName", server.getKey());
|
||||
return spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 MCP JSON。
|
||||
*
|
||||
* @param configJson MCP JSON
|
||||
* @return 配置 Map
|
||||
*/
|
||||
private Map<String, Object> parseConfig(String configJson) {
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
throw new BusinessException("MCP 配置 JSON 不能为空");
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(configJson, MAP_TYPE);
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("MCP 配置 JSON 格式错误");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取对象字段。
|
||||
*
|
||||
* @param source 配置 Map
|
||||
* @param key 字段名
|
||||
* @return 对象 Map
|
||||
*/
|
||||
private Map<String, Object> mapValue(Map<String, Object> source, String key) {
|
||||
Object value = source == null ? null : source.get(key);
|
||||
if (value == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
if (!(value instanceof Map<?, ?> raw)) {
|
||||
throw new BusinessException("MCP 配置字段必须是对象:" + key);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
raw.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取字符串数组字段。
|
||||
*
|
||||
* @param source 配置 Map
|
||||
* @param key 字段名
|
||||
* @return 字符串数组
|
||||
*/
|
||||
private List<String> stringListValue(Map<String, Object> source, String key) {
|
||||
Object value = source == null ? null : source.get(key);
|
||||
if (value == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (!(value instanceof Collection<?> collection)) {
|
||||
throw new BusinessException("MCP 配置字段必须是数组:" + key);
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
collection.stream().filter(item -> item != null).forEach(item -> result.add(String.valueOf(item)));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取字符串 Map 字段。
|
||||
*
|
||||
* @param source 配置 Map
|
||||
* @param key 字段名
|
||||
* @return 字符串 Map
|
||||
*/
|
||||
private Map<String, String> stringMapValue(Map<String, Object> source, String key) {
|
||||
Map<String, Object> raw = mapValue(source, key);
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
raw.forEach((name, value) -> {
|
||||
if (value != null) {
|
||||
result.put(name, String.valueOf(value));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取字符串字段。
|
||||
*
|
||||
* @param source 配置 Map
|
||||
* @param key 字段名
|
||||
* @param fallback 默认值
|
||||
* @return 字符串值
|
||||
*/
|
||||
private String stringValue(Map<String, Object> source, String key, String fallback) {
|
||||
Object value = source == null ? null : source.get(key);
|
||||
if (value == null || String.valueOf(value).isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
return String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取秒数或 ISO-8601 Duration。
|
||||
*
|
||||
* @param source 配置 Map
|
||||
* @param key 字段名
|
||||
* @return Duration 或 null
|
||||
*/
|
||||
private Duration durationValue(Map<String, Object> source, String key) {
|
||||
Object value = source == null ? null : source.get(key);
|
||||
if (value == null || String.valueOf(value).isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return Duration.ofSeconds(number.longValue());
|
||||
}
|
||||
try {
|
||||
return Duration.parse(String.valueOf(value).trim());
|
||||
} catch (Exception ignored) {
|
||||
try {
|
||||
return Duration.ofSeconds(Long.parseLong(String.valueOf(value).trim()));
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new BusinessException("MCP 配置字段必须是秒数或 Duration:" + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数组中的 MCP 输入变量。
|
||||
*
|
||||
* @param values 原值
|
||||
* @return 已解析值
|
||||
*/
|
||||
private List<String> resolveInputs(List<String> values) {
|
||||
List<String> result = new ArrayList<>();
|
||||
values.forEach(value -> result.add(resolveInput(value)));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Map 中的 MCP 输入变量。
|
||||
*
|
||||
* @param values 原值
|
||||
* @return 已解析值
|
||||
*/
|
||||
private Map<String, String> resolveInputMap(Map<String, String> values) {
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
values.forEach((key, value) -> result.put(key, resolveInput(value)));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从系统属性解析 MCP 输入变量。
|
||||
*
|
||||
* @param value 原值
|
||||
* @return 已解析值
|
||||
*/
|
||||
private String resolveInput(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
Matcher matcher = INPUT_PATTERN.matcher(value);
|
||||
StringBuffer result = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String key = matcher.group(1);
|
||||
String replacement = System.getProperty("mcp.input." + key);
|
||||
if (replacement == null || replacement.isBlank()) {
|
||||
throw new BusinessException("MCP 输入变量未解析:" + key);
|
||||
}
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成安全名称片段。
|
||||
*
|
||||
* @param value 原值
|
||||
* @return 安全片段
|
||||
*/
|
||||
private String safeSegment(String value) {
|
||||
String normalized = value.trim().replaceAll("[^A-Za-z0-9_-]", "_").replaceAll("_+", "_");
|
||||
return normalized.isBlank() ? "resource" : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取首个非空文本。
|
||||
*
|
||||
* @param first 首选值
|
||||
* @param second 备选值
|
||||
* @return 非空文本
|
||||
*/
|
||||
private String firstNonBlank(String first, String second) {
|
||||
return first == null || first.isBlank() ? second : first;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package tech.easyflow.ai.plugin;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 构建可持久化的插件受控连接快照。
|
||||
*/
|
||||
@Component
|
||||
public class PluginConnectionSnapshotFactory {
|
||||
|
||||
private static final TypeReference<List<Map<String, Object>>> HEADER_LIST_TYPE = new TypeReference<>() { };
|
||||
private static final Pattern INPUT_REFERENCE =
|
||||
Pattern.compile("^\\$\\{input:[A-Za-z0-9_.-]+}$");
|
||||
private static final Set<String> PUBLIC_HEADERS = Set.of(
|
||||
"accept", "accept-language", "content-type", "user-agent");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建插件连接快照工厂。
|
||||
*
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public PluginConnectionSnapshotFactory(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段白名单快照,并拒绝把插件凭据明文复制到发布快照。
|
||||
*
|
||||
* @param plugin 插件资源
|
||||
* @return 服务端 Runtime 使用的连接快照
|
||||
* @throws BusinessException 配置缺失或包含明文凭据时抛出
|
||||
*/
|
||||
public Map<String, Object> snapshot(Plugin plugin) {
|
||||
if (plugin == null || plugin.getId() == null) {
|
||||
throw new BusinessException("插件资源不能为空");
|
||||
}
|
||||
validateBaseUrl(plugin.getBaseUrl());
|
||||
validateHeaders(plugin.getHeaders());
|
||||
if ("apiKey".equalsIgnoreCase(plugin.getAuthType())
|
||||
&& !isInputReference(plugin.getTokenValue())) {
|
||||
throw new BusinessException("插件鉴权值必须使用 ${input:key} 引用");
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("id", plugin.getId());
|
||||
result.put("alias", plugin.getAlias());
|
||||
result.put("name", plugin.getName());
|
||||
result.put("description", plugin.getDescription());
|
||||
result.put("baseUrl", plugin.getBaseUrl());
|
||||
result.put("authType", plugin.getAuthType());
|
||||
result.put("position", plugin.getPosition());
|
||||
result.put("headers", plugin.getHeaders());
|
||||
result.put("tokenKey", plugin.getTokenKey());
|
||||
result.put("tokenValue", plugin.getTokenValue());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validateBaseUrl(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new BusinessException("插件基础地址不能为空");
|
||||
}
|
||||
String lower = value.toLowerCase(Locale.ROOT);
|
||||
if (lower.matches("^[a-z][a-z0-9+.-]*://[^/@]+:[^/@]+@.*")
|
||||
|| lower.matches(".*[?&](token|secret|password|api[_-]?key|authorization)=[^&]+.*")) {
|
||||
throw new BusinessException("插件基础地址不能包含明文凭据");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateHeaders(String headersJson) {
|
||||
if (headersJson == null || headersJson.isBlank()) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, Object>> headers;
|
||||
try {
|
||||
headers = objectMapper.readValue(headersJson, HEADER_LIST_TYPE);
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("插件请求头格式错误");
|
||||
}
|
||||
for (Map<String, Object> header : headers) {
|
||||
String name = text(header.get("label"));
|
||||
String value = text(header.get("value"));
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new BusinessException("插件请求头名称不能为空");
|
||||
}
|
||||
if (!PUBLIC_HEADERS.contains(name.toLowerCase(Locale.ROOT))
|
||||
&& value != null && !value.isBlank() && !isInputReference(value)) {
|
||||
throw new BusinessException("插件请求头凭据必须使用 ${input:key} 引用:" + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isInputReference(String value) {
|
||||
return value != null && INPUT_REFERENCE.matcher(value.trim()).matches();
|
||||
}
|
||||
|
||||
private String text(Object value) {
|
||||
return value == null ? null : String.valueOf(value).trim();
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,14 @@ public abstract class AbstractAiResourceLifecycleHandler<T> implements ApprovalS
|
||||
protected void afterOffline(BigInteger resourceId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下线真正生效前的二次引用检查钩子。
|
||||
*
|
||||
* @param resourceId 资源 ID
|
||||
*/
|
||||
protected void beforeOffline(BigInteger resourceId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成功前的额外副作用。
|
||||
*
|
||||
@@ -344,6 +352,7 @@ public abstract class AbstractAiResourceLifecycleHandler<T> implements ApprovalS
|
||||
return;
|
||||
}
|
||||
if (normalizedAction == ApprovalActionType.OFFLINE) {
|
||||
beforeOffline(resourceId);
|
||||
markResourceOffline(resourceId);
|
||||
afterOffline(resourceId);
|
||||
return;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.ResourceOfflineImpactService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider;
|
||||
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
|
||||
@@ -34,6 +35,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
private final ResourceOfflineImpactService resourceOfflineImpactService;
|
||||
private final WorkflowPluginBindingService workflowPluginBindingService;
|
||||
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
||||
private final AgentResourceReferenceService agentResourceReferenceService;
|
||||
private final List<WorkflowScheduleReferenceProvider> workflowScheduleReferenceProviders;
|
||||
|
||||
public WorkflowApprovalSubjectHandler(WorkflowService workflowService,
|
||||
@@ -42,6 +44,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
ResourceOfflineImpactService resourceOfflineImpactService,
|
||||
WorkflowPluginBindingService workflowPluginBindingService,
|
||||
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
|
||||
AgentResourceReferenceService agentResourceReferenceService,
|
||||
ObjectMapper objectMapper,
|
||||
List<WorkflowScheduleReferenceProvider> workflowScheduleReferenceProviders) {
|
||||
super(approvalInstanceService, objectMapper);
|
||||
@@ -50,6 +53,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
this.resourceOfflineImpactService = resourceOfflineImpactService;
|
||||
this.workflowPluginBindingService = workflowPluginBindingService;
|
||||
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
|
||||
this.agentResourceReferenceService = agentResourceReferenceService;
|
||||
this.workflowScheduleReferenceProviders = workflowScheduleReferenceProviders == null
|
||||
? List.of()
|
||||
: List.copyOf(workflowScheduleReferenceProviders);
|
||||
@@ -186,14 +190,12 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
if (impact.isHasPluginBindings()) {
|
||||
snapshot.put("pluginBindings", impact.getPluginBindings());
|
||||
}
|
||||
agentResourceReferenceService.assertWorkflowUnused(resource.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void validateDelete(Workflow resource, PublishStatus currentStatus) {
|
||||
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
|
||||
if (impact.isHasAgentBindings()) {
|
||||
throw new BusinessException("此工作流仍被智能体使用,请先取消绑定后再删除");
|
||||
}
|
||||
agentResourceReferenceService.assertWorkflowUnused(resource.getId());
|
||||
OfflineImpactBindingVo scheduledJob = findFirstScheduledJobReference(resource.getId());
|
||||
if (scheduledJob != null) {
|
||||
String jobName = scheduledJob.getTitle() == null ? "未命名任务" : scheduledJob.getTitle();
|
||||
@@ -235,7 +237,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterOffline(BigInteger resourceId) {
|
||||
resourceOfflineImpactService.unbindWorkflowFromAgents(resourceId);
|
||||
protected void beforeOffline(BigInteger resourceId) {
|
||||
agentResourceReferenceService.assertWorkflowUnused(resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,21 @@ public interface AgentResourceReferenceService {
|
||||
*/
|
||||
List<OfflineImpactBindingVo> listAgentsByWorkflowId(BigInteger workflowId);
|
||||
|
||||
/**
|
||||
* 查询引用指定工作流的 Skill。
|
||||
*
|
||||
* @param workflowId 工作流 ID
|
||||
* @return Skill 摘要列表
|
||||
*/
|
||||
List<OfflineImpactBindingVo> listSkillsByWorkflowId(BigInteger workflowId);
|
||||
|
||||
/**
|
||||
* 校验工作流没有被 Agent、Skill 草稿或有效发布快照引用。
|
||||
*
|
||||
* @param workflowId 工作流 ID
|
||||
*/
|
||||
void assertWorkflowUnused(BigInteger workflowId);
|
||||
|
||||
/**
|
||||
* 查询引用指定知识库的 Agent。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package tech.easyflow.ai.service;
|
||||
|
||||
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skill 对平台 Tool 资源引用的模块扩展点。
|
||||
*/
|
||||
public interface SkillToolReferenceProvider {
|
||||
|
||||
/** @param workflowId 工作流 ID @return 引用该工作流的 Skill 摘要 */
|
||||
List<OfflineImpactBindingVo> listSkillsByWorkflowId(BigInteger workflowId);
|
||||
|
||||
/** @param pluginItemId 插件工具 ID @return 引用该插件工具的 Skill 摘要 */
|
||||
List<OfflineImpactBindingVo> listSkillsByPluginItemId(BigInteger pluginItemId);
|
||||
|
||||
/** @param mcpId MCP ID @return 引用该 MCP 的 Skill 摘要 */
|
||||
List<OfflineImpactBindingVo> listSkillsByMcpId(BigInteger mcpId);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package tech.easyflow.ai.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import tech.easyflow.ai.service.AgentResourceBindingProvider;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.SkillToolReferenceProvider;
|
||||
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
@@ -20,15 +22,19 @@ import java.util.function.Function;
|
||||
@Service
|
||||
public class AgentResourceReferenceServiceImpl implements AgentResourceReferenceService {
|
||||
|
||||
private final List<AgentResourceBindingProvider> providers;
|
||||
private final ObjectProvider<AgentResourceBindingProvider> providers;
|
||||
private final ObjectProvider<SkillToolReferenceProvider> skillProviders;
|
||||
|
||||
/**
|
||||
* 创建 Agent 资源引用查询服务。
|
||||
*
|
||||
* @param providers Agent 资源绑定提供者
|
||||
* @param skillProviders Skill Tool 引用提供者
|
||||
*/
|
||||
public AgentResourceReferenceServiceImpl(List<AgentResourceBindingProvider> providers) {
|
||||
this.providers = providers == null ? List.of() : List.copyOf(providers);
|
||||
public AgentResourceReferenceServiceImpl(ObjectProvider<AgentResourceBindingProvider> providers,
|
||||
ObjectProvider<SkillToolReferenceProvider> skillProviders) {
|
||||
this.providers = providers;
|
||||
this.skillProviders = skillProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +45,19 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
|
||||
return merge(provider -> provider.listAgentsByWorkflowId(workflowId));
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public List<OfflineImpactBindingVo> listSkillsByWorkflowId(BigInteger workflowId) {
|
||||
return mergeSkills(provider -> provider.listSkillsByWorkflowId(workflowId));
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void assertWorkflowUnused(BigInteger workflowId) {
|
||||
assertUnused(merge(provider -> provider.listAgentsByWorkflowId(workflowId)), "工作流");
|
||||
assertUnused(mergeSkills(provider -> provider.listSkillsByWorkflowId(workflowId)), "工作流");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@@ -60,6 +79,7 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
|
||||
merge(provider -> provider.listAgentsByPluginItemId(pluginItemId)),
|
||||
"插件工具"
|
||||
);
|
||||
assertUnused(mergeSkills(provider -> provider.listSkillsByPluginItemId(pluginItemId)), "插件工具");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +89,7 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
|
||||
@Override
|
||||
public void assertMcpUnused(BigInteger mcpId) {
|
||||
assertUnused(merge(provider -> provider.listAgentsByMcpId(mcpId)), "MCP");
|
||||
assertUnused(mergeSkills(provider -> provider.listSkillsByMcpId(mcpId)), "MCP");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +148,23 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
|
||||
return new ArrayList<>(merged.values());
|
||||
}
|
||||
|
||||
private List<OfflineImpactBindingVo> mergeSkills(
|
||||
Function<SkillToolReferenceProvider, List<OfflineImpactBindingVo>> loader) {
|
||||
Map<BigInteger, OfflineImpactBindingVo> merged = new LinkedHashMap<>();
|
||||
for (SkillToolReferenceProvider provider : skillProviders.orderedStream().toList()) {
|
||||
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 引用。
|
||||
*
|
||||
@@ -139,8 +177,8 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
|
||||
}
|
||||
String agentTitle = bindings.get(0).getTitle();
|
||||
throw new BusinessException(
|
||||
resourceLabel + "仍被智能体“" + (agentTitle == null ? "未命名智能体" : agentTitle)
|
||||
+ "”使用,请先取消绑定或重新发布智能体后再删除"
|
||||
resourceLabel + "仍被" + (agentTitle == null ? "其他资源" : agentTitle)
|
||||
+ "使用,请先取消绑定或重新发布后再操作"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,9 +188,12 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
|
||||
* @return Agent 资源绑定提供者
|
||||
*/
|
||||
private List<AgentResourceBindingProvider> requireProviders() {
|
||||
if (providers.isEmpty()) {
|
||||
List<AgentResourceBindingProvider> resolvedProviders = providers == null
|
||||
? List.of()
|
||||
: providers.orderedStream().toList();
|
||||
if (resolvedProviders.isEmpty()) {
|
||||
throw new BusinessException("Agent 资源引用检查服务不可用,请稍后重试");
|
||||
}
|
||||
return providers;
|
||||
return resolvedProviders;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,17 +59,21 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
@Override
|
||||
public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) {
|
||||
List<OfflineImpactBindingVo> agentBindings = listAgentsByWorkflowId(workflowId);
|
||||
List<OfflineImpactBindingVo> skillBindings =
|
||||
agentResourceReferenceService.listSkillsByWorkflowId(workflowId);
|
||||
List<OfflineImpactBindingVo> pluginBindings =
|
||||
workflowPluginDependencyService.listPluginsByWorkflowId(workflowId);
|
||||
OfflineImpactCheckVo result = new OfflineImpactCheckVo();
|
||||
result.setCanProceed(true);
|
||||
result.setCanProceed(agentBindings.isEmpty() && skillBindings.isEmpty() && pluginBindings.isEmpty());
|
||||
result.setAgentBindings(agentBindings);
|
||||
result.setHasAgentBindings(!agentBindings.isEmpty());
|
||||
result.setSkillBindings(skillBindings);
|
||||
result.setHasSkillBindings(!skillBindings.isEmpty());
|
||||
result.setPluginBindings(pluginBindings);
|
||||
result.setHasPluginBindings(!pluginBindings.isEmpty());
|
||||
result.setWorkflowUsages(Collections.emptyList());
|
||||
result.setHasWorkflowUsages(false);
|
||||
result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, pluginBindings));
|
||||
result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, skillBindings, pluginBindings));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -167,21 +171,27 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
|
||||
* 生成工作流下线影响提示。
|
||||
*
|
||||
* @param agentBindings Agent 绑定
|
||||
* @param skillBindings Skill 绑定
|
||||
* @param pluginBindings 插件绑定
|
||||
* @return 提示信息
|
||||
*/
|
||||
private String resolveWorkflowOfflineImpactMessage(List<OfflineImpactBindingVo> agentBindings,
|
||||
List<OfflineImpactBindingVo> skillBindings,
|
||||
List<OfflineImpactBindingVo> pluginBindings) {
|
||||
if (!pluginBindings.isEmpty() && !agentBindings.isEmpty()) {
|
||||
return "当前工作流被插件和智能体引用,下线后插件将不可用,智能体将自动解绑";
|
||||
List<String> referenceTypes = new ArrayList<>(3);
|
||||
if (!agentBindings.isEmpty()) {
|
||||
referenceTypes.add("智能体");
|
||||
}
|
||||
if (!skillBindings.isEmpty()) {
|
||||
referenceTypes.add("Skill");
|
||||
}
|
||||
if (!pluginBindings.isEmpty()) {
|
||||
return "当前工作流被插件引用,下线后相关插件将不可用";
|
||||
referenceTypes.add("插件");
|
||||
}
|
||||
if (!agentBindings.isEmpty()) {
|
||||
return "当前工作流下线成功后,将自动从相关智能体中解绑";
|
||||
if (!referenceTypes.isEmpty()) {
|
||||
return "当前工作流仍被" + String.join("、", referenceTypes) + "引用,请先取消引用后再下线";
|
||||
}
|
||||
return "当前工作流下线后不会影响已有绑定";
|
||||
return "当前工作流可以下线";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,12 +16,16 @@ public class OfflineImpactCheckVo {
|
||||
|
||||
private boolean hasPluginBindings;
|
||||
|
||||
private boolean hasSkillBindings;
|
||||
|
||||
private List<OfflineImpactBindingVo> agentBindings = new ArrayList<>();
|
||||
|
||||
private List<OfflineImpactBindingVo> workflowUsages = new ArrayList<>();
|
||||
|
||||
private List<OfflineImpactBindingVo> pluginBindings = new ArrayList<>();
|
||||
|
||||
private List<OfflineImpactBindingVo> skillBindings = new ArrayList<>();
|
||||
|
||||
private String message;
|
||||
|
||||
/**
|
||||
@@ -130,6 +134,42 @@ public class OfflineImpactCheckVo {
|
||||
this.pluginBindings = pluginBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否存在 Skill 绑定。
|
||||
*
|
||||
* @return 是否存在 Skill 绑定
|
||||
*/
|
||||
public boolean isHasSkillBindings() {
|
||||
return hasSkillBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否存在 Skill 绑定。
|
||||
*
|
||||
* @param hasSkillBindings 是否存在 Skill 绑定
|
||||
*/
|
||||
public void setHasSkillBindings(boolean hasSkillBindings) {
|
||||
this.hasSkillBindings = hasSkillBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 绑定列表。
|
||||
*
|
||||
* @return Skill 绑定列表
|
||||
*/
|
||||
public List<OfflineImpactBindingVo> getSkillBindings() {
|
||||
return skillBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Skill 绑定列表。
|
||||
*
|
||||
* @param skillBindings Skill 绑定列表
|
||||
*/
|
||||
public void setSkillBindings(List<OfflineImpactBindingVo> skillBindings) {
|
||||
this.skillBindings = skillBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示信息。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.node.ConfirmNode;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.node.WorkflowNode;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Agent Workflow 冻结快照兼容性测试。
|
||||
*/
|
||||
public class AgentWorkflowSnapshotFactoryTest {
|
||||
|
||||
/**
|
||||
* 验证快照只保留 Runtime 白名单字段并使用准备后的内容。
|
||||
*/
|
||||
@Test
|
||||
public void shouldBuildWhitelistedSnapshotFromPreparedContent() {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
Workflow workflow = workflow();
|
||||
AgentWorkflowSnapshotFactory factory = factory(definition);
|
||||
|
||||
Map<String, Object> snapshot = factory.snapshot(workflow);
|
||||
|
||||
Assert.assertEquals(workflow.getId(), snapshot.get("id"));
|
||||
Assert.assertEquals("prepared-content", snapshot.get("content"));
|
||||
Assert.assertEquals(6, snapshot.size());
|
||||
Assert.assertFalse(snapshot.containsKey("tenantId"));
|
||||
Assert.assertFalse(snapshot.containsKey("publishedSnapshotJson"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 或 Agent 发布投影会提前拒绝子工作流节点。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectSubWorkflowNode() {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
definition.addNode(new WorkflowNode());
|
||||
|
||||
assertConflict(factory(definition), "子工作流节点");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 或 Agent 发布投影会提前拒绝内部确认节点。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectConfirmNode() {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
definition.addNode(new ConfirmNode());
|
||||
|
||||
assertConflict(factory(definition), "内部确认节点");
|
||||
}
|
||||
|
||||
private AgentWorkflowSnapshotFactory factory(ChainDefinition definition) {
|
||||
ChainParser parser = mock(ChainParser.class);
|
||||
WorkflowDatacenterContentService contentService = mock(WorkflowDatacenterContentService.class);
|
||||
when(contentService.prepareContent("raw-content")).thenReturn("prepared-content");
|
||||
when(parser.parse("prepared-content")).thenReturn(definition);
|
||||
return new AgentWorkflowSnapshotFactory(parser, contentService);
|
||||
}
|
||||
|
||||
private Workflow workflow() {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.ONE);
|
||||
workflow.setTitle("合同审查");
|
||||
workflow.setDescription("审查合同风险");
|
||||
workflow.setEnglishName("contract_review");
|
||||
workflow.setRevision(3);
|
||||
workflow.setContent("raw-content");
|
||||
workflow.setTenantId(BigInteger.TEN);
|
||||
workflow.setPublishedSnapshotJson(Map.of("secret", "hidden"));
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private void assertConflict(AgentWorkflowSnapshotFactory factory, String message) {
|
||||
try {
|
||||
factory.snapshot(workflow());
|
||||
Assert.fail("Expected incompatible workflow to be rejected");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertEquals(409, exception.getHttpStatus());
|
||||
Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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.ResourceOfflineImpactService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
|
||||
@@ -50,6 +51,7 @@ public class WorkflowApprovalSubjectHandlerTest {
|
||||
offlineImpactService,
|
||||
mock(WorkflowPluginBindingService.class),
|
||||
mock(WorkflowPluginSnapshotResolver.class),
|
||||
mock(AgentResourceReferenceService.class),
|
||||
new ObjectMapper(),
|
||||
List.of(scheduleReferenceProvider)
|
||||
);
|
||||
@@ -87,6 +89,7 @@ public class WorkflowApprovalSubjectHandlerTest {
|
||||
offlineImpactService,
|
||||
mock(WorkflowPluginBindingService.class),
|
||||
mock(WorkflowPluginSnapshotResolver.class),
|
||||
mock(AgentResourceReferenceService.class),
|
||||
new ObjectMapper(),
|
||||
List.of(scheduleReferenceProvider)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package tech.easyflow.ai.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory;
|
||||
import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 连接资源发布快照的凭据边界测试。
|
||||
*/
|
||||
public class ConnectionSnapshotFactoryTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* MCP 快照应保留拓扑和服务端输入引用,同时拒绝复制明文凭据。
|
||||
*/
|
||||
@Test
|
||||
public void mcpSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() {
|
||||
McpConnectionSnapshotFactory factory = new McpConnectionSnapshotFactory(objectMapper);
|
||||
Mcp mcp = mcp("""
|
||||
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
|
||||
"headers":{"Authorization":"${input:mcp.token}"},
|
||||
"queryParams":{"tenant":"${input:mcp.tenant}"}}}}
|
||||
""");
|
||||
|
||||
Map<String, Object> snapshot = factory.snapshot(mcp);
|
||||
|
||||
Assert.assertEquals(mcp.getId(), snapshot.get("id"));
|
||||
Assert.assertTrue(String.valueOf(snapshot.get("configJson")).contains("${input:mcp.token}"));
|
||||
Assert.assertNotNull(snapshot.get("configHash"));
|
||||
assertBusinessFailure(() -> factory.snapshot(mcp("""
|
||||
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
|
||||
"headers":{"Authorization":"Bearer plaintext-secret"}}}}
|
||||
""")), "必须使用");
|
||||
assertBusinessFailure(() -> factory.snapshot(mcp("""
|
||||
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
|
||||
"extension":{"nestedApiKey":"plaintext-secret"}}}}
|
||||
""")), "敏感配置");
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin 快照应只接受服务端输入引用形式的鉴权值和私有请求头。
|
||||
*/
|
||||
@Test
|
||||
public void pluginSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() {
|
||||
PluginConnectionSnapshotFactory factory = new PluginConnectionSnapshotFactory(objectMapper);
|
||||
Plugin plugin = plugin("${input:plugin.token}",
|
||||
"[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]");
|
||||
|
||||
Map<String, Object> snapshot = factory.snapshot(plugin);
|
||||
|
||||
Assert.assertEquals("${input:plugin.token}", snapshot.get("tokenValue"));
|
||||
Assert.assertFalse(snapshot.containsKey("tenantId"));
|
||||
assertBusinessFailure(() -> factory.snapshot(plugin(
|
||||
"plaintext-secret",
|
||||
"[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]")),
|
||||
"鉴权值");
|
||||
assertBusinessFailure(() -> factory.snapshot(plugin(
|
||||
"${input:plugin.token}",
|
||||
"[{\"label\":\"X-Secret\",\"value\":\"plaintext-secret\"}]")),
|
||||
"请求头凭据");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试 MCP。
|
||||
*
|
||||
* @param configJson MCP 配置
|
||||
* @return MCP
|
||||
*/
|
||||
private Mcp mcp(String configJson) {
|
||||
Mcp mcp = new Mcp();
|
||||
mcp.setId(BigInteger.ONE);
|
||||
mcp.setTitle("测试 MCP");
|
||||
mcp.setTransportType("SSE");
|
||||
mcp.setConfigJson(configJson);
|
||||
return mcp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试 Plugin。
|
||||
*
|
||||
* @param tokenValue 鉴权值
|
||||
* @param headers 请求头 JSON
|
||||
* @return Plugin
|
||||
*/
|
||||
private Plugin plugin(String tokenValue, String headers) {
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setId(BigInteger.TWO);
|
||||
plugin.setName("测试插件");
|
||||
plugin.setBaseUrl("https://plugin.example.test/api");
|
||||
plugin.setAuthType("apiKey");
|
||||
plugin.setPosition("headers");
|
||||
plugin.setTokenKey("Authorization");
|
||||
plugin.setTokenValue(tokenValue);
|
||||
plugin.setHeaders(headers);
|
||||
plugin.setTenantId(99L);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言业务校验失败且消息可定位。
|
||||
*
|
||||
* @param action 待执行动作
|
||||
* @param messageFragment 消息片段
|
||||
*/
|
||||
private void assertBusinessFailure(Runnable action, String messageFragment) {
|
||||
try {
|
||||
action.run();
|
||||
Assert.fail("Expected credential validation failure");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(messageFragment));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class ResourceOfflineImpactServiceImplTest {
|
||||
BigInteger workflowId = BigInteger.valueOf(10);
|
||||
OfflineImpactBindingVo binding = binding(BigInteger.ONE, "测试智能体");
|
||||
when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(List.of(binding));
|
||||
when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
|
||||
when(pluginDependencyService.listPluginsByWorkflowId(workflowId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
|
||||
@@ -50,11 +51,37 @@ public class ResourceOfflineImpactServiceImplTest {
|
||||
service.unbindWorkflowFromAgents(workflowId);
|
||||
|
||||
Assert.assertTrue(result.isHasAgentBindings());
|
||||
Assert.assertFalse(result.isCanProceed());
|
||||
Assert.assertEquals(List.of(binding), result.getAgentBindings());
|
||||
Assert.assertTrue(result.getMessage().contains("智能体"));
|
||||
verify(referenceService).unbindWorkflow(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 引用会直接阻止工作流下线并返回可处理摘要。
|
||||
*/
|
||||
@Test
|
||||
public void shouldBlockWorkflowOfflineWhenSkillReferencesIt() {
|
||||
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 skill = binding(BigInteger.valueOf(3), "合同审查 Skill");
|
||||
when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
|
||||
when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(List.of(skill));
|
||||
when(pluginDependencyService.listPluginsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
|
||||
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
|
||||
workflowService, documentCollectionService, pluginDependencyService, referenceService);
|
||||
|
||||
OfflineImpactCheckVo result = service.checkWorkflowImpact(workflowId);
|
||||
|
||||
Assert.assertFalse(result.isCanProceed());
|
||||
Assert.assertTrue(result.isHasSkillBindings());
|
||||
Assert.assertEquals(List.of(skill), result.getSkillBindings());
|
||||
Assert.assertTrue(result.getMessage().contains("Skill"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证知识库影响结果使用 Agent 绑定字段并委托 Agent 解绑。
|
||||
*/
|
||||
|
||||
@@ -91,4 +91,73 @@ public class ChatAssistantAccumulatorTest {
|
||||
Assert.assertEquals("mcp_123_search", toolCalls.get(0).get("name"));
|
||||
Assert.assertEquals("知识库 MCP - search", toolCalls.get(0).get("toolDisplayName"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 状态应按稳定键原位更新、剔除内部字段并持久化为可回放终态。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldPersistWhitelistedSkillInvocationTerminalState() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:round-1:skill-1",
|
||||
"status", "RUNNING",
|
||||
"skillId", "skill-1",
|
||||
"skillDisplayName", "合同审查",
|
||||
"internalSnapshot", "must-not-leak"));
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:round-1:skill-1",
|
||||
"status", "SUCCESS",
|
||||
"skillId", "skill-1",
|
||||
"skillDisplayName", "合同审查"));
|
||||
|
||||
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
|
||||
.buildPayload("完成")
|
||||
.get("skillInvocationStatuses");
|
||||
|
||||
Assert.assertEquals(1, statuses.size());
|
||||
Assert.assertEquals("SUCCESS", statuses.get(0).get("status"));
|
||||
Assert.assertFalse(statuses.get(0).containsKey("internalSnapshot"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式运行异常时仍在执行的 Skill 应收口为可恢复失败状态。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldFinalizePendingSkillInvocationAfterRunFailure() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:request-1:skill-1",
|
||||
"status", "RUNNING",
|
||||
"skillId", "skill-1"));
|
||||
|
||||
accumulator.finalizePendingSkillInvocations("FAILED", "本轮运行失败");
|
||||
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
|
||||
.buildPayload(null)
|
||||
.get("skillInvocationStatuses");
|
||||
|
||||
Assert.assertEquals("FAILED", statuses.get(0).get("status"));
|
||||
Assert.assertEquals("本轮运行失败", statuses.get(0).get("message"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 正常流结束但缺失终态事件时应落为未完成,避免历史页长期显示运行中。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldConvertDanglingRunningSkillInvocationToIncomplete() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendSkillInvocationStatus(Map.of(
|
||||
"statusKey", "skill-invocation:round-1:skill-1",
|
||||
"status", "RUNNING",
|
||||
"skillId", "skill-1"));
|
||||
|
||||
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
|
||||
.buildPayload(null)
|
||||
.get("skillInvocationStatuses");
|
||||
|
||||
Assert.assertEquals("INCOMPLETE", statuses.get(0).get("status"));
|
||||
Assert.assertEquals("技能调用未完成", statuses.get(0).get("message"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user