feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -49,6 +49,10 @@
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-skill</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 失败,请稍后重试");

View File

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

View File

@@ -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);
/**
* 构建删除审批使用的最小治理快照。
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,8 +69,10 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
when(skillMapper.updateApprovalState(any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publish(any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publish(any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillService.extractContentSnapshot(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(skillService.extractToolBindingsSnapshot(any())).thenReturn(Map.of());
when(skillMapper.markOfflineApproved(any(), any(), any())).thenReturn(1);
when(skillMapper.restoreApprovalState(any(), any(), any(), any())).thenReturn(1);
handler = new SkillApprovalSubjectHandler(
@@ -124,7 +126,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID);
verify(skillMapper).publish(
eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), any(Date.class),
eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), eq(Map.of()), any(Date.class),
eq(OPERATOR_ID), isNull());
verify(skillService).releaseSnapshotContents(previous);
verify(skillService, never()).releaseSnapshotContents(candidate);
@@ -228,6 +230,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
eq(BigInteger.ONE),
eq(instanceId),
same(candidate),
eq(Map.of()),
any(Date.class),
eq(OPERATOR_ID),
eq("candidate-hash"));
@@ -253,7 +256,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
BigInteger.valueOf(99)));
assertEquals(409, exception.getHttpStatus());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any(), any());
}
/**
@@ -273,7 +276,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID, instanceId);
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any(), any());
verify(skillService, never()).releaseSnapshotContents(any());
}

View File

@@ -0,0 +1,128 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.MockedStatic;
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.vo.SkillToolOptionPage;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Skill Tool 候选权限边界测试。
*/
public class SkillToolOptionQueryServiceTest {
/**
* 验证聚合查询在缺少 MCP 权限时仍返回其他已授权候选。
*/
@Test
public void allShouldOmitMcpWithoutBlockingOtherCandidates() {
Dependencies dependencies = new Dependencies();
LoginAccount account = account();
Workflow workflow = new Workflow();
workflow.setId(BigInteger.TEN);
workflow.setTenantId(account.getTenantId());
workflow.setTitle("合同审批");
workflow.setDescription("审批合同");
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
// tb_workflow.status 是历史字段,线上可用性以发布状态为准。
workflow.setStatus(0);
when(dependencies.workflowService.list(any(QueryWrapper.class))).thenReturn(List.of(workflow));
when(dependencies.resourceAccessService.canAccess(any(), any(), any())).thenReturn(true);
when(dependencies.pluginMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
when(dependencies.mcpAccessPermissionChecker.canUseMcp()).thenReturn(false);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(account);
SkillToolOptionPage result = dependencies.service().page(null, "ALL", 1, 20);
Assert.assertEquals(1L, result.total());
Assert.assertEquals("WORKFLOW", result.records().get(0).toolType());
verify(dependencies.mcpService, never()).list(any(QueryWrapper.class));
}
}
/**
* 验证显式查询 MCP 时仍严格要求 MCP 权限。
*/
@Test
public void explicitMcpShouldRejectMissingPermission() {
Dependencies dependencies = new Dependencies();
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
.when(dependencies.mcpAccessPermissionChecker).assertCanUseMcp();
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(account());
try {
dependencies.service().page(null, "MCP", 1, 20);
Assert.fail("显式 MCP 查询必须校验权限");
} catch (BusinessException exception) {
Assert.assertEquals(403, exception.getHttpStatus());
}
}
}
private LoginAccount account() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.valueOf(42));
return account;
}
/**
* 查询服务依赖夹具。
*/
private static final class Dependencies {
private final WorkflowService workflowService = mock(WorkflowService.class);
private final PluginItemService pluginItemService = mock(PluginItemService.class);
private final PluginMapper pluginMapper = mock(PluginMapper.class);
private final PluginVisibilityService pluginVisibilityService = mock(PluginVisibilityService.class);
private final McpService mcpService = mock(McpService.class);
private final McpAccessPermissionChecker mcpAccessPermissionChecker =
mock(McpAccessPermissionChecker.class);
private final SkillToolResourceService resourceService = mock(SkillToolResourceService.class);
private final ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
/**
* 创建待测服务。
*
* @return 待测服务
*/
private SkillToolOptionQueryService service() {
return new SkillToolOptionQueryService(
workflowService,
pluginItemService,
pluginMapper,
pluginVisibilityService,
mcpService,
mcpAccessPermissionChecker,
resourceService,
resourceAccessService
);
}
}
}

