feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -40,6 +40,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
|
||||
private BigInteger currentApprovalInstanceId;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> publishedSnapshotJson = new LinkedHashMap<>();
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> publishedToolBindingsJson = new LinkedHashMap<>();
|
||||
private Date publishedAt;
|
||||
private BigInteger publishedBy;
|
||||
private Date created;
|
||||
@@ -57,6 +59,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
|
||||
private String createdByName;
|
||||
@Column(ignore = true)
|
||||
private List<SkillResource> resources;
|
||||
@Column(ignore = true)
|
||||
private List<SkillToolBinding> toolBindings;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
@@ -86,6 +90,10 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
|
||||
public void setCurrentApprovalInstanceId(BigInteger currentApprovalInstanceId) { this.currentApprovalInstanceId = currentApprovalInstanceId; }
|
||||
public Map<String, Object> getPublishedSnapshotJson() { return publishedSnapshotJson; }
|
||||
public void setPublishedSnapshotJson(Map<String, Object> publishedSnapshotJson) { this.publishedSnapshotJson = publishedSnapshotJson == null ? new LinkedHashMap<>() : publishedSnapshotJson; }
|
||||
/** @return 平台 Tool 发布快照 */
|
||||
public Map<String, Object> getPublishedToolBindingsJson() { return publishedToolBindingsJson; }
|
||||
/** @param publishedToolBindingsJson 平台 Tool 发布快照 */
|
||||
public void setPublishedToolBindingsJson(Map<String, Object> publishedToolBindingsJson) { this.publishedToolBindingsJson = publishedToolBindingsJson == null ? new LinkedHashMap<>() : publishedToolBindingsJson; }
|
||||
public Date getPublishedAt() { return publishedAt; }
|
||||
public void setPublishedAt(Date publishedAt) { this.publishedAt = publishedAt; }
|
||||
public BigInteger getPublishedBy() { return publishedBy; }
|
||||
@@ -108,4 +116,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
|
||||
public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }
|
||||
public List<SkillResource> getResources() { return resources; }
|
||||
public void setResources(List<SkillResource> resources) { this.resources = resources; }
|
||||
/** @return 脱敏 Tool 草稿绑定摘要 */
|
||||
public List<SkillToolBinding> getToolBindings() { return toolBindings; }
|
||||
/** @param toolBindings 脱敏 Tool 草稿绑定摘要 */
|
||||
public void setToolBindings(List<SkillToolBinding> toolBindings) { this.toolBindings = toolBindings; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package tech.easyflow.skill.entity;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import tech.easyflow.common.entity.DateEntity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Skill 平台 Tool 草稿绑定。
|
||||
*
|
||||
* <p>该表只保存资源引用、调用前确认和 MCP 有界摘要;运行名、资源快照与 MCP Tool
|
||||
* 明细仅在发布时生成。</p>
|
||||
*/
|
||||
@Table("tb_skill_tool_binding")
|
||||
public class SkillToolBinding extends DateEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||
private BigInteger id;
|
||||
@Column(tenantId = true)
|
||||
private BigInteger tenantId;
|
||||
private BigInteger skillId;
|
||||
private String toolType;
|
||||
private BigInteger targetId;
|
||||
private Boolean hitlEnabled;
|
||||
private Integer mcpToolCount;
|
||||
private String mcpToolManifestHash;
|
||||
private Integer sortNo;
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Date modified;
|
||||
private BigInteger modifiedBy;
|
||||
@Column(ignore = true)
|
||||
private Map<String, Object> resourceSummary = new LinkedHashMap<>();
|
||||
|
||||
/** @return 绑定 ID */
|
||||
public BigInteger getId() { return id; }
|
||||
/** @param id 绑定 ID */
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
/** @return 租户 ID */
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
/** @param tenantId 租户 ID */
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
/** @return Skill ID */
|
||||
public BigInteger getSkillId() { return skillId; }
|
||||
/** @param skillId Skill ID */
|
||||
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
|
||||
/** @return Tool 类型 */
|
||||
public String getToolType() { return toolType; }
|
||||
/** @param toolType Tool 类型 */
|
||||
public void setToolType(String toolType) { this.toolType = toolType; }
|
||||
/** @return 目标资源 ID */
|
||||
public BigInteger getTargetId() { return targetId; }
|
||||
/** @param targetId 目标资源 ID */
|
||||
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
|
||||
/** @return 是否调用前确认 */
|
||||
public Boolean getHitlEnabled() { return hitlEnabled; }
|
||||
/** @param hitlEnabled 是否调用前确认 */
|
||||
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
|
||||
/** @return MCP Tool 数量 */
|
||||
public Integer getMcpToolCount() { return mcpToolCount; }
|
||||
/** @param mcpToolCount MCP Tool 数量 */
|
||||
public void setMcpToolCount(Integer mcpToolCount) { this.mcpToolCount = mcpToolCount; }
|
||||
/** @return MCP Tool manifest hash */
|
||||
public String getMcpToolManifestHash() { return mcpToolManifestHash; }
|
||||
/** @param mcpToolManifestHash MCP Tool manifest hash */
|
||||
public void setMcpToolManifestHash(String mcpToolManifestHash) { this.mcpToolManifestHash = mcpToolManifestHash; }
|
||||
/** @return 排序号 */
|
||||
public Integer getSortNo() { return sortNo; }
|
||||
/** @param sortNo 排序号 */
|
||||
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
|
||||
/** @return 创建时间 */
|
||||
@Override public Date getCreated() { return created; }
|
||||
/** @param created 创建时间 */
|
||||
@Override public void setCreated(Date created) { this.created = created; }
|
||||
/** @return 创建人 */
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
/** @param createdBy 创建人 */
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
/** @return 修改时间 */
|
||||
@Override public Date getModified() { return modified; }
|
||||
/** @param modified 修改时间 */
|
||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||
/** @return 修改人 */
|
||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||
/** @param modifiedBy 修改人 */
|
||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||
/** @return 脱敏资源摘要 */
|
||||
public Map<String, Object> getResourceSummary() { return resourceSummary; }
|
||||
/** @param resourceSummary 脱敏资源摘要 */
|
||||
public void setResourceSummary(Map<String, Object> resourceSummary) {
|
||||
this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package tech.easyflow.skill.enums;
|
||||
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
/**
|
||||
* Skill 可绑定的平台 Tool 类型。
|
||||
*/
|
||||
public enum SkillToolType {
|
||||
|
||||
/** 已发布工作流。 */
|
||||
WORKFLOW,
|
||||
/** 已启用插件工具。 */
|
||||
PLUGIN,
|
||||
/** 整组 MCP 服务。 */
|
||||
MCP;
|
||||
|
||||
/**
|
||||
* 解析 Tool 类型。
|
||||
*
|
||||
* @param value Tool 类型编码
|
||||
* @return Tool 类型
|
||||
* @throws BusinessException 类型为空或不受支持时抛出
|
||||
*/
|
||||
public static SkillToolType from(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new BusinessException("Skill 工具类型不能为空");
|
||||
}
|
||||
try {
|
||||
return valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new BusinessException("不支持的 Skill 工具类型:" + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,14 @@ public interface SkillMapper extends BaseMapper<Skill> {
|
||||
*/
|
||||
@Update("UPDATE tb_skill SET publish_status='PUBLISHED', "
|
||||
+ "published_snapshot_json=#{snapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, "
|
||||
+ "published_tool_bindings_json=#{toolSnapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, "
|
||||
+ "published_at=#{publishedAt}, published_by=#{publishedBy}, "
|
||||
+ "snapshot_hash=#{snapshotHash}, current_approval_instance_id=NULL "
|
||||
+ "WHERE id=#{id} AND tenant_id=#{tenantId}")
|
||||
int publish(@Param("id") BigInteger id,
|
||||
@Param("tenantId") BigInteger tenantId,
|
||||
@Param("snapshot") Map<String, Object> snapshot,
|
||||
@Param("toolSnapshot") Map<String, Object> toolSnapshot,
|
||||
@Param("publishedAt") Date publishedAt,
|
||||
@Param("publishedBy") BigInteger publishedBy,
|
||||
@Param("snapshotHash") String snapshotHash);
|
||||
@@ -68,6 +70,7 @@ public interface SkillMapper extends BaseMapper<Skill> {
|
||||
*/
|
||||
@Update("UPDATE tb_skill SET publish_status='PUBLISHED', "
|
||||
+ "published_snapshot_json=#{snapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, "
|
||||
+ "published_tool_bindings_json=#{toolSnapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, "
|
||||
+ "published_at=#{publishedAt}, published_by=#{publishedBy}, snapshot_hash=#{snapshotHash}, "
|
||||
+ "current_approval_instance_id=#{approvalInstanceId} WHERE id=#{id} AND tenant_id=#{tenantId} "
|
||||
+ "AND current_approval_instance_id=#{approvalInstanceId}")
|
||||
@@ -75,6 +78,7 @@ public interface SkillMapper extends BaseMapper<Skill> {
|
||||
@Param("tenantId") BigInteger tenantId,
|
||||
@Param("approvalInstanceId") BigInteger approvalInstanceId,
|
||||
@Param("snapshot") Map<String, Object> snapshot,
|
||||
@Param("toolSnapshot") Map<String, Object> toolSnapshot,
|
||||
@Param("publishedAt") Date publishedAt,
|
||||
@Param("publishedBy") BigInteger publishedBy,
|
||||
@Param("snapshotHash") String snapshotHash);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.skill.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||
|
||||
/**
|
||||
* Skill Tool 绑定 Mapper。
|
||||
*/
|
||||
public interface SkillToolBindingMapper extends BaseMapper<SkillToolBinding> {
|
||||
}
|
||||
@@ -126,12 +126,26 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
|
||||
@Override
|
||||
protected Map<String, Object> getPublishedSnapshot(Skill resource) {
|
||||
return resource.getPublishedSnapshotJson();
|
||||
Map<String, Object> content = resource.getPublishedSnapshotJson();
|
||||
if (content == null || content.isEmpty()) {
|
||||
return content;
|
||||
}
|
||||
Map<String, Object> toolSnapshot = resource.getPublishedToolBindingsJson();
|
||||
if (toolSnapshot == null || toolSnapshot.isEmpty()) {
|
||||
return content;
|
||||
}
|
||||
Map<String, Object> combined = new java.util.LinkedHashMap<>(content);
|
||||
Object contentHash = combined.remove("snapshotHash");
|
||||
combined.put("contentSnapshotHash", contentHash);
|
||||
combined.put("platformToolBindings", toolSnapshot);
|
||||
combined.put("toolBindingsHash", toolSnapshot.get("snapshotHash"));
|
||||
combined.put("snapshotHash", resource.getSnapshotHash());
|
||||
return combined;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, Object> buildResourceSnapshot(Skill resource) {
|
||||
return skillService.buildPublishSnapshot(resource);
|
||||
return skillService.buildApprovalSnapshot(resource);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +188,10 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
@Override
|
||||
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
|
||||
Skill existing = requireResource(resourceId);
|
||||
if (skillMapper.publish(resourceId, existing.getTenantId(), resourceSnapshot, new Date(), operatorId,
|
||||
Map<String, Object> contentSnapshot = skillService.extractContentSnapshot(resourceSnapshot);
|
||||
Map<String, Object> toolSnapshot = skillService.extractToolBindingsSnapshot(resourceSnapshot);
|
||||
if (skillMapper.publish(resourceId, existing.getTenantId(), contentSnapshot, toolSnapshot,
|
||||
new Date(), operatorId,
|
||||
stringValue(resourceSnapshot.get("snapshotHash"))) != 1) {
|
||||
throw new BusinessException(500, 500, "发布 Skill 失败,请稍后重试");
|
||||
}
|
||||
@@ -217,8 +234,10 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
}
|
||||
if (action == ApprovalActionType.PUBLISH) {
|
||||
skillService.assertSnapshotHash(resourceSnapshot);
|
||||
Map<String, Object> contentSnapshot = skillService.extractContentSnapshot(resourceSnapshot);
|
||||
Map<String, Object> toolSnapshot = skillService.extractToolBindingsSnapshot(resourceSnapshot);
|
||||
if (skillMapper.publishApproved(resourceId, existing.getTenantId(), approvalInstanceId,
|
||||
resourceSnapshot, new Date(), operatorId,
|
||||
contentSnapshot, toolSnapshot, new Date(), operatorId,
|
||||
stringValue(resourceSnapshot.get("snapshotHash"))) != 1) {
|
||||
throw new BusinessException(409, 4092, "Skill 发布状态已变化,请刷新后重试");
|
||||
}
|
||||
@@ -226,6 +245,7 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
return;
|
||||
}
|
||||
if (action == ApprovalActionType.OFFLINE) {
|
||||
skillService.assertNoActiveReferences(resourceId);
|
||||
if (skillMapper.markOfflineApproved(resourceId, existing.getTenantId(), approvalInstanceId) != 1) {
|
||||
throw new BusinessException(409, 4092, "Skill 下线状态已变化,请刷新后重试");
|
||||
}
|
||||
@@ -236,6 +256,7 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
||||
|
||||
@Override
|
||||
protected void markResourceOffline(BigInteger resourceId) {
|
||||
skillService.assertNoActiveReferences(resourceId);
|
||||
Skill existing = requireResource(resourceId);
|
||||
if (skillMapper.markOffline(resourceId, existing.getTenantId()) != 1) {
|
||||
throw new BusinessException(500, 500, "下线 Skill 失败,请稍后重试");
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package tech.easyflow.skill.service;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skill 被上层资源引用的无反向依赖查询扩展点。
|
||||
*/
|
||||
public interface SkillReferenceProvider {
|
||||
|
||||
/**
|
||||
* 查询草稿或有效发布快照中引用指定 Skill 的资源摘要。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @return 用户可识别的引用摘要
|
||||
*/
|
||||
List<String> listReferences(BigInteger skillId);
|
||||
}
|
||||
@@ -104,6 +104,30 @@ public interface SkillService extends IService<Skill> {
|
||||
*/
|
||||
Map<String, Object> buildPublishSnapshot(Skill skill);
|
||||
|
||||
/**
|
||||
* 构建审批冻结用的内容与平台 Tool 组合快照。
|
||||
*
|
||||
* @param skill Skill 草稿
|
||||
* @return 组合发布候选快照
|
||||
*/
|
||||
Map<String, Object> buildApprovalSnapshot(Skill skill);
|
||||
|
||||
/**
|
||||
* 从组合发布候选中提取保持标准包语义的内容快照。
|
||||
*
|
||||
* @param approvalSnapshot 组合发布候选
|
||||
* @return 标准 Skill 内容快照
|
||||
*/
|
||||
Map<String, Object> extractContentSnapshot(Map<String, Object> approvalSnapshot);
|
||||
|
||||
/**
|
||||
* 从组合发布候选中提取平台 Tool 快照。
|
||||
*
|
||||
* @param approvalSnapshot 组合发布候选
|
||||
* @return 平台 Tool 快照
|
||||
*/
|
||||
Map<String, Object> extractToolBindingsSnapshot(Map<String, Object> approvalSnapshot);
|
||||
|
||||
/**
|
||||
* 校验发布快照中的哈希与实际内容一致。
|
||||
*
|
||||
@@ -111,6 +135,20 @@ public interface SkillService extends IService<Skill> {
|
||||
*/
|
||||
void assertSnapshotHash(Map<String, Object> snapshot);
|
||||
|
||||
/**
|
||||
* 校验已发布 Skill 的内容、平台 Tool 与组合 hash。
|
||||
*
|
||||
* @param skill 已发布 Skill
|
||||
*/
|
||||
void assertPublishedAggregateHash(Skill skill);
|
||||
|
||||
/**
|
||||
* 校验 Skill 没有被 Agent 草稿或有效发布快照引用。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
*/
|
||||
void assertNoActiveReferences(BigInteger skillId);
|
||||
|
||||
/**
|
||||
* 构建删除审批使用的最小治理快照。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package tech.easyflow.skill.service;
|
||||
|
||||
import com.mybatisflex.core.service.IService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Skill 平台 Tool 绑定服务。
|
||||
*/
|
||||
public interface SkillToolBindingService extends IService<SkillToolBinding> {
|
||||
|
||||
/**
|
||||
* 原子替换 Skill 的全部 Tool 草稿绑定。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param bindings 客户端绑定引用
|
||||
* @return 规范化后的脱敏绑定摘要
|
||||
*/
|
||||
List<SkillToolBinding> replaceBindings(BigInteger skillId, List<SkillToolBinding> bindings);
|
||||
|
||||
/**
|
||||
* 查询 Skill 的草稿绑定并补齐脱敏摘要。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @return 稳定排序的绑定摘要
|
||||
*/
|
||||
List<SkillToolBinding> listSummaries(BigInteger skillId);
|
||||
|
||||
/**
|
||||
* 查询 Skill 的草稿绑定。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @return 稳定排序的草稿绑定
|
||||
*/
|
||||
List<SkillToolBinding> listBindings(BigInteger skillId);
|
||||
|
||||
/**
|
||||
* 构建并完整复核 Skill 的平台 Tool 发布快照。
|
||||
*
|
||||
* @param skill 已锁定的 Skill
|
||||
* @return Tool 发布快照
|
||||
*/
|
||||
Map<String, Object> buildPublishSnapshot(Skill skill);
|
||||
|
||||
/**
|
||||
* 复核已发布 Tool 快照中的目标资源、权限和 MCP manifest。
|
||||
*
|
||||
* @param skill 已发布 Skill
|
||||
*/
|
||||
void assertPublishedSnapshotUsable(Skill skill);
|
||||
|
||||
/**
|
||||
* 校验 Tool 发布快照的内容 hash。
|
||||
*
|
||||
* @param snapshot Tool 发布快照
|
||||
*/
|
||||
void assertPublishedSnapshotHash(Map<String, Object> snapshot);
|
||||
|
||||
/**
|
||||
* 删除指定 Skill 的全部草稿 Tool 绑定。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
*/
|
||||
void removeBySkillId(BigInteger skillId);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package tech.easyflow.skill.service;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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.permission.McpAccessPermissionChecker;
|
||||
import tech.easyflow.ai.service.McpService;
|
||||
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.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||
import tech.easyflow.skill.vo.SkillMcpToolManifestView;
|
||||
import tech.easyflow.skill.vo.SkillToolOptionPage;
|
||||
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.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Skill Studio 页面归属的 Tool 候选与 MCP 清单查询服务。
|
||||
*/
|
||||
@Service
|
||||
public class SkillToolOptionQueryService {
|
||||
|
||||
private final WorkflowService workflowService;
|
||||
private final PluginItemService pluginItemService;
|
||||
private final PluginMapper pluginMapper;
|
||||
private final PluginVisibilityService pluginVisibilityService;
|
||||
private final McpService mcpService;
|
||||
private final McpAccessPermissionChecker mcpAccessPermissionChecker;
|
||||
private final SkillToolResourceService resourceService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
|
||||
/**
|
||||
* 创建候选查询服务。
|
||||
*/
|
||||
public SkillToolOptionQueryService(WorkflowService workflowService,
|
||||
PluginItemService pluginItemService,
|
||||
PluginMapper pluginMapper,
|
||||
PluginVisibilityService pluginVisibilityService,
|
||||
McpService mcpService,
|
||||
McpAccessPermissionChecker mcpAccessPermissionChecker,
|
||||
SkillToolResourceService resourceService,
|
||||
ResourceAccessService resourceAccessService) {
|
||||
this.workflowService = workflowService;
|
||||
this.pluginItemService = pluginItemService;
|
||||
this.pluginMapper = pluginMapper;
|
||||
this.pluginVisibilityService = pluginVisibilityService;
|
||||
this.mcpService = mcpService;
|
||||
this.mcpAccessPermissionChecker = mcpAccessPermissionChecker;
|
||||
this.resourceService = resourceService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前操作者可绑定的 Tool 候选。
|
||||
*
|
||||
* @param keyword 名称或描述关键词
|
||||
* @param toolType 类型过滤
|
||||
* @param pageNum 页码
|
||||
* @param pageSize 每页数量
|
||||
* @return 安全候选分页
|
||||
*/
|
||||
public SkillToolOptionPage page(String keyword, String toolType, long pageNum, long pageSize) {
|
||||
LoginAccount account = requireAccount();
|
||||
String normalizedType = toolType == null ? "ALL" : toolType.trim().toUpperCase(Locale.ROOT);
|
||||
if (!List.of("ALL", "WORKFLOW", "PLUGIN", "MCP").contains(normalizedType)) {
|
||||
throw new BusinessException("不支持的 Tool 类型:" + toolType);
|
||||
}
|
||||
Predicate<SkillToolOptionPage.Item> keywordFilter = item -> matches(item, keyword);
|
||||
List<SkillToolOptionPage.Item> candidates = new ArrayList<>();
|
||||
if ("ALL".equals(normalizedType) || "WORKFLOW".equals(normalizedType)) {
|
||||
workflowService.list(QueryWrapper.create()
|
||||
.eq(Workflow::getTenantId, account.getTenantId())
|
||||
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode()))
|
||||
.stream().filter(item -> resourceAccessService.canAccess(
|
||||
CategoryResourceType.WORKFLOW, item, ResourceAction.USE))
|
||||
.map(item -> new SkillToolOptionPage.Item("WORKFLOW", item.getId(), item.getTitle(),
|
||||
item.getDescription(), true, false, 1))
|
||||
.filter(keywordFilter).forEach(candidates::add);
|
||||
}
|
||||
if ("ALL".equals(normalizedType) || "PLUGIN".equals(normalizedType)) {
|
||||
appendPlugins(account, keywordFilter, candidates);
|
||||
}
|
||||
if ("MCP".equals(normalizedType)) {
|
||||
mcpAccessPermissionChecker.assertCanUseMcp();
|
||||
appendMcps(account, keywordFilter, candidates);
|
||||
} else if ("ALL".equals(normalizedType) && mcpAccessPermissionChecker.canUseMcp()) {
|
||||
// 聚合查询只展示当前用户可用的资源,不能让缺少 MCP 权限影响其他候选。
|
||||
appendMcps(account, keywordFilter, candidates);
|
||||
}
|
||||
candidates.sort(Comparator.comparing(SkillToolOptionPage.Item::title,
|
||||
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))
|
||||
.thenComparing(SkillToolOptionPage.Item::targetId));
|
||||
long safePage = Math.max(1, pageNum);
|
||||
long safeSize = Math.max(1, Math.min(100, pageSize));
|
||||
int from = (int) Math.min(candidates.size(), (safePage - 1) * safeSize);
|
||||
int to = (int) Math.min(candidates.size(), from + safeSize);
|
||||
return new SkillToolOptionPage(candidates.subList(from, to), candidates.size(), safePage, safeSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加当前租户可用的 MCP 候选。
|
||||
*
|
||||
* @param account 当前账号
|
||||
* @param keywordFilter 关键词过滤器
|
||||
* @param candidates 候选集合
|
||||
*/
|
||||
private void appendMcps(LoginAccount account,
|
||||
Predicate<SkillToolOptionPage.Item> keywordFilter,
|
||||
List<SkillToolOptionPage.Item> candidates) {
|
||||
mcpService.list(QueryWrapper.create()
|
||||
.eq(Mcp::getTenantId, account.getTenantId())
|
||||
.eq(Mcp::getStatus, true))
|
||||
.stream().map(item -> new SkillToolOptionPage.Item("MCP", item.getId(), item.getTitle(),
|
||||
item.getDescription(), true, Boolean.TRUE.equals(item.getApprovalRequired()), null))
|
||||
.filter(keywordFilter).forEach(candidates::add);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现指定 MCP 的 Tool 清单。
|
||||
*
|
||||
* @param mcpId MCP ID
|
||||
* @return 脱敏清单
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SkillMcpToolManifestView mcpTools(BigInteger mcpId) {
|
||||
LoginAccount account = requireAccount();
|
||||
Skill pseudoSkill = new Skill();
|
||||
pseudoSkill.setTenantId(account.getTenantId());
|
||||
SkillToolBinding binding = new SkillToolBinding();
|
||||
binding.setToolType("MCP");
|
||||
binding.setTargetId(mcpId);
|
||||
SkillToolResourceService.McpResource resource = resourceService.requireMcp(pseudoSkill, binding);
|
||||
List<SkillMcpToolManifestView.Tool> tools = resource.manifest().stream()
|
||||
.map(item -> new SkillMcpToolManifestView.Tool(item.getName(), item.getDescription(),
|
||||
item.getInputSchema(), item.getOutputSchema()))
|
||||
.toList();
|
||||
return new SkillMcpToolManifestView(resource.manifestHash(), tools.size(), tools);
|
||||
}
|
||||
|
||||
private void appendPlugins(LoginAccount account,
|
||||
Predicate<SkillToolOptionPage.Item> keywordFilter,
|
||||
List<SkillToolOptionPage.Item> candidates) {
|
||||
Map<BigInteger, Plugin> plugins = pluginMapper.selectListByQuery(QueryWrapper.create()
|
||||
.eq(Plugin::getTenantId, account.getTenantId())).stream()
|
||||
.filter(plugin -> pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId()))
|
||||
.collect(java.util.stream.Collectors.toMap(Plugin::getId, plugin -> plugin));
|
||||
if (plugins.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
pluginItemService.list(QueryWrapper.create()
|
||||
.in(PluginItem::getPluginId, plugins.keySet())
|
||||
.eq(PluginItem::getStatus, 1))
|
||||
.stream().map(item -> new SkillToolOptionPage.Item("PLUGIN", item.getId(), item.getName(),
|
||||
item.getDescription(), true, false, 1))
|
||||
.filter(keywordFilter).forEach(candidates::add);
|
||||
}
|
||||
|
||||
private boolean matches(SkillToolOptionPage.Item item, String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
String needle = keyword.trim().toLowerCase(Locale.ROOT);
|
||||
return contains(item.title(), needle) || contains(item.description(), needle);
|
||||
}
|
||||
|
||||
private boolean contains(String value, String needle) {
|
||||
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
|
||||
}
|
||||
|
||||
private LoginAccount requireAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException(401, 401, "当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package tech.easyflow.skill.service;
|
||||
|
||||
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Skill Tool 目标资源的权限校验、清单读取与安全快照服务。
|
||||
*/
|
||||
public interface SkillToolResourceService {
|
||||
|
||||
/**
|
||||
* 校验并加载已发布工作流。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param binding 工作流绑定
|
||||
* @return 已发布工作流
|
||||
*/
|
||||
Workflow requireWorkflow(Skill skill, SkillToolBinding binding);
|
||||
|
||||
/**
|
||||
* 校验并加载已启用插件工具。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param binding 插件绑定
|
||||
* @return 插件工具
|
||||
*/
|
||||
PluginItem requirePlugin(Skill skill, SkillToolBinding binding);
|
||||
|
||||
/**
|
||||
* 校验并加载可用的单服务 MCP。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param binding MCP 绑定
|
||||
* @return MCP 与当前 Tool 清单
|
||||
*/
|
||||
McpResource requireMcp(Skill skill, SkillToolBinding binding);
|
||||
|
||||
/**
|
||||
* 构建运行时所需的资源快照。
|
||||
*
|
||||
* @param resource 资源实体
|
||||
* @return 包含插件项与父插件调用配置的服务端内部资源快照
|
||||
*/
|
||||
Map<String, Object> snapshotWorkflow(Workflow workflow);
|
||||
|
||||
/**
|
||||
* 构建插件工具运行快照。
|
||||
*
|
||||
* @param pluginItem 插件工具
|
||||
* @return 服务端内部资源快照
|
||||
*/
|
||||
Map<String, Object> snapshotPlugin(PluginItem pluginItem);
|
||||
|
||||
/**
|
||||
* 构建 MCP 受控连接快照。
|
||||
*
|
||||
* <p>该快照仅供服务端 Runtime 使用,字段使用显式白名单,禁止直接序列化 MCP 实体。</p>
|
||||
*
|
||||
* @param mcp MCP 资源
|
||||
* @return 服务端内部连接快照
|
||||
*/
|
||||
Map<String, Object> snapshotMcpConnection(Mcp mcp);
|
||||
|
||||
/**
|
||||
* 不连接外部服务地读取绑定资源脱敏摘要。
|
||||
*
|
||||
* @param binding Tool 绑定
|
||||
* @return 可用于详情展示的摘要
|
||||
*/
|
||||
Map<String, Object> currentSummary(SkillToolBinding binding);
|
||||
|
||||
/**
|
||||
* MCP 与已规范化 Tool 清单。
|
||||
*
|
||||
* @param mcp MCP 资源
|
||||
* @param manifest 冻结清单
|
||||
* @param manifestHash 完整清单 hash
|
||||
*/
|
||||
record McpResource(Mcp mcp,
|
||||
List<McpToolManifestEntry> manifest,
|
||||
String manifestHash) {
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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.beans.factory.ObjectProvider;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
@@ -25,6 +26,8 @@ import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
import tech.easyflow.skill.service.SkillResourceService;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.service.SkillToolBindingService;
|
||||
import tech.easyflow.skill.service.SkillReferenceProvider;
|
||||
import tech.easyflow.skill.store.DBSkillContentStore;
|
||||
import tech.easyflow.skill.support.SkillModelConverter;
|
||||
import tech.easyflow.skill.validation.SkillValidationIssue;
|
||||
@@ -59,33 +62,41 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
private final DefaultSkillValidator skillValidator = new DefaultSkillValidator();
|
||||
private final SkillCategoryService skillCategoryService;
|
||||
private final SkillResourceService skillResourceService;
|
||||
private final SkillToolBindingService skillToolBindingService;
|
||||
private final DBSkillContentStore contentStore;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final CategoryPermissionService categoryPermissionService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ObjectProvider<SkillReferenceProvider> referenceProviders;
|
||||
|
||||
/**
|
||||
* 创建 Skill 业务服务。
|
||||
*
|
||||
* @param skillCategoryService Skill 分类服务
|
||||
* @param skillResourceService 通用资源服务
|
||||
* @param skillToolBindingService 平台 Tool 绑定服务
|
||||
* @param contentStore 二进制内容仓库
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param categoryPermissionService 分类权限服务
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param referenceProviders 上层引用查询扩展点
|
||||
*/
|
||||
public SkillServiceImpl(SkillCategoryService skillCategoryService,
|
||||
SkillResourceService skillResourceService,
|
||||
SkillToolBindingService skillToolBindingService,
|
||||
DBSkillContentStore contentStore,
|
||||
ResourceAccessService resourceAccessService,
|
||||
CategoryPermissionService categoryPermissionService,
|
||||
ObjectMapper objectMapper) {
|
||||
ObjectMapper objectMapper,
|
||||
ObjectProvider<SkillReferenceProvider> referenceProviders) {
|
||||
this.skillCategoryService = skillCategoryService;
|
||||
this.skillResourceService = skillResourceService;
|
||||
this.skillToolBindingService = skillToolBindingService;
|
||||
this.contentStore = contentStore;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.categoryPermissionService = categoryPermissionService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.referenceProviders = referenceProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +118,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
Skill skill = requireSkill(id);
|
||||
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
|
||||
fillResourceDescriptors(skill);
|
||||
skill.setToolBindings(skillToolBindingService.listSummaries(id));
|
||||
return skill;
|
||||
}
|
||||
|
||||
@@ -338,7 +350,9 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("schemaVersion", 2);
|
||||
snapshot.put("name", detail.getName());
|
||||
snapshot.put("displayName", detail.getDisplayName());
|
||||
snapshot.put("description", detail.getDescription());
|
||||
snapshot.put("visibilityScope", detail.getVisibilityScope());
|
||||
snapshot.put("skillContent", detail.getSkillContent());
|
||||
snapshot.put("packageHash", detail.getPackageHash());
|
||||
snapshot.put("resources", buildResourceSnapshot(detail.getResources()));
|
||||
@@ -347,6 +361,53 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Map<String, Object> buildApprovalSnapshot(Skill skill) {
|
||||
Map<String, Object> contentSnapshot = buildPublishSnapshot(skill);
|
||||
Map<String, Object> toolSnapshot = skillToolBindingService.buildPublishSnapshot(skill);
|
||||
Map<String, Object> approvalSnapshot = new LinkedHashMap<>(contentSnapshot);
|
||||
String contentHash = String.valueOf(contentSnapshot.get("snapshotHash"));
|
||||
approvalSnapshot.remove("snapshotHash");
|
||||
approvalSnapshot.put("contentSnapshotHash", contentHash);
|
||||
approvalSnapshot.put("platformToolBindings", toolSnapshot);
|
||||
approvalSnapshot.put("toolBindingsHash", toolSnapshot.get("snapshotHash"));
|
||||
approvalSnapshot.put("snapshotHash", hashJson(approvalSnapshot));
|
||||
return approvalSnapshot;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Map<String, Object> extractContentSnapshot(Map<String, Object> approvalSnapshot) {
|
||||
if (approvalSnapshot == null || approvalSnapshot.isEmpty()) {
|
||||
throw new BusinessException("Skill 发布快照为空");
|
||||
}
|
||||
Map<String, Object> content = new LinkedHashMap<>(approvalSnapshot);
|
||||
Object contentHash = content.remove("contentSnapshotHash");
|
||||
content.remove("platformToolBindings");
|
||||
content.remove("toolBindingsHash");
|
||||
content.remove("snapshotHash");
|
||||
if (contentHash == null) {
|
||||
// 兼容 L13 仅含标准包内容的历史审批快照。
|
||||
return new LinkedHashMap<>(approvalSnapshot);
|
||||
}
|
||||
content.put("snapshotHash", String.valueOf(contentHash));
|
||||
assertSnapshotHash(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Map<String, Object> extractToolBindingsSnapshot(Map<String, Object> approvalSnapshot) {
|
||||
Object value = approvalSnapshot == null ? null : approvalSnapshot.get("platformToolBindings");
|
||||
if (!(value instanceof Map<?, ?> source)) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
source.forEach((key, item) -> result.put(String.valueOf(key), item));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@@ -386,6 +447,49 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void assertPublishedAggregateHash(Skill skill) {
|
||||
if (skill == null || skill.getPublishedSnapshotJson() == null
|
||||
|| skill.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException("Skill 发布快照为空");
|
||||
}
|
||||
Map<String, Object> content = skill.getPublishedSnapshotJson();
|
||||
Map<String, Object> toolSnapshot = skill.getPublishedToolBindingsJson() == null
|
||||
? new LinkedHashMap<>() : skill.getPublishedToolBindingsJson();
|
||||
assertSnapshotHash(content);
|
||||
if (toolSnapshot.isEmpty()) {
|
||||
if (skill.getSnapshotHash() != null && !skill.getSnapshotHash().isBlank()
|
||||
&& !skill.getSnapshotHash().equals(String.valueOf(content.get("snapshotHash")))) {
|
||||
throw new BusinessException("Skill 历史发布快照 hash 校验失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
skillToolBindingService.assertPublishedSnapshotHash(toolSnapshot);
|
||||
if (skill.getSnapshotHash() == null || skill.getSnapshotHash().isBlank()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> combined = new LinkedHashMap<>(content);
|
||||
Object contentHash = combined.remove("snapshotHash");
|
||||
combined.put("contentSnapshotHash", contentHash);
|
||||
combined.put("platformToolBindings", toolSnapshot);
|
||||
combined.put("toolBindingsHash", toolSnapshot.get("snapshotHash"));
|
||||
if (!skill.getSnapshotHash().equals(hashJson(combined))) {
|
||||
throw new BusinessException("Skill 发布组合快照 hash 校验失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void assertNoActiveReferences(BigInteger skillId) {
|
||||
for (SkillReferenceProvider provider : referenceProviders.orderedStream().toList()) {
|
||||
List<String> references = provider.listReferences(skillId);
|
||||
if (references != null && !references.isEmpty()) {
|
||||
throw new BusinessException("Skill 仍被" + references.get(0) + "使用,请先取消绑定或重新发布后再操作");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@@ -454,6 +558,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
// 文件和资源更新同样先锁 Skill 行;删除必须持有该锁直到引用计数变更完成。
|
||||
Skill skill = requireSkill(id, true);
|
||||
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill");
|
||||
assertNoActiveReferences(id);
|
||||
assertRemovableStatus(skill, lifecycleDelete);
|
||||
List<SkillResource> resources = listResources(id);
|
||||
if (!skillResourceService.remove(QueryWrapper.create()
|
||||
@@ -463,6 +568,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
throw new BusinessException(500, 500, "删除 Skill 资源失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
skillToolBindingService.removeBySkillId(id);
|
||||
if (getMapper().deleteByQuery(tenantSkillQuery(id)) != 1) {
|
||||
throw new BusinessException(500, 500, "删除 Skill 失败,请稍后重试");
|
||||
}
|
||||
@@ -776,7 +882,12 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
|
||||
if (value instanceof List<?> list) {
|
||||
return list.stream().map(this::canonicalizeJson).toList();
|
||||
}
|
||||
return value;
|
||||
if (value == null || value instanceof String || value instanceof Number
|
||||
|| value instanceof Boolean) {
|
||||
return value;
|
||||
}
|
||||
// MCP Manifest 等对象在发布时是 POJO,持久化后会恢复为 Map,需先投影为同一 JSON 结构。
|
||||
return canonicalizeJson(objectMapper.convertValue(value, Object.class));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
package tech.easyflow.skill.service.impl;
|
||||
|
||||
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
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;
|
||||
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.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||
import tech.easyflow.skill.enums.SkillToolType;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.mapper.SkillToolBindingMapper;
|
||||
import tech.easyflow.skill.service.SkillToolBindingService;
|
||||
import tech.easyflow.skill.service.SkillToolResourceService;
|
||||
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.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* Skill 平台 Tool 绑定服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class SkillToolBindingServiceImpl
|
||||
extends ServiceImpl<SkillToolBindingMapper, SkillToolBinding>
|
||||
implements SkillToolBindingService {
|
||||
|
||||
private static final int MAX_TOOL_COUNT = 20;
|
||||
|
||||
private final SkillMapper skillMapper;
|
||||
private final SkillToolResourceService resourceService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建 Skill Tool 绑定服务。
|
||||
*
|
||||
* @param skillMapper Skill Mapper
|
||||
* @param resourceService Tool 目标资源服务
|
||||
* @param resourceAccessService 资源权限服务
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public SkillToolBindingServiceImpl(SkillMapper skillMapper,
|
||||
SkillToolResourceService resourceService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.skillMapper = skillMapper;
|
||||
this.resourceService = resourceService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<SkillToolBinding> replaceBindings(BigInteger skillId,
|
||||
List<SkillToolBinding> bindings) {
|
||||
Skill skill = requireSkillForUpdate(skillId);
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理该 Skill");
|
||||
List<SkillToolBinding> normalized = normalizeBindings(skill, bindings, true);
|
||||
remove(QueryWrapper.create()
|
||||
.eq(SkillToolBinding::getTenantId, skill.getTenantId())
|
||||
.eq(SkillToolBinding::getSkillId, skill.getId()));
|
||||
if (!normalized.isEmpty()) {
|
||||
saveBatch(normalized);
|
||||
}
|
||||
return listSummaries(skillId);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public List<SkillToolBinding> listSummaries(BigInteger skillId) {
|
||||
Skill skill = skillMapper.selectOneById(skillId);
|
||||
if (skill == null) {
|
||||
throw new BusinessException(404, 404, "Skill 不存在");
|
||||
}
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
|
||||
List<SkillToolBinding> bindings = listBindings(skillId);
|
||||
for (SkillToolBinding binding : bindings) {
|
||||
binding.setResourceSummary(buildCurrentSummary(skill, binding));
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public List<SkillToolBinding> listBindings(BigInteger skillId) {
|
||||
if (skillId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return list(QueryWrapper.create()
|
||||
.eq(SkillToolBinding::getSkillId, skillId)
|
||||
.orderBy(SkillToolBinding::getSortNo, true)
|
||||
.orderBy(SkillToolBinding::getId, true));
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> buildPublishSnapshot(Skill skill) {
|
||||
if (skill == null || skill.getId() == null) {
|
||||
throw new BusinessException("Skill ID 不能为空");
|
||||
}
|
||||
List<SkillToolBinding> bindings = listBindings(skill.getId());
|
||||
assertUniqueBindings(bindings, "同一工具资源不能重复绑定");
|
||||
Map<String, Map<String, Object>> snapshotsByResource = new LinkedHashMap<>();
|
||||
int toolCount = 0;
|
||||
for (SkillToolBinding binding : stableResourceOrder(bindings)) {
|
||||
Map<String, Object> item = buildBindingSnapshot(skill, binding);
|
||||
toolCount += ((Number) item.get("toolCount")).intValue();
|
||||
if (toolCount > MAX_TOOL_COUNT) {
|
||||
throw new BusinessException(409, 4092, "单个 Skill 最多可绑定 20 个实际 Tool");
|
||||
}
|
||||
snapshotsByResource.put(bindingKey(binding), item);
|
||||
}
|
||||
List<Map<String, Object>> snapshots = bindings.stream()
|
||||
.map(binding -> snapshotsByResource.get(bindingKey(binding)))
|
||||
.toList();
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("schemaVersion", 1);
|
||||
snapshot.put("bindings", snapshots);
|
||||
snapshot.put("snapshotHash", hash(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void assertPublishedSnapshotUsable(Skill skill) {
|
||||
Map<String, Object> snapshot = skill == null ? null : skill.getPublishedToolBindingsJson();
|
||||
if (snapshot == null || snapshot.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Object rawBindings = snapshot.get("bindings");
|
||||
if (!(rawBindings instanceof List<?> items)) {
|
||||
throw new BusinessException(409, 4092, "Skill Tool 发布快照格式错误");
|
||||
}
|
||||
List<SkillToolBinding> bindings = new ArrayList<>();
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
if (!(items.get(index) instanceof Map<?, ?> raw)) {
|
||||
throw new BusinessException(409, 4092, "Skill Tool 发布快照格式错误");
|
||||
}
|
||||
bindings.add(snapshotBinding(raw, index));
|
||||
}
|
||||
assertUniqueBindings(bindings, "Skill Tool 发布快照包含重复资源");
|
||||
int toolCount = 0;
|
||||
for (SkillToolBinding binding : stableResourceOrder(bindings)) {
|
||||
SkillToolType type = SkillToolType.from(binding.getToolType());
|
||||
if (type == SkillToolType.WORKFLOW) {
|
||||
resourceService.requireWorkflow(skill, binding);
|
||||
toolCount++;
|
||||
} else if (type == SkillToolType.PLUGIN) {
|
||||
resourceService.requirePlugin(skill, binding);
|
||||
toolCount++;
|
||||
} else {
|
||||
SkillToolResourceService.McpResource mcp = resourceService.requireMcp(skill, binding);
|
||||
if (!mcp.manifestHash().equals(binding.getMcpToolManifestHash())) {
|
||||
throw new BusinessException(409, 4092, "已发布 Skill 的 MCP Tool 清单已变化,请重新发布 Skill");
|
||||
}
|
||||
toolCount += mcp.manifest().size();
|
||||
}
|
||||
if (toolCount > MAX_TOOL_COUNT) {
|
||||
throw new BusinessException(409, 4092, "已发布 Skill 的实际 Tool 数超过 20 个");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void assertPublishedSnapshotHash(Map<String, Object> snapshot) {
|
||||
if (snapshot == null || snapshot.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Object declared = snapshot.get("snapshotHash");
|
||||
if (declared == null) {
|
||||
throw new BusinessException("Skill Tool 发布快照缺少 hash");
|
||||
}
|
||||
Map<String, Object> canonical = new LinkedHashMap<>(snapshot);
|
||||
canonical.remove("snapshotHash");
|
||||
if (!String.valueOf(declared).equals(hash(canonical))) {
|
||||
throw new BusinessException("Skill Tool 发布快照 hash 校验失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void removeBySkillId(BigInteger skillId) {
|
||||
if (skillId == null) {
|
||||
return;
|
||||
}
|
||||
remove(QueryWrapper.create().eq(SkillToolBinding::getSkillId, skillId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化绑定并完成权限、MCP manifest 和数量复核。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param bindings 原始绑定
|
||||
* @param compareClientManifest 是否校验客户端看见的 MCP manifest
|
||||
* @return 可持久化的稳定绑定
|
||||
*/
|
||||
private List<SkillToolBinding> normalizeBindings(Skill skill,
|
||||
List<SkillToolBinding> bindings,
|
||||
boolean compareClientManifest) {
|
||||
if (bindings == null || bindings.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<SkillToolBinding> normalized = new ArrayList<>();
|
||||
for (int i = 0; i < bindings.size(); i++) {
|
||||
SkillToolBinding source = bindings.get(i);
|
||||
if (source == null || source.getTargetId() == null) {
|
||||
throw new BusinessException("Skill 工具绑定参数不完整");
|
||||
}
|
||||
SkillToolType type = SkillToolType.from(source.getToolType());
|
||||
normalized.add(copyForPersistence(skill, source, type, i));
|
||||
}
|
||||
assertUniqueBindings(normalized, "同一工具资源不能重复绑定");
|
||||
int toolCount = 0;
|
||||
for (SkillToolBinding binding : stableResourceOrder(normalized)) {
|
||||
SkillToolType type = SkillToolType.from(binding.getToolType());
|
||||
if (type == SkillToolType.WORKFLOW) {
|
||||
resourceService.requireWorkflow(skill, binding);
|
||||
toolCount++;
|
||||
} else if (type == SkillToolType.PLUGIN) {
|
||||
resourceService.requirePlugin(skill, binding);
|
||||
toolCount++;
|
||||
} else {
|
||||
SkillToolResourceService.McpResource mcp = resourceService.requireMcp(skill, binding);
|
||||
if (compareClientManifest && (binding.getMcpToolManifestHash() == null
|
||||
|| !binding.getMcpToolManifestHash().equals(mcp.manifestHash()))) {
|
||||
throw new BusinessException(409, 4092, "MCP Tool 清单已变化,请刷新后重新确认");
|
||||
}
|
||||
if (!compareClientManifest && binding.getMcpToolManifestHash() != null
|
||||
&& !binding.getMcpToolManifestHash().equals(mcp.manifestHash())) {
|
||||
throw new BusinessException(409, 4092, "MCP Tool 清单已变化,请重新保存绑定后发布");
|
||||
}
|
||||
binding.setMcpToolCount(mcp.manifest().size());
|
||||
binding.setMcpToolManifestHash(mcp.manifestHash());
|
||||
binding.setHitlEnabled(Boolean.TRUE.equals(binding.getHitlEnabled())
|
||||
|| Boolean.TRUE.equals(mcp.mcp().getApprovalRequired()));
|
||||
toolCount += mcp.manifest().size();
|
||||
}
|
||||
if (toolCount > MAX_TOOL_COUNT) {
|
||||
throw new BusinessException(409, 4092, "单个 Skill 最多可绑定 20 个实际 Tool");
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建安全持久化副本并写入审计字段。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param source 原始绑定
|
||||
* @param type Tool 类型
|
||||
* @param index 稳定顺序
|
||||
* @return 持久化绑定
|
||||
*/
|
||||
private SkillToolBinding copyForPersistence(Skill skill,
|
||||
SkillToolBinding source,
|
||||
SkillToolType type,
|
||||
int index) {
|
||||
LoginAccount account = requireCurrentAccount();
|
||||
Date now = new Date();
|
||||
SkillToolBinding binding = new SkillToolBinding();
|
||||
binding.setTenantId(skill.getTenantId());
|
||||
binding.setSkillId(skill.getId());
|
||||
binding.setToolType(type.name());
|
||||
binding.setTargetId(source.getTargetId());
|
||||
binding.setHitlEnabled(Boolean.TRUE.equals(source.getHitlEnabled()));
|
||||
binding.setMcpToolCount(type == SkillToolType.MCP ? source.getMcpToolCount() : null);
|
||||
binding.setMcpToolManifestHash(type == SkillToolType.MCP ? source.getMcpToolManifestHash() : null);
|
||||
binding.setSortNo(index);
|
||||
binding.setCreated(now);
|
||||
binding.setCreatedBy(account.getId());
|
||||
binding.setModified(now);
|
||||
binding.setModifiedBy(account.getId());
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单条发布绑定快照。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param binding 规范化绑定
|
||||
* @return 冻结绑定
|
||||
*/
|
||||
private Map<String, Object> buildBindingSnapshot(Skill skill, SkillToolBinding binding) {
|
||||
SkillToolType type = SkillToolType.from(binding.getToolType());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("toolType", type.name());
|
||||
result.put("targetId", binding.getTargetId());
|
||||
result.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled()));
|
||||
result.put("sortNo", binding.getSortNo());
|
||||
if (type == SkillToolType.WORKFLOW) {
|
||||
Workflow workflow = resourceService.requireWorkflow(skill, binding);
|
||||
result.put("displayName", workflow.getTitle());
|
||||
result.put("toolCount", 1);
|
||||
result.put("resourceSnapshot", resourceService.snapshotWorkflow(workflow));
|
||||
return result;
|
||||
}
|
||||
if (type == SkillToolType.PLUGIN) {
|
||||
PluginItem plugin = resourceService.requirePlugin(skill, binding);
|
||||
result.put("displayName", plugin.getName());
|
||||
result.put("toolCount", 1);
|
||||
result.put("resourceSnapshot", resourceService.snapshotPlugin(plugin));
|
||||
return result;
|
||||
}
|
||||
SkillToolResourceService.McpResource mcp = resourceService.requireMcp(skill, binding);
|
||||
if (binding.getMcpToolManifestHash() == null
|
||||
|| !binding.getMcpToolManifestHash().equals(mcp.manifestHash())) {
|
||||
throw new BusinessException(409, 4092, "MCP Tool 清单已变化,请重新保存绑定后发布");
|
||||
}
|
||||
result.put("displayName", mcp.mcp().getTitle());
|
||||
result.put("toolCount", mcp.manifest().size());
|
||||
result.put("mcpToolManifestHash", mcp.manifestHash());
|
||||
result.put("mcpToolManifest", mcp.manifest());
|
||||
result.put("resourceSnapshot", resourceService.snapshotMcpConnection(mcp.mcp()));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验资源引用不重复。
|
||||
*
|
||||
* @param bindings 绑定列表
|
||||
* @param message 重复时的错误消息
|
||||
*/
|
||||
private void assertUniqueBindings(List<SkillToolBinding> bindings, String message) {
|
||||
Set<String> unique = new LinkedHashSet<>();
|
||||
for (SkillToolBinding binding : bindings) {
|
||||
if (!unique.add(bindingKey(binding))) {
|
||||
throw new BusinessException(409, 4092, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按资源类型和 ID 生成稳定加锁顺序,同时保留原列表的展示顺序。
|
||||
*
|
||||
* @param bindings 绑定列表
|
||||
* @return 稳定排序副本
|
||||
*/
|
||||
private List<SkillToolBinding> stableResourceOrder(List<SkillToolBinding> bindings) {
|
||||
return bindings.stream()
|
||||
.sorted(Comparator.comparing((SkillToolBinding binding) ->
|
||||
SkillToolType.from(binding.getToolType()).name())
|
||||
.thenComparing(SkillToolBinding::getTargetId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成绑定资源唯一键。
|
||||
*
|
||||
* @param binding 绑定
|
||||
* @return 类型与目标 ID 组合键
|
||||
*/
|
||||
private String bindingKey(SkillToolBinding binding) {
|
||||
if (binding == null || binding.getTargetId() == null) {
|
||||
throw new BusinessException("Skill 工具绑定参数不完整");
|
||||
}
|
||||
return SkillToolType.from(binding.getToolType()).name() + ":" + binding.getTargetId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建当前资源的脱敏摘要。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param binding 绑定
|
||||
* @return 脱敏摘要
|
||||
*/
|
||||
private Map<String, Object> buildCurrentSummary(Skill skill, SkillToolBinding binding) {
|
||||
return resourceService.currentSummary(binding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询并锁定 Skill。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @return 已锁定 Skill
|
||||
*/
|
||||
private Skill requireSkillForUpdate(BigInteger skillId) {
|
||||
if (skillId == null) {
|
||||
throw new BusinessException("Skill ID 不能为空");
|
||||
}
|
||||
Skill skill = skillMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq(Skill::getId, skillId)
|
||||
.forUpdate());
|
||||
if (skill == null) {
|
||||
throw new BusinessException(404, 404, "Skill 不存在");
|
||||
}
|
||||
if (PublishStatus.from(skill.getPublishStatus()) == PublishStatus.DELETE_PENDING) {
|
||||
throw new BusinessException(409, 4092, "Skill 正在删除审批中,不能修改工具绑定");
|
||||
}
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Tool 发布快照 hash。
|
||||
*
|
||||
* @param value 待计算值
|
||||
* @return SHA-256
|
||||
*/
|
||||
private String hash(Map<String, Object> value) {
|
||||
try {
|
||||
return SkillHashes.sha256Hex(objectMapper.writeValueAsBytes(canonicalizeJson(value)));
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BusinessException(500, 500, "Skill 工具快照序列化失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将快照转换为与 JSON 持久化前后无关的稳定结构。
|
||||
*
|
||||
* @param value 快照节点
|
||||
* @return 键有序且只含 JSON 基础类型的结构
|
||||
*/
|
||||
private Object canonicalizeJson(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<String, Object> sorted = new TreeMap<>();
|
||||
map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalizeJson(item)));
|
||||
return sorted;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
return list.stream().map(this::canonicalizeJson).toList();
|
||||
}
|
||||
if (value == null || value instanceof String || value instanceof Number
|
||||
|| value instanceof Boolean) {
|
||||
return value;
|
||||
}
|
||||
// Manifest entries are POJOs before persistence and Maps after JSON loading.
|
||||
return canonicalizeJson(objectMapper.convertValue(value, Object.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
*/
|
||||
private LoginAccount requireCurrentAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null) {
|
||||
throw new BusinessException(401, 401, "当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将冻结快照项转换为只含校验字段的绑定引用。
|
||||
*
|
||||
* @param raw 冻结快照项
|
||||
* @param index 稳定顺序
|
||||
* @return 绑定引用
|
||||
*/
|
||||
private SkillToolBinding snapshotBinding(Map<?, ?> raw, int index) {
|
||||
SkillToolBinding binding = new SkillToolBinding();
|
||||
binding.setToolType(String.valueOf(raw.get("toolType")));
|
||||
Object targetId = raw.get("targetId");
|
||||
if (targetId == null) {
|
||||
throw new BusinessException(409, 4092, "Skill Tool 发布快照缺少目标 ID");
|
||||
}
|
||||
binding.setTargetId(new BigInteger(String.valueOf(targetId)));
|
||||
binding.setHitlEnabled(Boolean.TRUE.equals(raw.get("hitlEnabled")));
|
||||
Object manifestHash = raw.get("mcpToolManifestHash");
|
||||
binding.setMcpToolManifestHash(manifestHash == null ? null : String.valueOf(manifestHash));
|
||||
Object count = raw.get("toolCount");
|
||||
binding.setMcpToolCount(count instanceof Number number ? number.intValue() : null);
|
||||
binding.setSortNo(index);
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package tech.easyflow.skill.service.impl;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.SkillToolReferenceProvider;
|
||||
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||
import tech.easyflow.skill.enums.SkillToolType;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.service.SkillToolBindingService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Skill 草稿与有效发布快照中的平台 Tool 引用提供者。
|
||||
*/
|
||||
@Component
|
||||
public class SkillToolReferenceProviderImpl implements SkillToolReferenceProvider {
|
||||
|
||||
private final SkillService skillService;
|
||||
private final SkillToolBindingService bindingService;
|
||||
|
||||
/**
|
||||
* 创建 Skill Tool 引用提供者。
|
||||
*
|
||||
* @param skillService Skill 服务
|
||||
* @param bindingService Skill Tool 绑定服务
|
||||
*/
|
||||
public SkillToolReferenceProviderImpl(SkillService skillService,
|
||||
SkillToolBindingService bindingService) {
|
||||
this.skillService = skillService;
|
||||
this.bindingService = bindingService;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public List<OfflineImpactBindingVo> listSkillsByWorkflowId(BigInteger id) {
|
||||
return listReferences(SkillToolType.WORKFLOW, id);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public List<OfflineImpactBindingVo> listSkillsByPluginItemId(BigInteger id) {
|
||||
return listReferences(SkillToolType.PLUGIN, id);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public List<OfflineImpactBindingVo> listSkillsByMcpId(BigInteger id) {
|
||||
return listReferences(SkillToolType.MCP, id);
|
||||
}
|
||||
|
||||
private List<OfflineImpactBindingVo> listReferences(SkillToolType type, BigInteger targetId) {
|
||||
Set<BigInteger> ids = new LinkedHashSet<>();
|
||||
for (SkillToolBinding binding : bindingService.list(QueryWrapper.create()
|
||||
.eq(SkillToolBinding::getToolType, type.name())
|
||||
.eq(SkillToolBinding::getTargetId, targetId))) {
|
||||
ids.add(binding.getSkillId());
|
||||
}
|
||||
for (Skill skill : skillService.list(QueryWrapper.create()
|
||||
.select(Skill::getId, Skill::getPublishStatus, Skill::getPublishedToolBindingsJson)
|
||||
.isNotNull(Skill::getPublishedToolBindingsJson))) {
|
||||
if (PublishStatus.from(skill.getPublishStatus()).isExternallyVisible()
|
||||
&& contains(skill.getPublishedToolBindingsJson(), type, targetId)) {
|
||||
ids.add(skill.getId());
|
||||
}
|
||||
}
|
||||
List<OfflineImpactBindingVo> result = new ArrayList<>();
|
||||
for (Skill skill : skillService.listByIds(ids)) {
|
||||
OfflineImpactBindingVo item = new OfflineImpactBindingVo();
|
||||
item.setId(skill.getId());
|
||||
item.setTitle("Skill“" + (skill.getDisplayName() == null ? skill.getName() : skill.getDisplayName()) + "”");
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean contains(Map<String, Object> snapshot, SkillToolType type, BigInteger targetId) {
|
||||
Object raw = snapshot == null ? null : snapshot.get("bindings");
|
||||
if (!(raw instanceof List<?> bindings)) {
|
||||
return false;
|
||||
}
|
||||
return bindings.stream().anyMatch(item -> item instanceof Map<?, ?> binding
|
||||
&& type.name().equalsIgnoreCase(String.valueOf(binding.get("toolType")))
|
||||
&& targetId.toString().equals(String.valueOf(binding.get("targetId"))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package tech.easyflow.skill.service.impl;
|
||||
|
||||
import com.easyagents.agent.runtime.mcp.McpToolManifest;
|
||||
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
|
||||
import com.easyagents.agent.runtime.mcp.McpClientFactory;
|
||||
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import io.agentscope.core.tool.mcp.McpClientWrapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
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.easyagentsflow.repository.AgentWorkflowSnapshotFactory;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.mapper.PluginMapper;
|
||||
import tech.easyflow.ai.mcp.McpRuntimeSpecFactory;
|
||||
import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory;
|
||||
import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory;
|
||||
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
|
||||
import tech.easyflow.ai.service.McpService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||
import tech.easyflow.skill.enums.SkillToolType;
|
||||
import tech.easyflow.skill.service.SkillToolResourceService;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Skill Tool 目标资源服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class SkillToolResourceServiceImpl implements SkillToolResourceService {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SkillToolResourceServiceImpl.class);
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { };
|
||||
|
||||
private final WorkflowService workflowService;
|
||||
private final PluginItemService pluginItemService;
|
||||
private final PluginMapper pluginMapper;
|
||||
private final PluginVisibilityService pluginVisibilityService;
|
||||
private final McpService mcpService;
|
||||
private final McpAccessPermissionChecker mcpAccessPermissionChecker;
|
||||
private final McpRuntimeSpecFactory mcpRuntimeSpecFactory;
|
||||
private final McpConnectionSnapshotFactory mcpConnectionSnapshotFactory;
|
||||
private final PluginConnectionSnapshotFactory pluginConnectionSnapshotFactory;
|
||||
private final AgentWorkflowSnapshotFactory agentWorkflowSnapshotFactory;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建 Skill Tool 目标资源服务。
|
||||
*
|
||||
* @param workflowService 工作流服务
|
||||
* @param pluginItemService 插件工具服务
|
||||
* @param pluginMapper 插件 Mapper
|
||||
* @param pluginVisibilityService 插件可见性服务
|
||||
* @param mcpService MCP 服务
|
||||
* @param mcpAccessPermissionChecker MCP 权限检查器
|
||||
* @param mcpRuntimeSpecFactory MCP 运行声明工厂
|
||||
* @param mcpConnectionSnapshotFactory MCP 受控连接快照工厂
|
||||
* @param pluginConnectionSnapshotFactory 插件受控连接快照工厂
|
||||
* @param agentWorkflowSnapshotFactory Agent Workflow 冻结快照工厂
|
||||
* @param resourceAccessService 资源权限服务
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public SkillToolResourceServiceImpl(WorkflowService workflowService,
|
||||
PluginItemService pluginItemService,
|
||||
PluginMapper pluginMapper,
|
||||
PluginVisibilityService pluginVisibilityService,
|
||||
McpService mcpService,
|
||||
McpAccessPermissionChecker mcpAccessPermissionChecker,
|
||||
McpRuntimeSpecFactory mcpRuntimeSpecFactory,
|
||||
McpConnectionSnapshotFactory mcpConnectionSnapshotFactory,
|
||||
PluginConnectionSnapshotFactory pluginConnectionSnapshotFactory,
|
||||
AgentWorkflowSnapshotFactory agentWorkflowSnapshotFactory,
|
||||
ResourceAccessService resourceAccessService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.workflowService = workflowService;
|
||||
this.pluginItemService = pluginItemService;
|
||||
this.pluginMapper = pluginMapper;
|
||||
this.pluginVisibilityService = pluginVisibilityService;
|
||||
this.mcpService = mcpService;
|
||||
this.mcpAccessPermissionChecker = mcpAccessPermissionChecker;
|
||||
this.mcpRuntimeSpecFactory = mcpRuntimeSpecFactory;
|
||||
this.mcpConnectionSnapshotFactory = mcpConnectionSnapshotFactory;
|
||||
this.pluginConnectionSnapshotFactory = pluginConnectionSnapshotFactory;
|
||||
this.agentWorkflowSnapshotFactory = agentWorkflowSnapshotFactory;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Workflow requireWorkflow(Skill skill, SkillToolBinding binding) {
|
||||
BigInteger targetId = requireTargetId(binding);
|
||||
Workflow workflow = workflowService.getOne(QueryWrapper.create()
|
||||
.eq(Workflow::getId, targetId)
|
||||
.forUpdate());
|
||||
if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
throw new BusinessException("绑定工作流不存在或未发布");
|
||||
}
|
||||
assertSameTenant(skill, workflow.getTenantId(), "无权限绑定该工作流");
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, "无权限绑定该工作流");
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public PluginItem requirePlugin(Skill skill, SkillToolBinding binding) {
|
||||
BigInteger targetId = requireTargetId(binding);
|
||||
PluginItem current = pluginItemService.getById(targetId);
|
||||
if (current == null || current.getPluginId() == null) {
|
||||
throw new BusinessException("绑定插件不存在");
|
||||
}
|
||||
Plugin plugin = pluginMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq(Plugin::getId, current.getPluginId())
|
||||
.forUpdate());
|
||||
PluginItem item = pluginItemService.getOne(QueryWrapper.create()
|
||||
.eq(PluginItem::getId, targetId)
|
||||
.forUpdate());
|
||||
if (plugin == null || item == null || !Objects.equals(plugin.getId(), item.getPluginId())) {
|
||||
throw new BusinessException("绑定插件不存在");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(item.getStatus())) {
|
||||
throw new BusinessException("绑定插件未启用");
|
||||
}
|
||||
assertSameTenant(skill, plugin.getTenantId(), "无权限绑定该插件");
|
||||
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定该插件");
|
||||
return item;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public McpResource requireMcp(Skill skill, SkillToolBinding binding) {
|
||||
mcpAccessPermissionChecker.assertCanUseMcp();
|
||||
BigInteger targetId = requireTargetId(binding);
|
||||
Mcp mcp = mcpService.getOne(QueryWrapper.create()
|
||||
.eq(Mcp::getId, targetId)
|
||||
.forUpdate());
|
||||
if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) {
|
||||
throw new BusinessException("绑定 MCP 不存在或未启用");
|
||||
}
|
||||
assertSameTenant(skill, mcp.getTenantId(), "无权限绑定该 MCP");
|
||||
McpSpec spec = mcpRuntimeSpecFactory.build(mcp, true);
|
||||
McpClientWrapper client = null;
|
||||
List<io.modelcontextprotocol.spec.McpSchema.Tool> tools;
|
||||
try {
|
||||
client = new McpClientFactory().create(spec);
|
||||
// AgentScope checks initialization when listTools() is invoked, so the two remote
|
||||
// operations must be sequenced at invocation time instead of eagerly assembling both.
|
||||
client.initialize().block();
|
||||
tools = client.listTools().block();
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("读取 MCP Tool 清单失败,mcpId={}", targetId, exception);
|
||||
throw new BusinessException(503, 503, "MCP 当前不可用,请稍后重试");
|
||||
} finally {
|
||||
if (client != null) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (RuntimeException ignored) {
|
||||
// discovery client 无状态且不复用;关闭失败不覆盖真实的读取结果或连接异常。
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tools == null || tools.isEmpty()) {
|
||||
throw new BusinessException(409, 4092, "MCP 未提供可绑定的 Tool");
|
||||
}
|
||||
List<McpToolManifestEntry> manifest = McpToolManifest.fromTools(tools);
|
||||
if (manifest.isEmpty()) {
|
||||
throw new BusinessException(409, 4092, "MCP 未提供有效的 Tool 定义");
|
||||
}
|
||||
return new McpResource(mcp, manifest, McpToolManifest.hash(manifest));
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Map<String, Object> snapshotWorkflow(Workflow workflow) {
|
||||
return agentWorkflowSnapshotFactory.snapshot(workflow);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Map<String, Object> snapshotPlugin(PluginItem pluginItem) {
|
||||
if (pluginItem == null || pluginItem.getPluginId() == null) {
|
||||
throw new BusinessException("插件资源不能为空");
|
||||
}
|
||||
Plugin plugin = pluginMapper.selectOneById(pluginItem.getPluginId());
|
||||
if (plugin == null) {
|
||||
throw new BusinessException("绑定插件不存在");
|
||||
}
|
||||
Map<String, Object> snapshot = new java.util.LinkedHashMap<>();
|
||||
snapshot.put("pluginItem", objectMapper.convertValue(pluginItem, MAP_TYPE));
|
||||
// 父插件持有基础地址、请求头和鉴权配置,必须与子工具一起冻结,避免旧 Agent 热读新配置。
|
||||
snapshot.put("plugin", pluginConnectionSnapshotFactory.snapshot(plugin));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Map<String, Object> snapshotMcpConnection(Mcp mcp) {
|
||||
return mcpConnectionSnapshotFactory.snapshot(mcp);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Map<String, Object> currentSummary(SkillToolBinding binding) {
|
||||
SkillToolType type = SkillToolType.from(binding == null ? null : binding.getToolType());
|
||||
Map<String, Object> summary = new java.util.LinkedHashMap<>();
|
||||
summary.put("toolType", type.name());
|
||||
summary.put("targetId", binding.getTargetId());
|
||||
summary.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled()));
|
||||
if (type == SkillToolType.WORKFLOW) {
|
||||
Workflow workflow = workflowService.getById(binding.getTargetId());
|
||||
summary.put("title", workflow == null ? "已失效工作流" : workflow.getTitle());
|
||||
summary.put("description", workflow == null ? null : workflow.getDescription());
|
||||
summary.put("toolCount", 1);
|
||||
summary.put("available", workflow != null
|
||||
&& PublishStatus.from(workflow.getPublishStatus()) == PublishStatus.PUBLISHED);
|
||||
return summary;
|
||||
}
|
||||
if (type == SkillToolType.PLUGIN) {
|
||||
PluginItem plugin = pluginItemService.getById(binding.getTargetId());
|
||||
summary.put("title", plugin == null ? "已失效插件" : plugin.getName());
|
||||
summary.put("description", plugin == null ? null : plugin.getDescription());
|
||||
summary.put("toolCount", 1);
|
||||
summary.put("available", plugin != null && Integer.valueOf(1).equals(plugin.getStatus()));
|
||||
return summary;
|
||||
}
|
||||
Mcp mcp = mcpService.getById(binding.getTargetId());
|
||||
summary.put("title", mcp == null ? "已失效 MCP" : mcp.getTitle());
|
||||
summary.put("description", mcp == null ? null : mcp.getDescription());
|
||||
summary.put("toolCount", binding.getMcpToolCount() == null ? 0 : binding.getMcpToolCount());
|
||||
summary.put("approvalRequired", mcp != null && Boolean.TRUE.equals(mcp.getApprovalRequired()));
|
||||
summary.put("available", mcp != null && Boolean.TRUE.equals(mcp.getStatus()));
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取绑定目标 ID。
|
||||
*
|
||||
* @param binding Tool 绑定
|
||||
* @return 目标 ID
|
||||
* @throws BusinessException 目标为空时抛出
|
||||
*/
|
||||
private BigInteger requireTargetId(SkillToolBinding binding) {
|
||||
if (binding == null || binding.getTargetId() == null) {
|
||||
throw new BusinessException("Skill 工具目标不能为空");
|
||||
}
|
||||
return binding.getTargetId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Tool 与 Skill 属于同一租户。
|
||||
*
|
||||
* @param skill Skill
|
||||
* @param resourceTenantId 资源租户 ID
|
||||
* @param message 拒绝消息
|
||||
*/
|
||||
private void assertSameTenant(Skill skill, Object resourceTenantId, String message) {
|
||||
if (skill == null || skill.getTenantId() == null || resourceTenantId == null
|
||||
|| !skill.getTenantId().toString().equals(String.valueOf(resourceTenantId))) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package tech.easyflow.skill.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skill Studio MCP Tool 脱敏清单。
|
||||
*
|
||||
* @param manifestHash 规范化清单 hash
|
||||
* @param toolCount Tool 数
|
||||
* @param tools Tool 摘要
|
||||
*/
|
||||
public record SkillMcpToolManifestView(String manifestHash, int toolCount, List<Tool> tools) {
|
||||
|
||||
/**
|
||||
* MCP Tool 最小展示项。
|
||||
*
|
||||
* @param name 名称
|
||||
* @param description 描述
|
||||
* @param inputSchema 输入 Schema
|
||||
* @param outputSchema 输出 Schema
|
||||
*/
|
||||
public record Tool(String name, String description, Object inputSchema, Object outputSchema) { }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tech.easyflow.skill.vo;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skill Studio Tool 候选分页。
|
||||
*
|
||||
* @param records 候选项
|
||||
* @param total 总数
|
||||
* @param pageNum 页码
|
||||
* @param pageSize 每页数量
|
||||
*/
|
||||
public record SkillToolOptionPage(List<Item> records, long total, long pageNum, long pageSize) {
|
||||
|
||||
/**
|
||||
* 最小 Tool 候选。
|
||||
*
|
||||
* @param toolType 类型
|
||||
* @param targetId 目标 ID
|
||||
* @param title 名称
|
||||
* @param description 描述
|
||||
* @param available 是否可用
|
||||
* @param approvalRequired MCP 是否强制确认
|
||||
* @param knownToolCount 已知 Tool 数,可为空
|
||||
*/
|
||||
public record Item(String toolType, BigInteger targetId, String title, String description,
|
||||
boolean available, boolean approvalRequired, Integer knownToolCount) { }
|
||||
}
|
||||
Reference in New Issue
Block a user