View File

@@ -0,0 +1,108 @@
package tech.easyflow.skill.service.impl;
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.springframework.beans.factory.ObjectProvider;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.skill.service.SkillReferenceProvider;
import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.service.SkillToolBindingService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.mock;
/**
* Skill 组合发布快照 hash 测试。
*/
public class SkillServiceImplSnapshotHashTest {
/**
* 含 MCP Manifest POJO 的组合快照经过 JSON 持久化后仍应通过校验。
*
* @throws Exception JSON 或反射调用失败时抛出
*/
@Test
@SuppressWarnings("unchecked")
public void shouldVerifyAggregateSnapshotAfterJsonRoundTrip() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
SkillToolBindingService toolBindingService = mock(SkillToolBindingService.class);
SkillServiceImpl service = service(objectMapper, toolBindingService);
Map<String, Object> contentSnapshot = new LinkedHashMap<>();
contentSnapshot.put("schemaVersion", 2);
contentSnapshot.put("name", "l21-mcp-docs");
contentSnapshot.put("snapshotHash", hash(service, contentSnapshot));
Map<String, Object> binding = new LinkedHashMap<>();
binding.put("toolType", "MCP");
McpToolManifestEntry manifestEntry = new McpToolManifestEntry();
manifestEntry.setName("query-docs");
manifestEntry.setDescription("查询文档");
manifestEntry.setInputSchema(Map.of("type", "object"));
manifestEntry.setOutputSchema(Map.of());
binding.put("mcpToolManifest", List.of(manifestEntry));
Map<String, Object> toolSnapshot = new LinkedHashMap<>();
toolSnapshot.put("schemaVersion", 1);
toolSnapshot.put("bindings", List.of(binding));
toolSnapshot.put("snapshotHash", "verified-by-tool-service");
Map<String, Object> aggregate = new LinkedHashMap<>(contentSnapshot);
Object contentHash = aggregate.remove("snapshotHash");
aggregate.put("contentSnapshotHash", contentHash);
aggregate.put("platformToolBindings", toolSnapshot);
aggregate.put("toolBindingsHash", toolSnapshot.get("snapshotHash"));
Skill persistedSkill = new Skill();
persistedSkill.setPublishedSnapshotJson(objectMapper.readValue(
objectMapper.writeValueAsBytes(contentSnapshot), Map.class));
persistedSkill.setPublishedToolBindingsJson(objectMapper.readValue(
objectMapper.writeValueAsBytes(toolSnapshot), Map.class));
persistedSkill.setSnapshotHash(hash(service, aggregate));
service.assertPublishedAggregateHash(persistedSkill);
}
/**
* 创建仅用于快照校验的服务。
*
* @param objectMapper JSON 映射器
* @param toolBindingService Tool 快照服务
* @return Skill 服务
*/
@SuppressWarnings("unchecked")
private SkillServiceImpl service(ObjectMapper objectMapper,
SkillToolBindingService toolBindingService) {
return new SkillServiceImpl(
mock(SkillCategoryService.class),
mock(SkillResourceService.class),
toolBindingService,
mock(DBSkillContentStore.class),
mock(ResourceAccessService.class),
mock(CategoryPermissionService.class),
objectMapper,
mock(ObjectProvider.class));
}
/**
* 调用生产代码的统一快照 hash 算法。
*
* @param service Skill 服务
* @param value 待计算结构
* @return SHA-256 hash
* @throws Exception 反射调用失败时抛出
*/
private String hash(SkillServiceImpl service, Object value) throws Exception {
Method method = SkillServiceImpl.class.getDeclaredMethod("hashJson", Object.class);
method.setAccessible(true);
return (String) method.invoke(service, value);
}
}

View File

@@ -0,0 +1,200 @@
package tech.easyflow.skill.service.impl;
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
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.Workflow;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillToolBinding;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillToolResourceService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Skill Tool 绑定发布快照测试。
*/
public class SkillToolBindingServiceImplTest {
/**
* 发布快照应包含资源冻结数据,并拒绝任何后续篡改。
*/
@Test
public void shouldBuildAndVerifyFrozenToolSnapshot() {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding workflowBinding = binding("WORKFLOW", 100, null);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(100));
workflow.setTitle("合同审查流程");
when(resources.requireWorkflow(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(workflowBinding))).thenReturn(workflow);
when(resources.snapshotWorkflow(workflow)).thenReturn(Map.of(
"id", BigInteger.valueOf(100),
"content", "{\"nodes\":[]}"));
SkillToolBindingServiceImpl service = service(resources, List.of(workflowBinding));
Map<String, Object> snapshot = service.buildPublishSnapshot(skill());
service.assertPublishedSnapshotHash(snapshot);
verify(resources).requireWorkflow(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(workflowBinding));
Map<String, Object> tampered = new LinkedHashMap<>(snapshot);
tampered.put("schemaVersion", 2);
Assert.assertThrows(BusinessException.class,
() -> service.assertPublishedSnapshotHash(tampered));
}
/**
* 发布快照经过数据库 JSON 持久化后仍应保持同一 hash。
*
* @throws Exception JSON 往返失败时抛出
*/
@Test
@SuppressWarnings("unchecked")
public void shouldVerifyMcpSnapshotAfterJsonRoundTrip() throws Exception {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding mcpBinding = binding("MCP", 200, "manifest-hash");
Mcp mcp = mcp();
when(resources.requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource(
mcp, List.of(manifest("search")), "manifest-hash"));
when(resources.snapshotMcpConnection(mcp)).thenReturn(Map.of(
"id", mcp.getId(),
"configJson", "{\"mcpServers\":{}}"));
SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding));
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> snapshot = service.buildPublishSnapshot(skill());
Map<String, Object> persisted = mapper.readValue(
mapper.writeValueAsBytes(snapshot), Map.class);
service.assertPublishedSnapshotHash(persisted);
}
/**
* MCP 清单变化后发布必须失败,要求用户重新保存并确认绑定。
*/
@Test
public void shouldRejectChangedMcpManifestAtPublish() {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding mcpBinding = binding("MCP", 200, "old-hash");
when(resources.requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource(
mcp(), List.of(manifest("search")), "new-hash"));
SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding));
BusinessException exception = Assert.assertThrows(BusinessException.class,
() -> service.buildPublishSnapshot(skill()));
Assert.assertTrue(exception.getMessage().contains("清单已变化"));
}
/**
* 单个 Skill 展开后的实际 MCP Tool 数量不得超过二十个。
*/
@Test
public void shouldRejectMoreThanTwentyExpandedTools() {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding mcpBinding = binding("MCP", 200, "manifest-hash");
List<McpToolManifestEntry> manifest = new ArrayList<>();
for (int index = 1; index <= 21; index++) {
manifest.add(manifest("tool-" + index));
}
when(resources.requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource(
mcp(), manifest, "manifest-hash"));
SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding));
BusinessException exception = Assert.assertThrows(BusinessException.class,
() -> service.buildPublishSnapshot(skill()));
Assert.assertTrue(exception.getMessage().contains("20"));
verify(resources).requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding));
}
/**
* 创建可注入固定绑定列表的服务。
*
* @param resources Tool 资源服务
* @param bindings 固定绑定
* @return 测试服务
*/
private SkillToolBindingServiceImpl service(SkillToolResourceService resources,
List<SkillToolBinding> bindings) {
return new SkillToolBindingServiceImpl(
mock(SkillMapper.class), resources, mock(ResourceAccessService.class), new ObjectMapper()) {
@Override
public List<SkillToolBinding> listBindings(BigInteger skillId) {
return bindings;
}
};
}
/**
* 创建测试 Skill。
*
* @return Skill
*/
private Skill skill() {
Skill skill = new Skill();
skill.setId(BigInteger.ONE);
skill.setTenantId(BigInteger.ONE);
return skill;
}
/**
* 创建 Tool 绑定。
*
* @param type 类型
* @param targetId 目标 ID
* @param manifestHash MCP 清单 hash
* @return 绑定
*/
private SkillToolBinding binding(String type, long targetId, String manifestHash) {
SkillToolBinding binding = new SkillToolBinding();
binding.setToolType(type);
binding.setTargetId(BigInteger.valueOf(targetId));
binding.setMcpToolManifestHash(manifestHash);
binding.setSortNo(0);
return binding;
}
/**
* 创建测试 MCP。
*
* @return MCP
*/
private Mcp mcp() {
Mcp mcp = new Mcp();
mcp.setId(BigInteger.valueOf(200));
mcp.setTitle("测试 MCP");
return mcp;
}
/**
* 创建最小 MCP Tool 清单项。
*
* @param name Tool 名称
* @return 清单项
*/
private McpToolManifestEntry manifest(String name) {
McpToolManifestEntry entry = new McpToolManifestEntry();
entry.setName(name);
entry.setDescription("测试工具");
entry.setInputSchema(Map.of("type", "object"));
return entry;
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillToolBinding;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.service.SkillToolBindingService;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* Skill 对平台 Tool 的生命周期引用查询测试。
*/
public class SkillToolReferenceProviderImplTest {
/**
* 草稿绑定和有效发布快照都应参与 Workflow 下线与删除影响检查。
*/
@Test
public void shouldIncludeDraftAndPublishedWorkflowReferences() {
SkillService skillService = Mockito.mock(SkillService.class);
SkillToolBindingService bindingService = Mockito.mock(SkillToolBindingService.class);
SkillToolBinding draftBinding = new SkillToolBinding();
draftBinding.setSkillId(BigInteger.ONE);
draftBinding.setToolType("WORKFLOW");
draftBinding.setTargetId(BigInteger.TEN);
Skill publishedProjection = skill(BigInteger.TWO, "线上 Skill");
publishedProjection.setPublishStatus(PublishStatus.PUBLISHED.getCode());
publishedProjection.setPublishedToolBindingsJson(Map.of(
"bindings", List.of(Map.of(
"toolType", "WORKFLOW",
"targetId", BigInteger.TEN))));
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(draftBinding));
Mockito.when(skillService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(publishedProjection));
Mockito.when(skillService.listByIds(Mockito.anyCollection()))
.thenReturn(List.of(
skill(BigInteger.ONE, "草稿 Skill"),
skill(BigInteger.TWO, "线上 Skill")));
SkillToolReferenceProviderImpl provider = new SkillToolReferenceProviderImpl(
skillService, bindingService);
var references = provider.listSkillsByWorkflowId(BigInteger.TEN);
Assert.assertEquals(2, references.size());
Assert.assertEquals("Skill“草稿 Skill”", references.get(0).getTitle());
Assert.assertEquals("Skill“线上 Skill”", references.get(1).getTitle());
}
/**
* 已下线发布快照不应继续阻止 Tool 生命周期操作。
*/
@Test
public void shouldIgnoreOfflinePublishedSnapshot() {
SkillService skillService = Mockito.mock(SkillService.class);
SkillToolBindingService bindingService = Mockito.mock(SkillToolBindingService.class);
Skill offline = skill(BigInteger.ONE, "已下线 Skill");
offline.setPublishStatus(PublishStatus.OFFLINE.getCode());
offline.setPublishedToolBindingsJson(Map.of(
"bindings", List.of(Map.of(
"toolType", "MCP",
"targetId", BigInteger.TEN))));
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
Mockito.when(skillService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of(offline));
SkillToolReferenceProviderImpl provider = new SkillToolReferenceProviderImpl(
skillService, bindingService);
Assert.assertTrue(provider.listSkillsByMcpId(BigInteger.TEN).isEmpty());
}
/**
* 创建 Skill 摘要。
*
* @param id Skill ID
* @param displayName 展示名
* @return Skill
*/
private Skill skill(BigInteger id, String displayName) {
Skill skill = new Skill();
skill.setId(id);
skill.setName("skill-" + id);
skill.setDisplayName(displayName);
return skill;
}
}