feat: 重构标准 Skill 管理与发布链路

- 统一标准 ZIP 导入导出与通用资源模型

- 收口分类范围权限和创建人查询

- 完善发布快照、审批幂等与数据库清理迁移
This commit is contained in:
2026-08-14 18:53:26 +08:00
parent 9be9bd7665
commit 77d66e1b42
91 changed files with 1660 additions and 10957 deletions

View File

@@ -1,124 +0,0 @@
package tech.easyflow.skill.capability;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.validation.SkillValidationResult;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* Skill 能力绑定业务服务。
*/
public interface SkillCapabilityBindingService extends IService<SkillCapabilityBinding> {
/**
* 查询当前用户可查看的 Skill 能力绑定。
*
* @param skillId Skill ID
* @return 有序绑定列表
*/
List<SkillCapabilityBinding> listBindings(BigInteger skillId);
/**
* 查询面向管理端读取接口的安全绑定,并按当前用户 MANAGE 权限隐藏内部目标 ID。
*
* @param skillId Skill ID
* @return 有序安全绑定列表
*/
List<SkillCapabilityBinding> listVisibleBindings(BigInteger skillId);
/**
* 原子替换 Skill 能力绑定。
*
* @param skillId Skill ID
* @param bindings 新绑定列表
* @return 保存后的绑定列表
*/
List<SkillCapabilityBinding> replaceBindings(BigInteger skillId, List<SkillCapabilityBinding> bindings);
/**
* 按客户端读取到的能力 hash 原子替换绑定,防止多标签页相互覆盖。
*
* @param skillId Skill ID
* @param bindings 新绑定列表
* @param expectedCapabilityHash 客户端读取到的能力 hash
* @return 保存后的绑定列表
*/
List<SkillCapabilityBinding> replaceBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
String expectedCapabilityHash);
/**
* 校验待保存或现有绑定。
*
* @param skillId Skill ID
* @param bindings 可选待校验绑定,为空时校验已保存绑定
* @param publishValidation 是否执行发布级实时工具解析
* @return 结构化校验结果
*/
SkillValidationResult validateBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
boolean publishValidation);
/**
* 校验增强包导入预览中的能力绑定。
*
* <p>该入口不读取或写入 Skill 业务数据,也不要求已有 Skill 权限。未映射目标仅保留给
* 导入映射步骤处理;已经映射的目标仍会校验当前操作者的使用权限和可用状态。</p>
*
* @param bindings 从增强包 manifest 还原的能力绑定
* @return 结构化校验结果
*/
SkillValidationResult validateImportBindings(List<SkillCapabilityBinding> bindings);
/**
* 查询可绑定能力候选项。
*
* @param capabilityType 能力类型
* @param keyword 关键词
* @return 候选列表
*/
List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword);
/**
* 按需获取 MCP 工具清单。
*
* @param targetId MCP ID
* @return MCP 候选详情
*/
SkillCapabilityCandidate getMcpTools(BigInteger targetId);
/**
* 构建经过发布级校验的安全快照。
*
* @param skillId Skill ID
* @return 不含凭据的能力快照
*/
List<Map<String, Object>> buildPublishSnapshot(BigInteger skillId);
/**
* 计算当前能力配置 hash。
*
* @param bindings 能力绑定
* @return SHA-256 hash
*/
String calculateHash(List<SkillCapabilityBinding> bindings);
/**
* 基于数据库原始绑定计算 hash不暴露可能被展示边界脱敏的历史配置。
*
* @param skillId Skill ID
* @return SHA-256 hash
*/
String calculateStoredHash(BigInteger skillId);
/**
* 删除 Skill 的全部能力绑定。
*
* @param skillId Skill ID
*/
void removeBySkillId(BigInteger skillId);
}

View File

@@ -1,990 +0,0 @@
package tech.easyflow.skill.capability;
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.permission.McpAccessPermissionChecker;
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.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityExecutionMode;
import tech.easyflow.skill.enums.SkillCapabilitySelectionMode;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.security.SkillCredentialValueGuard;
import tech.easyflow.skill.security.SkillPortableTargetSanitizer;
import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer;
import tech.easyflow.skill.validation.SkillValidationIssue;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Skill 能力绑定业务服务实现。
*/
@Service
public class SkillCapabilityBindingServiceImpl
extends ServiceImpl<SkillCapabilityBindingMapper, SkillCapabilityBinding>
implements SkillCapabilityBindingService {
private static final Pattern RUNTIME_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$");
private static final Pattern MCP_TOOL_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,127}$");
private static final int MAX_BINDINGS = 200;
private static final int MAX_SELECTED_TOOLS = 200;
private static final int MAX_CONFIG_BYTES = 4096;
private final SkillMapper skillMapper;
private final SkillCapabilityTargetAccessService targetAccessService;
private final McpAccessPermissionChecker mcpAccessPermissionChecker;
private final ResourceAccessService resourceAccessService;
private final ObjectMapper objectMapper;
/**
* 创建 Skill 能力绑定服务。
*
* @param skillMapper Skill Mapper
* @param targetAccessService 目标授权服务
* @param mcpAccessPermissionChecker MCP 查询与使用权限检查器
* @param resourceAccessService Skill 资源授权服务
* @param objectMapper JSON 映射器
*/
public SkillCapabilityBindingServiceImpl(SkillMapper skillMapper,
SkillCapabilityTargetAccessService targetAccessService,
McpAccessPermissionChecker mcpAccessPermissionChecker,
ResourceAccessService resourceAccessService,
ObjectMapper objectMapper) {
this.skillMapper = skillMapper;
this.targetAccessService = targetAccessService;
this.mcpAccessPermissionChecker = mcpAccessPermissionChecker;
this.resourceAccessService = resourceAccessService;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityBinding> listBindings(BigInteger skillId) {
return listBindings(skillId, false);
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityBinding> listVisibleBindings(BigInteger skillId) {
return listBindings(skillId, true);
}
/**
* 查询并填充绑定展示状态,可选按 MANAGE 权限移除内部目标标识。
*
* @param skillId Skill ID
* @param redactReadOnlyTargets 是否为只读调用方脱敏
* @return 有序绑定列表
*/
private List<SkillCapabilityBinding> listBindings(BigInteger skillId, boolean redactReadOnlyTargets) {
Skill skill = requireSkill(skillId);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看 Skill 能力绑定");
boolean manageable = !redactReadOnlyTargets
|| resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE);
List<SkillCapabilityBinding> bindings = listRaw(skillId);
for (SkillCapabilityBinding binding : bindings) {
enrichDisplayStatus(binding);
boolean targetPermissionDenied = "NO_PERMISSION".equals(binding.getTargetStatus());
if (!manageable || targetPermissionDenied) {
binding.setTargetId(null);
if (targetPermissionDenied) {
// Skill 管理权限不能替代目标能力权限;目标不可读时只保留可删除的绑定外壳。
binding.setTargetLogicalRef(null);
binding.setTargetName(null);
binding.setSelectedToolNamesJson(List.of());
binding.setResolvedToolNames(List.of());
}
}
sanitizeBindingForExposure(binding);
}
return bindings;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<SkillCapabilityBinding> replaceBindings(BigInteger skillId, List<SkillCapabilityBinding> bindings) {
return replaceBindingsInternal(skillId, bindings, null);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<SkillCapabilityBinding> replaceBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
String expectedCapabilityHash) {
if (expectedCapabilityHash == null || !expectedCapabilityHash.matches("^[a-f0-9]{64}$")) {
throw new BusinessException(409, 4093, "缺少或无效的能力配置版本,请重新加载后再保存");
}
return replaceBindingsInternal(skillId, bindings, expectedCapabilityHash);
}
private List<SkillCapabilityBinding> replaceBindingsInternal(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
String expectedCapabilityHash) {
Skill skill = requireSkill(skillId, true);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理 Skill 能力绑定");
if (expectedCapabilityHash != null && !expectedCapabilityHash.equals(skill.getCapabilityHash())) {
throw new BusinessException(409, 4093, "能力配置已被其他操作更新,请重新加载后合并");
}
List<SkillCapabilityBinding> safeBindings = bindings == null ? new ArrayList<>() : new ArrayList<>(bindings);
if (safeBindings.size() > MAX_BINDINGS) {
throw new BusinessException("单个 Skill 最多绑定 " + MAX_BINDINGS + " 项能力");
}
SkillValidationResult validation = validateInternal(safeBindings, ValidationMode.SAVE);
assertNoErrors(validation);
QueryWrapper deleteQuery = QueryWrapper.create()
.eq(SkillCapabilityBinding::getTenantId, skill.getTenantId())
.eq(SkillCapabilityBinding::getSkillId, skillId);
long existingBindingCount = count(deleteQuery);
if (existingBindingCount > 0 && getMapper().deleteByQuery(deleteQuery) != existingBindingCount) {
throw new BusinessException(500, 500, "替换 Skill 能力绑定失败,请稍后重试");
}
LoginAccount account = requireAccount();
Date now = new Date();
for (int index = 0; index < safeBindings.size(); index++) {
SkillCapabilityBinding binding = safeBindings.get(index);
binding.setId(null);
binding.setTenantId(skill.getTenantId());
binding.setSkillId(skillId);
binding.setSortNo(index);
binding.setCreated(now);
binding.setCreatedBy(account.getId());
binding.setModified(now);
binding.setModifiedBy(account.getId());
}
if (!safeBindings.isEmpty()) {
if (!saveBatch(safeBindings)) {
throw new BusinessException(500, 500, "保存 Skill 能力绑定失败,请稍后重试");
}
}
Skill update = new Skill();
update.setId(skillId);
update.setCapabilityCount(safeBindings.size());
update.setCapabilityHash(calculateHash(safeBindings));
update.setModified(now);
update.setModifiedBy(account.getId());
QueryWrapper updateQuery = QueryWrapper.create()
.eq(Skill::getId, skillId)
.eq(Skill::getTenantId, skill.getTenantId());
if (expectedCapabilityHash != null) {
updateQuery.eq(Skill::getCapabilityHash, expectedCapabilityHash);
}
if (skillMapper.updateByQuery(update, updateQuery) != 1) {
if (expectedCapabilityHash != null) {
throw new BusinessException(409, 4093, "能力配置已被其他操作更新,请重新加载后合并");
}
throw new BusinessException(500, 500, "更新 Skill 能力摘要失败,请稍后重试");
}
return listBindings(skillId);
}
/**
* {@inheritDoc}
*/
@Override
public SkillValidationResult validateBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
boolean publishValidation) {
Skill skill = requireSkill(skillId);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill,
bindings == null ? ResourceAction.READ : ResourceAction.MANAGE,
bindings == null ? "无权限校验 Skill 能力绑定" : "无权限校验待保存的 Skill 能力绑定");
return validateInternal(bindings == null ? listRaw(skillId) : bindings,
publishValidation ? ValidationMode.PUBLISH : ValidationMode.SAVE);
}
/**
* {@inheritDoc}
*/
@Override
public SkillValidationResult validateImportBindings(List<SkillCapabilityBinding> bindings) {
return validateInternal(bindings == null ? List.of() : bindings, ValidationMode.IMPORT_PREVIEW);
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword) {
return targetAccessService.listCandidates(capabilityType, keyword);
}
/**
* {@inheritDoc}
*/
@Override
public SkillCapabilityCandidate getMcpTools(BigInteger targetId) {
return targetAccessService.getMcpTools(targetId);
}
/**
* {@inheritDoc}
*/
@Override
public List<Map<String, Object>> buildPublishSnapshot(BigInteger skillId) {
List<SkillCapabilityBinding> bindings = listRaw(skillId);
ValidatedBindings validated = validateInternalWithTargets(bindings, ValidationMode.PUBLISH);
assertNoErrors(validated.result());
List<Map<String, Object>> snapshots = new ArrayList<>();
for (int index = 0; index < bindings.size(); index++) {
SkillCapabilityBinding binding = bindings.get(index);
SkillCapabilityType capabilityType = SkillCapabilityType.from(binding.getCapabilityType());
SkillCapabilityTarget target = Boolean.TRUE.equals(binding.getEnabled())
? validated.targetsByIndex().get(index) : null;
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("capabilityType", binding.getCapabilityType());
snapshot.put("runtimeName", binding.getRuntimeName());
snapshot.put("enabled", binding.getEnabled());
snapshot.put("selectionMode", binding.getSelectionMode());
snapshot.put("selectedToolNames", binding.getSelectedToolNamesJson());
snapshot.put("resolvedToolNames", binding.getResolvedToolNames());
snapshot.put("executionMode", binding.getExecutionMode());
snapshot.put("hitlEnabled", binding.getHitlEnabled());
snapshot.put("hitlConfig", SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()));
snapshot.put("options", SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()));
snapshot.put("sortNo", binding.getSortNo());
if (target != null) {
String targetName = SkillPortableTargetSanitizer.safePortableMetadataOrNull(target.getName());
String targetRevision = SkillPortableTargetSanitizer.safePortableMetadataOrNull(target.getRevision());
if (targetName != null) {
snapshot.put("targetName", targetName);
}
snapshot.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
capabilityType, target.getLogicalRef()));
if (targetRevision != null) {
snapshot.put("targetRevision", targetRevision);
}
} else {
snapshot.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
capabilityType, binding.getTargetLogicalRef()));
}
assertCredentialFreeSnapshot(snapshot);
snapshots.add(snapshot);
}
return snapshots;
}
/**
* {@inheritDoc}
*/
@Override
public String calculateHash(List<SkillCapabilityBinding> bindings) {
List<Map<String, Object>> canonical = new ArrayList<>();
if (bindings != null) {
bindings.stream().sorted((left, right) -> Integer.compare(
left.getSortNo() == null ? 0 : left.getSortNo(),
right.getSortNo() == null ? 0 : right.getSortNo()))
.forEach(binding -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("type", binding.getCapabilityType());
item.put("targetId", binding.getTargetId() == null ? null : binding.getTargetId().toString());
item.put("targetLogicalRef", binding.getTargetLogicalRef());
item.put("runtimeName", binding.getRuntimeName());
item.put("enabled", binding.getEnabled());
item.put("selectionMode", binding.getSelectionMode());
item.put("selectedTools", binding.getSelectedToolNamesJson());
item.put("executionMode", binding.getExecutionMode());
item.put("hitlEnabled", binding.getHitlEnabled());
item.put("hitlConfig", SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()));
item.put("options", SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()));
canonical.add(item);
});
}
try {
return SkillHashes.sha256Hex(objectMapper.writeValueAsString(canonicalize(canonical))
.getBytes(StandardCharsets.UTF_8));
} catch (JsonProcessingException exception) {
throw new BusinessException(500, 500, "计算 Skill 能力配置 hash 失败", exception);
}
}
/**
* {@inheritDoc}
*/
@Override
public String calculateStoredHash(BigInteger skillId) {
Skill skill = requireSkill(skillId);
resourceAccessService.assertAccess(
CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限读取 Skill 能力摘要");
return calculateHash(listRaw(skillId));
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void removeBySkillId(BigInteger skillId) {
if (skillId != null) {
QueryWrapper deleteQuery = QueryWrapper.create()
.eq(SkillCapabilityBinding::getTenantId, requireAccount().getTenantId())
.eq(SkillCapabilityBinding::getSkillId, skillId);
long existingBindingCount = count(deleteQuery);
if (existingBindingCount > 0 && getMapper().deleteByQuery(deleteQuery) != existingBindingCount) {
throw new BusinessException(500, 500, "删除 Skill 能力绑定失败,请稍后重试");
}
}
}
private SkillValidationResult validateInternal(List<SkillCapabilityBinding> bindings, ValidationMode mode) {
return validateInternalWithTargets(bindings, mode).result();
}
/**
* 校验能力绑定并保留本次调用已授权的目标摘要,供发布快照复用。
*
* @param bindings 能力绑定
* @param mode 校验场景
* @return 校验结果与按绑定序号记录的目标摘要
*/
private ValidatedBindings validateInternalWithTargets(List<SkillCapabilityBinding> bindings,
ValidationMode mode) {
assertMcpAccessWhenPresent(bindings);
List<SkillValidationIssue> issues = new ArrayList<>();
if (bindings.size() > MAX_BINDINGS) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_BINDING_LIMIT",
"单个 Skill 最多绑定 " + MAX_BINDINGS + " 项能力", "capabilities"));
SkillValidationResult result = new SkillValidationResult();
result.setIssues(issues);
result.setValid(false);
return new ValidatedBindings(result, Map.of());
}
Set<String> runtimeNames = new HashSet<>();
Map<TargetCacheKey, SkillCapabilityTarget> targetCache = new HashMap<>();
Map<Integer, SkillCapabilityTarget> targetsByIndex = new HashMap<>();
for (int index = 0; index < bindings.size(); index++) {
validateOne(bindings.get(index), index, mode, runtimeNames, targetCache, targetsByIndex, issues);
}
SkillValidationResult result = new SkillValidationResult();
result.setIssues(issues);
result.setValid(issues.stream().noneMatch(issue -> "ERROR".equals(issue.getSeverity())));
return new ValidatedBindings(result, targetsByIndex);
}
/**
* 当配置中出现 MCP 能力时校验当前操作者的 MCP 查询与使用权限。
*
* <p>该检查先于目标映射执行,因此禁用或尚未映射的 MCP 绑定也不能绕过授权。</p>
*
* @param bindings 待校验能力绑定
*/
private void assertMcpAccessWhenPresent(List<SkillCapabilityBinding> bindings) {
boolean containsMcp = bindings.stream()
.filter(java.util.Objects::nonNull)
.map(SkillCapabilityBinding::getCapabilityType)
.anyMatch(type -> type != null && SkillCapabilityType.MCP.name().equalsIgnoreCase(type.trim()));
if (containsMcp) {
mcpAccessPermissionChecker.assertCanUseMcp();
}
}
private void validateOne(SkillCapabilityBinding binding,
int index,
ValidationMode mode,
Set<String> runtimeNames,
Map<TargetCacheKey, SkillCapabilityTarget> targetCache,
Map<Integer, SkillCapabilityTarget> targetsByIndex,
List<SkillValidationIssue> issues) {
String path = "capabilities[" + index + "]";
if (binding == null) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_EMPTY", "能力绑定不能为空", path));
return;
}
validateCredentialFields(binding, path, issues);
SkillCapabilityType type;
try {
type = SkillCapabilityType.from(binding.getCapabilityType());
binding.setCapabilityType(type.name());
} catch (BusinessException exception) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_TYPE_INVALID",
"能力类型不受支持", path + ".capabilityType"));
return;
}
boolean enabled = binding.getEnabled() == null || binding.getEnabled();
binding.setEnabled(enabled);
binding.setHitlEnabled(Boolean.TRUE.equals(binding.getHitlEnabled()));
validateSafeConfigs(binding, path, issues);
if (binding.getRuntimeName() == null || !RUNTIME_NAME_PATTERN.matcher(binding.getRuntimeName()).matches()) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_INVALID",
"运行时名称必须以字母开头,且只包含字母、数字、下划线或连字符,最长 64 个字符",
path + ".runtimeName"));
}
validateStaticTypeConfiguration(binding, type, enabled, path, issues);
if (binding.getTargetId() == null) {
if (!SkillPortableTargetSanitizer.isSafeLogicalRef(type, binding.getTargetLogicalRef())) {
issues.add(SkillValidationIssue.of("ERROR", "TARGET_LOGICAL_REF_INVALID",
"未映射能力的目标逻辑引用格式不正确", path + ".targetLogicalRef"));
}
if (mode.allowUnresolvedTarget()) {
validateRuntimeNamesWithoutTarget(binding, type, enabled, path, runtimeNames, issues);
} else {
issues.add(SkillValidationIssue.of(enabled ? "ERROR" : "WARNING", "TARGET_UNRESOLVED",
enabled ? "启用的能力必须映射目标资源" : "能力尚未映射目标资源,保持禁用后可保存",
path + ".targetId"));
}
return;
}
if (binding.getTargetLogicalRef() != null && binding.getTargetLogicalRef().length() > 512) {
issues.add(SkillValidationIssue.of("ERROR", "TARGET_LOGICAL_REF_INVALID",
"目标逻辑引用不能超过 512 个字符", path + ".targetLogicalRef"));
}
SkillCapabilityTarget target;
try {
boolean resolveMcpTools = mode.publishValidation() && enabled && type == SkillCapabilityType.MCP;
TargetCacheKey cacheKey = new TargetCacheKey(type, binding.getTargetId(), resolveMcpTools);
target = targetCache.get(cacheKey);
if (target == null) {
target = targetAccessService.requireUsableTarget(binding, resolveMcpTools);
targetCache.put(cacheKey, target);
}
} catch (BusinessException exception) {
boolean permissionError = exception.getHttpStatus() == 403;
issues.add(SkillValidationIssue.of(permissionError || enabled ? "ERROR" : "WARNING",
permissionError ? "TARGET_NO_PERMISSION" : "TARGET_UNAVAILABLE",
permissionError ? "当前用户无权使用目标能力" : "目标能力当前不可用",
path + ".targetId"));
return;
}
targetsByIndex.put(index, target);
validateResolvedTargetCredentials(target, path, issues);
binding.setTargetName(target.getName());
binding.setTargetStatus(target.getStatus());
binding.setTargetLogicalRef(target.getLogicalRef());
if (type == SkillCapabilityType.MCP) {
validateMcp(binding, target, enabled, mode.publishValidation(), path, runtimeNames, issues);
} else {
if (enabled && binding.getRuntimeName() != null
&& !runtimeNames.add(binding.getRuntimeName().toLowerCase(Locale.ROOT))) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE",
"最终运行时工具名重复", path + ".runtimeName"));
}
}
}
/**
* 校验能力绑定所有持久化字符串面不含认证凭据。
*
* @param binding 能力绑定
* @param path 能力绑定路径
* @param issues 问题集合
*/
private void validateCredentialFields(SkillCapabilityBinding binding,
String path,
List<SkillValidationIssue> issues) {
validateCredentialValue(binding.getCapabilityType(), path + ".capabilityType", issues);
validateCredentialValue(binding.getTargetLogicalRef(), path + ".targetLogicalRef", issues);
validateCredentialValue(binding.getRuntimeName(), path + ".runtimeName", issues);
validateCredentialValue(binding.getSelectionMode(), path + ".selectionMode", issues);
validateCredentialValue(binding.getExecutionMode(), path + ".executionMode", issues);
List<String> selectedTools = binding.getSelectedToolNamesJson() == null
? List.of() : binding.getSelectedToolNamesJson();
for (int index = 0; index < selectedTools.size(); index++) {
validateCredentialValue(selectedTools.get(index),
path + ".selectedToolNamesJson[" + index + "]", issues);
}
}
/**
* 将单个字符串中的凭据问题转换为稳定、无回显的校验结果。
*
* @param value 字符串值
* @param path 字段路径
* @param issues 问题集合
*/
private void validateCredentialValue(String value,
String path,
List<SkillValidationIssue> issues) {
if (SkillCredentialValueGuard.containsCredential(value)) {
issues.add(SkillValidationIssue.of("ERROR", "SENSITIVE_VALUE_DETECTED",
"能力配置不能包含认证凭据,请改用运行环境中的安全配置", path));
}
}
/**
* 校验目标解析结果中会进入发布快照的字符串字段。
*
* @param target 已授权目标摘要
* @param path 能力绑定路径
* @param issues 问题集合
*/
private void validateResolvedTargetCredentials(SkillCapabilityTarget target,
String path,
List<SkillValidationIssue> issues) {
// 展示元数据与逻辑引用在快照构造时采用 fail-closed 降级;工具名会直接成为运行时名称,必须阻断。
List<String> tools = target.getToolNames() == null ? List.of() : target.getToolNames();
for (int index = 0; index < tools.size(); index++) {
validateCredentialValue(tools.get(index), path + ".resolvedToolNames[" + index + "]", issues);
}
}
/**
* 对最终快照执行纵深凭据检查,防止未来新增字符串字段遗漏显式校验。
*
* @param snapshot 单项发布快照
*/
private void assertCredentialFreeSnapshot(Object snapshot) {
if (snapshot instanceof String text) {
if (SkillCredentialValueGuard.containsCredential(text)) {
throw new BusinessException("Skill 能力发布快照包含不安全配置");
}
return;
}
if (snapshot instanceof Map<?, ?> map) {
map.values().forEach(this::assertCredentialFreeSnapshot);
return;
}
if (snapshot instanceof List<?> list) {
list.forEach(this::assertCredentialFreeSnapshot);
}
}
/**
* 在目标尚未映射时校验可由 manifest 独立确定的最终运行时名称。
*
* @param binding 能力绑定
* @param type 能力类型
* @param enabled 是否启用
* @param path 问题路径
* @param runtimeNames 已占用的运行时名称
* @param issues 问题集合
*/
private void validateRuntimeNamesWithoutTarget(SkillCapabilityBinding binding,
SkillCapabilityType type,
boolean enabled,
String path,
Set<String> runtimeNames,
List<SkillValidationIssue> issues) {
if (type == SkillCapabilityType.MCP) {
validateMcp(binding, null, enabled, false, path, runtimeNames, issues);
return;
}
if (enabled && binding.getRuntimeName() != null
&& !runtimeNames.add(binding.getRuntimeName().toLowerCase(Locale.ROOT))) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE",
"最终运行时工具名重复", path + ".runtimeName"));
}
}
private void validateMcp(SkillCapabilityBinding binding,
SkillCapabilityTarget target,
boolean enabled,
boolean publishValidation,
String path,
Set<String> runtimeNames,
List<SkillValidationIssue> issues) {
SkillCapabilitySelectionMode mode = SkillCapabilitySelectionMode.fromOrDefault(binding.getSelectionMode());
List<String> selected = binding.getSelectedToolNamesJson();
if (!enabled) {
return;
}
List<String> available = target == null ? List.of() : target.getToolNames();
List<String> resolved;
if (mode == SkillCapabilitySelectionMode.SELECTED) {
resolved = selected;
} else if (publishValidation) {
resolved = available;
} else {
return;
}
if (resolved.isEmpty()) {
if (mode == SkillCapabilitySelectionMode.SELECTED) {
// SELECTED 空清单已经由静态配置校验给出精确问题,避免重复且含混的发布错误。
return;
}
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOLS_EMPTY", "MCP 当前没有可发布的工具",
path + ".selectedToolNamesJson"));
return;
}
if (publishValidation && mode == SkillCapabilitySelectionMode.SELECTED && !available.containsAll(selected)) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_MISSING",
"部分已选择的 MCP 工具已不存在,请重新选择", path + ".selectedToolNamesJson"));
return;
}
binding.setResolvedToolNames(resolved);
for (String toolName : resolved) {
String finalName = binding.getRuntimeName() + "_" + toolName;
if (finalName.length() > 128 || !Pattern.matches("^[A-Za-z][A-Za-z0-9_.-]{0,127}$", finalName)) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_RUNTIME_NAME_INVALID",
"MCP 最终工具名不符合平台命名规则", path + ".runtimeName"));
continue;
}
if (!runtimeNames.add(finalName.toLowerCase(Locale.ROOT))) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE",
"最终运行时工具名重复", path + ".runtimeName"));
}
}
}
private void validateStaticTypeConfiguration(SkillCapabilityBinding binding,
SkillCapabilityType type,
boolean enabled,
String path,
List<SkillValidationIssue> issues) {
if (type != SkillCapabilityType.MCP) {
if (binding.getSelectionMode() != null && !binding.getSelectionMode().isBlank()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_SELECTION_MODE_NOT_ALLOWED",
"工作流或插件能力不能配置 MCP 工具选择模式", path + ".selectionMode"));
}
if (binding.getSelectedToolNamesJson() != null && !binding.getSelectedToolNamesJson().isEmpty()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_SELECTION_NOT_ALLOWED",
"工作流或插件能力不能配置 MCP 工具清单", path + ".selectedToolNamesJson"));
}
try {
binding.setExecutionMode(SkillCapabilityExecutionMode.fromOrDefault(binding.getExecutionMode()).name());
} catch (BusinessException exception) {
issues.add(SkillValidationIssue.of("ERROR", "EXECUTION_MODE_INVALID",
"能力执行模式不受支持", path + ".executionMode"));
binding.setExecutionMode(SkillCapabilityExecutionMode.SYNC.name());
}
binding.setSelectionMode(null);
binding.setSelectedToolNamesJson(List.of());
return;
}
if (binding.getExecutionMode() != null && !binding.getExecutionMode().isBlank()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_EXECUTION_MODE_NOT_ALLOWED",
"MCP 能力不能配置工作流或插件执行模式", path + ".executionMode"));
}
binding.setExecutionMode(null);
SkillCapabilitySelectionMode mode;
try {
mode = SkillCapabilitySelectionMode.fromOrDefault(binding.getSelectionMode());
} catch (BusinessException exception) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_SELECTION_MODE_INVALID",
"MCP 工具选择模式不受支持", path + ".selectionMode"));
mode = SkillCapabilitySelectionMode.ALL;
}
binding.setSelectionMode(mode.name());
List<String> requested = binding.getSelectedToolNamesJson() == null
? List.of() : binding.getSelectedToolNamesJson();
if (requested.size() > MAX_SELECTED_TOOLS) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_SELECTION_LIMIT",
"MCP 最多选择 " + MAX_SELECTED_TOOLS + " 个工具", path + ".selectedToolNamesJson"));
}
Set<String> validTools = new LinkedHashSet<>();
for (int toolIndex = 0; toolIndex < requested.size(); toolIndex++) {
String tool = requested.get(toolIndex);
if (tool == null || !MCP_TOOL_NAME_PATTERN.matcher(tool).matches()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_NAME_INVALID",
"MCP 工具名不符合平台命名规则",
path + ".selectedToolNamesJson[" + toolIndex + "]"));
} else {
validTools.add(tool);
}
}
List<String> selected = new ArrayList<>(validTools);
selected.sort(String::compareTo);
binding.setSelectedToolNamesJson(selected);
if (mode == SkillCapabilitySelectionMode.SELECTED && selected.isEmpty()) {
issues.add(SkillValidationIssue.of(enabled ? "ERROR" : "WARNING", "MCP_TOOL_SELECTION_EMPTY",
"MCP SELECTED 模式至少选择一个工具", path + ".selectedToolNamesJson"));
}
}
private void validateSafeConfigs(SkillCapabilityBinding binding,
String path,
List<SkillValidationIssue> issues) {
Map<String, Object> originalHitl = binding.getHitlConfigJson() == null
? Map.of() : binding.getHitlConfigJson();
Map<String, Object> originalOptions = binding.getOptionsJson() == null
? Map.of() : binding.getOptionsJson();
Map<String, Object> safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson());
Map<String, Object> safeOptions = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson());
if (!safeHitl.equals(originalHitl)) {
issues.add(SkillValidationIssue.of("ERROR", "HITL_CONFIG_UNSAFE",
"HITL 配置包含未允许字段或复杂值", path + ".hitlConfigJson"));
}
if (!safeOptions.equals(originalOptions)) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTIONS_UNSAFE",
"能力选项包含未允许字段或复杂值", path + ".optionsJson"));
}
try {
if (objectMapper.writeValueAsBytes(safeHitl).length > MAX_CONFIG_BYTES
|| objectMapper.writeValueAsBytes(safeOptions).length > MAX_CONFIG_BYTES) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_CONFIG_TOO_LARGE",
"能力配置不能超过 4 KiB", path));
}
} catch (JsonProcessingException exception) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_CONFIG_INVALID",
"能力配置无法序列化", path));
}
binding.setHitlConfigJson(safeHitl);
binding.setOptionsJson(safeOptions);
validateSafeConfigValues(safeHitl, safeOptions, path, issues);
}
private void validateSafeConfigValues(Map<String, Object> hitl,
Map<String, Object> options,
String path,
List<SkillValidationIssue> issues) {
for (Map.Entry<String, Object> entry : hitl.entrySet()) {
int maxLength = "confirmLabel".equals(entry.getKey()) || "cancelLabel".equals(entry.getKey())
? 128 : 2_000;
if (!(entry.getValue() instanceof String text) || text.length() > maxLength) {
issues.add(SkillValidationIssue.of("ERROR", "HITL_CONFIG_VALUE_INVALID",
"HITL 配置字段类型或长度不正确:" + entry.getKey(), path + ".hitlConfigJson." + entry.getKey()));
} else if (SkillCredentialValueGuard.containsCredential(text)) {
issues.add(SkillValidationIssue.of("ERROR", "SENSITIVE_VALUE_DETECTED",
"HITL 配置不能包含认证凭据,请改用运行环境中的安全配置",
path + ".hitlConfigJson." + entry.getKey()));
}
}
for (Map.Entry<String, Object> entry : options.entrySet()) {
if (entry.getValue() instanceof String text) {
validateCredentialValue(text, path + ".optionsJson." + entry.getKey(), issues);
}
}
validateIntegerOption(options, "timeoutMs", 100, 300_000, path, issues);
validateIntegerOption(options, "retryCount", 0, 10, path, issues);
for (String key : List.of("async", "readOnly")) {
if (options.containsKey(key) && !(options.get(key) instanceof Boolean)) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTION_VALUE_INVALID",
"能力选项必须为布尔值:" + key, path + ".optionsJson." + key));
}
}
}
private void validateIntegerOption(Map<String, Object> options,
String key,
int minimum,
int maximum,
String path,
List<SkillValidationIssue> issues) {
if (!options.containsKey(key)) {
return;
}
Object value = options.get(key);
boolean valid = value instanceof Number number
&& number.doubleValue() == number.longValue()
&& number.longValue() >= minimum
&& number.longValue() <= maximum;
if (!valid) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTION_VALUE_INVALID",
"能力选项数值超出范围:" + key, path + ".optionsJson." + key));
}
}
private void enrichDisplayStatus(SkillCapabilityBinding binding) {
if (binding.getTargetId() == null) {
binding.setTargetStatus("UNRESOLVED");
return;
}
try {
SkillCapabilityTarget target = targetAccessService.requireUsableTarget(binding, false);
binding.setTargetName(target.getName());
binding.setTargetStatus("AVAILABLE");
} catch (BusinessException exception) {
boolean permissionDenied = exception.getHttpStatus() == 403;
binding.setTargetStatus(permissionDenied ? "NO_PERMISSION" : "UNAVAILABLE");
if (permissionDenied) {
// 目标不可读时不能沿用调用前对象中可能存在的展示残留。
binding.setTargetName(null);
binding.setResolvedToolNames(List.of());
}
}
}
/**
* 对读取出的历史能力配置执行展示边界脱敏,防止遗留脏数据通过列表或详情接口回显。
*
* @param binding 待读取能力绑定
*/
private void sanitizeBindingForExposure(SkillCapabilityBinding binding) {
binding.setCapabilityType(safeNonCredentialOrNull(binding.getCapabilityType()));
binding.setTargetLogicalRef(safeNonCredentialOrNull(binding.getTargetLogicalRef()));
binding.setRuntimeName(safeNonCredentialOrNull(binding.getRuntimeName()));
binding.setSelectionMode(safeNonCredentialOrNull(binding.getSelectionMode()));
binding.setExecutionMode(safeNonCredentialOrNull(binding.getExecutionMode()));
binding.setTargetName(SkillPortableTargetSanitizer.safePortableMetadataOrNull(binding.getTargetName()));
binding.setTargetStatus(safeNonCredentialOrNull(binding.getTargetStatus()));
binding.setSelectedToolNamesJson(sanitizeToolNamesForExposure(binding.getSelectedToolNamesJson()));
binding.setResolvedToolNames(sanitizeToolNamesForExposure(binding.getResolvedToolNames()));
Map<String, Object> hitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson());
hitl.entrySet().removeIf(entry -> !(entry.getValue() instanceof String text)
|| SkillCredentialValueGuard.containsCredential(text));
binding.setHitlConfigJson(hitl);
Map<String, Object> options = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson());
options.entrySet().removeIf(entry -> !isSafeOptionForExposure(entry.getKey(), entry.getValue()));
binding.setOptionsJson(options);
}
/**
* 过滤历史工具名中的异常或凭据式值。
*
* @param values 原始工具名
* @return 可安全展示的工具名
*/
private List<String> sanitizeToolNamesForExposure(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of();
}
return values.stream()
.filter(java.util.Objects::nonNull)
.filter(value -> MCP_TOOL_NAME_PATTERN.matcher(value).matches())
.filter(value -> !SkillCredentialValueGuard.containsCredential(value))
.toList();
}
/**
* 判断能力选项是否符合公开返回的严格类型和值域。
*
* @param key 选项键
* @param value 选项值
* @return 可安全展示时为 true
*/
private boolean isSafeOptionForExposure(String key, Object value) {
if (("async".equals(key) || "readOnly".equals(key))) {
return value instanceof Boolean;
}
if (!(value instanceof Number number)
|| number.doubleValue() != number.longValue()) {
return false;
}
long numeric = number.longValue();
if ("timeoutMs".equals(key)) {
return numeric >= 100 && numeric <= 300_000;
}
return "retryCount".equals(key) && numeric >= 0 && numeric <= 10;
}
/**
* 返回不含凭据的字符串;敏感或空白值统一移除。
*
* @param value 原始字符串
* @return 可安全返回的值
*/
private String safeNonCredentialOrNull(String value) {
return value == null || value.isBlank() || SkillCredentialValueGuard.containsCredential(value)
? null : value;
}
private List<SkillCapabilityBinding> listRaw(BigInteger skillId) {
return list(QueryWrapper.create()
.eq(SkillCapabilityBinding::getTenantId, requireAccount().getTenantId())
.eq(SkillCapabilityBinding::getSkillId, skillId)
.orderBy("sort_no asc, id asc"));
}
private Skill requireSkill(BigInteger skillId) {
return requireSkill(skillId, false);
}
private Skill requireSkill(BigInteger skillId, boolean forUpdate) {
if (skillId == null) {
throw new BusinessException("Skill ID 不能为空");
}
LoginAccount account = requireAccount();
QueryWrapper query = QueryWrapper.create()
.eq(Skill::getId, skillId)
.eq(Skill::getTenantId, account.getTenantId());
if (forUpdate) {
query.forUpdate();
}
Skill skill = skillMapper.selectOneByQuery(query);
if (skill == null) {
throw new BusinessException(404, 404, "Skill 不存在");
}
return skill;
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private void assertNoErrors(SkillValidationResult result) {
result.getIssues().stream().filter(issue -> "ERROR".equals(issue.getSeverity())).findFirst()
.ifPresent(issue -> {
if ("TARGET_NO_PERMISSION".equals(issue.getCode())) {
throw new BusinessException(403, 403, issue.getMessage());
}
throw new BusinessException(issue.getMessage());
});
}
private Object canonicalize(Object value) {
if (value instanceof Map<?, ?> map) {
Map<String, Object> sorted = new java.util.TreeMap<>();
map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalize(item)));
return sorted;
}
if (value instanceof List<?> list) {
return list.stream().map(this::canonicalize).toList();
}
return value;
}
/**
* 能力绑定校验场景。
*
* @param publishValidation 是否执行发布级 MCP 工具解析
* @param allowUnresolvedTarget 是否允许目标留待导入映射处理
*/
private record ValidationMode(boolean publishValidation, boolean allowUnresolvedTarget) {
private static final ValidationMode SAVE = new ValidationMode(false, false);
private static final ValidationMode PUBLISH = new ValidationMode(true, false);
private static final ValidationMode IMPORT_PREVIEW = new ValidationMode(false, true);
}
/**
* 单次校验内目标查询的稳定缓存键。
*
* @param type 能力类型
* @param targetId 目标 ID
* @param resolveMcpTools 是否解析 MCP 工具清单
*/
private record TargetCacheKey(SkillCapabilityType type,
BigInteger targetId,
boolean resolveMcpTools) {
}
/**
* 校验结果及其已授权目标摘要。
*
* @param result 结构化校验结果
* @param targetsByIndex 按绑定序号记录的目标摘要
*/
private record ValidatedBindings(SkillValidationResult result,
Map<Integer, SkillCapabilityTarget> targetsByIndex) {
}
}

View File

@@ -1,37 +0,0 @@
package tech.easyflow.skill.capability;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* 当前操作者可绑定的能力候选项。
*/
public class SkillCapabilityCandidate {
private String capabilityType;
private BigInteger targetId;
private String name;
private String description;
private String logicalRef;
private String revision;
private String status;
private List<String> toolNames = new ArrayList<>();
public String getCapabilityType() { return capabilityType; }
public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; }
public BigInteger getTargetId() { return targetId; }
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getLogicalRef() { return logicalRef; }
public void setLogicalRef(String logicalRef) { this.logicalRef = logicalRef; }
public String getRevision() { return revision; }
public void setRevision(String revision) { this.revision = revision; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public List<String> getToolNames() { return toolNames; }
public void setToolNames(List<String> toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); }
}

View File

@@ -1,30 +0,0 @@
package tech.easyflow.skill.capability;
import java.util.ArrayList;
import java.util.List;
/**
* 已授权能力目标的安全解析结果。
*/
public class SkillCapabilityTarget {
private String name;
private String description;
private String logicalRef;
private String revision;
private String status;
private List<String> toolNames = new ArrayList<>();
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getLogicalRef() { return logicalRef; }
public void setLogicalRef(String logicalRef) { this.logicalRef = logicalRef; }
public String getRevision() { return revision; }
public void setRevision(String revision) { this.revision = revision; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public List<String> getToolNames() { return toolNames; }
public void setToolNames(List<String> toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); }
}

View File

@@ -1,48 +0,0 @@
package tech.easyflow.skill.capability;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import java.math.BigInteger;
import java.util.List;
/**
* Skill 能力目标的统一授权与安全摘要服务。
*/
public interface SkillCapabilityTargetAccessService {
/**
* 解析并校验一个能力绑定目标。
*
* @param binding 能力绑定
* @param resolveMcpTools 是否实时解析 MCP 工具
* @return 不含凭据的目标摘要
*/
SkillCapabilityTarget requireUsableTarget(SkillCapabilityBinding binding, boolean resolveMcpTools);
/**
* 查询当前操作者可绑定的目标。
*
* @param capabilityType 能力类型
* @param keyword 关键词
* @return 可绑定目标
*/
List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword);
/**
* 按逻辑引用尝试解析当前环境目标。
*
* @param capabilityType 能力类型
* @param logicalRef 逻辑引用
* @return 当前用户有权使用的目标 ID未匹配时为空
*/
BigInteger resolveLogicalRef(SkillCapabilityType capabilityType, String logicalRef);
/**
* 按需解析一个已授权 MCP 的工具清单。
*
* @param targetId MCP ID
* @return MCP 候选详情及工具名
*/
SkillCapabilityCandidate getMcpTools(BigInteger targetId);
}

View File

@@ -1,537 +0,0 @@
package tech.easyflow.skill.capability;
import com.mybatisflex.core.query.QueryWrapper;
import io.modelcontextprotocol.spec.McpSchema;
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.enums.PublishStatus;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.security.SkillPortableTargetSanitizer;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Skill 能力目标授权服务默认实现。
*/
@Service
public class SkillCapabilityTargetAccessServiceImpl implements SkillCapabilityTargetAccessService {
private static final Logger LOG = LoggerFactory.getLogger(SkillCapabilityTargetAccessServiceImpl.class);
private static final int MAX_CANDIDATES = 100;
private static final String UNRESOLVED_PREFIX = "unresolved:";
private final WorkflowService workflowService;
private final PluginItemService pluginItemService;
private final PluginService pluginService;
private final PluginVisibilityService pluginVisibilityService;
private final McpService mcpService;
private final McpAccessPermissionChecker mcpAccessPermissionChecker;
private final ResourceAccessService resourceAccessService;
private final WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
private final CategoryPermissionService categoryPermissionService;
/**
* 创建能力目标授权服务。
*
* @param workflowService 工作流服务
* @param pluginItemService 插件工具项服务
* @param pluginService 插件服务
* @param pluginVisibilityService 插件可见性服务
* @param mcpService MCP 服务
* @param mcpAccessPermissionChecker MCP 查询与使用权限检查器
* @param resourceAccessService 分类资源访问服务
* @param workflowVisibilityQueryHelper 工作流可见性查询助手
* @param categoryPermissionService 分类权限服务
*/
public SkillCapabilityTargetAccessServiceImpl(WorkflowService workflowService,
PluginItemService pluginItemService,
PluginService pluginService,
PluginVisibilityService pluginVisibilityService,
McpService mcpService,
McpAccessPermissionChecker mcpAccessPermissionChecker,
ResourceAccessService resourceAccessService,
WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper,
CategoryPermissionService categoryPermissionService) {
this.workflowService = workflowService;
this.pluginItemService = pluginItemService;
this.pluginService = pluginService;
this.pluginVisibilityService = pluginVisibilityService;
this.mcpService = mcpService;
this.mcpAccessPermissionChecker = mcpAccessPermissionChecker;
this.resourceAccessService = resourceAccessService;
this.workflowVisibilityQueryHelper = workflowVisibilityQueryHelper;
this.categoryPermissionService = categoryPermissionService;
}
/**
* {@inheritDoc}
*/
@Override
public SkillCapabilityTarget requireUsableTarget(SkillCapabilityBinding binding, boolean resolveMcpTools) {
if (binding == null || binding.getTargetId() == null) {
throw new BusinessException("能力绑定目标不能为空");
}
SkillCapabilityType type = SkillCapabilityType.from(binding.getCapabilityType());
return switch (type) {
case WORKFLOW -> requireWorkflow(binding.getTargetId());
case PLUGIN_ITEM -> requirePluginItem(binding.getTargetId());
case MCP -> requireMcp(binding.getTargetId(), resolveMcpTools);
};
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword) {
String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase();
return switch (capabilityType) {
case WORKFLOW -> workflowCandidates(normalizedKeyword);
case PLUGIN_ITEM -> pluginCandidates(normalizedKeyword);
case MCP -> mcpCandidates(normalizedKeyword);
};
}
/**
* {@inheritDoc}
*/
@Override
public BigInteger resolveLogicalRef(SkillCapabilityType capabilityType, String logicalRef) {
if (capabilityType == SkillCapabilityType.MCP) {
// 显式未映射引用仍属于增强包 MCP 映射流程,不能绕过模块权限。
mcpAccessPermissionChecker.assertCanUseMcp();
}
if (!SkillPortableTargetSanitizer.isSafeLogicalRef(capabilityType, logicalRef)) {
return null;
}
if (logicalRef.startsWith(UNRESOLVED_PREFIX)
|| "workflow:unmapped".equals(logicalRef)
|| "plugin-item:unmapped/unmapped".equals(logicalRef)
|| "mcp:unmapped".equals(logicalRef)) {
return null;
}
try {
return switch (capabilityType) {
case WORKFLOW -> resolveWorkflowRef(logicalRef);
case PLUGIN_ITEM -> resolvePluginItemRef(logicalRef);
case MCP -> resolveMcpRef(logicalRef);
};
} catch (BusinessException exception) {
if (exception.getHttpStatus() == 401 || exception.getHttpStatus() == 403) {
throw exception;
}
return null;
}
}
/**
* {@inheritDoc}
*/
@Override
public SkillCapabilityCandidate getMcpTools(BigInteger targetId) {
SkillCapabilityTarget target = requireMcp(targetId, true);
return toCandidate(SkillCapabilityType.MCP, targetId, target);
}
private SkillCapabilityTarget requireWorkflow(BigInteger targetId) {
Workflow workflow = workflowService.getOne(QueryWrapper.create()
.eq(Workflow::getId, targetId)
.eq(Workflow::getTenantId, requireAccount().getTenantId()));
return toWorkflowTarget(workflow, targetId, true);
}
private SkillCapabilityTarget toWorkflowTarget(Workflow workflow, BigInteger targetId, boolean assertPermission) {
if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException(404, 404, "绑定工作流不存在、未发布或没有有效发布快照");
}
if (assertPermission) {
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE,
"无权限使用绑定工作流");
}
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName(workflow.getTitle());
target.setDescription(workflow.getDescription());
String stableRef = firstNonBlank(workflow.getAlias(), workflow.getEnglishName());
target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
SkillCapabilityType.WORKFLOW, stableRef == null ? null : "workflow:" + stableRef));
target.setRevision(workflow.getPublishedAt() == null ? null : String.valueOf(workflow.getPublishedAt().getTime()));
target.setStatus("AVAILABLE");
return target;
}
private SkillCapabilityTarget requirePluginItem(BigInteger targetId) {
LoginAccount account = requireAccount();
PluginItem item = pluginItemService.getOne(QueryWrapper.create()
.eq(PluginItem::getId, targetId)
.and("plugin_id IN (SELECT id FROM tb_plugin WHERE tenant_id = ?)",
account.getTenantId().longValue()));
if (item == null || !Integer.valueOf(1).equals(item.getStatus())
|| !Integer.valueOf(1).equals(item.getServiceStatus())) {
throw new BusinessException(404, 404, "绑定插件工具项不存在或未启用");
}
Plugin plugin = pluginService.getOne(QueryWrapper.create()
.eq(Plugin::getId, item.getPluginId())
.eq(Plugin::getTenantId, account.getTenantId().longValue()));
return toPluginTarget(item, plugin, true, false);
}
private SkillCapabilityTarget toPluginTarget(PluginItem item, Plugin plugin,
boolean assertPermission, boolean alreadyPrepared) {
if (plugin == null) {
throw new BusinessException(404, 404, "绑定插件工具项所属插件不存在");
}
LoginAccount account = requireAccount();
if (!Objects.equals(plugin.getTenantId(), account.getTenantId().longValue())) {
throw new BusinessException(403, 403, "无权限使用绑定插件");
}
if (assertPermission && !pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
throw new BusinessException(403, 403, "无权限使用绑定插件");
}
Plugin prepared = alreadyPrepared ? plugin : pluginService.preparePluginForCurrentUser(plugin);
if (prepared != null && Boolean.FALSE.equals(prepared.getAvailable())) {
throw new BusinessException(firstNonBlank(prepared.getReasonMessage(), "绑定插件当前不可用"));
}
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName(plugin.getName() + " / " + item.getName());
target.setDescription(item.getDescription());
String pluginRef = firstNonBlank(plugin.getAlias());
String itemRef = firstNonBlank(item.getEnglishName());
String logicalRef = pluginRef == null || itemRef == null
? null : "plugin-item:" + pluginRef + "/" + itemRef;
target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
SkillCapabilityType.PLUGIN_ITEM, logicalRef));
target.setRevision(item.getSchemaHash());
target.setStatus("AVAILABLE");
return target;
}
private SkillCapabilityTarget requireMcp(BigInteger targetId, boolean resolveTools) {
mcpAccessPermissionChecker.assertCanUseMcp();
Mcp mcp = mcpService.getOne(QueryWrapper.create()
.eq(Mcp::getId, targetId)
.eq(Mcp::getTenantId, requireAccount().getTenantId()));
return toMcpTarget(mcp, targetId, resolveTools);
}
private SkillCapabilityTarget toMcpTarget(Mcp mcp, BigInteger targetId, boolean resolveTools) {
LoginAccount account = requireAccount();
if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) {
throw new BusinessException(404, 404, "绑定 MCP 不存在或未启用");
}
// MCP 尚未纳入 CategoryResourceType显式限制到当前租户防止使用 ID 绕过租户隔离。
if (!Objects.equals(account.getTenantId(), mcp.getTenantId())) {
throw new BusinessException(403, 403, "无权限使用绑定 MCP");
}
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName(mcp.getTitle());
target.setDescription(mcp.getDescription());
target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
SkillCapabilityType.MCP, mcp.getTitle() == null ? null : "mcp:" + mcp.getTitle()));
target.setRevision(mcp.getModified() == null ? null : String.valueOf(mcp.getModified().getTime()));
target.setStatus("AVAILABLE");
if (resolveTools) {
try {
Mcp resolved = mcpService.getMcpTools(targetId.toString());
if (resolved == null || resolved.getTools() == null) {
throw new BusinessException("MCP 当前未连接,无法解析工具清单");
}
target.setToolNames(resolved.getTools().stream().map(McpSchema.Tool::name).sorted().toList());
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
LOG.error("解析 Skill 绑定 MCP 工具清单失败targetId={}", targetId, exception);
throw new BusinessException(502, 5021,
"MCP 工具清单解析失败,请检查服务连接状态", exception);
}
}
return target;
}
private List<SkillCapabilityCandidate> workflowCandidates(String keyword) {
QueryWrapper query = QueryWrapper.create()
.eq(Workflow::getTenantId, requireAccount().getTenantId())
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.orderBy("modified desc");
workflowVisibilityQueryHelper.applyReadableAccess(query);
query.limit(MAX_CANDIDATES);
applyKeyword(query, keyword, "title", "description", "alias", "english_name");
List<SkillCapabilityCandidate> result = new ArrayList<>();
for (Workflow workflow : workflowService.list(query)) {
if (!resourceAccessService.canAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE)) {
continue;
}
SkillCapabilityTarget target;
try {
target = toWorkflowTarget(workflow, workflow.getId(), false);
} catch (BusinessException ignored) {
continue;
}
if (!matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) {
continue;
}
result.add(toCandidate(SkillCapabilityType.WORKFLOW, workflow.getId(), target));
if (result.size() >= MAX_CANDIDATES) {
break;
}
}
return result;
}
private List<SkillCapabilityCandidate> pluginCandidates(String keyword) {
QueryWrapper query = QueryWrapper.create()
.eq(PluginItem::getStatus, 1)
.eq(PluginItem::getServiceStatus, 1)
.orderBy("created desc");
applyPluginReadableAccess(query);
query.limit(MAX_CANDIDATES);
applyKeyword(query, keyword, "name", "description", "english_name");
List<PluginItem> items = pluginItemService.list(query);
Map<BigInteger, Plugin> plugins = loadPlugins(items);
Map<BigInteger, Plugin> preparedPlugins = new LinkedHashMap<>();
for (Plugin plugin : plugins.values()) {
preparedPlugins.put(plugin.getId(), pluginService.preparePluginForCurrentUser(plugin));
}
List<SkillCapabilityCandidate> result = new ArrayList<>();
for (PluginItem item : items) {
Plugin plugin = preparedPlugins.get(item.getPluginId());
if (plugin == null) {
continue;
}
try {
SkillCapabilityTarget target = toPluginTarget(item, plugin, false, true);
if (matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) {
result.add(toCandidate(SkillCapabilityType.PLUGIN_ITEM, item.getId(), target));
if (result.size() >= MAX_CANDIDATES) {
break;
}
}
} catch (BusinessException ignored) {
// 候选列表只展示当前可用项,具体不可用原因在已保存绑定的校验结果中返回。
}
}
return result;
}
private List<SkillCapabilityCandidate> mcpCandidates(String keyword) {
// 候选枚举在查询数据前完成模块权限校验,避免把无权限误装成空列表。
mcpAccessPermissionChecker.assertCanUseMcp();
QueryWrapper query = QueryWrapper.create().eq(Mcp::getStatus, true)
.eq(Mcp::getTenantId, requireAccount().getTenantId())
.orderBy("modified desc").limit(MAX_CANDIDATES);
applyKeyword(query, keyword, "title", "description");
List<SkillCapabilityCandidate> result = new ArrayList<>();
for (Mcp mcp : mcpService.list(query)) {
try {
SkillCapabilityTarget target = toMcpTarget(mcp, mcp.getId(), false);
if (matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) {
result.add(toCandidate(SkillCapabilityType.MCP, mcp.getId(), target));
if (result.size() >= MAX_CANDIDATES) {
break;
}
}
} catch (BusinessException ignored) {
// 同租户且启用的 MCP 才能成为候选。
}
}
return result;
}
private Map<BigInteger, Plugin> loadPlugins(List<PluginItem> items) {
List<BigInteger> ids = items.stream().map(PluginItem::getPluginId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
Map<BigInteger, Plugin> result = new LinkedHashMap<>();
for (Plugin plugin : pluginService.list(QueryWrapper.create()
.eq(Plugin::getTenantId, requireAccount().getTenantId().longValue())
.in(Plugin::getId, ids))) {
result.put(plugin.getId(), plugin);
}
return result;
}
private void applyPluginReadableAccess(QueryWrapper itemQuery) {
LoginAccount account = requireAccount();
itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE tenant_id = ?)",
account.getTenantId().longValue());
RoleCategoryAccessSnapshot snapshot = categoryPermissionService.getCurrentAccess("PLUGIN");
if (snapshot.isSuperAdmin() || !snapshot.isRestricted()) {
return;
}
if (snapshot.getAccountId() == null) {
itemQuery.and("1 = 0");
return;
}
if (snapshot.getCategoryIds().isEmpty()) {
itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE created_by = ?)",
snapshot.getAccountId());
return;
}
String placeholders = String.join(",", java.util.Collections.nCopies(
snapshot.getCategoryIds().size(), "?"));
List<Object> arguments = new ArrayList<>();
arguments.add(snapshot.getAccountId());
arguments.addAll(snapshot.getCategoryIds());
itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE created_by = ? OR id IN "
+ "(SELECT plugin_id FROM tb_plugin_category_mapping WHERE category_id IN (" + placeholders + ")))",
arguments.toArray());
}
private BigInteger resolveWorkflowRef(String logicalRef) {
if (!logicalRef.startsWith("workflow:")) {
return null;
}
String key = logicalRef.substring("workflow:".length());
QueryWrapper query = QueryWrapper.create();
query.eq(Workflow::getTenantId, requireAccount().getTenantId());
query.and("(alias = ? OR english_name = ?)", key, key);
query.limit(2);
List<Workflow> matches = workflowService.list(query).stream()
.filter(workflow -> resourceAccessService.canAccess(
CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE))
.toList();
if (matches.size() != 1) {
return null;
}
toWorkflowTarget(matches.get(0), matches.get(0).getId(), false);
return matches.get(0).getId();
}
private BigInteger resolvePluginItemRef(String logicalRef) {
if (!logicalRef.startsWith("plugin-item:") || !logicalRef.substring("plugin-item:".length()).contains("/")) {
return null;
}
String value = logicalRef.substring("plugin-item:".length());
int separator = value.indexOf('/');
String pluginKey = value.substring(0, separator);
String itemKey = value.substring(separator + 1);
QueryWrapper pluginQuery = QueryWrapper.create();
pluginQuery.eq(Plugin::getTenantId, requireAccount().getTenantId().longValue())
.eq(Plugin::getAlias, pluginKey);
pluginQuery.limit(2);
List<Plugin> plugins = pluginService.list(pluginQuery).stream()
.filter(plugin -> pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId()))
.toList();
if (plugins.size() != 1) {
return null;
}
QueryWrapper itemQuery = QueryWrapper.create().eq(PluginItem::getPluginId, plugins.get(0).getId());
itemQuery.and("(english_name = ? OR name = ?)", itemKey, itemKey);
itemQuery.limit(2);
List<PluginItem> items = pluginItemService.list(itemQuery);
if (items.size() != 1) {
return null;
}
toPluginTarget(items.get(0), plugins.get(0), false, false);
return items.get(0).getId();
}
private BigInteger resolveMcpRef(String logicalRef) {
if (!logicalRef.startsWith("mcp:")) {
return null;
}
String title = logicalRef.substring("mcp:".length());
List<Mcp> matches = mcpService.list(QueryWrapper.create()
.eq(Mcp::getTenantId, requireAccount().getTenantId())
.eq(Mcp::getTitle, title).limit(2));
List<Mcp> usable = matches.stream().filter(mcp -> {
try {
toMcpTarget(mcp, mcp.getId(), false);
return true;
} catch (BusinessException exception) {
return false;
}
}).toList();
return usable.size() == 1 ? usable.get(0).getId() : null;
}
private SkillCapabilityCandidate toCandidate(SkillCapabilityType type, BigInteger id, SkillCapabilityTarget target) {
SkillCapabilityCandidate candidate = new SkillCapabilityCandidate();
candidate.setCapabilityType(type.name());
candidate.setTargetId(id);
candidate.setName(target.getName());
candidate.setDescription(target.getDescription());
candidate.setLogicalRef(target.getLogicalRef());
candidate.setRevision(target.getRevision());
candidate.setStatus(target.getStatus());
candidate.setToolNames(target.getToolNames());
return candidate;
}
private boolean matches(String keyword, String... values) {
if (keyword == null || keyword.isBlank()) {
return true;
}
for (String value : values) {
if (value != null && value.toLowerCase(Locale.ROOT).contains(keyword)) {
return true;
}
}
return false;
}
private void applyKeyword(QueryWrapper query, String keyword, String... columns) {
if (keyword == null || keyword.isBlank() || columns.length == 0) {
return;
}
String pattern = SearchKeywordUtil.literalContainsPattern(keyword.toLowerCase(Locale.ROOT));
StringBuilder condition = new StringBuilder("(");
Object[] arguments = new Object[columns.length];
for (int index = 0; index < columns.length; index++) {
if (index > 0) {
condition.append(" OR ");
}
condition.append("LOWER(").append(columns[index]).append(") LIKE ?");
arguments[index] = pattern;
}
condition.append(')');
query.and(condition.toString(), arguments);
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -32,20 +32,10 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
private String name;
private String displayName;
private String description;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
private String skillContent;
private Boolean enabled;
private String visibilityScope;
private String sourceType;
private String packageHash;
private String capabilityHash;
private String snapshotHash;
private Integer resourceCount;
private Integer capabilityCount;
private Integer referenceCount;
private Integer scriptCount;
private Integer assetCount;
private String publishStatus;
private BigInteger currentApprovalInstanceId;
@Column(typeHandler = FastjsonTypeHandler.class)
@@ -66,15 +56,7 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
@Column(ignore = true)
private String createdByName;
@Column(ignore = true)
private List<SkillReference> references;
@Column(ignore = true)
private List<SkillScript> scripts;
@Column(ignore = true)
private List<SkillAsset> assets;
@Column(ignore = true)
private List<SkillResource> resources;
@Column(ignore = true)
private List<SkillCapabilityBinding> capabilityBindings;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
@@ -90,32 +72,14 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
public String getSkillContent() { return skillContent; }
public void setSkillContent(String skillContent) { this.skillContent = skillContent; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public String getVisibilityScope() { return visibilityScope; }
public void setVisibilityScope(String visibilityScope) { this.visibilityScope = visibilityScope; }
public String getSourceType() { return sourceType; }
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
public String getPackageHash() { return packageHash; }
public void setPackageHash(String packageHash) { this.packageHash = packageHash; }
public String getCapabilityHash() { return capabilityHash; }
public void setCapabilityHash(String capabilityHash) { this.capabilityHash = capabilityHash; }
public String getSnapshotHash() { return snapshotHash; }
public void setSnapshotHash(String snapshotHash) { this.snapshotHash = snapshotHash; }
public Integer getResourceCount() { return resourceCount; }
public void setResourceCount(Integer resourceCount) { this.resourceCount = resourceCount; }
public Integer getCapabilityCount() { return capabilityCount; }
public void setCapabilityCount(Integer capabilityCount) { this.capabilityCount = capabilityCount; }
public Integer getReferenceCount() { return referenceCount; }
public void setReferenceCount(Integer referenceCount) { this.referenceCount = referenceCount; }
public Integer getScriptCount() { return scriptCount; }
public void setScriptCount(Integer scriptCount) { this.scriptCount = scriptCount; }
public Integer getAssetCount() { return assetCount; }
public void setAssetCount(Integer assetCount) { this.assetCount = assetCount; }
public String getPublishStatus() { return publishStatus; }
public void setPublishStatus(String publishStatus) { this.publishStatus = publishStatus; }
public BigInteger getCurrentApprovalInstanceId() { return currentApprovalInstanceId; }
@@ -142,14 +106,6 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
public void setDisplayPublishStatus(String displayPublishStatus) { this.displayPublishStatus = displayPublishStatus; }
public String getCreatedByName() { return createdByName; }
public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }
public List<SkillReference> getReferences() { return references; }
public void setReferences(List<SkillReference> references) { this.references = references; }
public List<SkillScript> getScripts() { return scripts; }
public void setScripts(List<SkillScript> scripts) { this.scripts = scripts; }
public List<SkillAsset> getAssets() { return assets; }
public void setAssets(List<SkillAsset> assets) { this.assets = assets; }
public List<SkillResource> getResources() { return resources; }
public void setResources(List<SkillResource> resources) { this.resources = resources; }
public List<SkillCapabilityBinding> getCapabilityBindings() { return capabilityBindings; }
public void setCapabilityBindings(List<SkillCapabilityBinding> capabilityBindings) { this.capabilityBindings = capabilityBindings; }
}

View File

@@ -1,56 +0,0 @@
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 com.mybatisflex.core.handler.FastjsonTypeHandler;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill asset 实体。
*/
@Table("tb_skill_asset")
public class SkillAsset 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 path;
private String name;
private String mediaType;
private String contentRef;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getMediaType() { return mediaType; }
public void setMediaType(String mediaType) { this.mediaType = mediaType; }
public String getContentRef() { return contentRef; }
public void setContentRef(String contentRef) { this.contentRef = contentRef; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
}

View File

@@ -1,102 +0,0 @@
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 com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Skill 与平台能力的绑定实体。
*/
@Table("tb_skill_capability_binding")
public class SkillCapabilityBinding 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 capabilityType;
private BigInteger targetId;
private String targetLogicalRef;
private String runtimeName;
private Boolean enabled;
private String selectionMode;
@Column(typeHandler = FastjsonTypeHandler.class)
private List<String> selectedToolNamesJson = new ArrayList<>();
private String executionMode;
private Boolean hitlEnabled;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> hitlConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> optionsJson = new LinkedHashMap<>();
private Integer sortNo;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
@Column(ignore = true)
private String targetName;
@Column(ignore = true)
private String targetStatus;
@Column(ignore = true)
private List<String> resolvedToolNames = new ArrayList<>();
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
public String getCapabilityType() { return capabilityType; }
public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; }
public BigInteger getTargetId() { return targetId; }
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
public String getTargetLogicalRef() { return targetLogicalRef; }
public void setTargetLogicalRef(String targetLogicalRef) { this.targetLogicalRef = targetLogicalRef; }
public String getRuntimeName() { return runtimeName; }
public void setRuntimeName(String runtimeName) { this.runtimeName = runtimeName; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public String getSelectionMode() { return selectionMode; }
public void setSelectionMode(String selectionMode) { this.selectionMode = selectionMode; }
public List<String> getSelectedToolNamesJson() { return selectedToolNamesJson; }
public void setSelectedToolNamesJson(List<String> selectedToolNamesJson) { this.selectedToolNamesJson = selectedToolNamesJson == null ? new ArrayList<>() : new ArrayList<>(selectedToolNamesJson); }
public String getExecutionMode() { return executionMode; }
public void setExecutionMode(String executionMode) { this.executionMode = executionMode; }
public Boolean getHitlEnabled() { return hitlEnabled; }
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
public Map<String, Object> getHitlConfigJson() { return hitlConfigJson; }
public void setHitlConfigJson(Map<String, Object> hitlConfigJson) { this.hitlConfigJson = hitlConfigJson == null ? new LinkedHashMap<>() : hitlConfigJson; }
public Map<String, Object> getOptionsJson() { return optionsJson; }
public void setOptionsJson(Map<String, Object> optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
public String getTargetName() { return targetName; }
public void setTargetName(String targetName) { this.targetName = targetName; }
public String getTargetStatus() { return targetStatus; }
public void setTargetStatus(String targetStatus) { this.targetStatus = targetStatus; }
public List<String> getResolvedToolNames() { return resolvedToolNames; }
public void setResolvedToolNames(List<String> resolvedToolNames) { this.resolvedToolNames = resolvedToolNames == null ? new ArrayList<>() : new ArrayList<>(resolvedToolNames); }
}

View File

@@ -23,7 +23,6 @@ public class SkillImportStage implements Serializable {
private BigInteger accountId;
private String filePath;
private String originalName;
private String format;
private String status;
private Date expiresAt;
private Date created;
@@ -38,8 +37,6 @@ public class SkillImportStage implements Serializable {
public void setFilePath(String filePath) { this.filePath = filePath; }
public String getOriginalName() { return originalName; }
public void setOriginalName(String originalName) { this.originalName = originalName; }
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Date getExpiresAt() { return expiresAt; }

View File

@@ -1,53 +0,0 @@
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 com.mybatisflex.core.handler.FastjsonTypeHandler;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill reference 文档实体。
*/
@Table("tb_skill_reference")
public class SkillReference 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 path;
private String name;
private String content;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
}

View File

@@ -4,14 +4,11 @@ import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
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 通用资源实体,统一承载文本与二进制包内文件。
@@ -28,17 +25,12 @@ public class SkillResource extends DateEntity implements Serializable {
private BigInteger skillId;
private String path;
private String normalizedPath;
private String kind;
private String language;
private String mediaType;
private Boolean isText;
private String textContent;
private String contentRef;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
private Integer sortNo;
private Date created;
private BigInteger createdBy;
private Date modified;
@@ -54,10 +46,6 @@ public class SkillResource extends DateEntity implements Serializable {
public void setPath(String path) { this.path = path; }
public String getNormalizedPath() { return normalizedPath; }
public void setNormalizedPath(String normalizedPath) { this.normalizedPath = normalizedPath; }
public String getKind() { return kind; }
public void setKind(String kind) { this.kind = kind; }
public String getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public String getMediaType() { return mediaType; }
public void setMediaType(String mediaType) { this.mediaType = mediaType; }
public Boolean getIsText() { return isText; }
@@ -70,10 +58,6 @@ public class SkillResource extends DateEntity implements Serializable {
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }

View File

@@ -1,53 +0,0 @@
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 com.mybatisflex.core.handler.FastjsonTypeHandler;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill script 实体。
*/
@Table("tb_skill_script")
public class SkillScript 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 path;
private String language;
private String content;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
}

View File

@@ -1,30 +0,0 @@
package tech.easyflow.skill.enums;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* Skill 能力执行模式配置。
*/
public enum SkillCapabilityExecutionMode {
SYNC,
ASYNC;
/**
* 解析执行模式,空值默认同步。
*
* @param value 模式编码
* @return 执行模式
*/
public static SkillCapabilityExecutionMode fromOrDefault(String value) {
if (value == null || value.isBlank()) {
return SYNC;
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的能力执行模式");
}
}
}

View File

@@ -1,30 +0,0 @@
package tech.easyflow.skill.enums;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* MCP 工具选择模式。
*/
public enum SkillCapabilitySelectionMode {
ALL,
SELECTED;
/**
* 解析选择模式,空值默认全部。
*
* @param value 模式编码
* @return 选择模式
*/
public static SkillCapabilitySelectionMode fromOrDefault(String value) {
if (value == null || value.isBlank()) {
return ALL;
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的 MCP 工具选择模式");
}
}
}

View File

@@ -1,31 +0,0 @@
package tech.easyflow.skill.enums;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* Skill 可绑定的平台能力类型。
*/
public enum SkillCapabilityType {
WORKFLOW,
PLUGIN_ITEM,
MCP;
/**
* 解析能力类型。
*
* @param value 类型编码
* @return 能力类型
*/
public static SkillCapabilityType from(String value) {
if (value == null || value.isBlank()) {
throw new BusinessException("能力类型不能为空");
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的能力类型");
}
}
}

View File

@@ -1,8 +1,6 @@
package tech.easyflow.skill.file;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.model.SkillResourceKind;
import com.easyagents.skill.model.SkillScriptLanguage;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillPaths;
import com.easyagents.skill.util.SkillResources;
@@ -157,10 +155,7 @@ public class SkillFileServiceImpl implements SkillFileService {
update.setId(source.getId());
update.setCategoryId(source.getCategoryId());
update.setDisplayName(source.getDisplayName());
update.setMetadataJson(source.getMetadataJson() == null
? null : new LinkedHashMap<>(source.getMetadataJson()));
update.setSkillContent(content);
update.setEnabled(source.getEnabled());
update.setVisibilityScope(source.getVisibilityScope());
return update;
}
@@ -230,9 +225,7 @@ public class SkillFileServiceImpl implements SkillFileService {
boolean text = Boolean.TRUE.equals(resource.getIsText());
resource.setPath(targetPath);
resource.setNormalizedPath(targetPath);
resource.setKind(SkillResources.classify(targetPath).name());
resource.setLanguage(resolveLanguage(targetPath, resource.getKind()));
resource.setMediaType(resolveMediaType(targetPath, resource.getKind(), text));
resource.setMediaType(resolveMediaType(targetPath, text));
try {
if (!skillResourceService.update(resource, tenantResourceQuery(resource.getSkillId(), resource.getId())
.eq(SkillResource::getContentHash, request.getExpectedContentHash()))) {
@@ -339,8 +332,6 @@ public class SkillFileServiceImpl implements SkillFileService {
resource.setPath(normalizedPath);
resource.setNormalizedPath(normalizedPath);
}
resource.setKind(SkillResources.classify(normalizedPath).name());
resource.setLanguage(null);
resource.setMediaType(mediaType);
resource.setIsText(false);
resource.setTextContent(null);
@@ -410,10 +401,7 @@ public class SkillFileServiceImpl implements SkillFileService {
resource.setNormalizedPath(path);
}
byte[] contentBytes = bytes(content);
SkillResourceKind kind = SkillResources.classify(path);
resource.setKind(kind.name());
resource.setLanguage(resolveLanguage(path, kind.name()));
resource.setMediaType(resolveMediaType(path, kind.name(), true));
resource.setMediaType(resolveMediaType(path, true));
resource.setIsText(true);
resource.setTextContent(content);
resource.setContentRef(null);
@@ -486,7 +474,7 @@ public class SkillFileServiceImpl implements SkillFileService {
return skillResourceService.list(QueryWrapper.create()
.eq(SkillResource::getTenantId, currentTenantId())
.eq(SkillResource::getSkillId, skillId)
.orderBy("sort_no asc, normalized_path asc"));
.orderBy("normalized_path asc"));
}
private List<SkillResource> listResourceDescriptors(BigInteger skillId) {
@@ -528,9 +516,9 @@ public class SkillFileServiceImpl implements SkillFileService {
private SkillFileContent toContent(SkillResource resource) {
SkillFileContent content = new SkillFileContent();
content.setPath(resource.getNormalizedPath());
content.setType(resource.getKind());
content.setType(SkillResources.classify(resource.getNormalizedPath()).name());
content.setContent(resource.getTextContent());
content.setLanguage(resource.getLanguage());
content.setLanguage(resolveLanguage(resource.getNormalizedPath()));
content.setMediaType(resource.getMediaType());
content.setIsText(resource.getIsText());
content.setSize(resource.getSize());
@@ -539,7 +527,8 @@ public class SkillFileServiceImpl implements SkillFileService {
}
private SkillFileNode fileNode(SkillResource resource) {
return fileNode(resource.getNormalizedPath(), resource.getKind(), resource.getLanguage(),
String path = resource.getNormalizedPath();
return fileNode(path, SkillResources.classify(path).name(), resolveLanguage(path),
resource.getMediaType(), resource.getIsText(), resource.getSize(), resource.getContentHash());
}
@@ -631,45 +620,44 @@ public class SkillFileServiceImpl implements SkillFileService {
}
/**
* 使用 M18 的统一路径、类型和媒体规则判断资源的规范存储表示
* 根据路径与媒体类型判断资源的规范存储表示;目录分类不参与文本编码决策
*
* @param path 规范化资源路径
* @return 应以内联严格 UTF-8 文本保存时返回 true
*/
private boolean shouldStoreAsText(String path) {
SkillResourceKind kind = SkillResources.classify(path);
return SkillResources.isText(path, kind, detectMediaType(path));
return SkillResources.isText(path, detectMediaType(path));
}
/**
* 将上传的脚本文件按安全上限严格解码为 UTF-8 文本。
* 将上传的文本资源按安全上限严格解码为 UTF-8 文本。
*
* @param file 上传文件
* @param path 目标路径
* @return 脚本文本
* @return 资源文本
*/
private String readStrictUtf8(MultipartFile file, String path) {
if (file.getSize() > MAX_TEXT_RESOURCE_BYTES) {
throw new BusinessException(413, 4131, "Skill 脚本超过 2 MiB 限制:" + path);
throw new BusinessException(413, 4131, "Skill 文本资源超过 2 MiB 限制:" + path);
}
try (InputStream inputStream = file.getInputStream()) {
return readStrictUtf8(inputStream, path);
} catch (IOException exception) {
throw new BusinessException(500, 500, "读取 Skill 脚本上传内容失败", exception);
throw new BusinessException(500, 500, "读取 Skill 文本资源上传内容失败", exception);
}
}
/**
* 将已有二进制资源转换为脚本文本,供跨目录重命名安全收敛表示。
* 将已有二进制资源转换为文本,供重命名后收敛存储表示。
*
* @param resource 源资源
* @param targetPath 目标脚本路径
* @param targetPath 目标资源路径
* @return 严格 UTF-8 文本
*/
private String readStrictUtf8Content(SkillResource resource, String targetPath) {
if (resource.getContentRef() == null || resource.getSize() == null
|| resource.getSize() > MAX_TEXT_RESOURCE_BYTES) {
throw new BusinessException("二进制资源不能转换为本:" + targetPath);
throw new BusinessException("二进制资源不能转换为本:" + targetPath);
}
try (InputStream inputStream = contentStore.open(resource.getContentRef())) {
return readStrictUtf8(inputStream, targetPath);
@@ -697,7 +685,7 @@ public class SkillFileServiceImpl implements SkillFileService {
}
total += length;
if (total > MAX_TEXT_RESOURCE_BYTES) {
throw new BusinessException(413, 4131, "Skill 脚本超过 2 MiB 限制:" + path);
throw new BusinessException(413, 4131, "Skill 文本资源超过 2 MiB 限制:" + path);
}
output.write(buffer, 0, length);
}
@@ -707,9 +695,9 @@ public class SkillFileServiceImpl implements SkillFileService {
.decode(ByteBuffer.wrap(output.toByteArray()))
.toString();
} catch (CharacterCodingException exception) {
throw new BusinessException("Skill 脚本必须使用严格 UTF-8 编码:" + path);
throw new BusinessException("Skill 文本资源必须使用严格 UTF-8 编码:" + path);
} catch (IOException exception) {
throw new BusinessException(500, 500, "读取 Skill 脚本内容失败", exception);
throw new BusinessException(500, 500, "读取 Skill 文本资源失败", exception);
}
}
@@ -741,24 +729,54 @@ public class SkillFileServiceImpl implements SkillFileService {
return normalized;
}
private String resolveLanguage(String path, String kind) {
if (SkillResourceKind.SCRIPT.name().equals(kind)) {
SkillScriptLanguage language = SkillScriptLanguage.fromPath(path);
return language == SkillScriptLanguage.UNKNOWN ? null : language.name();
private String resolveLanguage(String path) {
String lowerPath = path.toLowerCase(Locale.ROOT);
if (isMarkdownPath(path)) {
return "MARKDOWN";
}
return SkillResourceKind.REFERENCE.name().equals(kind) && isMarkdownPath(path)
? "MARKDOWN" : null;
if (lowerPath.endsWith(".py")) {
return "PYTHON";
}
if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs") || lowerPath.endsWith(".cjs")
|| lowerPath.endsWith(".jsx")) {
return "JAVASCRIPT";
}
if (lowerPath.endsWith(".ts") || lowerPath.endsWith(".tsx")) {
return "TYPESCRIPT";
}
if (lowerPath.endsWith(".sh") || lowerPath.endsWith(".bash") || lowerPath.endsWith(".zsh")) {
return "SHELL";
}
if (lowerPath.endsWith(".json")) {
return "JSON";
}
if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) {
return "YAML";
}
if (lowerPath.endsWith(".xml")) {
return "XML";
}
if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
|| lowerPath.endsWith(".vue") || lowerPath.endsWith(".svelte")) {
return "HTML";
}
if (lowerPath.endsWith(".css") || lowerPath.endsWith(".scss") || lowerPath.endsWith(".less")) {
return "CSS";
}
if (lowerPath.endsWith(".java") || lowerPath.endsWith(".kt") || lowerPath.endsWith(".kts")) {
return "JAVA";
}
return lowerPath.endsWith(".sql") ? "SQL" : null;
}
/**
* 根据重命名后的路径、语义类型和文本表示重新计算媒体类型。
*
* @param path 资源路径
* @param kind 资源语义类型
* @param text 是否为文本
* @return 媒体类型
*/
private String resolveMediaType(String path, String kind, boolean text) {
private String resolveMediaType(String path, boolean text) {
if (!text) {
return detectMediaType(path);
}

View File

@@ -1,492 +0,0 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.util.SkillPaths;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipOutputStream;
import java.util.zip.CRC32;
/**
* 将 EasyFlow Bundle 安全转换为标准 Skill ZIP并提取平台 manifest。
*/
@Component
public class EasyFlowBundleReader {
private static final Logger LOG = LoggerFactory.getLogger(EasyFlowBundleReader.class);
private final EasyFlowSkillManifestCodec manifestCodec;
/**
* 创建 EasyFlow Bundle 读取器。
*
* @param manifestCodec manifest 编解码器
*/
public EasyFlowBundleReader(EasyFlowSkillManifestCodec manifestCodec) {
this.manifestCodec = manifestCodec;
}
/**
* 判断 ZIP 是否包含 EasyFlow manifest。
*
* @param inputStream ZIP 输入流
* @return 包含时为 true
*/
public boolean containsManifest(InputStream inputStream) {
SkillPackageLimits limits = SkillPackageLimits.defaults();
Path packageFile = null;
try {
packageFile = copyCompressedPackage(inputStream, limits.getMaxCompressedPackageBytes());
try (ZipFile zip = new ZipFile(packageFile)) {
Enumeration<ZipArchiveEntry> entries = zip.getEntries();
int count = 0;
long declaredTotalBytes = 0;
long actualTotalBytes = 0;
boolean containsManifest = false;
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
if (++count > limits.getMaxEntryCount() + 1) {
throw new BusinessException("EasyFlow Skill 包文件数量超过限制");
}
String fullPath = validateCentralEntry(zip, entry, limits);
if (entry.isDirectory()) {
continue;
}
declaredTotalBytes = safeAdd(
declaredTotalBytes, entry.getSize(), limits.getMaxTotalUncompressedBytes());
long singleLimit = EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath)
? EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES : limits.getMaxBinaryFileBytes();
long actualSize = readAndVerifyEntry(zip, entry, fullPath, singleLimit);
actualTotalBytes = safeAdd(
actualTotalBytes, actualSize, limits.getMaxTotalUncompressedBytes());
containsManifest |= EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath);
}
return containsManifest;
}
} catch (ZipException exception) {
throw invalidZip(exception);
} catch (IOException exception) {
ZipException zipException = findZipException(exception);
if (zipException != null) {
throw invalidZip(zipException);
}
throw new BusinessException(500, 500, "读取 Skill 包格式失败", exception);
} finally {
deleteQuietly(packageFile);
}
}
/**
* 提取 manifest并将 skills/ 前缀下的标准包内容流式写到临时 ZIP。
*
* @param inputStream EasyFlow Bundle 输入流
* @return 可自动清理的准备结果
*/
public PreparedBundle prepare(InputStream inputStream) {
SkillPackageLimits limits = SkillPackageLimits.defaults();
Path packageFile = null;
Path standardZip = null;
try {
packageFile = copyCompressedPackage(inputStream, limits.getMaxCompressedPackageBytes());
standardZip = Files.createTempFile("easyflow-bundle-standard-", ".zip");
byte[] manifestBytes = null;
long declaredTotalBytes = 0;
long actualTotalBytes = 0;
int entryCount = 0;
Set<String> paths = new HashSet<>();
Set<String> collisionKeys = new HashSet<>();
try (ZipFile input = new ZipFile(packageFile);
ZipOutputStream output = new ZipOutputStream(
Files.newOutputStream(standardZip, StandardOpenOption.TRUNCATE_EXISTING),
StandardCharsets.UTF_8)) {
Enumeration<ZipArchiveEntry> entries = input.getEntries();
byte[] buffer = new byte[8192];
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
if (++entryCount > limits.getMaxEntryCount() + 1) {
throw new BusinessException("EasyFlow Skill 包文件数量超过限制");
}
String fullPath = validateCentralEntry(input, entry, limits);
if (entry.isDirectory()) {
continue;
}
declaredTotalBytes = safeAdd(
declaredTotalBytes, entry.getSize(), limits.getMaxTotalUncompressedBytes());
if (EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath)) {
if (manifestBytes != null) {
throw new BusinessException("EasyFlow Skill 包包含重复 manifest");
}
try (InputStream entryInput = input.getInputStream(entry)) {
manifestBytes = readLimited(entryInput, EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES);
}
verifyCrc(entry, crc32(manifestBytes), fullPath);
actualTotalBytes = safeAdd(actualTotalBytes, manifestBytes.length,
limits.getMaxTotalUncompressedBytes());
if (manifestBytes.length != entry.getSize()) {
throw new BusinessException("EasyFlow Skill manifest 实际大小与目录信息不一致");
}
continue;
}
if (!fullPath.startsWith("skills/")) {
throw new BusinessException("EasyFlow Skill 包根目录只能包含 manifest 和 skills/");
}
String relativePath = normalizePackagePath(fullPath.substring("skills/".length()));
if (!paths.add(relativePath) || !collisionKeys.add(SkillPaths.collisionKey(relativePath))) {
throw new BusinessException("EasyFlow Skill 包存在重复或大小写冲突路径:" + relativePath);
}
ZipEntry outputEntry = new ZipEntry(relativePath);
outputEntry.setTime(0L);
output.putNextEntry(outputEntry);
long entryBytes = 0;
CRC32 crc = new CRC32();
try (InputStream entryInput = input.getInputStream(entry)) {
int length;
while ((length = entryInput.read(buffer)) >= 0) {
entryBytes += length;
if (entryBytes > limits.getMaxBinaryFileBytes()) {
throw new BusinessException("EasyFlow Skill 包单文件解压大小超过限制");
}
actualTotalBytes = safeAdd(actualTotalBytes, length,
limits.getMaxTotalUncompressedBytes());
crc.update(buffer, 0, length);
output.write(buffer, 0, length);
}
}
if (entryBytes != entry.getSize()) {
throw new BusinessException("EasyFlow Skill 包条目实际大小与目录信息不一致");
}
verifyCrc(entry, crc.getValue(), fullPath);
output.closeEntry();
}
output.finish();
}
if (manifestBytes == null) {
throw new BusinessException("EasyFlow Skill 包缺少 easyflow-manifest.json");
}
return new PreparedBundle(standardZip, manifestCodec.decode(manifestBytes));
} catch (RuntimeException | IOException exception) {
deleteQuietly(standardZip);
if (exception instanceof BusinessException businessException) {
throw businessException;
}
if (exception instanceof SkillPackageException skillPackageException) {
throw skillPackageException;
}
ZipException zipException = findZipException(exception);
if (zipException != null) {
throw invalidZip(zipException);
}
LOG.error("解析 EasyFlow Skill Bundle 失败", exception);
throw new BusinessException(500, 500, "解析 EasyFlow Skill Bundle 失败", exception);
}
finally {
deleteQuietly(packageFile);
}
}
private Path copyCompressedPackage(InputStream input, long limit) throws IOException {
if (input == null) {
throw new BusinessException("Skill 包输入流不能为空");
}
Path target = Files.createTempFile("easyflow-bundle-compressed-", ".zip");
try (OutputStream output = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING)) {
byte[] buffer = new byte[8192];
long total = 0;
int length;
while ((length = input.read(buffer)) >= 0) {
total += length;
if (total > limit) {
throw new BusinessException(413, 4131,
"Skill 包压缩文件超过 " + limit + " 字节限制");
}
output.write(buffer, 0, length);
}
return target;
} catch (RuntimeException | IOException exception) {
deleteQuietly(target);
throw exception;
}
}
/**
* 校验外层 ZIP 中央目录元数据并返回规范化路径。
*
* @param zip ZIP 文件
* @param entry ZIP 条目
* @param limits 包安全限制
* @return 规范化包内路径
* @throws BusinessException 条目类型、路径、大小或压缩比不安全
* @throws SkillPackageException 原始文件名或 CRC 元数据不合法
*/
private String validateCentralEntry(ZipFile zip, ZipArchiveEntry entry, SkillPackageLimits limits) {
if (!zip.canReadEntryData(entry)) {
throw new BusinessException("Skill 包包含加密或不支持的压缩条目");
}
if (entry.isUnixSymlink()) {
throw new BusinessException("Skill 包不允许包含符号链接");
}
String path = strictUtf8EntryName(entry);
if (entry.isDirectory()) {
while (path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
if (path.isBlank()) {
throw new BusinessException("Skill 包包含非法空目录路径");
}
}
String normalized = normalizePackagePath(path);
if (normalized.length() > limits.getMaxPathLength()
|| normalized.split("/").length > limits.getMaxPathDepth()) {
throw new BusinessException("Skill 包路径长度或层级超过限制");
}
long size = entry.getSize();
long compressedSize = entry.getCompressedSize();
if (size < 0 || compressedSize < 0) {
throw new BusinessException("Skill 包条目缺少可靠大小信息");
}
if (!entry.isDirectory() && entry.getCrc() < 0) {
throw packageError("UNKNOWN_ENTRY_CRC", normalized,
"EasyFlow Skill 包条目缺少中央目录 CRC");
}
double ratio = size == 0 ? 0D : (double) size / Math.max(1L, compressedSize);
if (ratio > limits.getMaxCompressionRatio()) {
throw new BusinessException("Skill 包条目压缩比超过安全限制");
}
return normalized;
}
/**
* 严格按 UTF-8 解码 ZIP 中央目录的原始文件名字节,并拒绝 Unicode extra field 造成的歧义。
*
* @param entry ZIP 条目
* @return 唯一的 UTF-8 文件名
* @throws SkillPackageException 原始文件名字节非法或与解析结果不一致
*/
private String strictUtf8EntryName(ZipArchiveEntry entry) {
byte[] rawName = entry.getRawName();
if (rawName == null) {
throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"EasyFlow Skill 包条目缺少原始文件名字节");
}
try {
String decodedName = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(rawName))
.toString();
if (!decodedName.equals(entry.getName())) {
throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"EasyFlow Skill 包条目文件名必须具有唯一 UTF-8 表示");
}
return decodedName;
} catch (CharacterCodingException exception) {
throw new SkillPackageException("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"EasyFlow Skill 包条目文件名不是合法 UTF-8", exception);
}
}
/**
* 流式读取单个外层 ZIP 条目,并同时校验实际大小与中央目录 CRC。
*
* @param zip ZIP 文件
* @param entry ZIP 条目
* @param path 已校验路径
* @param sizeLimit 单文件解压上限
* @return 实际解压字节数
* @throws IOException 条目读取失败
* @throws SkillPackageException CRC 不匹配
*/
private long readAndVerifyEntry(ZipFile zip,
ZipArchiveEntry entry,
String path,
long sizeLimit) throws IOException {
CRC32 crc = new CRC32();
byte[] buffer = new byte[8192];
long actualSize = 0;
try (InputStream input = zip.getInputStream(entry)) {
int length;
while ((length = input.read(buffer)) >= 0) {
actualSize += length;
if (actualSize > sizeLimit) {
throw new BusinessException(413, 4131, "EasyFlow Skill 包单文件解压大小超过限制");
}
crc.update(buffer, 0, length);
}
}
if (actualSize != entry.getSize()) {
throw packageError("ENTRY_SIZE_MISMATCH", path,
"EasyFlow Skill 包条目实际大小与中央目录不一致");
}
verifyCrc(entry, crc.getValue(), path);
return actualSize;
}
/**
* 校验 ZIP 条目 CRC-32。
*
* @param entry ZIP 条目
* @param actualCrc 实际内容 CRC-32
* @param path 包内路径
* @throws SkillPackageException CRC 与中央目录不一致
*/
private void verifyCrc(ZipArchiveEntry entry, long actualCrc, String path) {
if (entry.getCrc() != actualCrc) {
throw packageError("CRC_MISMATCH", path,
"EasyFlow Skill 包条目 CRC 与实际内容不一致");
}
}
/**
* 计算字节内容的 CRC-32。
*
* @param bytes 内容字节
* @return CRC-32
*/
private long crc32(byte[] bytes) {
CRC32 crc = new CRC32();
crc.update(bytes);
return crc.getValue();
}
/**
* 创建带稳定错误码和包内路径的 Skill 包异常。
*
* @param code 稳定错误码
* @param path 包内路径
* @param message 错误信息
* @return Skill 包异常
*/
private SkillPackageException packageError(String code, String path, String message) {
return new SkillPackageException(code, path, message);
}
private long safeAdd(long current, long value, long limit) {
if (value < 0 || current > limit - value) {
throw new BusinessException("EasyFlow Skill 包解压总大小超过限制");
}
return current + value;
}
private String normalizePackagePath(String path) {
try {
return SkillPaths.normalize(path);
} catch (SkillException exception) {
throw new BusinessException("Skill 包路径不合法:" + exception.getMessage());
}
}
private byte[] readLimited(InputStream input, long limit) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
long total = 0;
int length;
while ((length = input.read(buffer)) >= 0) {
total += length;
if (total > limit) {
throw new BusinessException(413, 4131, "EasyFlow Skill manifest 超过 1 MiB 限制");
}
output.write(buffer, 0, length);
}
return output.toByteArray();
}
private BusinessException invalidZip(ZipException exception) {
return new BusinessException(400, 4001, "Skill 包不是有效的 ZIP 文件", exception);
}
/**
* 沿异常链查找被 Commons Compress 包装的 ZIP 格式异常。
*
* @param exception 外层读取异常
* @return ZIP 格式异常;不存在时返回 {@code null}
*/
private ZipException findZipException(Throwable exception) {
Throwable current = exception;
for (int depth = 0; current != null && depth < 32; depth++) {
if (current instanceof ZipException zipException) {
return zipException;
}
if (current == current.getCause()) {
break;
}
current = current.getCause();
}
return null;
}
private void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
LOG.warn("清理 EasyFlow Bundle 临时标准包失败path={}", path, exception);
}
}
/**
* EasyFlow Bundle 准备结果。
*/
public final class PreparedBundle implements AutoCloseable {
private final Path standardZip;
private final Map<String, Object> manifest;
private PreparedBundle(Path standardZip, Map<String, Object> manifest) {
this.standardZip = standardZip;
this.manifest = manifest;
}
/**
* 打开转换后的标准 ZIP。
*
* @return 输入流
* @throws IOException 临时文件无法读取
*/
public InputStream openStandardZip() throws IOException {
return Files.newInputStream(standardZip);
}
/**
* 获取已做基础版本校验的 manifest。
*
* @return manifest
*/
public Map<String, Object> getManifest() {
return manifest;
}
/**
* 删除转换临时文件。
*/
@Override
public void close() {
deleteQuietly(standardZip);
}
}
}

View File

@@ -1,573 +0,0 @@
package tech.easyflow.skill.imports;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.capability.SkillCapabilityTarget;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityExecutionMode;
import tech.easyflow.skill.enums.SkillCapabilitySelectionMode;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.security.SkillCredentialValueGuard;
import tech.easyflow.skill.security.SkillPortableTargetSanitizer;
import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* EasyFlow Skill Bundle manifest 的版本化白名单编解码器。
*/
@Component
public class EasyFlowSkillManifestCodec {
private static final Set<String> ROOT_FIELDS = Set.of("schemaVersion", "skills");
private static final Set<String> SKILL_FIELDS = Set.of("packageRoot", "packageHash", "capabilities");
private static final Set<String> BINDING_FIELDS = Set.of(
"bindingKey", "capabilityType", "runtimeName", "enabled", "selectionMode",
"selectedToolNames", "executionMode", "hitlEnabled", "hitlConfig", "options", "sortNo",
"targetLogicalRef", "targetStatus", "targetName", "targetRevision");
private static final Pattern RUNTIME_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$");
private static final Pattern MCP_TOOL_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,127}$");
/** EasyFlow Bundle manifest 固定路径。 */
public static final String MANIFEST_PATH = "easyflow-manifest.json";
/** manifest 最大字节数。 */
public static final long MAX_MANIFEST_BYTES = 1024L * 1024;
/** 单个增强包最大 Skill 数。 */
public static final int MAX_SKILLS = 100;
/** packageRoot 最大字符数。 */
public static final int MAX_PACKAGE_ROOT_LENGTH = 128;
/** 单个 Skill 最大能力绑定数。 */
public static final int MAX_BINDINGS_PER_SKILL = 200;
/** 单个增强包最大能力绑定总数。 */
public static final int MAX_TOTAL_BINDINGS = 1_000;
private final ObjectMapper objectMapper;
private final SkillCapabilityTargetAccessService targetAccessService;
/**
* 创建 manifest 编解码器。
*
* @param objectMapper JSON 映射器
* @param targetAccessService 能力目标授权服务
*/
public EasyFlowSkillManifestCodec(ObjectMapper objectMapper,
SkillCapabilityTargetAccessService targetAccessService) {
this.objectMapper = objectMapper;
this.targetAccessService = targetAccessService;
}
/**
* 将 Skill 列表编码为不含凭据的 manifest。
*
* @param skills Skill 详情
* @return UTF-8 JSON
*/
public byte[] encode(List<Skill> skills) {
if (skills == null || skills.size() > MAX_SKILLS) {
throw new BusinessException("单个 EasyFlow Skill Bundle 最多包含 " + MAX_SKILLS + " 个 Skill");
}
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("schemaVersion", "1.0");
List<Map<String, Object>> skillItems = new ArrayList<>();
int totalBindings = 0;
for (int skillIndex = 0; skillIndex < skills.size(); skillIndex++) {
Skill skill = skills.get(skillIndex);
String skillPath = "skills[" + skillIndex + "]";
if (skill == null) {
throw new SkillManifestValidationException("SKILL_EMPTY", skillPath,
"EasyFlow Skill manifest 的 Skill 不能为空");
}
String packageRoot = boundedRequiredString(
skill.getName(), skillPath + ".packageRoot", MAX_PACKAGE_ROOT_LENGTH);
String packageHash = boundedRequiredString(
skill.getPackageHash(), skillPath + ".packageHash", 128);
validatePortableMetadata(packageRoot, skillPath + ".packageRoot");
validatePortableMetadata(packageHash, skillPath + ".packageHash");
Map<String, Object> item = new LinkedHashMap<>();
item.put("packageRoot", packageRoot);
item.put("packageHash", packageHash);
List<Map<String, Object>> bindings = new ArrayList<>();
List<SkillCapabilityBinding> sourceBindings = skill.getCapabilityBindings() == null
? List.of() : skill.getCapabilityBindings();
if (sourceBindings.size() > MAX_BINDINGS_PER_SKILL
|| (totalBindings += sourceBindings.size()) > MAX_TOTAL_BINDINGS) {
throw new BusinessException("EasyFlow Skill Bundle 能力绑定数量超过限制");
}
for (int index = 0; index < sourceBindings.size(); index++) {
bindings.add(bindingManifest(skillIndex, packageRoot, index, sourceBindings.get(index)));
}
item.put("capabilities", bindings);
skillItems.add(item);
}
manifest.put("skills", skillItems);
validateCredentialFreeTree(manifest, "");
validateSkills(skillItems);
try {
byte[] bytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(manifest);
if (bytes.length > MAX_MANIFEST_BYTES) {
throw new BusinessException("EasyFlow Skill manifest 超过 1 MiB 限制");
}
return bytes;
} catch (JsonProcessingException exception) {
throw new BusinessException(500, 500, "生成 EasyFlow Skill manifest 失败", exception);
}
}
/**
* 解码并校验 manifest 基础版本结构。
*
* @param bytes manifest 字节
* @return manifest 对象
*/
public Map<String, Object> decode(byte[] bytes) {
if (bytes == null || bytes.length == 0 || bytes.length > MAX_MANIFEST_BYTES) {
if (bytes != null && bytes.length > MAX_MANIFEST_BYTES) {
throw new BusinessException(413, 4131, "EasyFlow Skill manifest 超过 1 MiB 限制");
}
throw new BusinessException("EasyFlow Skill manifest 不能为空");
}
try {
Map<String, Object> manifest = objectMapper.readerFor(new TypeReference<Map<String, Object>>() { })
.with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.readValue(bytes);
if (manifest == null) {
throw new BusinessException("EasyFlow Skill manifest 根对象不能为空");
}
// 在枚举解析和错误消息构造前先覆盖整个平台 manifest 字符串面,避免敏感值回显。
validateCredentialFreeTree(manifest, "");
assertOnlyFields(manifest, ROOT_FIELDS, "根对象");
if (!"1.0".equals(String.valueOf(manifest.get("schemaVersion")))) {
throw new BusinessException("不支持的 EasyFlow Skill manifest 版本");
}
if (!(manifest.get("skills") instanceof List<?> skills)) {
throw new BusinessException("EasyFlow Skill manifest 缺少 skills 列表");
}
validateSkills(skills);
return manifest;
} catch (java.io.IOException exception) {
throw new BusinessException("EasyFlow Skill manifest JSON 格式不正确");
}
}
private void validateSkills(List<?> skills) {
if (skills.size() > MAX_SKILLS) {
throw new BusinessException("EasyFlow Skill manifest 最多包含 " + MAX_SKILLS + " 个 Skill");
}
java.util.Set<String> roots = new java.util.HashSet<>();
java.util.Set<String> bindingKeys = new java.util.HashSet<>();
int totalBindings = 0;
for (int skillIndex = 0; skillIndex < skills.size(); skillIndex++) {
String skillPath = "skills[" + skillIndex + "]";
Object source = skills.get(skillIndex);
if (!(source instanceof Map<?, ?> skill)) {
throw new SkillManifestValidationException("SKILL_INVALID", skillPath,
"EasyFlow Skill manifest 的 Skill 项格式不正确");
}
assertOnlyFields(skill, SKILL_FIELDS, skillPath);
String packageRoot = boundedRequiredString(
skill.get("packageRoot"), skillPath + ".packageRoot", MAX_PACKAGE_ROOT_LENGTH);
validatePortableMetadata(packageRoot, skillPath + ".packageRoot");
validatePortableMetadata(boundedRequiredString(
skill.get("packageHash"), skillPath + ".packageHash", 128),
skillPath + ".packageHash");
if (!roots.add(packageRoot)) {
throw new SkillManifestValidationException("PACKAGE_ROOT_DUPLICATE",
skillPath + ".packageRoot", "EasyFlow Skill manifest 存在重复 packageRoot");
}
Object capabilitiesValue = skill.get("capabilities");
if (!(capabilitiesValue instanceof List<?> capabilities)) {
throw new SkillManifestValidationException("CAPABILITIES_REQUIRED",
skillPath + ".capabilities", "EasyFlow Skill manifest 缺少 capabilities 列表");
}
if (capabilities.size() > MAX_BINDINGS_PER_SKILL) {
throw new BusinessException("单个 Skill 的能力绑定不能超过 " + MAX_BINDINGS_PER_SKILL + "");
}
totalBindings += capabilities.size();
if (totalBindings > MAX_TOTAL_BINDINGS) {
throw new BusinessException("EasyFlow Skill manifest 能力绑定总数不能超过 "
+ MAX_TOTAL_BINDINGS + "");
}
for (int bindingIndex = 0; bindingIndex < capabilities.size(); bindingIndex++) {
Object bindingValue = capabilities.get(bindingIndex);
String bindingPath = skillPath + ".capabilities[" + bindingIndex + "]";
if (!(bindingValue instanceof Map<?, ?> binding)) {
throw new SkillManifestValidationException("CAPABILITY_INVALID", bindingPath,
"EasyFlow Skill manifest 的能力绑定格式不正确");
}
assertOnlyFields(binding, BINDING_FIELDS, bindingPath);
String bindingKey = boundedRequiredString(
binding.get("bindingKey"), bindingPath + ".bindingKey", 256);
validatePortableMetadata(bindingKey, bindingPath + ".bindingKey");
if (!bindingKeys.add(bindingKey)) {
throw new SkillManifestValidationException("BINDING_KEY_DUPLICATE",
bindingPath + ".bindingKey", "EasyFlow Skill manifest 存在重复 bindingKey");
}
SkillCapabilityType type = parseCapabilityType(binding.get("capabilityType"), bindingPath);
String runtimeName = boundedRequiredString(
binding.get("runtimeName"), bindingPath + ".runtimeName", 64);
if (!RUNTIME_NAME_PATTERN.matcher(runtimeName).matches()) {
throw new SkillManifestValidationException("RUNTIME_NAME_INVALID",
bindingPath + ".runtimeName", "EasyFlow Skill manifest 的运行时名称格式不正确");
}
String logicalRef = boundedRequiredString(
binding.get("targetLogicalRef"), bindingPath + ".targetLogicalRef", 512);
validateLogicalRef(type, logicalRef, bindingPath + ".targetLogicalRef");
validateSelectionMode(boundedOptionalString(
binding.get("selectionMode"), bindingPath + ".selectionMode", 16), bindingPath);
validateExecutionMode(boundedOptionalString(
binding.get("executionMode"), bindingPath + ".executionMode", 16), bindingPath);
validatePortableMetadata(boundedOptionalString(
binding.get("targetStatus"), bindingPath + ".targetStatus", 32),
bindingPath + ".targetStatus");
validatePortableMetadata(boundedOptionalString(
binding.get("targetName"), bindingPath + ".targetName", 256),
bindingPath + ".targetName");
validatePortableMetadata(boundedOptionalString(
binding.get("targetRevision"), bindingPath + ".targetRevision", 256),
bindingPath + ".targetRevision");
validateBoolean(binding.get("enabled"), bindingPath + ".enabled");
validateBoolean(binding.get("hitlEnabled"), bindingPath + ".hitlEnabled");
validateInteger(binding.get("sortNo"), bindingPath + ".sortNo");
validateStringList(binding.get("selectedToolNames"), bindingPath + ".selectedToolNames");
validateSafeConfig(binding.get("hitlConfig"), true, bindingPath + ".hitlConfig");
validateSafeConfig(binding.get("options"), false, bindingPath + ".options");
}
}
}
private void assertOnlyFields(Map<?, ?> source, Set<String> allowed, String path) {
for (Object key : source.keySet()) {
if (!(key instanceof String field) || !allowed.contains(field)) {
throw new SkillManifestValidationException("FIELD_NOT_ALLOWED", path,
"EasyFlow Skill manifest 包含未允许字段");
}
}
}
private void validateBoolean(Object value, String field) {
if (value != null && !(value instanceof Boolean)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确");
}
}
private void validateInteger(Object value, String field) {
if (value != null && (!(value instanceof Number number)
|| number.doubleValue() != number.longValue()
|| number.longValue() < Integer.MIN_VALUE || number.longValue() > Integer.MAX_VALUE)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确");
}
}
private void validateStringList(Object value, String field) {
if (value == null) {
return;
}
if (!(value instanceof List<?> values) || values.size() > 200) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确或超过限制");
}
for (int index = 0; index < values.size(); index++) {
Object item = values.get(index);
if (!(item instanceof String text) || !MCP_TOOL_NAME_PATTERN.matcher(text).matches()) {
throw new SkillManifestValidationException("MCP_TOOL_NAME_INVALID",
field + "[" + index + "]", "EasyFlow Skill manifest 包含非法工具名");
}
}
}
private void validateLogicalRef(SkillCapabilityType type, String logicalRef, String path) {
if (!SkillPortableTargetSanitizer.isSafeLogicalRef(type, logicalRef)) {
throw new SkillManifestValidationException("TARGET_LOGICAL_REF_INVALID", path,
"EasyFlow Skill manifest 的目标逻辑引用格式不正确");
}
}
/**
* 校验 manifest 可移植元数据不含本机路径或认证材料。
*
* @param value 元数据值
* @param field 字段名
*/
private void validatePortableMetadata(String value, String field) {
if (SkillCredentialValueGuard.containsCredential(value)) {
throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED", field,
"EasyFlow Skill manifest 不能包含认证凭据");
}
if (!SkillPortableTargetSanitizer.isSafePortableMetadata(value)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含不安全内容");
}
}
private void validateSafeConfig(Object value, boolean hitl, String field) {
if (value == null) {
return;
}
if (!(value instanceof Map<?, ?> raw)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确");
}
Map<String, Object> source = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : raw.entrySet()) {
if (!(entry.getKey() instanceof String key)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含非法键");
}
source.put(key, entry.getValue());
}
Map<String, Object> safe = hitl
? SkillSensitiveConfigSanitizer.sanitizeHitl(source)
: SkillSensitiveConfigSanitizer.sanitizeOptions(source);
if (!safe.equals(source)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含未允许或敏感配置");
}
if (hitl) {
for (Map.Entry<String, Object> entry : safe.entrySet()) {
int maxLength = "confirmLabel".equals(entry.getKey()) || "cancelLabel".equals(entry.getKey())
? 128 : 2_000;
if (!(entry.getValue() instanceof String text) || text.length() > maxLength) {
throw new SkillManifestValidationException("HITL_CONFIG_VALUE_INVALID",
field + "." + entry.getKey(),
"EasyFlow Skill manifest 的 HITL 配置字段类型或长度不正确");
}
if (SkillCredentialValueGuard.containsCredential(text)) {
throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED",
field + "." + entry.getKey(),
"EasyFlow Skill manifest 的 HITL 配置不能包含认证凭据");
}
}
return;
}
validateOptionValue(safe, "timeoutMs", field);
validateOptionValue(safe, "retryCount", field);
for (String key : List.of("async", "readOnly")) {
if (safe.containsKey(key) && !(safe.get(key) instanceof Boolean)) {
throw new SkillManifestValidationException("CAPABILITY_OPTION_VALUE_INVALID",
field + "." + key, "EasyFlow Skill manifest 的能力选项类型不正确");
}
}
}
/**
* 校验数值型能力选项。
*
* @param options 能力选项
* @param key 选项键
* @param path options 字段路径
*/
private void validateOptionValue(Map<String, Object> options,
String key,
String path) {
if (!options.containsKey(key)) {
return;
}
Object value = options.get(key);
// manifest 解码只负责结构、类型和安全边界;运行时值域由能力预览校验统一返回结构化问题。
boolean valid = value instanceof Number number
&& number.doubleValue() == number.longValue()
&& number.longValue() >= Integer.MIN_VALUE
&& number.longValue() <= Integer.MAX_VALUE;
if (!valid) {
throw new SkillManifestValidationException("CAPABILITY_OPTION_VALUE_INVALID",
path + "." + key, "EasyFlow Skill manifest 的能力选项数值不正确");
}
}
/**
* 解析能力类型并将不可信输入转换为稳定错误。
*
* @param value 原始类型值
* @param bindingPath 能力绑定路径
* @return 能力类型
*/
private SkillCapabilityType parseCapabilityType(Object value, String bindingPath) {
String path = bindingPath + ".capabilityType";
String type = boundedRequiredString(value, path, 32);
try {
return SkillCapabilityType.from(type);
} catch (BusinessException exception) {
throw new SkillManifestValidationException("CAPABILITY_TYPE_INVALID", path,
"EasyFlow Skill manifest 的能力类型不受支持");
}
}
/**
* 校验 MCP 工具选择模式且不回显原始值。
*
* @param value 模式值
* @param bindingPath 能力绑定路径
*/
private void validateSelectionMode(String value, String bindingPath) {
if (value == null || value.isBlank()) {
return;
}
try {
SkillCapabilitySelectionMode.fromOrDefault(value);
} catch (BusinessException exception) {
throw new SkillManifestValidationException("MCP_SELECTION_MODE_INVALID",
bindingPath + ".selectionMode", "EasyFlow Skill manifest 的 MCP 工具选择模式不受支持");
}
}
/**
* 校验能力执行模式且不回显原始值。
*
* @param value 模式值
* @param bindingPath 能力绑定路径
*/
private void validateExecutionMode(String value, String bindingPath) {
if (value == null || value.isBlank()) {
return;
}
try {
SkillCapabilityExecutionMode.fromOrDefault(value);
} catch (BusinessException exception) {
throw new SkillManifestValidationException("EXECUTION_MODE_INVALID",
bindingPath + ".executionMode", "EasyFlow Skill manifest 的执行模式不受支持");
}
}
/**
* 递归校验 manifest 中实际会携带的全部字符串值。
*
* @param value 当前值
* @param path 当前字段路径
*/
private void validateCredentialFreeTree(Object value, String path) {
Deque<ManifestNode> pending = new ArrayDeque<>();
pending.push(new ManifestNode(value, path));
while (!pending.isEmpty()) {
ManifestNode node = pending.pop();
if (node.value() instanceof String text) {
if (SkillCredentialValueGuard.containsCredential(text)) {
throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED",
node.path().isBlank() ? "manifest" : node.path(),
"EasyFlow Skill manifest 不能包含认证凭据");
}
continue;
}
if (node.value() instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getKey() instanceof String key) {
String childPath = node.path().isBlank() ? key : node.path() + "." + key;
pending.push(new ManifestNode(entry.getValue(), childPath));
}
}
continue;
}
if (node.value() instanceof List<?> list) {
for (int index = list.size() - 1; index >= 0; index--) {
pending.push(new ManifestNode(list.get(index), node.path() + "[" + index + "]"));
}
}
}
}
/**
* manifest 迭代扫描节点。
*
* @param value 当前值
* @param path 当前路径
*/
private record ManifestNode(Object value, String path) {
}
private String boundedRequiredString(Object value, String field, int maxLength) {
String result = boundedOptionalString(value, field, maxLength);
if (result == null || result.isBlank()) {
throw new BusinessException("EasyFlow Skill manifest 缺少 " + field);
}
return result;
}
private String boundedOptionalString(Object value, String field, int maxLength) {
if (value == null) {
return null;
}
if (!(value instanceof String result) || result.length() > maxLength) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 超过限制或类型不正确");
}
return result;
}
private Map<String, Object> bindingManifest(int skillIndex,
String packageRoot,
int index,
SkillCapabilityBinding binding) {
String bindingPath = "skills[" + skillIndex + "].capabilities[" + index + "]";
if (binding == null) {
throw new SkillManifestValidationException("CAPABILITY_EMPTY", bindingPath,
"EasyFlow Skill manifest 的能力绑定不能为空");
}
Map<String, Object> credentialSurface = new LinkedHashMap<>();
credentialSurface.put("capabilityType", binding.getCapabilityType());
credentialSurface.put("runtimeName", binding.getRuntimeName());
credentialSurface.put("selectionMode", binding.getSelectionMode());
credentialSurface.put("selectedToolNames", binding.getSelectedToolNamesJson());
credentialSurface.put("executionMode", binding.getExecutionMode());
validateCredentialFreeTree(credentialSurface, bindingPath);
SkillCapabilityType type = parseCapabilityType(binding.getCapabilityType(), bindingPath);
Map<String, Object> item = new LinkedHashMap<>();
item.put("bindingKey", packageRoot + ":" + index);
item.put("capabilityType", type.name());
item.put("runtimeName", binding.getRuntimeName());
item.put("enabled", binding.getEnabled());
item.put("selectionMode", binding.getSelectionMode());
item.put("selectedToolNames", binding.getSelectedToolNamesJson());
item.put("executionMode", binding.getExecutionMode());
item.put("hitlEnabled", binding.getHitlEnabled());
Map<String, Object> safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson());
Map<String, Object> safeOptions = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson());
validateSafeConfig(safeHitl, true, bindingPath + ".hitlConfig");
validateSafeConfig(safeOptions, false, bindingPath + ".options");
item.put("hitlConfig", safeHitl);
item.put("options", safeOptions);
item.put("sortNo", binding.getSortNo());
String fallbackRef = SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
type, binding.getTargetLogicalRef());
if (binding.getTargetId() == null || !Boolean.TRUE.equals(binding.getEnabled())) {
item.put("targetLogicalRef", fallbackRef);
item.put("targetStatus", binding.getTargetId() == null ? "UNRESOLVED" : "DISABLED");
return item;
}
try {
SkillCapabilityTarget target = targetAccessService.requireUsableTarget(binding, false);
item.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
type, target.getLogicalRef()));
putSafeMetadata(item, "targetName", target.getName());
putSafeMetadata(item, "targetRevision", target.getRevision());
item.put("targetStatus", "AVAILABLE");
} catch (BusinessException exception) {
// 备份导出必须可用;目标会在导入映射或再次发布时重新校验。
item.put("targetLogicalRef", fallbackRef);
putSafeMetadata(item, "targetName", binding.getTargetName());
item.put("targetStatus", exception.getHttpStatus() == 403 ? "NO_PERMISSION" : "UNAVAILABLE");
}
return item;
}
/**
* 仅在目标元数据安全且非空时写入 manifest。
*
* @param target 目标字段映射
* @param field 字段名
* @param value 原始元数据
*/
private void putSafeMetadata(Map<String, Object> target, String field, String value) {
String safeValue = SkillPortableTargetSanitizer.safePortableMetadataOrNull(value);
if (safeValue != null) {
target.put(field, safeValue);
}
}
}

View File

@@ -10,10 +10,7 @@ import java.util.List;
public class SkillExportRequest {
private List<BigInteger> ids = new ArrayList<>();
private String format;
public List<BigInteger> getIds() { return ids; }
public void setIds(List<BigInteger> ids) { this.ids = ids == null ? new ArrayList<>() : new ArrayList<>(ids); }
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
}

View File

@@ -4,7 +4,7 @@ import java.math.BigInteger;
import java.util.Collection;
/**
* Skill zip 导出服务。
* 标准 Skill ZIP 导出服务。
*/
public interface SkillExportService {
@@ -12,8 +12,7 @@ public interface SkillExportService {
* 在写入 HTTP 响应前完整构建导出临时产物。
*
* @param skillIds Skill ID 集合
* @param format 导出格式
* @return 可自动清理的导出产物
*/
SkillExportArtifact prepare(Collection<BigInteger> skillIds, SkillImportFormat format);
SkillExportArtifact prepare(Collection<BigInteger> skillIds);
}

View File

@@ -5,7 +5,6 @@ import com.easyagents.skill.codec.ZipSkillPackageCodec;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.model.SkillPackage;
import com.easyagents.skill.model.SkillPackageLayout;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.validation.SkillValidationIssue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -17,26 +16,19 @@ import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.support.SkillModelConverter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import java.util.zip.Deflater;
/**
* 标准 Skill ZIP 与 EasyFlow Bundle 安全导出服务。
* 标准 Skill ZIP 导出服务。
*/
@Service
public class SkillExportServiceImpl implements SkillExportService {
@@ -45,74 +37,55 @@ public class SkillExportServiceImpl implements SkillExportService {
private final SkillService skillService;
private final DBSkillContentStore contentStore;
private final EasyFlowSkillManifestCodec manifestCodec;
/**
* 创建 Skill 导出服务。
* 创建标准 Skill ZIP 导出服务。
*
* @param skillService Skill 服务
* @param contentStore 二进制内容仓库
* @param manifestCodec EasyFlow manifest 编解码器
*/
public SkillExportServiceImpl(SkillService skillService,
DBSkillContentStore contentStore,
EasyFlowSkillManifestCodec manifestCodec) {
public SkillExportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) {
this.skillService = skillService;
this.contentStore = contentStore;
this.manifestCodec = manifestCodec;
}
/**
* {@inheritDoc}
*/
@Override
public SkillExportArtifact prepare(Collection<BigInteger> skillIds, SkillImportFormat format) {
public SkillExportArtifact prepare(Collection<BigInteger> skillIds) {
if (skillIds == null || skillIds.isEmpty()) {
throw new BusinessException("请选择要导出的 Skill");
}
SkillImportFormat effectiveFormat = format == null ? SkillImportFormat.STANDARD : format;
List<Skill> skills = loadAuthorizedSkills(
skillIds, effectiveFormat == SkillImportFormat.EASYFLOW);
Path standardPackage = null;
Path finalPackage = null;
List<Skill> skills = loadAuthorizedSkills(skillIds);
Path target = null;
try {
standardPackage = Files.createTempFile("easyflow-skill-standard-", ".zip");
encodeStandard(skills, standardPackage);
finalPackage = effectiveFormat == SkillImportFormat.STANDARD
? standardPackage : buildEasyFlowBundle(skills, standardPackage);
if (!finalPackage.equals(standardPackage)) {
deleteQuietly(standardPackage);
target = Files.createTempFile("easyflow-skill-standard-", ".zip");
SkillPackage skillPackage = new SkillPackage(
skills.size() == 1 ? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY,
skills.stream().map(SkillModelConverter::toAgentSkill).toList());
try (OutputStream output = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING)) {
new ZipSkillPackageCodec(contentStore)
.encode(skillPackage, output, SkillPackageWriteOptions.defaults());
}
String fileStem = skills.size() == 1 ? safeFileStem(skills.get(0).getName()) : "skills";
return new SkillExportArtifact(finalPackage,
fileStem + (effectiveFormat == SkillImportFormat.EASYFLOW ? ".efskill" : ".zip"),
effectiveFormat == SkillImportFormat.EASYFLOW
? "application/vnd.easyflow.skill+zip" : "application/zip");
} catch (BusinessException exception) {
deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null);
deleteQuietly(standardPackage);
throw exception;
return new SkillExportArtifact(target, fileStem + ".zip", "application/zip");
} catch (SkillPackageException exception) {
deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null);
deleteQuietly(standardPackage);
throw mapPackageException(exception, effectiveFormat, skillIds);
deleteQuietly(target);
throw mapPackageException(exception, skillIds);
} catch (Exception exception) {
deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null);
deleteQuietly(standardPackage);
LOG.error("导出 Skill 包失败format={}, skillIds={}", effectiveFormat, skillIds, exception);
deleteQuietly(target);
LOG.error("导出标准 Skill ZIP 失败skillIds={}", skillIds, exception);
throw new BusinessException(500, 500, "导出 Skill 包失败,请稍后重试", exception);
}
}
private List<Skill> loadAuthorizedSkills(Collection<BigInteger> skillIds, boolean includeCapabilities) {
private List<Skill> loadAuthorizedSkills(Collection<BigInteger> skillIds) {
List<Skill> skills = new ArrayList<>();
Set<BigInteger> uniqueIds = new HashSet<>();
for (BigInteger skillId : skillIds) {
if (skillId != null && uniqueIds.add(skillId)) {
// 标准 ZIP 不读取平台能力目标;两个入口都在服务端逐项执行 Skill READ 权限校验。
skills.add(includeCapabilities
? skillService.getDetail(skillId)
: skillService.getPackageDetail(skillId));
skills.add(skillService.getPackageDetail(skillId));
}
}
if (skills.isEmpty()) {
@@ -121,219 +94,47 @@ public class SkillExportServiceImpl implements SkillExportService {
return skills;
}
private void encodeStandard(List<Skill> skills, Path target) throws IOException {
List<com.easyagents.skill.model.Skill> agentSkills = skills.stream()
.map(SkillModelConverter::toAgentSkill)
.toList();
SkillPackage skillPackage = new SkillPackage(
agentSkills.size() == 1 ? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY,
agentSkills);
try (OutputStream output = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING)) {
new ZipSkillPackageCodec(contentStore).encode(skillPackage, output, SkillPackageWriteOptions.defaults());
}
ensureStandardDirectoryEntries(target);
}
/**
* 为标准包中的每个 Skill 根目录补齐可移植的标准空目录项。
*
* @param packagePath 已由标准编解码器生成的 ZIP 路径
* @throws IOException 读取、重写或替换 ZIP 失败时抛出
*/
private void ensureStandardDirectoryEntries(Path packagePath) throws IOException {
Path rewritten = Files.createTempFile("easyflow-skill-directories-", ".zip");
Set<String> entryNames = new HashSet<>();
List<String> skillRoots = new ArrayList<>();
try {
try (ZipInputStream input = new ZipInputStream(
Files.newInputStream(packagePath), StandardCharsets.UTF_8);
ZipOutputStream output = new ZipOutputStream(
Files.newOutputStream(rewritten, StandardOpenOption.TRUNCATE_EXISTING),
StandardCharsets.UTF_8)) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = input.getNextEntry()) != null) {
String entryName = entry.getName();
entryNames.add(entryName);
if (entryName.endsWith("/SKILL.md")) {
skillRoots.add(entryName.substring(0, entryName.length() - "SKILL.md".length()));
} else if ("SKILL.md".equals(entryName)) {
skillRoots.add("");
}
ZipEntry copied = new ZipEntry(entryName);
copied.setTime(0L);
output.putNextEntry(copied);
if (!entry.isDirectory()) {
int length;
while ((length = input.read(buffer)) >= 0) {
if (length > 0) {
output.write(buffer, 0, length);
}
}
}
output.closeEntry();
}
for (String root : skillRoots) {
for (String directory : List.of("references/", "scripts/", "assets/")) {
String directoryPath = root + directory;
if (entryNames.add(directoryPath)) {
writeDirectoryEntry(output, directoryPath);
}
}
}
output.finish();
}
Files.move(rewritten, packagePath, StandardCopyOption.REPLACE_EXISTING);
} finally {
deleteQuietly(rewritten);
}
}
private Path buildEasyFlowBundle(List<Skill> skills, Path standardPackage) throws IOException {
SkillPackageLimits limits = SkillPackageLimits.defaults();
Path bundle = Files.createTempFile("easyflow-skill-bundle-", ".efskill");
try {
byte[] manifestBytes = manifestCodec.encode(skills);
long totalBytes = addExportBytes(0, manifestBytes.length, limits.getMaxTotalUncompressedBytes());
int entryCount = 1;
try (ZipOutputStream output = new ZipOutputStream(
Files.newOutputStream(bundle, StandardOpenOption.TRUNCATE_EXISTING), StandardCharsets.UTF_8)) {
// 禁用二次高比率压缩,保证成功导出的外层 Bundle 能通过同一导入压缩比门禁。
output.setLevel(Deflater.NO_COMPRESSION);
writeEntry(output, EasyFlowSkillManifestCodec.MANIFEST_PATH, manifestBytes);
try (ZipInputStream input = new ZipInputStream(
Files.newInputStream(standardPackage), StandardCharsets.UTF_8)) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = input.getNextEntry()) != null) {
if (++entryCount > limits.getMaxEntryCount() + 1) {
throw exportLimit("EasyFlow Skill 包文件数量超过限制");
}
String targetPath = "skills/" + entry.getName();
if (targetPath.length() > limits.getMaxPathLength()
|| targetPath.split("/").length > limits.getMaxPathDepth()) {
throw exportLimit("EasyFlow Skill 包路径长度或层级超过限制");
}
ZipEntry targetEntry = new ZipEntry(targetPath);
targetEntry.setTime(0L);
output.putNextEntry(targetEntry);
if (!entry.isDirectory()) {
int length;
while ((length = input.read(buffer)) >= 0) {
if (length == 0) {
continue;
}
totalBytes = addExportBytes(
totalBytes, length, limits.getMaxTotalUncompressedBytes());
output.write(buffer, 0, length);
}
}
output.closeEntry();
}
}
output.finish();
}
if (Files.size(bundle) > limits.getMaxCompressedPackageBytes()) {
throw exportLimit("EasyFlow Skill 包压缩文件超过限制");
}
return bundle;
} catch (RuntimeException | IOException exception) {
deleteQuietly(bundle);
throw exception;
}
}
private long addExportBytes(long current, long increment, long limit) {
if (increment < 0 || current > limit - increment) {
throw exportLimit("EasyFlow Skill 包解压总大小超过限制");
}
return current + increment;
}
private BusinessException exportLimit(String message) {
return new BusinessException(413, 4131, message);
}
private BusinessException mapPackageException(SkillPackageException exception,
SkillImportFormat format,
Collection<BigInteger> skillIds) {
Collection<BigInteger> skillIds) {
List<String> codes = new ArrayList<>();
codes.add(exception.getCode() == null ? "SKILL_PACKAGE_FAILED" : exception.getCode());
if (exception.getReport() != null) {
exception.getReport().getIssues().stream()
.map(SkillValidationIssue::getCode)
.forEach(codes::add);
exception.getReport().getIssues().stream().map(SkillValidationIssue::getCode).forEach(codes::add);
}
if (codes.stream().anyMatch(code -> Set.of(
"ZIP_IO_ERROR", "CONTENT_STORE_ERROR", "CONTENT_NOT_FOUND", "SKILL_CONTENT_STORE_ERROR",
"SKILL_CONTENT_ROLLBACK_ERROR", "CONTENT_REF_MISMATCH",
"RESOURCE_SIZE_MISMATCH", "RESOURCE_HASH_MISMATCH", "CRC_MISMATCH")
.contains(code))) {
LOG.error("导出 Skill 包内部失败format={}, skillIds={}, code={}, path={}",
format, skillIds, exception.getCode(), exception.getPath(), exception);
"SKILL_CONTENT_ROLLBACK_ERROR", "CONTENT_REF_MISMATCH", "RESOURCE_SIZE_MISMATCH",
"RESOURCE_HASH_MISMATCH", "CRC_MISMATCH").contains(code))) {
LOG.error("导出标准 Skill ZIP 内部失败skillIds={}, code={}, path={}",
skillIds, exception.getCode(), exception.getPath(), exception);
return new BusinessException(500, 500, "导出 Skill 包失败,请稍后重试", exception);
}
if (codes.stream().anyMatch(code -> code != null && (code.endsWith("_LIMIT")
|| code.contains("SIZE_LIMIT")))) {
return new BusinessException(413, 4131,
"Skill 包超过导出限制:" + firstPackageMessage(exception), exception);
if (codes.stream().anyMatch(code -> code != null
&& (code.endsWith("_LIMIT") || code.contains("SIZE_LIMIT")))) {
return new BusinessException(413, 4131, "Skill 包超过导出限制:" + firstMessage(exception), exception);
}
return new BusinessException(400, 4001,
"Skill 包不符合导出规范:" + firstPackageMessage(exception), exception);
"Skill 包不符合导出规范:" + firstMessage(exception), exception);
}
/**
* 读取结构化报告中的首个可执行错误消息。
*
* @param exception M18 包异常
* @return 错误消息
*/
private String firstPackageMessage(SkillPackageException exception) {
private String firstMessage(SkillPackageException exception) {
if (exception.getReport() != null) {
return exception.getReport().getIssues().stream()
.map(SkillValidationIssue::getMessage)
.filter(message -> message != null && !message.isBlank())
.findFirst()
return exception.getReport().getIssues().stream().map(SkillValidationIssue::getMessage)
.filter(message -> message != null && !message.isBlank()).findFirst()
.orElse("Skill 包校验失败");
}
return exception.getMessage() == null || exception.getMessage().isBlank()
? "Skill 包校验失败" : exception.getMessage();
}
private void writeEntry(ZipOutputStream output, String path, byte[] bytes) throws IOException {
ZipEntry entry = new ZipEntry(path);
entry.setTime(0L);
output.putNextEntry(entry);
output.write(bytes);
output.closeEntry();
}
/**
* 写入确定时间戳的 ZIP 目录项。
*
* @param output ZIP 输出流
* @param path 以斜杠结尾的目录路径
* @throws IOException 写入目录项失败时抛出
*/
private void writeDirectoryEntry(ZipOutputStream output, String path) throws IOException {
ZipEntry entry = new ZipEntry(path.endsWith("/") ? path : path + "/");
entry.setTime(0L);
output.putNextEntry(entry);
output.closeEntry();
}
private String safeFileStem(String value) {
if (value == null || value.isBlank()) {
return "skill";
}
String normalized = java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFKC)
.toLowerCase(java.util.Locale.ROOT)
.replaceAll("[^a-z0-9_-]+", "-")
.toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9-]+", "-")
.replaceAll("^-+|-+$", "");
if (normalized.isBlank()) {
return "skill";
}
return normalized.substring(0, Math.min(normalized.length(), 80));
return normalized.isBlank() ? "skill" : normalized.substring(0, Math.min(normalized.length(), 80));
}
private void deleteQuietly(Path path) {

View File

@@ -1,35 +0,0 @@
package tech.easyflow.skill.imports;
import java.math.BigInteger;
/**
* EasyFlow Bundle 能力目标映射项。
*/
public class SkillImportCapabilityMapping {
private String bindingKey;
private String packageRoot;
private String capabilityType;
private String targetLogicalRef;
private String targetName;
private String status;
private BigInteger targetId;
private boolean disabled;
public String getBindingKey() { return bindingKey; }
public void setBindingKey(String bindingKey) { this.bindingKey = bindingKey; }
public String getPackageRoot() { return packageRoot; }
public void setPackageRoot(String packageRoot) { this.packageRoot = packageRoot; }
public String getCapabilityType() { return capabilityType; }
public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; }
public String getTargetLogicalRef() { return targetLogicalRef; }
public void setTargetLogicalRef(String targetLogicalRef) { this.targetLogicalRef = targetLogicalRef; }
public String getTargetName() { return targetName; }
public void setTargetName(String targetName) { this.targetName = targetName; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public BigInteger getTargetId() { return targetId; }
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
public boolean isDisabled() { return disabled; }
public void setDisabled(boolean disabled) { this.disabled = disabled; }
}

View File

@@ -1,15 +0,0 @@
package tech.easyflow.skill.imports;
import java.math.BigInteger;
/**
* 导入确认阶段的窄能力映射请求。
*
* @param bindingKey manifest 中的能力绑定键
* @param targetId 当前环境目标 ID禁用时为空
* @param disabled 是否保持未映射并禁用
*/
public record SkillImportCapabilityOverride(String bindingKey,
BigInteger targetId,
boolean disabled) {
}

View File

@@ -1,9 +1,7 @@
package tech.easyflow.skill.imports;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -13,18 +11,18 @@ public class SkillImportConfirmRequest {
private String importToken;
private BigInteger categoryId;
private String visibilityScope;
private String conflictStrategy;
private Map<String, String> renames = new LinkedHashMap<>();
private List<SkillImportCapabilityOverride> capabilityMappings = new ArrayList<>();
public String getImportToken() { return importToken; }
public void setImportToken(String importToken) { this.importToken = importToken; }
public BigInteger getCategoryId() { return categoryId; }
public void setCategoryId(BigInteger categoryId) { this.categoryId = categoryId; }
public String getVisibilityScope() { return visibilityScope; }
public void setVisibilityScope(String visibilityScope) { this.visibilityScope = visibilityScope; }
public String getConflictStrategy() { return conflictStrategy; }
public void setConflictStrategy(String conflictStrategy) { this.conflictStrategy = conflictStrategy; }
public Map<String, String> getRenames() { return renames; }
public void setRenames(Map<String, String> renames) { this.renames = renames == null ? new LinkedHashMap<>() : new LinkedHashMap<>(renames); }
public List<SkillImportCapabilityOverride> getCapabilityMappings() { return capabilityMappings; }
public void setCapabilityMappings(List<SkillImportCapabilityOverride> capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); }
}

View File

@@ -1,30 +0,0 @@
package tech.easyflow.skill.imports;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* Skill 导入导出格式。
*/
public enum SkillImportFormat {
STANDARD,
EASYFLOW;
/**
* 解析格式编码。
*
* @param value 格式编码
* @return 格式
*/
public static SkillImportFormat from(String value) {
if (value == null || value.isBlank()) {
return STANDARD;
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的 Skill 包格式:" + value);
}
}
}

View File

@@ -12,9 +12,7 @@ public class SkillImportPreview {
private List<SkillImportPreviewItem> skills = new ArrayList<>();
private String importToken;
private String format;
private Date expiresAt;
private List<SkillImportCapabilityMapping> capabilityMappings = new ArrayList<>();
private List<SkillValidationIssue> issues = new ArrayList<>();
/**
@@ -37,12 +35,8 @@ public class SkillImportPreview {
public String getImportToken() { return importToken; }
public void setImportToken(String importToken) { this.importToken = importToken; }
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
public Date getExpiresAt() { return expiresAt; }
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
public List<SkillImportCapabilityMapping> getCapabilityMappings() { return capabilityMappings; }
public void setCapabilityMappings(List<SkillImportCapabilityMapping> capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); }
public List<SkillValidationIssue> getIssues() { return issues; }
public void setIssues(List<SkillValidationIssue> issues) { this.issues = issues == null ? new ArrayList<>() : new ArrayList<>(issues); }
}

View File

@@ -6,14 +6,14 @@ import java.util.List;
import org.springframework.web.multipart.MultipartFile;
/**
* Skill zip 导入服务。
* 标准 Skill ZIP 导入服务。
*/
public interface SkillImportService {
/**
* 上传并创建可单次确认的导入预览。
*
* @param file 标准 ZIP 或 .efskill
* @param file 标准 Skill ZIP
* @return 导入预览与 importToken
*/
SkillImportPreview preview(MultipartFile file);

View File

@@ -59,11 +59,10 @@ public class SkillImportStageStore {
*
* @param filePath 临时包存储路径
* @param originalName 原始文件名
* @param format 包格式
* @return 临时包索引
*/
@Transactional(rollbackFor = Exception.class)
public SkillImportStage create(String filePath, String originalName, SkillImportFormat format) {
public SkillImportStage create(String filePath, String originalName) {
LoginAccount account = requireAccount();
Date now = new Date();
SkillImportStage stage = new SkillImportStage();
@@ -72,7 +71,6 @@ public class SkillImportStageStore {
stage.setAccountId(account.getId());
stage.setFilePath(filePath);
stage.setOriginalName(originalName);
stage.setFormat(format.name());
stage.setStatus("PENDING");
stage.setCreated(now);
stage.setExpiresAt(new Date(now.getTime() + SESSION_TTL.toMillis()));

View File

@@ -1,45 +0,0 @@
package tech.easyflow.skill.imports;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* 携带结构化问题码和字段路径的 EasyFlow Skill manifest 校验异常。
*/
public class SkillManifestValidationException extends BusinessException {
private static final long serialVersionUID = 1L;
private final String validationCode;
private final String path;
/**
* 创建 manifest 校验异常。
*
* @param validationCode 稳定问题码
* @param path manifest 字段路径
* @param message 不包含原始敏感值的安全消息
*/
public SkillManifestValidationException(String validationCode, String path, String message) {
super(400, 4001, message);
this.validationCode = validationCode;
this.path = path;
}
/**
* 获取稳定问题码。
*
* @return 问题码
*/
public String getValidationCode() {
return validationCode;
}
/**
* 获取 manifest 字段路径。
*
* @return 字段路径
*/
public String getPath() {
return path;
}
}

View File

@@ -1,10 +0,0 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
/**
* Skill 能力绑定 Mapper。
*/
public interface SkillCapabilityBindingMapper extends BaseMapper<SkillCapabilityBinding> {
}

View File

@@ -54,6 +54,31 @@ public interface SkillMapper extends BaseMapper<Skill> {
@Param("publishedBy") BigInteger publishedBy,
@Param("snapshotHash") String snapshotHash);
/**
* 按当前审批实例原子发布 Skill并保留已应用实例作为幂等账本。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param approvalInstanceId 审批实例 ID
* @param snapshot 已发布快照
* @param publishedAt 发布时间
* @param publishedBy 发布人
* @param snapshotHash 快照哈希
* @return 更新行数
*/
@Update("UPDATE tb_skill SET publish_status='PUBLISHED', "
+ "published_snapshot_json=#{snapshot,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}")
int publishApproved(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("approvalInstanceId") BigInteger approvalInstanceId,
@Param("snapshot") Map<String, Object> snapshot,
@Param("publishedAt") Date publishedAt,
@Param("publishedBy") BigInteger publishedBy,
@Param("snapshotHash") String snapshotHash);
/**
* 在租户边界内将 Skill 标记为下线,并清空审批实例 ID。
*
@@ -65,42 +90,48 @@ public interface SkillMapper extends BaseMapper<Skill> {
+ "WHERE id=#{id} AND tenant_id=#{tenantId}")
int markOffline(@Param("id") BigInteger id, @Param("tenantId") BigInteger tenantId);
/**
* 按当前审批实例原子下线 Skill并保留已应用实例作为幂等账本。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param approvalInstanceId 审批实例 ID
* @return 更新行数
*/
@Update("UPDATE tb_skill SET publish_status='OFFLINE', current_approval_instance_id=#{approvalInstanceId} "
+ "WHERE id=#{id} AND tenant_id=#{tenantId} AND current_approval_instance_id=#{approvalInstanceId}")
int markOfflineApproved(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("approvalInstanceId") BigInteger approvalInstanceId);
/**
* 按当前审批实例恢复提交前状态并清空待审批实例。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param approvalInstanceId 审批实例 ID
* @param publishStatus 恢复状态
* @return 更新行数
*/
@Update("UPDATE tb_skill SET publish_status=#{publishStatus}, current_approval_instance_id=NULL "
+ "WHERE id=#{id} AND tenant_id=#{tenantId} AND current_approval_instance_id=#{approvalInstanceId}")
int restoreApprovalState(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("approvalInstanceId") BigInteger approvalInstanceId,
@Param("publishStatus") String publishStatus);
/**
* 无审计污染地回填迁移后缺失的包摘要,仅处理 package_hash 为空的旧记录。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param packageHash 包哈希
* @param resourceCount 资源总数
* @param referenceCount 引用数
* @param scriptCount 脚本数
* @param assetCount 二进制资源数
* @return 更新行数
*/
@Update("UPDATE tb_skill SET package_hash=#{packageHash}, resource_count=#{resourceCount}, "
+ "reference_count=#{referenceCount}, script_count=#{scriptCount}, asset_count=#{assetCount}, "
+ "modified=modified, modified_by=modified_by "
@Update("UPDATE tb_skill SET package_hash=#{packageHash}, modified=modified, modified_by=modified_by "
+ "WHERE id=#{id} AND tenant_id=#{tenantId} AND package_hash IS NULL")
int backfillPackageSummary(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("packageHash") String packageHash,
@Param("resourceCount") Integer resourceCount,
@Param("referenceCount") Integer referenceCount,
@Param("scriptCount") Integer scriptCount,
@Param("assetCount") Integer assetCount);
/**
* 无审计污染地回填迁移后缺失的能力哈希。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param capabilityHash 能力哈希
* @return 更新行数
*/
@Update("UPDATE tb_skill SET capability_hash=#{capabilityHash}, modified=modified, modified_by=modified_by "
+ "WHERE id=#{id} AND tenant_id=#{tenantId} AND capability_hash IS NULL")
int backfillCapabilityHash(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("capabilityHash") String capabilityHash);
int backfillPackageHash(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("packageHash") String packageHash);
}

View File

@@ -8,6 +8,7 @@ import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -80,12 +81,10 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
* {@inheritDoc}
*/
@Override
public ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId) {
ApprovalSubmitRequest request = super.buildSubmitRequest(resourceId, actionType, operatorId);
if (ApprovalActionType.PUBLISH.getCode().equals(request.getActionType())) {
skillService.retainSnapshotContents(readResourceSnapshot(request.getSnapshotJson()));
public void retainSubmittedSnapshot(String actionType, Map<String, Object> resourceSnapshot) {
if (ApprovalActionType.PUBLISH.getCode().equals(actionType)) {
skillService.retainSnapshotContents(resourceSnapshot);
}
return request;
}
@Override
@@ -136,7 +135,7 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
}
/**
* 删除审批只记录最小治理信息,避免失效能力阻断删除或把提示词、资源内容及能力配置写入审批快照。
* 删除审批只记录最小治理信息,避免把指令和资源内容写入审批快照。
*
* @param resource Skill
* @return 删除审批治理快照
@@ -182,6 +181,59 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
skillService.releaseSnapshotContents(existing.getPublishedSnapshotJson());
}
/**
* 按审批实例和快照哈希执行 Skill 生命周期回调,拒绝旧申请覆盖新状态。
*
* @param actionType 动作类型
* @param resourceId Skill ID
* @param resourceSnapshot 审批冻结快照
* @param operatorId 操作人 ID
* @param approvalInstanceId 审批实例 ID
*/
@Override
public void applyApprovedAction(String actionType,
BigInteger resourceId,
Map<String, Object> resourceSnapshot,
BigInteger operatorId,
BigInteger approvalInstanceId) {
ApprovalActionType action = ApprovalActionType.from(actionType);
requireMatchingInstance(approvalInstanceId, resourceId, action, ApprovalInstanceStatus.APPROVED);
Skill existing = findCurrentTenantSkill(resourceId, true);
if (existing == null) {
if (action == ApprovalActionType.DELETE) {
if (approvalInstanceService.isLatestResourceInstance(
approvalInstanceId, resourceType(), resourceId)) {
return;
}
throw new BusinessException(409, 4092, "审批申请已过期,不能删除当前 Skill");
}
throw new BusinessException(404, 404, "Skill 不存在");
}
if (!approvalInstanceId.equals(existing.getCurrentApprovalInstanceId())) {
throw new BusinessException(409, 4092, "审批申请已过期,不能更新当前 Skill");
}
if (isAppliedTerminal(existing, action, resourceSnapshot)) {
return;
}
if (action == ApprovalActionType.PUBLISH) {
skillService.assertSnapshotHash(resourceSnapshot);
if (skillMapper.publishApproved(resourceId, existing.getTenantId(), approvalInstanceId,
resourceSnapshot, new Date(), operatorId,
stringValue(resourceSnapshot.get("snapshotHash"))) != 1) {
throw new BusinessException(409, 4092, "Skill 发布状态已变化,请刷新后重试");
}
skillService.releaseSnapshotContents(existing.getPublishedSnapshotJson());
return;
}
if (action == ApprovalActionType.OFFLINE) {
if (skillMapper.markOfflineApproved(resourceId, existing.getTenantId(), approvalInstanceId) != 1) {
throw new BusinessException(409, 4092, "Skill 下线状态已变化,请刷新后重试");
}
return;
}
removeResource(resourceId);
}
@Override
protected void markResourceOffline(BigInteger resourceId) {
Skill existing = requireResource(resourceId);
@@ -222,10 +274,105 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
super.restoreState(resourceId, previousStatus);
}
/**
* 仅允许当前审批实例恢复状态并释放其候选快照引用。
*
* @param resourceId Skill ID
* @param previousStatus 提交前状态
* @param approvalInstanceId 审批实例 ID
*/
@Override
public void restoreState(BigInteger resourceId,
PublishStatus previousStatus,
BigInteger approvalInstanceId) {
ApprovalInstance instance = requireRestorableInstance(approvalInstanceId, resourceId);
Skill skill = requireResource(resourceId);
if (!approvalInstanceId.equals(skill.getCurrentApprovalInstanceId())) {
if (skill.getCurrentApprovalInstanceId() == null
&& PublishStatus.from(skill.getPublishStatus()) == previousStatus
&& approvalInstanceService.isLatestResourceInstance(
approvalInstanceId, resourceType(), resourceId)) {
return;
}
throw new BusinessException(409, 4092, "审批申请已过期,不能恢复当前 Skill");
}
if (ApprovalActionType.PUBLISH.getCode().equals(instance.getActionType())) {
skillService.releaseSnapshotContents(readResourceSnapshot(instance.getSnapshotJson()));
}
if (skillMapper.restoreApprovalState(resourceId, skill.getTenantId(), approvalInstanceId,
previousStatus.getCode()) != 1) {
throw new BusinessException(409, 4092, "Skill 审批状态已变化,请刷新后重试");
}
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 判断同一审批实例的终态动作是否已经应用。
*
* @param skill 当前 Skill
* @param action 审批动作
* @param snapshot 审批冻结快照
* @return 已应用时返回 {@code true}
*/
private boolean isAppliedTerminal(Skill skill,
ApprovalActionType action,
Map<String, Object> snapshot) {
PublishStatus status = PublishStatus.from(skill.getPublishStatus());
if (action == ApprovalActionType.PUBLISH) {
return status == PublishStatus.PUBLISHED
&& java.util.Objects.equals(skill.getSnapshotHash(), snapshot.get("snapshotHash"));
}
return action == ApprovalActionType.OFFLINE && status == PublishStatus.OFFLINE;
}
/**
* 校验审批实例与当前回调的资源、动作和终态完全匹配。
*
* @param instanceId 审批实例 ID
* @param resourceId Skill ID
* @param action 动作类型
* @param expectedStatus 期望实例终态
* @return 已校验的审批实例
*/
private ApprovalInstance requireMatchingInstance(BigInteger instanceId,
BigInteger resourceId,
ApprovalActionType action,
ApprovalInstanceStatus expectedStatus) {
ApprovalInstance instance = approvalInstanceService.getById(instanceId);
if (instance == null
|| !resourceType().equals(instance.getResourceType())
|| !resourceId.equals(instance.getResourceId())
|| !action.getCode().equals(instance.getActionType())
|| expectedStatus != ApprovalInstanceStatus.from(instance.getStatus())) {
throw new BusinessException(409, 4092, "审批回调与 Skill 申请不匹配");
}
return instance;
}
/**
* 校验驳回或撤回实例可用于恢复当前 Skill。
*
* @param instanceId 审批实例 ID
* @param resourceId Skill ID
* @return 已校验的审批实例
*/
private ApprovalInstance requireRestorableInstance(BigInteger instanceId, BigInteger resourceId) {
ApprovalInstance instance = approvalInstanceService.getById(instanceId);
if (instance == null
|| !resourceType().equals(instance.getResourceType())
|| !resourceId.equals(instance.getResourceId())) {
throw new BusinessException(409, 4092, "审批回调与 Skill 申请不匹配");
}
ApprovalInstanceStatus status = ApprovalInstanceStatus.from(instance.getStatus());
if (status != ApprovalInstanceStatus.REJECTED && status != ApprovalInstanceStatus.REVOKED) {
throw new BusinessException(409, 4092, "审批实例尚未进入可恢复终态");
}
return instance;
}
private Skill findCurrentTenantSkill(BigInteger id, boolean forUpdate) {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {

View File

@@ -31,10 +31,18 @@ public class SkillPublishAppService {
* 提交 Skill 发布审批。
*
* @param id Skill ID
* @param applicationReason 发布说明
* @return 审批动作结果
*/
public ApprovalActionResult submitPublishApproval(BigInteger id) {
return submit(id, ApprovalActionType.PUBLISH);
public ApprovalActionResult submitPublishApproval(BigInteger id, String applicationReason) {
if (applicationReason == null || applicationReason.isBlank()) {
throw new BusinessException("发布说明不能为空");
}
String normalizedReason = applicationReason.trim();
if (normalizedReason.length() > 500) {
throw new BusinessException("发布说明不能超过 500 个字符");
}
return submit(id, ApprovalActionType.PUBLISH, normalizedReason);
}
/**
@@ -44,7 +52,7 @@ public class SkillPublishAppService {
* @return 审批动作结果
*/
public ApprovalActionResult submitOfflineApproval(BigInteger id) {
return submit(id, ApprovalActionType.OFFLINE);
return submit(id, ApprovalActionType.OFFLINE, null);
}
/**
@@ -54,10 +62,10 @@ public class SkillPublishAppService {
* @return 审批动作结果
*/
public ApprovalActionResult submitDeleteApproval(BigInteger id) {
return submit(id, ApprovalActionType.DELETE);
return submit(id, ApprovalActionType.DELETE, null);
}
private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) {
private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType, String applicationReason) {
if (id == null) {
throw new BusinessException("Skill 审批时资源ID不能为空");
}
@@ -69,7 +77,8 @@ public class SkillPublishAppService {
ApprovalResourceType.SKILL.getCode(),
id,
actionType.getCode(),
account.getId()
account.getId(),
applicationReason
);
}
}

View File

@@ -1,228 +0,0 @@
package tech.easyflow.skill.repository;
import com.easyagents.skill.model.SkillDescriptor;
import com.easyagents.skill.repository.SkillRepository;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
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.SkillResource;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.support.SkillModelConverter;
import java.math.BigInteger;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 基于数据库的 easy-agents-skill 仓储适配器。
*/
@Repository
public class DBSkillRepository implements SkillRepository {
private final SkillService skillService;
private final DBSkillContentStore contentStore;
private final SkillVisibilityQueryHelper visibilityQueryHelper;
/**
* 创建数据库 Skill 仓储。
*
* @param skillService Skill 服务
* @param contentStore 二进制内容仓库
* @param visibilityQueryHelper 可见性查询助手
*/
public DBSkillRepository(SkillService skillService,
DBSkillContentStore contentStore,
SkillVisibilityQueryHelper visibilityQueryHelper) {
this.skillService = skillService;
this.contentStore = contentStore;
this.visibilityQueryHelper = visibilityQueryHelper;
}
/**
* 保存 Skill并转移新增二进制内容引用的所有权。
*
* <p>调用方在新增 Skill 或为已有 Skill 增加二进制资源前,必须通过内容仓库的
* {@code put}/{@code commit} 为每个新增资源取得一份引用。保存成功后,这些新增引用转由
* Skill 聚合持有;保存失败时不发生所有权转移,本方法产生的引用计数变更随事务回滚,
* 调用方仍负责释放在外部事务中预先取得的新引用。更新时,旧、新资源多重集的交集复用旧
* 聚合已有所有权,本适配器会在替换前 retain 相同次数,以抵消资源替换对旧聚合的 release
* 仅出现在新聚合中的引用直接接管调用方已取得的引用。</p>
*
* @param skill 待保存的 Skill 聚合
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void save(com.easyagents.skill.model.Skill skill) {
requireAccount();
Skill entity = SkillModelConverter.fromAgentSkill(skill);
BigInteger parsedId = tryParseId(skill.getId());
if (parsedId != null && findReadable(parsedId) != null) {
Skill existing = skillService.getDetail(parsedId);
retainReusedContentRefs(existing.getResources(), entity.getResources());
entity.setId(parsedId);
skillService.updateDraft(entity);
return;
}
skillService.saveDraft(entity);
}
/**
* 为旧、新资源多重集的交集增加临时持有,抵消替换流程对旧聚合引用的统一释放。
*
* @param existingResources 旧聚合资源
* @param incomingResources 新聚合资源
*/
private void retainReusedContentRefs(List<SkillResource> existingResources,
List<SkillResource> incomingResources) {
Map<String, Integer> remainingOldRefs = contentRefCounts(existingResources);
if (incomingResources == null || incomingResources.isEmpty() || remainingOldRefs.isEmpty()) {
return;
}
for (SkillResource resource : incomingResources) {
String contentRef = resource == null ? null : resource.getContentRef();
Integer remaining = remainingOldRefs.get(contentRef);
if (remaining == null || remaining <= 0) {
continue;
}
contentStore.retain(contentRef);
if (remaining == 1) {
remainingOldRefs.remove(contentRef);
} else {
remainingOldRefs.put(contentRef, remaining - 1);
}
}
}
/**
* 统计二进制内容引用多重集。
*
* @param resources Skill 资源
* @return contentRef 到出现次数的映射
*/
private Map<String, Integer> contentRefCounts(List<SkillResource> resources) {
Map<String, Integer> counts = new HashMap<>();
if (resources == null) {
return counts;
}
for (SkillResource resource : resources) {
String contentRef = resource == null ? null : resource.getContentRef();
if (contentRef != null && !contentRef.isBlank()) {
counts.merge(contentRef, 1, Integer::sum);
}
}
return counts;
}
/**
* {@inheritDoc}
*/
@Override
public Optional<com.easyagents.skill.model.Skill> get(String skillId) {
BigInteger id = parseId(skillId);
if (findReadable(id) == null) {
return Optional.empty();
}
Skill skill = skillService.getDetail(id);
return Optional.of(SkillModelConverter.toAgentSkill(skill));
}
/**
* {@inheritDoc}
*/
@Override
public Optional<SkillDescriptor> getDescriptor(String skillId) {
BigInteger id = parseId(skillId);
QueryWrapper query = descriptorQuery().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
Skill skill = skillService.getOne(query);
if (skill == null) {
return Optional.empty();
}
return Optional.of(new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(),
new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson())));
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillDescriptor> listDescriptors() {
requireAccount();
QueryWrapper query = descriptorQuery();
visibilityQueryHelper.applyReadableAccess(query);
return skillService.list(query).stream()
.map(skill -> new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(),
new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson())))
.toList();
}
/**
* {@inheritDoc}
*/
@Override
public void delete(String skillId) {
skillService.removeAggregate(parseId(skillId));
}
/**
* {@inheritDoc}
*/
@Override
public boolean exists(String skillId) {
BigInteger id = parseId(skillId);
QueryWrapper query = QueryWrapper.create().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
return skillService.count(query) > 0;
}
private Skill findReadable(BigInteger id) {
QueryWrapper query = QueryWrapper.create().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
return skillService.getOne(query);
}
private QueryWrapper descriptorQuery() {
return QueryWrapper.create().select(
"id", "tenant_id", "dept_id", "category_id", "name", "description", "metadata_json",
"visibility_scope", "created_by");
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private BigInteger parseId(String skillId) {
requireAccount();
if (skillId == null || skillId.isBlank()) {
throw new BusinessException("Skill ID 不能为空");
}
try {
return new BigInteger(skillId);
} catch (NumberFormatException exception) {
throw new BusinessException("Skill ID 格式不正确");
}
}
private BigInteger tryParseId(String skillId) {
if (skillId == null || skillId.isBlank()) {
return null;
}
try {
return new BigInteger(skillId);
} catch (NumberFormatException ignored) {
return null;
}
}
}

View File

@@ -1,447 +0,0 @@
package tech.easyflow.skill.security;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.util.Base64;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* EasyFlow 平台附加配置中的高置信凭据值检测器。
*
* <p>该检测器只用于能力配置和增强包元数据,不应用于标准 Skill 文档或资源正文。</p>
*/
public final class SkillCredentialValueGuard {
private static final int MAX_PERCENT_DECODE_PASSES = 5;
private static final Pattern URI_USER_INFO = Pattern.compile(
"(?i)\\b[a-z][a-z0-9+.-]*://([^\\s/?#@]+)@");
private static final Pattern ASSIGNMENT = Pattern.compile(
"(?i)(?:^|[\\s?&#{\\[,;])['\"]?([A-Z0-9_.\\[\\]-]{1,160})['\"]?\\s*[:=]\\s*");
private static final Pattern AUTHORIZATION_SCHEME = Pattern.compile("(?i)^(?:bearer|basic)\\s+");
private static final Pattern STANDALONE_AUTHORIZATION_SCHEME = Pattern.compile(
"(?i)(?<![A-Za-z0-9])(bearer|basic)\\s+");
private static final Pattern PRIVATE_KEY_MARKER = Pattern.compile(
"(?i)-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----");
private static final Pattern COMMON_TOKEN_PREFIX = Pattern.compile(
"(?<![A-Za-z0-9])(?:sk-proj-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|"
+ "gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|"
+ "glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{20,}|"
+ "(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}|npm_[A-Za-z0-9]{20,}|"
+ "AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})(?![A-Za-z0-9])");
private static final Pattern JWT = Pattern.compile(
"(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}"
+ "(?![A-Za-z0-9_-])");
private static final Pattern PLACEHOLDER = Pattern.compile(
"(?i)^(?:\\$\\{[A-Z0-9_.:-]+}|\\{\\{\\s*[A-Z0-9_.:-]+\\s*}}|"
+ "<[A-Z0-9_.:-]+>|\\[(?:REDACTED|MASKED|HIDDEN|TOKEN|API[-_]?KEY|SECRET|PASSWORD)]|"
+ "\\*{3,})$");
private static final Pattern SAFE_SENTINEL = Pattern.compile(
"(?i)^(?:none|null|unset|disabled|not[-_ ]?set|n/?a)$");
private static final Set<String> AUTH_PROSE_WORDS = Set.of(
"authentication", "authorization", "credentials", "credential", "information",
"header", "scheme", "token", "example", "placeholder");
/**
* 禁止实例化纯静态安全工具。
*/
private SkillCredentialValueGuard() {
}
/**
* 判断字符串是否包含可识别的实际凭据材料。
*
* @param value 待检查的平台附加配置值
* @return 检测到实际凭据时为 true
*/
public static boolean containsCredential(String value) {
if (value == null || value.isBlank()) {
return false;
}
String candidate = normalize(value);
for (int pass = 0; pass <= MAX_PERCENT_DECODE_PASSES; pass++) {
if (containsCredentialNormalized(candidate)) {
return true;
}
String decoded = percentDecode(candidate);
if (decoded.equals(candidate)) {
return false;
}
if (pass == MAX_PERCENT_DECODE_PASSES) {
// 超过有界规范化深度仍持续变化时按高风险输入处理,避免任意层编码绕过。
return true;
}
candidate = normalize(decoded);
}
return false;
}
/**
* 对单层规范化文本执行结构化凭据检测。
*
* @param value 已规范化文本
* @return 检测到凭据时为 true
*/
private static boolean containsCredentialNormalized(String value) {
if (PRIVATE_KEY_MARKER.matcher(value).find()
|| COMMON_TOKEN_PREFIX.matcher(value).find()
|| JWT.matcher(value).find()) {
return true;
}
Matcher userInfoMatcher = URI_USER_INFO.matcher(value);
while (userInfoMatcher.find()) {
String userInfo = stripWrappingQuotes(userInfoMatcher.group(1));
int separator = userInfo.lastIndexOf(':');
if (separator >= 0 && !isSafeCredentialScalar(userInfo.substring(separator + 1))) {
return true;
}
}
Matcher assignmentMatcher = ASSIGNMENT.matcher(value);
while (assignmentMatcher.find()) {
if (!isSensitiveKey(assignmentMatcher.group(1))) {
continue;
}
String assignedValue = extractAssignedValue(value, assignmentMatcher.end());
if (assignedValue.isEmpty()) {
continue;
}
if (!isSafeCredentialScalar(assignedValue)) {
return true;
}
}
Matcher schemeMatcher = STANDALONE_AUTHORIZATION_SCHEME.matcher(value);
while (schemeMatcher.find()) {
String payload = extractAuthorizationPayload(value, schemeMatcher.end());
if (looksLikeAuthorizationPayload(schemeMatcher.group(1), payload)) {
return true;
}
}
return false;
}
/**
* 从赋值分隔符后提取一个受限标量,支持常见引号和占位符形式。
*
* @param source 完整文本
* @param start 值起始位置
* @return 去除外层引号的标量
*/
private static String extractAssignedValue(String source, int start) {
int cursor = start;
while (cursor < source.length() && Character.isWhitespace(source.charAt(cursor))) {
cursor++;
}
if (cursor >= source.length()) {
return "";
}
char first = source.charAt(cursor);
if (first == '\'' || first == '"') {
int quoteEnd = findClosingQuote(source, cursor, first);
if (quoteEnd < 0) {
return stripWrappingQuotes(source.substring(cursor));
}
int end = extendScalarTail(source, quoteEnd + 1);
return stripWrappingQuotes(source.substring(cursor, end));
}
Matcher schemeMatcher = AUTHORIZATION_SCHEME.matcher(source.substring(cursor));
if (schemeMatcher.find()) {
String payload = extractAssignedValue(source, cursor + schemeMatcher.end());
return source.substring(cursor, cursor + schemeMatcher.end()) + payload;
}
int placeholderEnd = findPairedPlaceholderEnd(source, cursor);
if (placeholderEnd >= 0) {
return source.substring(cursor, extendScalarTail(source, placeholderEnd)).trim();
}
int end = cursor;
while (end < source.length() && !isScalarDelimiter(source.charAt(end))) {
end++;
}
return stripWrappingQuotes(source.substring(cursor, end));
}
/**
* 提取认证方案后的值;普通说明句保留为整体,供结构化判定区分 Token 与文案。
*
* @param source 完整文本
* @param start 认证值起始位置
* @return 认证载荷
*/
private static String extractAuthorizationPayload(String source, int start) {
int cursor = start;
while (cursor < source.length() && Character.isWhitespace(source.charAt(cursor))) {
cursor++;
}
if (cursor >= source.length()) {
return "";
}
int placeholderEnd = findPairedPlaceholderEnd(source, cursor);
if (placeholderEnd >= 0) {
return source.substring(cursor, extendScalarTail(source, placeholderEnd)).trim();
}
int end = cursor;
while (end < source.length()
&& !Character.isWhitespace(source.charAt(end))
&& !isScalarDelimiter(source.charAt(end))) {
end++;
}
return stripWrappingQuotes(source.substring(cursor, end));
}
/**
* 查找当前位置开始的成对占位符结束位置。
*
* @param source 完整文本
* @param start 起始位置
* @return 占位符结束位置(不含);当前位置不是完整占位符时返回 -1
*/
private static int findPairedPlaceholderEnd(String source, int start) {
String closing;
if (source.startsWith("${", start)) {
closing = "}";
} else if (source.startsWith("{{", start)) {
closing = "}}";
} else if (source.startsWith("<", start)) {
closing = ">";
} else if (source.startsWith("[", start)) {
closing = "]";
} else {
return -1;
}
int end = source.indexOf(closing, start + 1);
return end < 0 ? -1 : end + closing.length();
}
/**
* 查找未转义的结束引号。
*
* @param source 完整文本
* @param start 起始引号位置
* @param quote 引号字符
* @return 结束引号位置;未闭合时返回 -1
*/
private static int findClosingQuote(String source, int start, char quote) {
boolean escaped = false;
for (int index = start + 1; index < source.length(); index++) {
char current = source.charAt(index);
if (current == quote && !escaped) {
return index;
}
escaped = current == '\\' && !escaped;
if (current != '\\') {
escaped = false;
}
}
return -1;
}
/**
* 将紧邻占位符或引号的尾随字符纳入同一标量,避免占位符前缀绕过。
*
* @param source 完整文本
* @param start 尾随内容起始位置
* @return 标量结束位置
*/
private static int extendScalarTail(String source, int start) {
int end = start;
while (end < source.length() && !isScalarDelimiter(source.charAt(end))) {
end++;
}
return end;
}
/**
* 判断字符是否结束当前凭据标量。
*
* @param value 待判断字符
* @return 属于结构分隔符时为 true
*/
private static boolean isScalarDelimiter(char value) {
return value == ',' || value == ';' || value == '}' || value == ']'
|| value == '&' || value == '#';
}
/**
* 判断提取值是否为明确的非凭据占位符。
*
* @param value 提取值
* @return 属于允许占位符时为 true
*/
private static boolean isPlaceholder(String value) {
return PLACEHOLDER.matcher(stripWrappingQuotes(value).trim()).matches();
}
/**
* 判断赋值或认证方案后的标量是否明确不含真实凭据。
*
* @param value 原始标量
* @return 完整占位符或明确空值哨兵时为 true
*/
private static boolean isSafeCredentialScalar(String value) {
String scalar = stripWrappingQuotes(value).trim();
if ("bearer".equalsIgnoreCase(scalar) || "basic".equalsIgnoreCase(scalar)) {
return true;
}
Matcher schemeMatcher = AUTHORIZATION_SCHEME.matcher(scalar);
if (schemeMatcher.find()) {
String scheme = scalar.substring(0, schemeMatcher.end()).trim();
String payload = scalar.substring(schemeMatcher.end()).trim();
return !looksLikeAuthorizationPayload(scheme, payload);
}
return isPlaceholder(scalar) || SAFE_SENTINEL.matcher(scalar).matches();
}
/**
* 判断认证方案后的载荷是否具有实际凭据结构。
*
* @param scheme 认证方案
* @param value 认证载荷
* @return 具有实际凭据结构时为 true
*/
private static boolean looksLikeAuthorizationPayload(String scheme, String value) {
String payload = stripWrappingQuotes(value).trim();
if (payload.isEmpty() || isPlaceholder(payload) || SAFE_SENTINEL.matcher(payload).matches()) {
return false;
}
int placeholderEnd = findPairedPlaceholderEnd(payload, 0);
if (placeholderEnd > 0 && !payload.substring(placeholderEnd).trim().isEmpty()) {
return true;
}
if (payload.chars().anyMatch(Character::isWhitespace)) {
return false;
}
if (AUTH_PROSE_WORDS.contains(payload.toLowerCase(Locale.ROOT))) {
return false;
}
if ("basic".equalsIgnoreCase(scheme)) {
return isBasicCredential(payload);
}
return payload.length() >= 12 && payload.matches("[A-Za-z0-9._~+/=-]+");
}
/**
* 判断 Basic 载荷是否能解码为 user:secret 结构。
*
* @param payload Base64 载荷
* @return 符合 Basic 凭据结构时为 true
*/
private static boolean isBasicCredential(String payload) {
if (payload.length() < 8 || !payload.matches("[A-Za-z0-9+/]+={0,2}")) {
return false;
}
try {
byte[] decoded = Base64.getDecoder().decode(payload);
String text = new String(decoded, StandardCharsets.UTF_8);
int separator = text.indexOf(':');
return separator > 0 && separator < text.length() - 1
&& text.chars().noneMatch(Character::isISOControl);
} catch (IllegalArgumentException exception) {
return false;
}
}
/**
* 判断赋值左侧字段是否属于凭据语义。
*
* @param key 原始字段名
* @return 敏感字段时为 true
*/
private static boolean isSensitiveKey(String key) {
String normalized = normalize(key).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
return normalized.equals("key")
|| normalized.endsWith("authorization")
|| normalized.endsWith("apikey")
|| normalized.endsWith("accesskey")
|| normalized.endsWith("secretaccesskey")
|| normalized.endsWith("accesstoken")
|| normalized.endsWith("refreshtoken")
|| normalized.endsWith("idtoken")
|| normalized.endsWith("authtoken")
|| normalized.endsWith("token")
|| normalized.endsWith("clientsecret")
|| normalized.endsWith("password")
|| normalized.endsWith("passwd")
|| normalized.endsWith("secret")
|| normalized.endsWith("cookie")
|| normalized.endsWith("session")
|| normalized.endsWith("sessionid")
|| normalized.endsWith("credential")
|| normalized.endsWith("signature");
}
/**
* 去除成对单引号或双引号。
*
* @param value 原始标量
* @return 去除外层引号的标量
*/
private static String stripWrappingQuotes(String value) {
if (value == null) {
return "";
}
String trimmed = value.trim();
if (trimmed.length() >= 2) {
char first = trimmed.charAt(0);
char last = trimmed.charAt(trimmed.length() - 1);
if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) {
return trimmed.substring(1, trimmed.length() - 1).trim();
}
}
return trimmed;
}
/**
* 执行 Unicode 兼容规范化。
*
* @param value 原始文本
* @return NFKC 文本
*/
private static String normalize(String value) {
String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC);
StringBuilder visible = new StringBuilder(normalized.length());
normalized.codePoints()
.filter(codePoint -> Character.getType(codePoint) != Character.FORMAT)
.filter(codePoint -> !Character.isISOControl(codePoint))
.forEach(visible::appendCodePoint);
return visible.toString();
}
/**
* 尝试解码一层百分号转义,非法转义保持原文。
*
* @param value 原始文本
* @return 解码结果或原文
*/
private static String percentDecode(String value) {
try {
StringBuilder escapedInvalidPercent = new StringBuilder(value.length());
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (current == '%' && (index + 2 >= value.length()
|| !isHexDigit(value.charAt(index + 1))
|| !isHexDigit(value.charAt(index + 2)))) {
escapedInvalidPercent.append("%25");
} else {
escapedInvalidPercent.append(current);
}
}
return URLDecoder.decode(escapedInvalidPercent.toString(), StandardCharsets.UTF_8);
} catch (IllegalArgumentException exception) {
return value;
}
}
/**
* 判断字符是否为十六进制数字。
*
* @param value 待判断字符
* @return 十六进制数字时为 true
*/
private static boolean isHexDigit(char value) {
return value >= '0' && value <= '9'
|| value >= 'a' && value <= 'f'
|| value >= 'A' && value <= 'F';
}
}

View File

@@ -1,169 +0,0 @@
package tech.easyflow.skill.security;
import tech.easyflow.skill.enums.SkillCapabilityType;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Skill 跨环境目标引用和展示元数据的安全校验器。
*/
public final class SkillPortableTargetSanitizer {
private static final Pattern LOGICAL_SEGMENT = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.-]{0,199}");
private static final Pattern URI_USER_INFO = Pattern.compile(
"(?i)\\b[a-z][a-z0-9+.-]*://[^\\s/?#]*@");
private static final Pattern CREDENTIAL_QUERY = Pattern.compile(
"(?i)[?&;](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|api[_-]?key|key|"
+ "secret|client[_-]?secret|password|passwd|authorization|auth|"
+ "(?:x-amz-)?signature|credential)\\s*=");
private static final Pattern ABSOLUTE_PATH = Pattern.compile(
"(?:^|[^A-Za-z0-9_.:/-])(?:/(?!/)[^\\s]+|\\\\\\\\[^\\s]+|"
+ "[A-Za-z]:[\\\\/][^\\s]+|~[\\\\/][^\\s]+)");
private static final Pattern FILE_URI = Pattern.compile("(?i)\\bfile:(?://)?[/\\\\]");
/**
* 禁止实例化纯静态安全工具。
*/
private SkillPortableTargetSanitizer() {
}
/**
* 判断逻辑引用是否符合当前能力类型的严格可移植语法。
*
* @param type 能力类型
* @param logicalRef 待校验逻辑引用
* @return 符合安全语法时为 true
*/
public static boolean isSafeLogicalRef(SkillCapabilityType type, String logicalRef) {
if (type == null || logicalRef == null || logicalRef.isBlank()
|| SkillCredentialValueGuard.containsCredential(logicalRef)) {
return false;
}
if (unresolvedRef(type).equals(logicalRef)) {
return true;
}
return switch (type) {
case WORKFLOW -> hasSingleSafeSegment(logicalRef, "workflow:");
case MCP -> hasSingleSafeSegment(logicalRef, "mcp:");
case PLUGIN_ITEM -> hasTwoSafeSegments(logicalRef, "plugin-item:");
};
}
/**
* 返回安全逻辑引用;历史脏值统一降级为不可解析引用。
*
* @param type 能力类型
* @param logicalRef 原始逻辑引用
* @return 安全逻辑引用
*/
public static String safeLogicalRefOrUnresolved(SkillCapabilityType type, String logicalRef) {
return isSafeLogicalRef(type, logicalRef) ? logicalRef : unresolvedRef(type);
}
/**
* 构造能力类型对应的不可解析逻辑引用。
*
* @param type 能力类型
* @return 不可解析逻辑引用
*/
public static String unresolvedRef(SkillCapabilityType type) {
return "unresolved:" + type.name().toLowerCase(Locale.ROOT).replace('_', '-');
}
/**
* 判断展示元数据是否不含凭据式 URI、认证查询参数和绝对路径。
*
* @param value 待校验元数据
* @return 可安全写入增强包时为 true
*/
public static boolean isSafePortableMetadata(String value) {
if (value == null) {
return true;
}
String normalized = value;
for (int pass = 0; pass < 3; pass++) {
if (!isSafePortableMetadataValue(normalized)) {
return false;
}
String decoded = percentDecode(normalized);
if (decoded.equals(normalized)) {
return true;
}
normalized = decoded;
}
return isSafePortableMetadataValue(normalized);
}
/**
* 返回安全展示元数据;空白或不安全内容返回 null。
*
* @param value 原始元数据
* @return 安全元数据或 null
*/
public static String safePortableMetadataOrNull(String value) {
return value == null || value.isBlank() || !isSafePortableMetadata(value) ? null : value;
}
/**
* 校验单段类型逻辑引用。
*
* @param logicalRef 逻辑引用
* @param prefix 类型前缀
* @return 单段符合安全语法时为 true
*/
private static boolean hasSingleSafeSegment(String logicalRef, String prefix) {
return logicalRef.startsWith(prefix)
&& LOGICAL_SEGMENT.matcher(logicalRef.substring(prefix.length())).matches();
}
/**
* 校验插件与工具组成的双段逻辑引用。
*
* @param logicalRef 逻辑引用
* @param prefix 类型前缀
* @return 两段均符合安全语法时为 true
*/
private static boolean hasTwoSafeSegments(String logicalRef, String prefix) {
if (!logicalRef.startsWith(prefix)) {
return false;
}
String value = logicalRef.substring(prefix.length());
int separator = value.indexOf('/');
return separator > 0 && separator == value.lastIndexOf('/')
&& LOGICAL_SEGMENT.matcher(value.substring(0, separator)).matches()
&& LOGICAL_SEGMENT.matcher(value.substring(separator + 1)).matches();
}
/**
* 对单次规范化后的元数据执行危险内容检测。
*
* @param value 元数据
* @return 未发现危险内容时为 true
*/
private static boolean isSafePortableMetadataValue(String value) {
return value.chars().noneMatch(Character::isISOControl)
&& !SkillCredentialValueGuard.containsCredential(value)
&& !URI_USER_INFO.matcher(value).find()
&& !CREDENTIAL_QUERY.matcher(value).find()
&& !ABSOLUTE_PATH.matcher(value).find()
&& !FILE_URI.matcher(value).find();
}
/**
* 尝试解码一层百分号转义,非法转义保持原文。
*
* @param value 原始值
* @return 解码结果或原文
*/
private static String percentDecode(String value) {
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
} catch (IllegalArgumentException exception) {
// 非法百分号转义不能安全规范化,按原文继续检查并由调用方的字段语法约束处理。
return value;
}
}
}

View File

@@ -1,68 +0,0 @@
package tech.easyflow.skill.security;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* Skill 平台配置的敏感字段白名单清洗器。
*/
public final class SkillSensitiveConfigSanitizer {
private static final Set<String> HITL_ALLOWED_KEYS = Set.of(
"prompt", "title", "description", "confirmLabel", "cancelLabel"
);
private static final Set<String> OPTIONS_ALLOWED_KEYS = Set.of(
"timeoutMs", "retryCount", "async", "readOnly"
);
private SkillSensitiveConfigSanitizer() {
}
/**
* 仅保留已定义的非敏感 HITL 展示配置。
*
* @param source 原始 HITL 配置
* @return 白名单配置
*/
public static Map<String, Object> sanitizeHitl(Map<String, Object> source) {
return sanitizeAllowed(source, HITL_ALLOWED_KEYS);
}
/**
* 仅保留已定义的非敏感执行选项。
*
* @param source 原始执行选项
* @return 白名单配置
*/
public static Map<String, Object> sanitizeOptions(Map<String, Object> source) {
return sanitizeAllowed(source, OPTIONS_ALLOWED_KEYS);
}
private static Map<String, Object> sanitizeAllowed(Map<String, Object> source, Set<String> allowedKeys) {
if (source == null || source.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, Object> sanitized = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
if (entry.getKey() == null || !allowedKeys.contains(entry.getKey())) {
continue;
}
Object value = sanitizeScalar(entry.getValue());
if (value != null) {
sanitized.put(entry.getKey(), value);
}
}
return sanitized;
}
private static Object sanitizeScalar(Object value) {
if (value == null) {
return null;
}
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
return value;
}
return null;
}
}

View File

@@ -73,11 +73,6 @@ public class SkillVisibilityQueryHelper {
if (access.isRestricted()) {
visible = SKILL.CATEGORY_ID.in(access.getCategoryIds()).and(visible);
}
QueryCondition readable = owner.or(visible);
if (access.isAllAccess()) {
// L13 明确约定 ALL 分类范围可以读取未分类 Skill包括其他创建者的私有草稿。
readable = readable.or(SKILL.CATEGORY_ID.isNull());
}
queryWrapper.and(readable);
queryWrapper.and(owner.or(visible));
}
}

View File

@@ -29,7 +29,7 @@ public interface SkillService extends IService<Skill> {
Skill getManagementDetail(BigInteger id);
/**
* 获取仅包含标准 Skill 包内容的授权详情,不解析平台能力目标
* 获取仅包含标准 Skill 包内容的授权详情。
*
* @param id Skill ID
* @return Skill 包内容详情
@@ -81,16 +81,16 @@ public interface SkillService extends IService<Skill> {
Skill copyDraft(BigInteger sourceId, String name, String displayName, BigInteger categoryId);
/**
* 对当前 Skill 包和能力绑定执行全量校验。
* 对当前标准 Skill 包执行校验。
*
* @param id Skill ID
* @param publishValidation 是否执行发布级能力解析
* @param publishValidation 是否执行发布级完整校验
* @return 结构化校验结果
*/
SkillValidationResult validateSkill(BigInteger id, boolean publishValidation);
/**
* 在文件级修改后重新计算资源计数和包 hash。
* 在文件级修改后重新计算包 hash。
*
* @param id Skill ID
*/
@@ -104,11 +104,18 @@ public interface SkillService extends IService<Skill> {
*/
Map<String, Object> buildPublishSnapshot(Skill skill);
/**
* 校验发布快照中的哈希与实际内容一致。
*
* @param snapshot 发布快照
*/
void assertSnapshotHash(Map<String, Object> snapshot);
/**
* 构建删除审批使用的最小治理快照。
*
* @param skill Skill
* @return 不含提示词、资源内容和能力配置的治理快照
* @return 不含 SKILL.md 正文和资源内容的治理快照
*/
Map<String, Object> buildGovernanceSnapshot(Skill skill);

View File

@@ -34,10 +34,10 @@ public class SkillResourceServiceImpl extends ServiceImpl<SkillResourceMapper, S
*/
QueryWrapper descriptorQuery(BigInteger skillId, BigInteger tenantId) {
return QueryWrapper.create()
.select("id", "tenant_id", "skill_id", "path", "normalized_path", "kind", "language",
"media_type", "is_text", "content_hash", "size", "metadata_json", "sort_no")
.select("id", "tenant_id", "skill_id", "path", "normalized_path",
"media_type", "is_text", "content_hash", "size")
.eq(SkillResource::getTenantId, tenantId)
.eq(SkillResource::getSkillId, skillId)
.orderBy("sort_no asc, normalized_path asc");
.orderBy("normalized_path asc");
}
}

View File

@@ -19,9 +19,7 @@ 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.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillCategoryService;
@@ -29,7 +27,6 @@ import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.support.SkillModelConverter;
import tech.easyflow.skill.support.SkillResourceModelAdapter;
import tech.easyflow.skill.validation.SkillValidationIssue;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
@@ -62,7 +59,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
private final DefaultSkillValidator skillValidator = new DefaultSkillValidator();
private final SkillCategoryService skillCategoryService;
private final SkillResourceService skillResourceService;
private final SkillCapabilityBindingService capabilityBindingService;
private final DBSkillContentStore contentStore;
private final ResourceAccessService resourceAccessService;
private final CategoryPermissionService categoryPermissionService;
@@ -73,7 +69,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
*
* @param skillCategoryService Skill 分类服务
* @param skillResourceService 通用资源服务
* @param capabilityBindingService 能力绑定服务
* @param contentStore 二进制内容仓库
* @param resourceAccessService 资源访问服务
* @param categoryPermissionService 分类权限服务
@@ -81,14 +76,12 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
*/
public SkillServiceImpl(SkillCategoryService skillCategoryService,
SkillResourceService skillResourceService,
SkillCapabilityBindingService capabilityBindingService,
DBSkillContentStore contentStore,
ResourceAccessService resourceAccessService,
CategoryPermissionService categoryPermissionService,
ObjectMapper objectMapper) {
this.skillCategoryService = skillCategoryService;
this.skillResourceService = skillResourceService;
this.capabilityBindingService = capabilityBindingService;
this.contentStore = contentStore;
this.resourceAccessService = resourceAccessService;
this.categoryPermissionService = categoryPermissionService;
@@ -103,7 +96,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
Skill skill = requireSkill(id);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
fillResources(skill);
fillCapabilityBindings(skill);
return skill;
}
@@ -115,23 +107,9 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
Skill skill = requireSkill(id);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
fillResourceDescriptors(skill);
fillCapabilityBindings(skill);
return skill;
}
private void fillCapabilityBindings(Skill skill) {
List<tech.easyflow.skill.entity.SkillCapabilityBinding> bindings =
capabilityBindingService.listBindings(skill.getId());
skill.setCapabilityBindings(bindings);
boolean containsRedactedTarget = bindings.stream()
.anyMatch(binding -> "NO_PERMISSION".equals(binding.getTargetStatus()));
if (!containsRedactedTarget && (skill.getCapabilityHash() == null || skill.getCapabilityHash().isBlank())) {
String capabilityHash = capabilityBindingService.calculateStoredHash(skill.getId());
skill.setCapabilityHash(capabilityHash);
getMapper().backfillCapabilityHash(skill.getId(), skill.getTenantId(), capabilityHash);
}
}
/**
* {@inheritDoc}
*/
@@ -156,12 +134,12 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
skillCategoryService.lockAndValidateUsableCategory(skill.getCategoryId());
validateDraft(skill);
assertUniqueName(skill.getName(), null);
List<SkillResource> resources = SkillResourceModelAdapter.toResources(skill);
List<SkillResource> resources = skill.getResources() == null
? new ArrayList<>() : new ArrayList<>(skill.getResources());
applyDraftDefaults(skill);
normalizeResources(skill, resources);
skill.setPackageHash(calculatePackageHash(skill.getSkillContent(), resources));
skill.setResources(resources);
syncCounts(skill, resources);
try {
if (!save(skill)) {
throw new BusinessException(500, 500, "保存 Skill 失败,请稍后重试");
@@ -239,19 +217,9 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
draft.setDisplayName(displayName == null || displayName.isBlank()
? normalizedName : displayName.trim());
draft.setSkillContent(document.render());
draft.setEnabled(true);
draft.setVisibilityScope(VisibilityScope.PRIVATE.name());
draft.setSourceType("MANUAL");
draft.setResources(resources);
Skill saved = saveDraft(draft);
List<SkillCapabilityBinding> bindings = source.getCapabilityBindings() == null ? List.of()
: source.getCapabilityBindings().stream().map(this::copyBinding).toList();
if (!bindings.isEmpty()) {
capabilityBindingService.replaceBindings(saved.getId(), bindings, saved.getCapabilityHash());
return getDetail(saved.getId());
}
return saved;
return saveDraft(draft);
}
private Skill updateDraftInternal(Skill skill,
@@ -278,18 +246,15 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
throw new BusinessException(409, 4091, "文件已被其他操作更新,请重新加载后合并内容");
}
}
// 来源由服务端继承,使旧版下划线导入草稿可继续编辑,同时仍在发布校验中阻断。
skill.setSourceType(existing.getSourceType());
validateDraft(skill);
assertUniqueName(skill.getName(), skill.getId());
List<SkillResource> resources = hasResourcePayload(skill)
? SkillResourceModelAdapter.toResources(skill)
List<SkillResource> resources = skill.getResources() != null
? new ArrayList<>(skill.getResources())
: listResources(skill.getId());
normalizeResources(existing, resources);
applyDraftUpdate(existing, skill);
existing.setPackageHash(calculatePackageHash(existing.getSkillContent(), resources));
existing.setResources(resources);
syncCounts(existing, resources);
try {
QueryWrapper updateQuery = tenantSkillQuery(existing.getId());
if (expectedSkillContentHash != null) {
@@ -305,7 +270,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
} catch (DuplicateKeyException exception) {
throw new BusinessException(409, 4092, "当前租户已存在同名 Skill");
}
if (hasResourcePayload(skill)) {
if (skill.getResources() != null) {
replaceResources(existing, resources);
}
return getDetail(existing.getId());
@@ -335,8 +300,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
issue.setSuggestion(source.getSuggestion());
issues.add(issue);
}
SkillValidationResult capabilityResult = capabilityBindingService.validateBindings(id, null, publishValidation);
issues.addAll(capabilityResult.getIssues());
SkillValidationResult result = new SkillValidationResult();
result.setIssues(issues);
result.setValid(issues.stream().noneMatch(issue -> "ERROR".equals(issue.getSeverity())));
@@ -353,7 +316,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE,
"无权限管理该 Skill");
List<SkillResource> resources = listResources(id);
syncCounts(skill, resources);
skill.setPackageHash(calculatePackageHash(skill.getSkillContent(), resources));
skill.setModified(new Date());
skill.setModifiedBy(requireCurrentLoginAccount().getId());
@@ -374,27 +336,12 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
throw new BusinessException("Skill 发布校验失败:" + issue.getMessage());
});
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("schemaVersion", 1);
snapshot.put("id", detail.getId());
snapshot.put("tenantId", detail.getTenantId());
snapshot.put("deptId", detail.getDeptId());
snapshot.put("createdBy", detail.getCreatedBy());
snapshot.put("categoryId", detail.getCategoryId());
snapshot.put("schemaVersion", 2);
snapshot.put("name", detail.getName());
snapshot.put("displayName", detail.getDisplayName());
snapshot.put("description", detail.getDescription());
snapshot.put("metadataJson", detail.getMetadataJson());
snapshot.put("skillContent", detail.getSkillContent());
snapshot.put("enabled", detail.getEnabled());
snapshot.put("visibilityScope", detail.getVisibilityScope());
snapshot.put("sourceType", detail.getSourceType());
snapshot.put("packageHash", detail.getPackageHash());
snapshot.put("resources", buildResourceSnapshot(detail.getResources()));
List<Map<String, Object>> capabilitySnapshot = capabilityBindingService.buildPublishSnapshot(detail.getId());
// 发布态 hash 覆盖解析后的目标版本和 MCP ALL 最终工具清单,目标变化会形成新快照。
String capabilityHash = hashJson(capabilitySnapshot);
snapshot.put("capabilityHash", capabilityHash);
snapshot.put("capabilities", capabilitySnapshot);
String snapshotHash = hashJson(snapshot);
snapshot.put("snapshotHash", snapshotHash);
return snapshot;
@@ -409,7 +356,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
throw new BusinessException("Skill 治理快照缺少资源标识");
}
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("schemaVersion", 1);
snapshot.put("schemaVersion", 2);
snapshot.put("id", skill.getId());
snapshot.put("tenantId", skill.getTenantId());
snapshot.put("deptId", skill.getDeptId());
@@ -417,17 +364,28 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
snapshot.put("name", skill.getName());
snapshot.put("displayName", skill.getDisplayName());
snapshot.put("publishStatus", skill.getPublishStatus());
snapshot.put("enabled", skill.getEnabled());
snapshot.put("visibilityScope", skill.getVisibilityScope());
snapshot.put("sourceType", skill.getSourceType());
snapshot.put("packageHash", skill.getPackageHash());
snapshot.put("capabilityHash", skill.getCapabilityHash());
snapshot.put("resourceCount", skill.getResourceCount());
snapshot.put("capabilityCount", skill.getCapabilityCount());
snapshot.put("createdBy", skill.getCreatedBy());
return snapshot;
}
/**
* {@inheritDoc}
*/
@Override
public void assertSnapshotHash(Map<String, Object> snapshot) {
if (snapshot == null || snapshot.isEmpty()) {
throw new BusinessException("Skill 发布快照为空");
}
Object declared = snapshot.get("snapshotHash");
Map<String, Object> content = new LinkedHashMap<>(snapshot);
content.remove("snapshotHash");
if (!(declared instanceof String value) || !value.equals(hashJson(content))) {
throw new BusinessException(409, 4092, "Skill 发布快照校验失败");
}
}
/**
* {@inheritDoc}
*/
@@ -459,16 +417,8 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
throw new BusinessException("Skill 发布快照为空");
}
Skill skill = objectMapper.convertValue(snapshot, Skill.class);
skill.setId(toBigInteger(snapshot.get("id")));
skill.setTenantId(toBigInteger(snapshot.get("tenantId")));
skill.setDeptId(toBigInteger(snapshot.get("deptId")));
skill.setCreatedBy(toBigInteger(snapshot.get("createdBy")));
skill.setCategoryId(toBigInteger(snapshot.get("categoryId")));
skill.setPublishStatus(PublishStatus.PUBLISHED.getCode());
skill.setPublishedSnapshotJson(snapshot);
if (skill.getResources() != null) {
SkillResourceModelAdapter.fillCompatibilityViews(skill, skill.getResources());
}
return skill;
}
@@ -501,7 +451,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
if (id == null) {
return;
}
// 文件资源和能力更新同样先锁 Skill 行;删除必须持有该锁直到引用计数变更完成。
// 文件资源更新同样先锁 Skill 行;删除必须持有该锁直到引用计数变更完成。
Skill skill = requireSkill(id, true);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill");
assertRemovableStatus(skill, lifecycleDelete);
@@ -513,7 +463,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
throw new BusinessException(500, 500, "删除 Skill 资源失败,请稍后重试");
}
}
capabilityBindingService.removeBySkillId(id);
if (getMapper().deleteByQuery(tenantSkillQuery(id)) != 1) {
throw new BusinessException(500, 500, "删除 Skill 失败,请稍后重试");
}
@@ -575,7 +524,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) {
throw new BusinessException("SKILL.md 内容不能为空");
}
if (!isCanonicalName(skill.getName()) && !isLegacyImportName(skill)) {
if (!isCanonicalName(skill.getName())) {
throw new BusinessException("Skill 名称仅支持小写字母、数字和连字符");
}
validateTargetCategoryVisible(skill.getCategoryId());
@@ -614,11 +563,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
skill.setCreatedBy(account.getId());
skill.setModified(now);
skill.setModifiedBy(account.getId());
skill.setEnabled(skill.getEnabled() == null || skill.getEnabled());
skill.setSourceType(skill.getSourceType() == null ? "MANUAL" : skill.getSourceType());
skill.setPublishStatus(PublishStatus.DRAFT.getCode());
skill.setCapabilityCount(0);
skill.setCapabilityHash(capabilityBindingService.calculateHash(List.of()));
}
private void applyDraftUpdate(Skill existing, Skill incoming) {
@@ -627,41 +572,16 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
existing.setName(incoming.getName());
existing.setDisplayName(incoming.getDisplayName());
existing.setDescription(incoming.getDescription());
existing.setMetadataJson(incoming.getMetadataJson());
existing.setSkillContent(incoming.getSkillContent());
existing.setEnabled(incoming.getEnabled() == null || incoming.getEnabled());
existing.setVisibilityScope(incoming.getVisibilityScope());
if (incoming.getSourceType() != null && !incoming.getSourceType().isBlank()) {
existing.setSourceType(incoming.getSourceType());
}
existing.setModified(new Date());
existing.setModifiedBy(account.getId());
}
private void syncCounts(Skill skill, List<SkillResource> resources) {
int references = 0;
int scripts = 0;
int assets = 0;
for (SkillResource resource : resources) {
if ("REFERENCE".equals(resource.getKind())) {
references++;
} else if ("SCRIPT".equals(resource.getKind())) {
scripts++;
} else if (!Boolean.TRUE.equals(resource.getIsText())) {
assets++;
}
}
skill.setResourceCount(resources.size());
skill.setReferenceCount(references);
skill.setScriptCount(scripts);
skill.setAssetCount(assets);
}
private void fillResources(Skill skill) {
List<SkillResource> resources = listResources(skill.getId());
skill.setResources(resources);
refreshPackageSummary(skill, resources);
SkillResourceModelAdapter.fillCompatibilityViews(skill, resources);
}
private void fillResourceDescriptors(Skill skill) {
@@ -673,11 +593,9 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
private void refreshPackageSummary(Skill skill, List<SkillResource> resources) {
String previousHash = skill.getPackageHash();
syncCounts(skill, resources);
skill.setPackageHash(calculatePackageHash(skill.getSkillContent(), resources));
if (previousHash == null || previousHash.isBlank()) {
getMapper().backfillPackageSummary(skill.getId(), skill.getTenantId(), skill.getPackageHash(),
skill.getResourceCount(), skill.getReferenceCount(), skill.getScriptCount(), skill.getAssetCount());
getMapper().backfillPackageHash(skill.getId(), skill.getTenantId(), skill.getPackageHash());
}
}
@@ -685,7 +603,7 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
return skillResourceService.list(QueryWrapper.create()
.eq(SkillResource::getTenantId, requireCurrentLoginAccount().getTenantId())
.eq(SkillResource::getSkillId, skillId)
.orderBy("sort_no asc, normalized_path asc"));
.orderBy("normalized_path asc"));
}
private void replaceResources(Skill skill, List<SkillResource> resources) {
@@ -719,41 +637,12 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
SkillResource target = new SkillResource();
target.setPath(source.getPath());
target.setNormalizedPath(source.getNormalizedPath());
target.setKind(source.getKind());
target.setLanguage(source.getLanguage());
target.setMediaType(source.getMediaType());
target.setIsText(source.getIsText());
target.setTextContent(source.getTextContent());
target.setContentRef(source.getContentRef());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(new LinkedHashMap<>(source.getMetadataJson()));
target.setSortNo(source.getSortNo());
return target;
}
/**
* 复制能力绑定配置,目标授权和派生状态由替换流程重新解析。
*
* @param source 源能力绑定
* @return 无持久化标识的绑定副本
*/
private SkillCapabilityBinding copyBinding(SkillCapabilityBinding source) {
SkillCapabilityBinding target = new SkillCapabilityBinding();
target.setCapabilityType(source.getCapabilityType());
target.setTargetId(source.getTargetId());
target.setTargetLogicalRef(source.getTargetLogicalRef());
target.setRuntimeName(source.getRuntimeName());
target.setEnabled(source.getEnabled());
target.setSelectionMode(source.getSelectionMode());
target.setSelectedToolNamesJson(source.getSelectedToolNamesJson());
target.setExecutionMode(source.getExecutionMode());
target.setHitlEnabled(source.getHitlEnabled());
target.setHitlConfigJson(source.getHitlConfigJson() == null
? Map.of() : new LinkedHashMap<>(source.getHitlConfigJson()));
target.setOptionsJson(source.getOptionsJson() == null
? Map.of() : new LinkedHashMap<>(source.getOptionsJson()));
target.setSortNo(source.getSortNo());
return target;
}
@@ -787,7 +676,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
resource.setSkillId(skill.getId());
resource.setPath(normalizedPath);
resource.setNormalizedPath(normalizedPath);
resource.setSortNo(resource.getSortNo() == null ? index : resource.getSortNo());
resource.setCreated(now);
resource.setCreatedBy(account.getId());
resource.setModified(now);
@@ -812,11 +700,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
}
}
private boolean hasResourcePayload(Skill skill) {
return skill.getResources() != null || skill.getReferences() != null
|| skill.getScripts() != null || skill.getAssets() != null;
}
private void releaseContents(List<SkillResource> resources) {
for (SkillResource resource : resources) {
if (resource.getContentRef() != null) {
@@ -828,16 +711,14 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
private void normalizeFromSkillContent(Skill skill) {
try {
com.easyagents.skill.model.Skill parsed = SkillFactory.createWithResources(
skill.getId() == null ? "draft" : String.valueOf(skill.getId()),
skill.getSkillContent(),
SkillModelConverter.toAgentResources(SkillResourceModelAdapter.toResources(skill))
SkillModelConverter.toAgentResources(skill.getResources())
);
skill.setName(parsed.getName());
if (skill.getDisplayName() == null || skill.getDisplayName().isBlank()) {
skill.setDisplayName(parsed.getName());
}
skill.setDescription(parsed.getDescription());
skill.setMetadataJson(parsed.getMetadata().getValues());
} catch (SkillException exception) {
throw new BusinessException("SKILL.md frontmatter 不合法:" + exception.getMessage());
}
@@ -867,15 +748,12 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
for (SkillResource resource : resources) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("path", resource.getNormalizedPath());
item.put("kind", resource.getKind());
item.put("language", resource.getLanguage());
item.put("mediaType", resource.getMediaType());
item.put("text", resource.getIsText());
item.put("textContent", resource.getTextContent());
item.put("contentRef", resource.getContentRef());
item.put("contentHash", resource.getContentHash());
item.put("size", resource.getSize());
item.put("metadata", resource.getMetadataJson());
result.add(item);
}
return result;
@@ -944,12 +822,6 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
return name != null && name.matches("[a-z0-9]+(?:-[a-z0-9]+)*");
}
private boolean isLegacyImportName(Skill skill) {
String sourceType = skill.getSourceType();
return ("STANDARD_ZIP".equals(sourceType) || "EASYFLOW_BUNDLE".equals(sourceType))
&& skill.getName() != null && skill.getName().matches("[a-z0-9]+(?:[_-][a-z0-9]+)*");
}
private String collisionKey(String path) {
return Normalizer.normalize(path, Normalizer.Form.NFKC).toLowerCase(Locale.ROOT);
}
@@ -968,16 +840,4 @@ public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements
.eq(Skill::getTenantId, requireCurrentLoginAccount().getTenantId());
}
private BigInteger toBigInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigInteger bigInteger) {
return bigInteger;
}
if (value instanceof Number number) {
return BigInteger.valueOf(number.longValue());
}
return new BigInteger(String.valueOf(value));
}
}

View File

@@ -112,24 +112,6 @@ public class DBSkillContentStore implements SkillContentStore {
SkillHashes.sha256Ref(safeBytes), safeBytes.length, DEFAULT_MEDIA_TYPE));
}
/**
* 流式保存内容。
*
* @param inputStream 内容流
* @param maxBytes 最大字节数
* @return 内容引用
*/
@Override
public String put(InputStream inputStream, long maxBytes) {
SkillContentStage stage = stage(inputStream, maxBytes);
try {
return commit(stage);
} catch (RuntimeException exception) {
rollback(stage);
throw exception;
}
}
/**
* 将二进制内容流式写入本机受控临时文件,完成全包校验前不增加正式引用。
*
@@ -163,7 +145,7 @@ public class DBSkillContentStore implements SkillContentStore {
}
}
String hash = java.util.HexFormat.of().formatHex(digest.digest());
return new SkillContentStage(path.toAbsolutePath().toString(), "sha256:" + hash, hash, size, false);
return new SkillContentStage(path.toAbsolutePath().toString(), "sha256:" + hash, hash, size);
} catch (BusinessException exception) {
deleteStageQuietly(path);
throw exception;
@@ -286,21 +268,6 @@ public class DBSkillContentStore implements SkillContentStore {
}
}
/**
* 读取全部内容。仅为旧版 M18 接口兼容保留,大文件路径应使用 {@link #open(String)}。
*
* @param contentRef 内容引用
* @return 内容字节
*/
@Override
public byte[] readAllBytes(String contentRef) {
try (InputStream inputStream = open(contentRef)) {
return inputStream.readAllBytes();
} catch (IOException exception) {
throw new BusinessException(500, 500, "读取 Skill 二进制内容失败", exception);
}
}
/**
* 将内容流式复制到目标输出流。
*
@@ -686,7 +653,7 @@ public class DBSkillContentStore implements SkillContentStore {
*/
private void validateStage(SkillContentStage stage, Path path) {
validateContentRef(stage.getContentRef());
if (stage.isAlreadyCommitted() || stage.getSize() < 0
if (stage.getSize() < 0
|| !stage.getContentRef().substring("sha256:".length()).equals(stage.getContentHash())) {
throw new BusinessException(500, 500, "Skill 内容暂存描述不一致");
}

View File

@@ -1,19 +1,14 @@
package tech.easyflow.skill.support;
import com.easyagents.skill.factory.SkillFactory;
import com.easyagents.skill.model.SkillMetadata;
import com.easyagents.skill.model.SkillScriptLanguage;
import com.easyagents.skill.util.SkillResources;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillAsset;
import tech.easyflow.skill.entity.SkillReference;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.entity.SkillScript;
import java.math.BigInteger;
import java.util.List;
/**
* EasyFlow Skill 实体与 easy-agents-skill 模型转换器。
* EasyFlow Skill 持久化模型与标准 Skill 模型转换器。
*/
public final class SkillModelConverter {
@@ -21,177 +16,71 @@ public final class SkillModelConverter {
}
/**
* 转换为 easy-agents-skill 聚合。
* 转换为标准 Skill 聚合。
*
* @param skill Skill 主实体
* @return easy-agents-skill 聚合
* @return 标准 Skill 聚合
*/
public static com.easyagents.skill.model.Skill toAgentSkill(Skill skill) {
com.easyagents.skill.model.Skill result = SkillFactory.createWithResources(
String.valueOf(skill.getId()),
skill.getSkillContent(),
toAgentResources(skill.getResources() == null
? SkillResourceModelAdapter.toResources(skill) : skill.getResources())
);
skill.getSkillContent(), toAgentResources(skill.getResources()));
result.setPackageRoot(skill.getName());
return result;
}
/**
* 转换导入模型到主实体。
* 将标准 Skill 包聚合转换为待保存实体。
*
* @param imported 导入 Skill
* @return Skill 主实体
* @param imported 标准 Skill 聚合
* @return 待保存 Skill
*/
public static Skill fromAgentSkill(com.easyagents.skill.model.Skill imported) {
Skill skill = new Skill();
skill.setName(imported.getName());
skill.setDisplayName(imported.getName());
skill.setDescription(imported.getDescription());
skill.setMetadataJson(imported.getMetadata().getValues());
skill.setSkillContent(imported.getSkillContent());
skill.setResources(imported.getResources().stream().map(SkillModelConverter::fromAgentResource).toList());
skill.setReferences(imported.getReferences().stream()
.map(item -> fromAgentReference(null, null, item))
.toList());
skill.setScripts(imported.getScripts().stream()
.map(item -> fromAgentScript(null, null, item))
.toList());
skill.setAssets(imported.getAssets().stream()
.map(item -> fromAgentAsset(null, null, item))
.toList());
skill.setReferenceCount(imported.getReferences().size());
skill.setScriptCount(imported.getScripts().size());
skill.setAssetCount(imported.getAssets().size());
return skill;
}
/**
* 转换通用资源列表到 M18 标准模型
* 转换数据库资源为标准包资源
*
* @param resources EasyFlow 通用资源
* @return M18 通用资源
* @param resources 数据库资源
* @return 标准包资源
*/
public static List<com.easyagents.skill.model.SkillResource> toAgentResources(List<SkillResource> resources) {
return resources == null ? List.of() : resources.stream().map(source -> {
com.easyagents.skill.model.SkillResource target = new com.easyagents.skill.model.SkillResource();
target.setPath(source.getNormalizedPath() == null ? source.getPath() : source.getNormalizedPath());
target.setKind(parseResourceKind(source.getKind()));
String path = source.getNormalizedPath() == null ? source.getPath() : source.getNormalizedPath();
target.setPath(path);
target.setKind(SkillResources.classify(path));
target.setMediaType(source.getMediaType());
target.setTextContent(source.getTextContent());
target.setContentRef(source.getContentRef());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize() == null ? 0L : source.getSize());
target.setMetadata(new SkillMetadata(source.getMetadataJson()));
return target;
}).toList();
}
/**
* 转换 M18 通用资源到 EasyFlow 持久化模型
* 转换标准包资源为数据库资源
*
* @param source M18 通用资源
* @return EasyFlow 通用资源
* @param source 标准包资源
* @return 数据库资源
*/
public static SkillResource fromAgentResource(com.easyagents.skill.model.SkillResource source) {
SkillResource target = new SkillResource();
target.setPath(source.getPath());
target.setNormalizedPath(source.getPath());
target.setKind(source.getKind().name());
target.setLanguage(resolveResourceLanguage(source));
target.setMediaType(source.getMediaType());
target.setIsText(source.isText());
target.setTextContent(source.getTextContent());
target.setContentRef(source.getContentRef());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(source.getMetadata().getValues());
return target;
}
/**
* 转换导入 reference。
*
* @param skillId Skill ID
* @param tenantId 租户 ID
* @param source 导入模型
* @return reference 实体
*/
public static SkillReference fromAgentReference(BigInteger skillId, BigInteger tenantId,
com.easyagents.skill.model.SkillReference source) {
SkillReference target = new SkillReference();
target.setTenantId(tenantId);
target.setSkillId(skillId);
target.setPath(source.getPath());
target.setName(source.getName());
target.setContent(source.getContent());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(source.getMetadata().getValues());
return target;
}
/**
* 转换导入 script。
*
* @param skillId Skill ID
* @param tenantId 租户 ID
* @param source 导入模型
* @return script 实体
*/
public static SkillScript fromAgentScript(BigInteger skillId, BigInteger tenantId,
com.easyagents.skill.model.SkillScript source) {
SkillScript target = new SkillScript();
target.setTenantId(tenantId);
target.setSkillId(skillId);
target.setPath(source.getPath());
target.setLanguage(source.getLanguage().name());
target.setContent(source.getContent());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(source.getMetadata().getValues());
return target;
}
/**
* 转换导入 asset。
*
* @param skillId Skill ID
* @param tenantId 租户 ID
* @param source 导入模型
* @return asset 实体
*/
public static SkillAsset fromAgentAsset(BigInteger skillId, BigInteger tenantId,
com.easyagents.skill.model.SkillAsset source) {
SkillAsset target = new SkillAsset();
target.setTenantId(tenantId);
target.setSkillId(skillId);
target.setPath(source.getPath());
target.setName(source.getName());
target.setMediaType(source.getMediaType());
target.setContentRef(source.getContentRef());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(source.getMetadata().getValues());
return target;
}
private static com.easyagents.skill.model.SkillResourceKind parseResourceKind(String kind) {
if (kind == null || kind.isBlank()) {
return com.easyagents.skill.model.SkillResourceKind.OTHER;
}
try {
return com.easyagents.skill.model.SkillResourceKind.valueOf(kind);
} catch (IllegalArgumentException ignored) {
return com.easyagents.skill.model.SkillResourceKind.OTHER;
}
}
private static String resolveResourceLanguage(com.easyagents.skill.model.SkillResource resource) {
if (resource.getKind() == com.easyagents.skill.model.SkillResourceKind.SCRIPT) {
SkillScriptLanguage language = SkillScriptLanguage.fromPath(resource.getPath());
return language == SkillScriptLanguage.UNKNOWN ? null : language.name();
}
return resource.getKind() == com.easyagents.skill.model.SkillResourceKind.REFERENCE ? "MARKDOWN" : null;
}
}

View File

@@ -1,162 +0,0 @@
package tech.easyflow.skill.support;
import com.easyagents.skill.model.SkillResourceKind;
import com.easyagents.skill.model.SkillScriptLanguage;
import com.easyagents.skill.util.SkillPaths;
import com.easyagents.skill.util.SkillResources;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillAsset;
import tech.easyflow.skill.entity.SkillReference;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.entity.SkillScript;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
/**
* 通用 Skill 资源与试验版三类资源视图之间的兼容适配器。
*/
public final class SkillResourceModelAdapter {
private SkillResourceModelAdapter() {
}
/**
* 将 Skill 入参中的通用资源或旧资源视图归一化为通用资源。
*
* @param skill Skill 聚合
* @return 通用资源列表
*/
public static List<SkillResource> toResources(Skill skill) {
if (skill.getResources() != null) {
return new ArrayList<>(skill.getResources());
}
List<SkillResource> resources = new ArrayList<>();
if (skill.getReferences() != null) {
for (SkillReference reference : skill.getReferences()) {
SkillResource resource = textResource(reference.getPath(), SkillResourceKind.REFERENCE,
"MARKDOWN", "text/markdown", reference.getContent(), reference.getContentHash(),
reference.getSize(), reference.getMetadataJson());
resources.add(resource);
}
}
if (skill.getScripts() != null) {
for (SkillScript script : skill.getScripts()) {
SkillResource resource = textResource(script.getPath(), SkillResourceKind.SCRIPT,
script.getLanguage(), "text/plain", script.getContent(), script.getContentHash(),
script.getSize(), script.getMetadataJson());
resources.add(resource);
}
}
if (skill.getAssets() != null) {
for (SkillAsset asset : skill.getAssets()) {
SkillResource resource = new SkillResource();
resource.setPath(asset.getPath());
resource.setNormalizedPath(SkillPaths.normalize(asset.getPath()));
resource.setKind(SkillResourceKind.ASSET.name());
resource.setMediaType(asset.getMediaType());
resource.setIsText(false);
resource.setContentRef(asset.getContentRef());
resource.setContentHash(asset.getContentHash());
resource.setSize(asset.getSize());
resource.setMetadataJson(asset.getMetadataJson());
resources.add(resource);
}
}
return resources;
}
/**
* 根据通用资源回填旧版 reference/script/asset 只读兼容视图。
*
* @param skill Skill 聚合
* @param resources 通用资源
*/
public static void fillCompatibilityViews(Skill skill, List<SkillResource> resources) {
List<SkillReference> references = new ArrayList<>();
List<SkillScript> scripts = new ArrayList<>();
List<SkillAsset> assets = new ArrayList<>();
for (SkillResource resource : resources == null ? List.<SkillResource>of() : resources) {
SkillResourceKind kind = parseKind(resource.getKind(), resource.getNormalizedPath());
if (kind == SkillResourceKind.REFERENCE) {
SkillReference reference = new SkillReference();
reference.setId(resource.getId());
reference.setTenantId(resource.getTenantId());
reference.setSkillId(resource.getSkillId());
reference.setPath(resource.getNormalizedPath());
reference.setName(SkillPaths.fileName(resource.getNormalizedPath()));
reference.setContent(resource.getTextContent());
reference.setContentHash(resource.getContentHash());
reference.setSize(resource.getSize());
reference.setMetadataJson(resource.getMetadataJson());
references.add(reference);
} else if (kind == SkillResourceKind.SCRIPT) {
SkillScript script = new SkillScript();
script.setId(resource.getId());
script.setTenantId(resource.getTenantId());
script.setSkillId(resource.getSkillId());
script.setPath(resource.getNormalizedPath());
script.setLanguage(resource.getLanguage());
script.setContent(resource.getTextContent());
script.setContentHash(resource.getContentHash());
script.setSize(resource.getSize());
script.setMetadataJson(resource.getMetadataJson());
scripts.add(script);
} else if (!Boolean.TRUE.equals(resource.getIsText())) {
SkillAsset asset = new SkillAsset();
asset.setId(resource.getId());
asset.setTenantId(resource.getTenantId());
asset.setSkillId(resource.getSkillId());
asset.setPath(resource.getNormalizedPath());
asset.setName(SkillPaths.fileName(resource.getNormalizedPath()));
asset.setMediaType(resource.getMediaType());
asset.setContentRef(resource.getContentRef());
asset.setContentHash(resource.getContentHash());
asset.setSize(resource.getSize());
asset.setMetadataJson(resource.getMetadataJson());
assets.add(asset);
}
}
references.sort(Comparator.comparing(SkillReference::getPath));
scripts.sort(Comparator.comparing(SkillScript::getPath));
assets.sort(Comparator.comparing(SkillAsset::getPath));
skill.setReferences(references);
skill.setScripts(scripts);
skill.setAssets(assets);
}
private static SkillResource textResource(String path,
SkillResourceKind kind,
String language,
String mediaType,
String content,
String hash,
Long size,
java.util.Map<String, Object> metadata) {
SkillResource resource = new SkillResource();
resource.setPath(path);
resource.setNormalizedPath(SkillPaths.normalize(path));
resource.setKind(kind.name());
resource.setLanguage(language);
resource.setMediaType(mediaType);
resource.setIsText(true);
resource.setTextContent(content);
resource.setContentHash(hash);
resource.setSize(size);
resource.setMetadataJson(metadata == null ? new LinkedHashMap<>() : metadata);
return resource;
}
private static SkillResourceKind parseKind(String value, String path) {
if (value != null) {
try {
return SkillResourceKind.valueOf(value);
} catch (IllegalArgumentException ignored) {
// 旧数据或外部扩展类型按路径与文本属性安全降级。
}
}
return SkillResources.classify(path);
}
}

View File

@@ -1,7 +1,7 @@
package tech.easyflow.skill.validation;
/**
* Skill 包或能力配置的结构化校验问题。
* 标准 Skill 包的结构化校验问题。
*/
public class SkillValidationIssue {

View File

@@ -1,730 +0,0 @@
package tech.easyflow.skill.capability;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
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.SkillCapabilityBinding;
import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.validation.SkillValidationIssue;
import tech.easyflow.skill.validation.SkillValidationResult;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doReturn;
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.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* {@link SkillCapabilityBindingServiceImpl} 能力命名、MCP 选择、安全配置和权限测试。
*/
public class SkillCapabilityBindingServiceImplTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private SkillMapper skillMapper;
private SkillCapabilityTargetAccessService targetAccessService;
private McpAccessPermissionChecker mcpAccessPermissionChecker;
private ResourceAccessService resourceAccessService;
private SkillCapabilityBindingServiceImpl service;
private Skill skill;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化能力绑定服务及默认可用目标。
*/
@Before
public void setUp() {
skillMapper = mock(SkillMapper.class);
targetAccessService = mock(SkillCapabilityTargetAccessService.class);
mcpAccessPermissionChecker = mock(McpAccessPermissionChecker.class);
resourceAccessService = mock(ResourceAccessService.class);
service = new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mcpAccessPermissionChecker,
resourceAccessService, new ObjectMapper());
skill = new Skill();
skill.setId(SKILL_ID);
skill.setTenantId(BigInteger.ONE);
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
.thenReturn(target(List.of("alpha", "beta")));
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 验证非 MCP 能力的 runtimeName 按大小写不敏感规则判重。
*/
@Test
public void duplicateRuntimeNamesAreRejectedCaseInsensitively() {
SkillCapabilityBinding first = binding("WORKFLOW", 1, "RunFlow");
SkillCapabilityBinding second = binding("PLUGIN_ITEM", 2, "runflow");
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(first, second), false);
assertFalse(result.isValid());
assertTrue(hasIssue(result, "RUNTIME_NAME_DUPLICATE"));
}
/**
* 验证 MCP SELECTED 工具会确定性去重、排序并固化最终工具名。
*/
@Test
public void selectedMcpToolsAreDeduplicatedAndSorted() {
SkillCapabilityBinding binding = mcpBinding("demo", List.of("beta", "alpha", "alpha"));
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), true);
assertTrue(result.getIssues().toString(), result.isValid());
assertEquals(List.of("alpha", "beta"), binding.getSelectedToolNamesJson());
assertEquals(List.of("alpha", "beta"), binding.getResolvedToolNames());
}
/**
* 验证保存 MCP 绑定会重新校验目标权限,并在任何删除或写入发生前拒绝无权用户。
*/
@Test
public void replacingMcpBindingsRejectsMissingTargetPermissionBeforePersistence() {
SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha"));
when(targetAccessService.requireUsableTarget(binding, false))
.thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.replaceBindings(SKILL_ID, List.of(binding)));
assertEquals(403, exception.getHttpStatus());
verify(mcpAccessPermissionChecker).assertCanUseMcp();
verify(skillMapper, never()).updateByQuery(any(Skill.class), any(QueryWrapper.class));
}
/**
* 验证禁用且尚未映射的 MCP 绑定也不能绕过保存时的 MCP 模块权限。
*/
@Test
public void disabledUnmappedMcpStillRequiresPermissionOnSave() {
SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha"));
binding.setTargetId(null);
binding.setTargetLogicalRef("mcp:unmapped");
binding.setEnabled(false);
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
.when(mcpAccessPermissionChecker).assertCanUseMcp();
BusinessException exception = assertThrows(BusinessException.class,
() -> service.replaceBindings(SKILL_ID, List.of(binding)));
assertEquals(403, exception.getHttpStatus());
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
verify(skillMapper, never()).updateByQuery(any(Skill.class), any(QueryWrapper.class));
}
/**
* 验证发布快照会重新校验 MCP 权限,不能沿用保存时或前端传入的授权状态。
*/
@Test
public void publishingMcpBindingRevalidatesTargetPermission() {
SkillCapabilityBinding binding = mcpBinding("securedMcp", List.of("alpha"));
SkillCapabilityBindingServiceImpl publishService = spy(service);
doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class));
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
.when(mcpAccessPermissionChecker).assertCanUseMcp();
BusinessException exception = assertThrows(BusinessException.class,
() -> publishService.buildPublishSnapshot(SKILL_ID));
assertEquals(403, exception.getHttpStatus());
verify(mcpAccessPermissionChecker).assertCanUseMcp();
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
}
/**
* 验证草稿能力 hash 只覆盖持久化配置,不受发布时解析工具清单影响。
*/
@Test
public void draftCapabilityHashIgnoresTransientResolvedTools() {
SkillCapabilityBinding saved = mcpBinding("demo", List.of("beta", "alpha"));
saved.setSortNo(0);
SkillValidationResult validation = service.validateBindings(SKILL_ID, List.of(saved), false);
assertTrue(validation.getIssues().toString(), validation.isValid());
String responseHash = service.calculateHash(List.of(saved));
SkillCapabilityBinding reloaded = mcpBinding("demo", List.of("alpha", "beta"));
reloaded.setSortNo(0);
// targetLogicalRef 是校验后持久化的稳定配置,模拟数据库回读时应与已保存值一致。
reloaded.setTargetLogicalRef(saved.getTargetLogicalRef());
reloaded.setHitlEnabled(saved.getHitlEnabled());
reloaded.setResolvedToolNames(List.of());
String persistedHash = service.calculateHash(List.of(reloaded));
assertEquals(responseHash, persistedHash);
reloaded.setResolvedToolNames(List.of("changed-after-publish"));
assertEquals(persistedHash, service.calculateHash(List.of(reloaded)));
}
/**
* 能力批量写入失败属于服务端持久化故障,应返回 5xx。
*/
@Test
public void replacePersistenceFailureUsesServerErrorStatus() {
SkillCapabilityBindingServiceImpl failingService = spy(service);
doReturn(0L).when(failingService).count(any(QueryWrapper.class));
doReturn(false).when(failingService).saveBatch(any(List.class));
BusinessException exception = assertThrows(BusinessException.class,
() -> failingService.replaceBindings(
SKILL_ID, List.of(binding("WORKFLOW", 1, "runFlow"))));
assertEquals(500, exception.getHttpStatus());
}
/**
* 清空能力绑定时必须删除全部旧记录,并将能力摘要归零为确定性的空列表 hash。
*/
@Test
public void clearingBindingsDeletesAllRowsAndResetsSummary() {
SkillCapabilityBindingMapper bindingMapper = mock(SkillCapabilityBindingMapper.class);
SkillCapabilityBindingServiceImpl clearingService = spy(service);
String emptyCapabilityHash = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
doReturn(bindingMapper).when(clearingService).getMapper();
doReturn(2L).when(clearingService).count(any(QueryWrapper.class));
doReturn(List.of()).when(clearingService).list(any(QueryWrapper.class));
when(bindingMapper.deleteByQuery(any(QueryWrapper.class))).thenReturn(2);
when(skillMapper.updateByQuery(any(Skill.class), any(QueryWrapper.class))).thenReturn(1);
List<SkillCapabilityBinding> result = clearingService.replaceBindings(SKILL_ID, List.of());
ArgumentCaptor<Skill> updateCaptor = ArgumentCaptor.forClass(Skill.class);
verify(clearingService, times(1)).count(any(QueryWrapper.class));
verify(bindingMapper, times(1)).deleteByQuery(any(QueryWrapper.class));
verify(skillMapper).updateByQuery(updateCaptor.capture(), any(QueryWrapper.class));
assertTrue(result.isEmpty());
assertEquals(Integer.valueOf(0), updateCaptor.getValue().getCapabilityCount());
assertEquals(emptyCapabilityHash, updateCaptor.getValue().getCapabilityHash());
}
/**
* 验证 MCP SELECTED 空选择和已消失工具都会返回明确结构化错误。
*/
@Test
public void selectedMcpRequiresToolsAndRejectsMissingToolsOnPublish() {
SkillCapabilityBinding empty = mcpBinding("empty", List.of());
SkillValidationResult emptyResult = service.validateBindings(SKILL_ID, List.of(empty), false);
assertTrue(hasIssue(emptyResult, "MCP_TOOL_SELECTION_EMPTY"));
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), eq(true)))
.thenReturn(target(List.of("alpha")));
SkillCapabilityBinding missing = mcpBinding("missing", List.of("alpha", "removed"));
SkillValidationResult missingResult = service.validateBindings(SKILL_ID, List.of(missing), true);
assertFalse(missingResult.isValid());
assertTrue(hasIssue(missingResult, "MCP_TOOL_MISSING"));
assertFalse(issue(missingResult, "MCP_TOOL_MISSING").getMessage().contains("removed"));
}
/**
* 验证客户端提交的敏感或未知 options 被拒绝,并只留下安全白名单字段。
*/
@Test
public void sensitiveAndUnknownClientOptionsAreRejected() {
SkillCapabilityBinding binding = binding("WORKFLOW", 1, "safeFlow");
Map<String, Object> options = new LinkedHashMap<>();
options.put("timeoutMs", 2_000);
options.put("token", "secret");
options.put("customOption", true);
options.put("readOnly", Map.of("nested", "unsafe"));
binding.setOptionsJson(options);
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), false);
assertFalse(result.isValid());
assertTrue(hasIssue(result, "CAPABILITY_OPTIONS_UNSAFE"));
assertEquals(Map.of("timeoutMs", 2_000), binding.getOptionsJson());
}
/**
* 验证导入预览会报告 manifest 静态配置问题,同时不把待映射目标本身视为错误。
*/
@Test
public void importPreviewReportsStaticErrorsWithoutBlockingUnresolvedTargets() {
SkillCapabilityBinding first = unresolvedBinding("WORKFLOW", "workflow:first", "sharedName");
first.setSelectionMode("SELECTED");
first.setSelectedToolNamesJson(List.of("search"));
first.setOptionsJson(Map.of("timeoutMs", 99, "retryCount", 11));
SkillCapabilityBinding second = unresolvedBinding("PLUGIN_ITEM", "plugin-item:demo/tool", "sharedName");
SkillCapabilityBinding mcp = unresolvedBinding("MCP", "mcp:demo", "mcpTools");
mcp.setSelectionMode("SELECTED");
mcp.setSelectedToolNamesJson(List.of());
mcp.setExecutionMode("SYNC");
SkillValidationResult result = service.validateImportBindings(List.of(first, second, mcp));
assertFalse(result.isValid());
assertTrue(hasIssue(result, "CAPABILITY_OPTION_VALUE_INVALID"));
assertTrue(hasIssue(result, "RUNTIME_NAME_DUPLICATE"));
assertTrue(hasIssue(result, "MCP_SELECTION_MODE_NOT_ALLOWED"));
assertTrue(hasIssue(result, "MCP_TOOL_SELECTION_NOT_ALLOWED"));
assertTrue(hasIssue(result, "MCP_EXECUTION_MODE_NOT_ALLOWED"));
assertTrue(hasIssue(result, "MCP_TOOL_SELECTION_EMPTY"));
assertFalse(hasIssue(result, "TARGET_UNRESOLVED"));
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
verifyNoInteractions(skillMapper, resourceAccessService);
}
/**
* 验证有效的未映射能力可通过导入静态校验,留待映射步骤处理。
*/
@Test
public void importPreviewAcceptsValidUnresolvedBinding() {
SkillCapabilityBinding binding = unresolvedBinding(
"WORKFLOW", "workflow:portable-flow", "portableFlow");
SkillValidationResult result = service.validateImportBindings(List.of(binding));
assertTrue(result.getIssues().toString(), result.isValid());
assertTrue(result.getIssues().isEmpty());
verify(targetAccessService, never()).requireUsableTarget(any(), anyBoolean());
verifyNoInteractions(skillMapper, resourceAccessService);
}
/**
* 验证自动映射成功的目标仍执行可用性和当前操作者授权校验。
*/
@Test
public void importPreviewRevalidatesResolvedTargetPermission() {
SkillCapabilityBinding binding = binding("WORKFLOW", 92, "securedFlow");
binding.setTargetLogicalRef("workflow:secured-flow");
when(targetAccessService.requireUsableTarget(binding, false))
.thenThrow(new BusinessException(403, 403, "无权限使用绑定工作流"));
SkillValidationResult result = service.validateImportBindings(List.of(binding));
assertFalse(result.isValid());
assertTrue(hasIssue(result, "TARGET_NO_PERMISSION"));
verify(targetAccessService).requireUsableTarget(binding, false);
verifyNoInteractions(skillMapper, resourceAccessService);
}
/**
* 验证发布快照复用发布校验得到的目标摘要,并缓存同一目标的重复绑定查询。
*/
@Test
public void publishSnapshotReusesValidatedTargetWithinRequest() {
SkillCapabilityBinding first = binding("WORKFLOW", 93, "firstFlow");
SkillCapabilityBinding second = binding("WORKFLOW", 93, "secondFlow");
SkillCapabilityBindingServiceImpl publishService = spy(new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mcpAccessPermissionChecker,
resourceAccessService, new ObjectMapper()));
doReturn(List.of(first, second)).when(publishService).list(any(QueryWrapper.class));
List<Map<String, Object>> snapshots = publishService.buildPublishSnapshot(SKILL_ID);
assertEquals(2, snapshots.size());
assertEquals("target", snapshots.get(0).get("targetName"));
assertEquals("target", snapshots.get(1).get("targetName"));
verify(targetAccessService, times(1)).requireUsableTarget(any(SkillCapabilityBinding.class), eq(false));
}
/**
* 发布快照必须移除凭据式目标元数据,并将非法逻辑引用降级为不可解析引用。
*
* @throws Exception JSON 序列化失败
*/
@Test
public void publishSnapshotSanitizesPortableTargetMetadata() throws Exception {
SkillCapabilityBinding binding = binding("WORKFLOW", 94, "secureFlow");
SkillCapabilityTarget unsafeTarget = target(List.of());
unsafeTarget.setName("https://user:password@example.test/flow");
unsafeTarget.setLogicalRef("workflow:../../private");
unsafeTarget.setRevision("/Users/admin/.config/secret");
when(targetAccessService.requireUsableTarget(binding, false)).thenReturn(unsafeTarget);
SkillCapabilityBindingServiceImpl publishService = spy(new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mcpAccessPermissionChecker,
resourceAccessService, new ObjectMapper()));
doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class));
Map<String, Object> snapshot = publishService.buildPublishSnapshot(SKILL_ID).get(0);
String json = new ObjectMapper().writeValueAsString(snapshot);
assertNull(snapshot.get("targetName"));
assertNull(snapshot.get("targetRevision"));
assertEquals("unresolved:workflow", snapshot.get("targetLogicalRef"));
assertFalse(json.contains("password"));
assertFalse(json.contains("/Users/admin"));
}
/**
* 验证单项配置 4 KiB 和 MCP 选择 200 项的计数限额。
*/
@Test
public void capabilityConfigAndSelectedToolLimitsAreReported() {
SkillCapabilityBinding oversizedConfig = binding("WORKFLOW", 1, "largeConfig");
oversizedConfig.setOptionsJson(Map.of("timeoutMs", "x".repeat(5_000)));
SkillValidationResult configResult = service.validateBindings(
SKILL_ID, List.of(oversizedConfig), false);
assertTrue(hasIssue(configResult, "CAPABILITY_CONFIG_TOO_LARGE"));
List<String> tools = IntStream.range(0, 201)
.mapToObj(index -> String.format("tool%03d", index))
.toList();
SkillCapabilityBinding oversizedSelection = mcpBinding("many", tools);
SkillValidationResult selectionResult = service.validateBindings(
SKILL_ID, List.of(oversizedSelection), false);
assertTrue(hasIssue(selectionResult, "MCP_TOOL_SELECTION_LIMIT"));
}
/**
* 验证保存、导入和发布共用的能力校验会精确报告 HITL 字符串中的凭据。
*/
@Test
public void sensitiveHitlValueIsRejectedWithExactPath() {
SkillCapabilityBinding binding = unresolvedBinding(
"WORKFLOW", "workflow:portable-flow", "portableFlow");
binding.setHitlConfigJson(Map.of(
"title", "人工确认",
"prompt", "Authorization: Bearer actual-secret-value"));
SkillValidationResult result = service.validateImportBindings(List.of(binding));
assertFalse(result.isValid());
SkillValidationIssue issue = result.getIssues().stream()
.filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode()))
.findFirst()
.orElseThrow();
assertEquals("capabilities[0].hitlConfigJson.prompt", issue.getPath());
assertFalse(issue.getMessage().contains("actual-secret-value"));
}
/**
* 验证保存、导入和发布共用校验覆盖运行时名称、目标引用和工具名。
*/
@Test
public void sensitiveBindingStringsAreRejectedWithExactPaths() {
SkillCapabilityBinding runtimeBinding = unresolvedBinding(
"WORKFLOW", "workflow:portable-flow", "sk-proj-abcdefghijklmnopqrstuvwxyz123456");
SkillCapabilityBinding targetBinding = unresolvedBinding(
"WORKFLOW", "workflow:sk-proj-abcdefghijklmnopqrstuvwxyz123456", "portableFlow");
SkillCapabilityBinding toolBinding = unresolvedBinding("MCP", "mcp:portable", "portableMcp");
toolBinding.setEnabled(false);
toolBinding.setSelectionMode("SELECTED");
toolBinding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
SkillValidationResult result = service.validateImportBindings(
List.of(runtimeBinding, targetBinding, toolBinding));
assertFalse(result.isValid());
List<String> sensitivePaths = result.getIssues().stream()
.filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode()))
.map(SkillValidationIssue::getPath)
.toList();
assertTrue(sensitivePaths.contains("capabilities[0].runtimeName"));
assertTrue(sensitivePaths.contains("capabilities[1].targetLogicalRef"));
assertTrue(sensitivePaths.contains("capabilities[2].selectedToolNamesJson[0]"));
assertTrue(result.getIssues().stream().noneMatch(
item -> item.getMessage().contains("sk-proj-")));
}
/**
* 验证禁用能力也不能将凭据式工具名写入发布快照。
*/
@Test
public void publishSnapshotRejectsCredentialInDisabledBinding() {
SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:portable", "portableMcp");
binding.setEnabled(false);
binding.setSelectionMode("SELECTED");
binding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
SkillCapabilityBindingServiceImpl publishService = spy(service);
doReturn(List.of(binding)).when(publishService).list(any(QueryWrapper.class));
BusinessException exception = assertThrows(
BusinessException.class, () -> publishService.buildPublishSnapshot(SKILL_ID));
assertFalse(exception.getMessage().contains("sk-proj-"));
}
/**
* 验证目标解析阶段返回的凭据式 MCP 工具名不能进入发布快照。
*/
@Test
public void publishValidationRejectsCredentialFromResolvedMcpTools() {
SkillCapabilityBinding binding = mcpBinding("portableMcp", List.of("alpha"));
when(targetAccessService.requireUsableTarget(binding, true)).thenReturn(
target(List.of("alpha", "sk-proj-abcdefghijklmnopqrstuvwxyz123456")));
SkillValidationResult result = service.validateBindings(SKILL_ID, List.of(binding), true);
assertFalse(result.isValid());
SkillValidationIssue issue = result.getIssues().stream()
.filter(item -> "SENSITIVE_VALUE_DETECTED".equals(item.getCode()))
.findFirst()
.orElseThrow();
assertEquals("capabilities[0].resolvedToolNames[1]", issue.getPath());
assertFalse(issue.getMessage().contains("sk-proj-"));
}
/**
* 验证列表和详情读取边界会移除历史数据库中的凭据式展示值。
*/
@Test
public void listBindingsRedactsLegacyCredentialValues() {
SkillCapabilityBinding binding = binding(
"WORKFLOW", 95, "sk-proj-abcdefghijklmnopqrstuvwxyz123456");
binding.setSelectedToolNamesJson(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
binding.setResolvedToolNames(List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
binding.setHitlConfigJson(Map.of("prompt", "Bearer actual-secret-value"));
binding.setOptionsJson(Map.of("timeoutMs", "token=actual-secret-value"));
SkillCapabilityBindingServiceImpl listService = spy(service);
doReturn(List.of(binding)).when(listService).list(any(QueryWrapper.class));
SkillCapabilityBinding result = listService.listBindings(SKILL_ID).get(0);
assertNull(result.getRuntimeName());
assertTrue(result.getSelectedToolNamesJson().isEmpty());
assertTrue(result.getResolvedToolNames().isEmpty());
assertTrue(result.getHitlConfigJson().isEmpty());
assertTrue(result.getOptionsJson().isEmpty());
}
/**
* 验证 replaceBindings 在进入持久化前拒绝超过 200 项的能力列表。
*/
@Test
public void replaceRejectsMoreThanTwoHundredBindings() {
List<SkillCapabilityBinding> bindings = new ArrayList<>();
for (int index = 0; index < 201; index++) {
bindings.add(binding("WORKFLOW", index + 1, "flow" + index));
}
assertThrows(BusinessException.class, () -> service.replaceBindings(SKILL_ID, bindings));
verify(resourceAccessService).assertAccess(
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理 Skill 能力绑定");
}
/**
* 验证传入待保存 bindings 的校验必须执行 MANAGE 权限,不允许降级为 READ。
*/
@Test
public void validatingClientBindingsRequiresManagePermission() {
SkillCapabilityBinding binding = binding("WORKFLOW", 1, "managedFlow");
service.validateBindings(SKILL_ID, List.of(binding), false);
verify(resourceAccessService).assertAccess(
eq(CategoryResourceType.SKILL), same(skill), eq(ResourceAction.MANAGE), anyString());
}
/**
* 验证只有 READ 权限的用户读取绑定时看不到当前环境目标 ID 和无权限目标残留名称。
*/
@Test
public void visibleBindingsRedactTargetIdentityWithoutManagePermission() {
SkillCapabilityBinding binding = binding("WORKFLOW", 81, "readOnlyFlow");
binding.setTargetLogicalRef("workflow:private-flow");
binding.setTargetName("stale-private-name");
binding.setSelectedToolNamesJson(List.of("stale-private-selected-tool"));
binding.setResolvedToolNames(List.of("stale-private-tool"));
SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mcpAccessPermissionChecker,
resourceAccessService, new ObjectMapper()));
doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class));
when(resourceAccessService.canAccess(
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(false);
when(targetAccessService.requireUsableTarget(binding, false))
.thenThrow(new BusinessException(403, 403, "无权限使用目标"));
List<SkillCapabilityBinding> result = viewService.listVisibleBindings(SKILL_ID);
assertEquals(1, result.size());
assertNull(result.get(0).getTargetId());
assertNull(result.get(0).getTargetLogicalRef());
assertNull(result.get(0).getTargetName());
assertTrue(result.get(0).getSelectedToolNamesJson().isEmpty());
assertTrue(result.get(0).getResolvedToolNames().isEmpty());
assertEquals("NO_PERMISSION", result.get(0).getTargetStatus());
}
/**
* 验证拥有 MANAGE 权限的用户读取绑定时仍可获得目标 ID 用于编辑。
*/
@Test
public void visibleBindingsKeepTargetIdWithManagePermission() {
SkillCapabilityBinding binding = binding("WORKFLOW", 82, "managedFlow");
SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mcpAccessPermissionChecker,
resourceAccessService, new ObjectMapper()));
doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class));
when(resourceAccessService.canAccess(
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(true);
List<SkillCapabilityBinding> result = viewService.listVisibleBindings(SKILL_ID);
assertEquals(BigInteger.valueOf(82), result.get(0).getTargetId());
assertEquals("AVAILABLE", result.get(0).getTargetStatus());
}
/**
* 验证 Skill MANAGE 权限不能替代 MCP 查询权限,目标标识和工具元数据仍需脱敏。
*/
@Test
public void visibleBindingsRedactMcpTargetWithoutTargetPermissionEvenWhenSkillManageable() {
SkillCapabilityBinding binding = mcpBinding("privateMcp", List.of("private_tool"));
binding.setTargetLogicalRef("mcp:private-server");
binding.setTargetName("private-server");
binding.setResolvedToolNames(List.of("private_tool"));
SkillCapabilityBindingServiceImpl viewService = spy(new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mcpAccessPermissionChecker,
resourceAccessService, new ObjectMapper()));
doReturn(List.of(binding)).when(viewService).list(any(QueryWrapper.class));
when(resourceAccessService.canAccess(
CategoryResourceType.SKILL, skill, ResourceAction.MANAGE)).thenReturn(true);
when(targetAccessService.requireUsableTarget(binding, false))
.thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"));
List<SkillCapabilityBinding> result = viewService.listVisibleBindings(SKILL_ID);
assertEquals(1, result.size());
assertEquals("NO_PERMISSION", result.get(0).getTargetStatus());
assertNull(result.get(0).getTargetId());
assertNull(result.get(0).getTargetLogicalRef());
assertNull(result.get(0).getTargetName());
assertTrue(result.get(0).getSelectedToolNamesJson().isEmpty());
assertTrue(result.get(0).getResolvedToolNames().isEmpty());
}
/**
* 创建基础能力绑定。
*
* @param type 能力类型
* @param targetId 目标 ID
* @param runtimeName 运行时名称
* @return 能力绑定
*/
private SkillCapabilityBinding binding(String type, long targetId, String runtimeName) {
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType(type);
binding.setTargetId(BigInteger.valueOf(targetId));
binding.setRuntimeName(runtimeName);
binding.setEnabled(true);
binding.setHitlConfigJson(new LinkedHashMap<>());
binding.setOptionsJson(new LinkedHashMap<>());
return binding;
}
/**
* 创建 MCP SELECTED 能力绑定。
*
* @param runtimeName 命名空间
* @param selectedTools 已选工具
* @return MCP 绑定
*/
private SkillCapabilityBinding mcpBinding(String runtimeName, List<String> selectedTools) {
SkillCapabilityBinding binding = binding("MCP", 10, runtimeName);
binding.setSelectionMode("SELECTED");
binding.setSelectedToolNamesJson(selectedTools);
return binding;
}
/**
* 创建等待导入映射的能力绑定。
*
* @param type 能力类型
* @param logicalRef 可移植逻辑引用
* @param runtimeName 运行时名称
* @return 未映射能力绑定
*/
private SkillCapabilityBinding unresolvedBinding(String type, String logicalRef, String runtimeName) {
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType(type);
binding.setTargetLogicalRef(logicalRef);
binding.setRuntimeName(runtimeName);
binding.setEnabled(true);
binding.setHitlConfigJson(new LinkedHashMap<>());
binding.setOptionsJson(new LinkedHashMap<>());
return binding;
}
/**
* 创建可用能力目标。
*
* @param toolNames MCP 工具名
* @return 目标摘要
*/
private SkillCapabilityTarget target(List<String> toolNames) {
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName("target");
target.setLogicalRef("target://demo");
target.setRevision("r1");
target.setStatus("AVAILABLE");
target.setToolNames(toolNames);
return target;
}
/**
* 判断校验结果是否包含指定问题码。
*
* @param result 校验结果
* @param code 问题码
* @return 包含时为 true
*/
private boolean hasIssue(SkillValidationResult result, String code) {
return result.getIssues().stream().anyMatch(item -> code.equals(item.getCode()));
}
/**
* 获取指定问题码的首个问题。
*
* @param result 校验结果
* @param code 问题码
* @return 校验问题
*/
private SkillValidationIssue issue(SkillValidationResult result, String code) {
return result.getIssues().stream()
.filter(item -> code.equals(item.getCode()))
.findFirst()
.orElseThrow();
}
}

View File

@@ -1,204 +0,0 @@
package tech.easyflow.skill.capability;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* 能力绑定畸形客户端输入的结构化诊断测试。
*/
public class SkillCapabilityMalformedInputTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private SkillCapabilityBindingServiceImpl service;
/**
* 初始化具有当前租户上下文的被测服务。
*/
@Before
public void setUp() {
SkillMapper skillMapper = mock(SkillMapper.class);
Skill skill = new Skill();
skill.setId(SKILL_ID);
skill.setTenantId(BigInteger.ONE);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName("target");
target.setLogicalRef("target:demo");
target.setStatus("AVAILABLE");
target.setToolNames(List.of("search"));
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
.thenReturn(target);
service = new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class),
mock(ResourceAccessService.class), new ObjectMapper());
}
/**
* 验证 MCP 工具数组中的 null 返回结构化错误,不触发排序空指针。
*/
@Test
public void nullMcpToolNameShouldReturnStructuredIssue() {
SkillCapabilityBinding binding = binding("MCP");
binding.setSelectionMode("SELECTED");
List<String> tools = new ArrayList<>();
tools.add("search");
tools.add(null);
binding.setSelectedToolNamesJson(tools);
SkillValidationResult result = validate(binding);
assertFalse(result.isValid());
assertTrue(result.getIssues().stream()
.anyMatch(issue -> "MCP_TOOL_NAME_INVALID".equals(issue.getCode())));
}
/**
* 验证非法执行模式进入结构化问题列表,不以枚举异常中断校验。
*/
@Test
public void invalidExecutionModeShouldReturnStructuredIssue() {
SkillCapabilityBinding binding = binding("WORKFLOW");
binding.setExecutionMode("INVALID_MODE");
SkillValidationResult result = validate(binding);
assertFalse(result.isValid());
assertTrue(result.getIssues().stream()
.anyMatch(issue -> issue.getPath() != null && issue.getPath().endsWith("executionMode")));
}
/**
* 验证非法 MCP 选择模式进入结构化问题列表。
*/
@Test
public void invalidSelectionModeShouldReturnStructuredIssue() {
SkillCapabilityBinding binding = binding("MCP");
binding.setSelectionMode("INVALID_MODE");
SkillValidationResult result = validate(binding);
assertFalse(result.isValid());
assertTrue(result.getIssues().stream()
.anyMatch(issue -> issue.getPath() != null && issue.getPath().endsWith("selectionMode")));
}
/**
* 验证禁用且未映射的 MCP 仍执行选择模式静态校验,不能借 targetId 为空绕过。
*/
@Test
public void disabledUnresolvedMcpShouldStillValidateSelectionMode() {
SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:missing");
binding.setSelectionMode("INVALID_MODE");
SkillValidationResult result = validate(binding);
assertFalse(result.isValid());
assertTrue(result.getIssues().stream()
.anyMatch(issue -> "MCP_SELECTION_MODE_INVALID".equals(issue.getCode())));
}
/**
* 验证禁用且未映射的 MCP 仍拒绝非法工具名,避免恶意值持久化并再次导出。
*/
@Test
public void disabledUnresolvedMcpShouldStillValidateToolNames() {
SkillCapabilityBinding binding = unresolvedBinding("MCP", "mcp:missing");
binding.setSelectionMode("SELECTED");
binding.setSelectedToolNamesJson(List.of("invalid tool name"));
SkillValidationResult result = validate(binding);
assertFalse(result.isValid());
assertTrue(result.getIssues().stream()
.anyMatch(issue -> "MCP_TOOL_NAME_INVALID".equals(issue.getCode())));
}
/**
* 验证禁用且未映射的非 MCP 能力仍执行 executionMode 静态校验。
*/
@Test
public void disabledUnresolvedWorkflowShouldStillValidateExecutionMode() {
SkillCapabilityBinding binding = unresolvedBinding("WORKFLOW", "workflow:missing");
binding.setExecutionMode("INVALID_MODE");
SkillValidationResult result = validate(binding);
assertFalse(result.isValid());
assertTrue(result.getIssues().stream()
.anyMatch(issue -> "EXECUTION_MODE_INVALID".equals(issue.getCode())));
}
/**
* 验证逻辑引用 scheme 必须与能力类型一致。
*/
@Test
public void unresolvedLogicalRefShouldMatchCapabilityType() {
SkillCapabilityBinding binding = unresolvedBinding("MCP", "workflow:wrong-type");
binding.setSelectionMode("ALL");
SkillValidationResult result = validate(binding);
assertFalse(result.isValid());
assertTrue(result.getIssues().stream()
.anyMatch(issue -> "TARGET_LOGICAL_REF_INVALID".equals(issue.getCode())));
}
private SkillValidationResult validate(SkillCapabilityBinding binding) {
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account());
return service.validateBindings(SKILL_ID, List.of(binding), false);
}
}
private SkillCapabilityBinding binding(String type) {
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType(type);
binding.setTargetId(BigInteger.valueOf(9));
binding.setRuntimeName("demoTool");
binding.setEnabled(true);
binding.setHitlConfigJson(Map.of());
binding.setOptionsJson(Map.of());
return binding;
}
private SkillCapabilityBinding unresolvedBinding(String type, String logicalRef) {
SkillCapabilityBinding binding = binding(type);
binding.setTargetId(null);
binding.setTargetLogicalRef(logicalRef);
binding.setEnabled(false);
return binding;
}
private LoginAccount account() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
return account;
}
}

View File

@@ -1,242 +0,0 @@
package tech.easyflow.skill.capability;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.validation.SkillValidationIssue;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.system.service.CategoryPermissionService;
import java.math.BigInteger;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Skill 能力绑定的租户边界和严格校验回归测试。
*/
public class SkillCapabilityTenantAndValidationTest {
/**
* 验证当前用户即使拥有全局插件可见范围,也不能绑定其他租户的插件工具项。
*/
@Test
public void pluginItemShouldNeverCrossTenantBoundary() {
PluginItemService pluginItemService = mock(PluginItemService.class);
PluginService pluginService = mock(PluginService.class);
PluginVisibilityService pluginVisibilityService = mock(PluginVisibilityService.class);
PluginItem item = new PluginItem();
item.setId(BigInteger.valueOf(11));
item.setPluginId(BigInteger.valueOf(22));
item.setName("tool");
item.setStatus(1);
item.setServiceStatus(1);
Plugin plugin = new Plugin();
plugin.setId(BigInteger.valueOf(22));
plugin.setTenantId(2L);
plugin.setCreatedBy(8L);
plugin.setName("other-tenant-plugin");
when(pluginItemService.getOne(any(QueryWrapper.class))).thenReturn(item);
// 即使底层查询实现错误地返回了跨租户对象,服务层防御检查仍必须拒绝。
when(pluginService.getOne(any(QueryWrapper.class))).thenReturn(plugin);
when(pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())).thenReturn(true);
when(pluginService.preparePluginForCurrentUser(plugin)).thenReturn(plugin);
SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl(
mock(WorkflowService.class), pluginItemService, pluginService, pluginVisibilityService,
mock(McpService.class), mock(McpAccessPermissionChecker.class), mock(ResourceAccessService.class),
mock(WorkflowVisibilityQueryHelper.class), mock(CategoryPermissionService.class));
SkillCapabilityBinding binding = binding("PLUGIN_ITEM", item.getId(), "tool");
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.requireUsableTarget(binding, false));
assertEquals(403, exception.getHttpStatus());
}
verify(pluginService, never()).preparePluginForCurrentUser(plugin);
}
/**
* 验证包含凭据式 URL 的 MCP 标题不会被复制进跨环境逻辑引用。
*/
@Test
public void unsafeMcpTitleShouldBecomeUnresolvedLogicalRef() {
McpService mcpService = mock(McpService.class);
Mcp mcp = new Mcp();
mcp.setId(BigInteger.valueOf(31));
mcp.setTenantId(BigInteger.ONE);
mcp.setStatus(true);
mcp.setTitle("https://user:secret@example.test/mcp?token=must-not-enter");
when(mcpService.getOne(any(QueryWrapper.class))).thenReturn(mcp);
McpAccessPermissionChecker mcpPermissionChecker = mock(McpAccessPermissionChecker.class);
SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl(
mock(WorkflowService.class), mock(PluginItemService.class), mock(PluginService.class),
mock(PluginVisibilityService.class), mcpService, mcpPermissionChecker, mock(ResourceAccessService.class),
mock(WorkflowVisibilityQueryHelper.class), mock(CategoryPermissionService.class));
SkillCapabilityBinding binding = binding("MCP", mcp.getId(), "mcpTool");
SkillCapabilityTarget target;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
target = service.requireUsableTarget(binding, false);
}
assertEquals("unresolved:mcp", target.getLogicalRef());
assertFalse(target.getLogicalRef().contains("secret"));
assertFalse(target.getLogicalRef().contains("token"));
}
/**
* 验证 MCP 候选、工具解析、目标绑定和增强导入映射都在访问数据前校验 MCP 模块权限。
*/
@Test
public void mcpOperationsRejectCallerWithoutMcpQueryPermissionBeforeDataAccess() {
McpService mcpService = mock(McpService.class);
McpAccessPermissionChecker permissionChecker = mock(McpAccessPermissionChecker.class);
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
.when(permissionChecker).assertCanUseMcp();
SkillCapabilityTargetAccessServiceImpl service = new SkillCapabilityTargetAccessServiceImpl(
mock(WorkflowService.class), mock(PluginItemService.class), mock(PluginService.class),
mock(PluginVisibilityService.class), mcpService, permissionChecker,
mock(ResourceAccessService.class), mock(WorkflowVisibilityQueryHelper.class),
mock(CategoryPermissionService.class));
SkillCapabilityBinding binding = binding("MCP", BigInteger.valueOf(31), "mcpTool");
BusinessException candidates = assertThrows(BusinessException.class,
() -> service.listCandidates(SkillCapabilityType.MCP, null));
BusinessException tools = assertThrows(BusinessException.class,
() -> service.getMcpTools(BigInteger.valueOf(31)));
BusinessException bindingAccess = assertThrows(BusinessException.class,
() -> service.requireUsableTarget(binding, false));
BusinessException importMapping = assertThrows(BusinessException.class,
() -> service.resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo"));
assertEquals(403, candidates.getHttpStatus());
assertEquals(403, tools.getHttpStatus());
assertEquals(403, bindingAccess.getHttpStatus());
assertEquals(403, importMapping.getHttpStatus());
verify(permissionChecker, times(4)).assertCanUseMcp();
verifyNoInteractions(mcpService);
}
/**
* 验证省略 HITL 和 options 时按空配置处理,不产生不安全配置误报。
*/
@Test
public void nullSafeConfigsShouldBeNormalizedToEmptyMaps() {
SkillMapper skillMapper = mock(SkillMapper.class);
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
Skill skill = skill(101, 1);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
.thenReturn(target());
SkillCapabilityBindingServiceImpl service = new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class),
mock(ResourceAccessService.class), new ObjectMapper());
SkillCapabilityBinding binding = binding("WORKFLOW", BigInteger.valueOf(9), "workflowTool");
SkillValidationResult result;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
result = service.validateBindings(skill.getId(), List.of(binding), false);
}
assertTrue(result.getIssues().toString(), result.isValid());
assertTrue(binding.getHitlConfigJson().isEmpty());
assertTrue(binding.getOptionsJson().isEmpty());
}
/**
* 验证目标 USE 权限失败时,即使绑定被禁用也不能作为 warning 绕过保存校验。
*/
@Test
public void disabledBindingShouldNotBypassTargetPermission() {
SkillMapper skillMapper = mock(SkillMapper.class);
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
Skill skill = skill(101, 1);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(skill);
when(targetAccessService.requireUsableTarget(any(SkillCapabilityBinding.class), anyBoolean()))
.thenThrow(new BusinessException(403, 403, "无权限使用目标"));
SkillCapabilityBindingServiceImpl service = new SkillCapabilityBindingServiceImpl(
skillMapper, targetAccessService, mock(McpAccessPermissionChecker.class),
mock(ResourceAccessService.class), new ObjectMapper());
SkillCapabilityBinding binding = binding("WORKFLOW", BigInteger.valueOf(9), "workflowTool");
binding.setEnabled(false);
SkillValidationResult result;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account(7, 1));
result = service.validateBindings(skill.getId(), List.of(binding), false);
}
assertFalse(result.isValid());
SkillValidationIssue issue = result.getIssues().stream()
.filter(item -> "TARGET_NO_PERMISSION".equals(item.getCode()))
.findFirst().orElseThrow();
assertEquals("ERROR", issue.getSeverity());
}
private SkillCapabilityBinding binding(String type, BigInteger targetId, String runtimeName) {
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType(type);
binding.setTargetId(targetId);
binding.setRuntimeName(runtimeName);
binding.setEnabled(true);
return binding;
}
private Skill skill(long id, long tenantId) {
Skill skill = new Skill();
skill.setId(BigInteger.valueOf(id));
skill.setTenantId(BigInteger.valueOf(tenantId));
return skill;
}
private SkillCapabilityTarget target() {
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName("target");
target.setLogicalRef("workflow:target");
target.setStatus("AVAILABLE");
return target;
}
private LoginAccount account(long accountId, long tenantId) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(accountId));
account.setTenantId(BigInteger.valueOf(tenantId));
return account;
}
}

View File

@@ -153,8 +153,8 @@ public class SkillFileServiceImplTransactionTest {
SkillResource resource = savedResource.get();
assertTrue(resource.getIsText());
assertEquals("SCRIPT", resource.getKind());
assertEquals("PYTHON", resource.getLanguage());
assertEquals("SCRIPT", result.getType());
assertEquals("PYTHON", result.getLanguage());
assertEquals("print('ok')\n", resource.getTextContent());
assertNull(resource.getContentRef());
assertTrue(result.getIsText());
@@ -177,7 +177,7 @@ public class SkillFileServiceImplTransactionTest {
}
/**
* 验证二进制资源重命名到 scripts 后转为文本,并释放原内容引用。
* 验证二进制资源改为文本扩展名后转为文本,并释放原内容引用。
*/
@Test
public void binaryRenameToScriptConvertsAndReleasesContent() {
@@ -197,8 +197,8 @@ public class SkillFileServiceImplTransactionTest {
assertTrue(resource.getIsText());
assertEquals("scripts/tool.py", resource.getNormalizedPath());
assertEquals("SCRIPT", resource.getKind());
assertEquals("PYTHON", resource.getLanguage());
assertEquals("SCRIPT", result.getType());
assertEquals("PYTHON", result.getLanguage());
assertNull(resource.getContentRef());
assertEquals("print('ok')\n", resource.getTextContent());
assertTrue(result.getIsText());
@@ -206,45 +206,82 @@ public class SkillFileServiceImplTransactionTest {
}
/**
* 验证文本资源重命名到 assets 后转为二进制内容引用
* 验证文本资源移动到 assets 后仍按扩展名保留文本表示
*/
@Test
public void textRenameToAssetConvertsToBinaryRepresentation() {
public void textRenameToAssetKeepsTextRepresentation() {
String sourceHash = "c".repeat(64);
SkillResource resource = resource(
"references/guide.md", true, "# Guide\n", null, sourceHash, 8L);
when(skillResourceService.list(any(QueryWrapper.class)))
.thenReturn(List.of(), List.of(resource), List.of(resource), List.of(resource));
when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true);
when(contentStore.put(any(byte[].class))).thenReturn(NEW_CONTENT_REF);
SkillFileRenameRequest request = renameRequest(
"references/guide.md", "assets/guide.md", sourceHash);
SkillFileContent result = service.renameFile(request);
assertFalse(resource.getIsText());
assertEquals("ASSET", resource.getKind());
assertEquals(NEW_CONTENT_REF, resource.getContentRef());
assertNull(resource.getTextContent());
assertFalse(result.getIsText());
verify(contentStore).put("# Guide\n".getBytes(StandardCharsets.UTF_8));
assertTrue(resource.getIsText());
assertEquals("ASSET", result.getType());
assertNull(resource.getContentRef());
assertEquals("# Guide\n", resource.getTextContent());
assertTrue(result.getIsText());
verify(contentStore, never()).put(any(byte[].class));
}
/**
* 验证 assets 路径不能通过文本创建入口形成非规范表示
* 验证 assets 目录允许创建可编辑文本资源
*/
@Test
public void createTextAssetIsRejected() {
public void createTextAssetUsesCanonicalTextRepresentation() {
AtomicReference<SkillResource> savedResource = new AtomicReference<>();
when(skillResourceService.list(any(QueryWrapper.class)))
.thenAnswer(invocation -> savedResource.get() == null
? List.of() : List.of(savedResource.get()));
when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> {
SkillResource resource = invocation.getArgument(0);
resource.setId(BigInteger.valueOf(11));
savedResource.set(resource);
return true;
});
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("assets/readme.txt");
request.setContent("text");
BusinessException exception = assertThrows(BusinessException.class,
() -> service.createTextFile(request));
SkillFileContent result = service.createTextFile(request);
assertTrue(exception.getMessage().contains("二进制文件管理"));
verify(skillResourceService, never()).save(any(SkillResource.class));
assertEquals("ASSET", result.getType());
assertTrue(result.getIsText());
assertEquals("text", savedResource.get().getTextContent());
assertNull(savedResource.get().getContentRef());
}
/**
* 验证 scripts 目录中的不透明文件按二进制资源无损保存。
*/
@Test
public void binaryScriptUploadUsesContentStore() {
AtomicReference<SkillResource> savedResource = new AtomicReference<>();
when(skillResourceService.list(any(QueryWrapper.class)))
.thenAnswer(invocation -> savedResource.get() == null
? List.of() : List.of(savedResource.get()));
when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> {
SkillResource resource = invocation.getArgument(0);
resource.setId(BigInteger.valueOf(12));
savedResource.set(resource);
return true;
});
SkillFileContent result = service.uploadResource(
SKILL_ID, "scripts/helper.bin", new TestMultipartFile(
"helper.bin", new byte[]{0, (byte) 0xFF, 1}));
assertEquals("SCRIPT", result.getType());
assertFalse(result.getIsText());
assertEquals(NEW_CONTENT_REF, savedResource.get().getContentRef());
assertNull(savedResource.get().getTextContent());
verify(contentStore).put(any(MultipartFile.class), anyString());
}
/**
@@ -269,9 +306,9 @@ public class SkillFileServiceImplTransactionTest {
SkillFileContent result = service.createTextFile(request);
assertEquals("SCRIPT", savedResource.get().getKind());
assertEquals("SCRIPT", result.getType());
assertTrue(savedResource.get().getIsText());
assertNull(savedResource.get().getLanguage());
assertNull(result.getLanguage());
assertEquals("text/plain", savedResource.get().getMediaType());
assertEquals("puts 'ok'\n", result.getContent());
}
@@ -284,8 +321,6 @@ public class SkillFileServiceImplTransactionTest {
String sourceHash = "d".repeat(64);
SkillResource resource = resource(
"references/data.json", true, "{}", null, sourceHash, 2L);
resource.setKind("REFERENCE");
resource.setLanguage(null);
resource.setMediaType("application/json");
when(skillResourceService.list(any(QueryWrapper.class)))
.thenReturn(List.of(resource), List.of(resource));
@@ -298,9 +333,9 @@ public class SkillFileServiceImplTransactionTest {
SkillFileContent result = service.saveContent(request);
assertEquals("REFERENCE", resource.getKind());
assertEquals("REFERENCE", result.getType());
assertEquals("application/json", resource.getMediaType());
assertNull(resource.getLanguage());
assertEquals("JSON", result.getLanguage());
assertEquals("application/json", result.getMediaType());
assertEquals("{\"ok\":true}\n", result.getContent());
}
@@ -319,10 +354,8 @@ public class SkillFileServiceImplTransactionTest {
persisted.setTenantId(BigInteger.ONE);
persisted.setCategoryId(BigInteger.valueOf(3));
persisted.setDisplayName("演示 Skill");
persisted.setEnabled(false);
persisted.setVisibilityScope("DEPT");
persisted.setSkillContent(oldContent);
persisted.getMetadataJson().put("owner", "qa");
AtomicReference<Skill> updateRef = new AtomicReference<>();
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(persisted);
when(skillService.updateDraftIfContentMatches(any(Skill.class), eq(oldHash)))
@@ -344,10 +377,7 @@ public class SkillFileServiceImplTransactionTest {
assertEquals(newContent, update.getSkillContent());
assertEquals(persisted.getCategoryId(), update.getCategoryId());
assertEquals(persisted.getDisplayName(), update.getDisplayName());
assertEquals(persisted.getEnabled(), update.getEnabled());
assertEquals(persisted.getVisibilityScope(), update.getVisibilityScope());
assertNotSame(persisted.getMetadataJson(), update.getMetadataJson());
assertEquals(persisted.getMetadataJson(), update.getMetadataJson());
}
/**

View File

@@ -1,174 +0,0 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.exception.SkillPackageException;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
/**
* {@link EasyFlowBundleReader} 外层 ZIP 文件数量边界测试。
*/
public class EasyFlowBundleReaderEntryLimitTest {
/**
* 验证标准包最大文件数之外允许额外携带一个 EasyFlow manifest。
*/
@Test
public void containsManifestAllowsOneManifestBeyondStandardEntryLimit() {
int standardEntryLimit = SkillPackageLimits.defaults().getMaxEntryCount();
byte[] bundle = bundle(standardEntryLimit);
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
assertTrue(reader.containsManifest(new ByteArrayInputStream(bundle)));
}
/**
* 验证外层 ZIP 不能借 manifest 配额多携带第二个普通文件。
*/
@Test
public void containsManifestRejectsMoreThanOneEntryBeyondStandardLimit() {
int standardEntryLimit = SkillPackageLimits.defaults().getMaxEntryCount();
byte[] bundle = bundle(standardEntryLimit + 1);
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
assertThrows(BusinessException.class,
() -> reader.containsManifest(new ByteArrayInputStream(bundle)));
}
/**
* 损坏的增强包属于客户端输入错误,不能伪装成服务端存储故障。
*/
@Test
public void corruptedBundleUsesClientErrorStatus() {
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
BusinessException detectionError = assertThrows(BusinessException.class,
() -> reader.containsManifest(new ByteArrayInputStream("not-a-zip".getBytes(StandardCharsets.UTF_8))));
BusinessException prepareError = assertThrows(BusinessException.class,
() -> reader.prepare(new ByteArrayInputStream("not-a-zip".getBytes(StandardCharsets.UTF_8))));
assertEquals(400, detectionError.getHttpStatus());
assertEquals(400, prepareError.getHttpStatus());
}
/**
* manifest 之后的非法原始文件名字节也必须被完整扫描并返回稳定错误码。
*/
@Test
public void invalidUtf8EntryNameUsesStablePackageCode() {
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
SkillPackageException detectionError = assertThrows(SkillPackageException.class,
() -> reader.containsManifest(new ByteArrayInputStream(invalidUtf8EntryNameBundle())));
SkillPackageException prepareError = assertThrows(SkillPackageException.class,
() -> reader.prepare(new ByteArrayInputStream(invalidUtf8EntryNameBundle())));
assertEquals("INVALID_UTF8_ENTRY_NAME", detectionError.getCode());
assertEquals("INVALID_UTF8_ENTRY_NAME", prepareError.getCode());
}
/**
* 外层 ZIP 中央目录 CRC 被篡改时必须在重新打包前拒绝,并返回稳定错误码。
*/
@Test
public void crcMismatchUsesStablePackageCode() {
byte[] corrupted = tamperFirstCentralDirectoryCrc(bundle(1));
EasyFlowBundleReader reader = new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class));
SkillPackageException detectionError = assertThrows(SkillPackageException.class,
() -> reader.containsManifest(new ByteArrayInputStream(corrupted)));
SkillPackageException prepareError = assertThrows(SkillPackageException.class,
() -> reader.prepare(new ByteArrayInputStream(corrupted)));
assertEquals("CRC_MISMATCH", detectionError.getCode());
assertEquals("CRC_MISMATCH", prepareError.getCode());
assertTrue(detectionError.getPath().startsWith("skills/"));
}
/**
* 创建将 manifest 放在末尾的增强包,以覆盖完整枚举边界。
*
* @param standardEntries 普通文件数量
* @return 增强包字节
*/
private byte[] bundle(int standardEntries) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
for (int index = 0; index < standardEntries; index++) {
zip.putNextEntry(new ZipEntry(String.format(
"skills/demo-skill/assets/file-%04d.txt", index)));
zip.closeEntry();
}
zip.putNextEntry(new ZipEntry(EasyFlowSkillManifestCodec.MANIFEST_PATH));
zip.write("{}".getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建增强包文件数量边界样例失败", exception);
}
}
/**
* 创建 manifest 位于非法文件名前方的恶意增强包,验证检测流程不会提前返回。
*
* @return 恶意增强包字节
*/
private byte[] invalidUtf8EntryNameBundle() {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) {
output.setEncoding(StandardCharsets.ISO_8859_1.name());
output.setUseLanguageEncodingFlag(false);
output.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NEVER);
ZipArchiveEntry manifest = new ZipArchiveEntry(EasyFlowSkillManifestCodec.MANIFEST_PATH);
output.putArchiveEntry(manifest);
output.write("{}".getBytes(StandardCharsets.UTF_8));
output.closeArchiveEntry();
ZipArchiveEntry invalidName = new ZipArchiveEntry("skills/demo-skill/assets/\u00ff.bin");
output.putArchiveEntry(invalidName);
output.write(new byte[]{1});
output.closeArchiveEntry();
output.finish();
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建非法 UTF-8 文件名增强包失败", exception);
}
}
/**
* 篡改首个中央目录条目的 CRC 字段。
*
* @param source 原始 ZIP
* @return 篡改后的 ZIP
*/
private byte[] tamperFirstCentralDirectoryCrc(byte[] source) {
byte[] bytes = Arrays.copyOf(source, source.length);
for (int index = 0; index <= bytes.length - 20; index++) {
if (bytes[index] == 0x50 && bytes[index + 1] == 0x4B
&& bytes[index + 2] == 0x01 && bytes[index + 3] == 0x02) {
bytes[index + 16] ^= 0x01;
return bytes;
}
}
throw new IllegalStateException("未找到 ZIP 中央目录");
}
}

View File

@@ -1,84 +0,0 @@
package tech.easyflow.skill.imports;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import java.nio.charset.StandardCharsets;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
/**
* EasyFlow manifest 输入侧敏感配置拒绝测试。
*/
public class EasyFlowManifestStrictInputTest {
/**
* 验证导入包中的凭据键会被明确拒绝,不能依靠静默清洗掩盖不合规包。
*/
@Test
public void decodeShouldRejectCredentialFieldsInsideCapability() {
EasyFlowSkillManifestCodec codec = new EasyFlowSkillManifestCodec(
new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class));
byte[] manifest = """
{
"schemaVersion": "1.0",
"skills": [
{
"packageRoot": "demo-skill",
"packageHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"capabilities": [
{
"bindingKey": "demo-skill:0",
"capabilityType": "MCP",
"runtimeName": "demo",
"targetLogicalRef": "mcp:demo",
"enabled": false,
"token": "must-not-be-accepted"
}
]
}
]
}
""".getBytes(StandardCharsets.UTF_8);
assertThrows(BusinessException.class, () -> codec.decode(manifest));
}
/**
* 验证 options 内出现认证字段时也会在输入边界被拒绝。
*/
@Test
public void decodeShouldRejectCredentialFieldsInsideOptions() {
EasyFlowSkillManifestCodec codec = new EasyFlowSkillManifestCodec(
new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class));
byte[] manifest = """
{
"schemaVersion": "1.0",
"skills": [
{
"packageRoot": "demo-skill",
"packageHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"capabilities": [
{
"bindingKey": "demo-skill:0",
"capabilityType": "WORKFLOW",
"runtimeName": "demo",
"targetLogicalRef": "workflow:demo",
"enabled": true,
"options": {
"timeoutMs": 3000,
"authorization": "Bearer must-not-be-accepted"
}
}
]
}
]
}
""".getBytes(StandardCharsets.UTF_8);
assertThrows(BusinessException.class, () -> codec.decode(manifest));
}
}

View File

@@ -1,406 +0,0 @@
package tech.easyflow.skill.imports;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.capability.SkillCapabilityTarget;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* {@link EasyFlowSkillManifestCodec} 安全白名单与输入限额测试。
*/
public class EasyFlowSkillManifestCodecTest {
private ObjectMapper objectMapper;
private SkillCapabilityTargetAccessService targetAccessService;
private EasyFlowSkillManifestCodec codec;
/**
* 初始化 manifest 编解码器。
*/
@Before
public void setUp() {
objectMapper = new ObjectMapper();
targetAccessService = mock(SkillCapabilityTargetAccessService.class);
codec = new EasyFlowSkillManifestCodec(objectMapper, targetAccessService);
}
/**
* 验证增强 manifest 仅导出 HITL/options 白名单字段,不泄露 Token、Header 与嵌套凭据。
*
* @throws Exception JSON 解析失败
*/
@Test
public void encodeExportsOnlySafeCapabilityConfiguration() throws Exception {
SkillCapabilityBinding binding = unresolvedBinding();
Map<String, Object> hitl = new LinkedHashMap<>();
hitl.put("prompt", "确认执行");
hitl.put("token", "hitl-secret");
hitl.put("headers", Map.of("Authorization", "Bearer nested-secret"));
binding.setHitlConfigJson(hitl);
Map<String, Object> options = new LinkedHashMap<>();
options.put("timeoutMs", 3_000);
options.put("retryCount", 2);
options.put("apiKey", "api-secret");
options.put("authorization", "Bearer option-secret");
options.put("readOnly", List.of("complex-value-must-be-dropped"));
binding.setOptionsJson(options);
Skill skill = skillWithBindings(List.of(binding));
byte[] encoded = codec.encode(List.of(skill));
String json = new String(encoded, StandardCharsets.UTF_8);
Map<String, Object> manifest = objectMapper.readValue(encoded, new TypeReference<>() { });
Map<String, Object> encodedBinding = firstBinding(manifest);
assertFalse(json.contains("hitl-secret"));
assertFalse(json.contains("nested-secret"));
assertFalse(json.contains("api-secret"));
assertFalse(json.contains("option-secret"));
assertFalse(json.contains("complex-value-must-be-dropped"));
assertEquals(Map.of("prompt", "确认执行"), encodedBinding.get("hitlConfig"));
assertEquals(Map.of("timeoutMs", 3_000, "retryCount", 2), encodedBinding.get("options"));
assertEquals("unresolved:mcp", encodedBinding.get("targetLogicalRef"));
assertFalse(encodedBinding.containsKey("targetId"));
verifyNoInteractions(targetAccessService);
}
/**
* 验证目标服务返回的凭据 URI、查询 Token 和绝对路径不会进入增强导出。
*
* @throws Exception JSON 解析失败
*/
@Test
public void encodeDowngradesUnsafeResolvedTargetMetadata() throws Exception {
SkillCapabilityBinding binding = unresolvedBinding();
binding.setTargetId(java.math.BigInteger.valueOf(91));
binding.setEnabled(true);
binding.setTargetLogicalRef("mcp:https://user:secret@example.test?token=stored-secret");
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setLogicalRef("mcp:https://user:secret@example.test?token=resolved-secret");
target.setName("https://user:secret@example.test/service");
target.setRevision("/Users/operator/.config/easyflow/credential.json");
when(targetAccessService.requireUsableTarget(binding, false)).thenReturn(target);
byte[] encoded = codec.encode(List.of(skillWithBindings(List.of(binding))));
String json = new String(encoded, StandardCharsets.UTF_8);
Map<String, Object> encodedBinding = firstBinding(
objectMapper.readValue(encoded, new TypeReference<>() { }));
assertEquals("unresolved:mcp", encodedBinding.get("targetLogicalRef"));
assertFalse(encodedBinding.containsKey("targetName"));
assertFalse(encodedBinding.containsKey("targetRevision"));
assertFalse(encodedBinding.containsKey("targetId"));
assertFalse(json.contains("secret"));
assertFalse(json.contains("/Users/operator"));
}
/**
* 验证编码和解码都拒绝超过 1 MiB 的 manifest。
*/
@Test
public void manifestByteLimitIsEnforcedOnEncodeAndDecode() {
SkillCapabilityBinding binding = unresolvedBinding();
binding.setHitlConfigJson(Map.of("prompt", "x".repeat((int) EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES)));
assertThrows(BusinessException.class,
() -> codec.encode(List.of(skillWithBindings(List.of(binding)))));
assertThrows(BusinessException.class,
() -> codec.decode(new byte[(int) EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES + 1]));
}
/**
* 验证版本和 skills 基础结构必须存在。
*/
@Test
public void decodeRejectsUnsupportedVersionAndMissingSkills() {
assertThrows(BusinessException.class,
() -> codec.decode("{\"schemaVersion\":\"2.0\",\"skills\":[]}".getBytes(StandardCharsets.UTF_8)));
assertThrows(BusinessException.class,
() -> codec.decode("{\"schemaVersion\":\"1.0\"}".getBytes(StandardCharsets.UTF_8)));
}
/**
* 验证 targetLogicalRef 只接受能力类型对应的严格逻辑段语法。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsUrlAndQueryInsideTargetLogicalRef() throws Exception {
assertRejectedField("targetLogicalRef", "mcp:https://example.test/service");
assertRejectedField("targetLogicalRef", "mcp:demo?access_token=must-not-enter");
assertRejectedField("targetLogicalRef", "mcp:/Users/operator/.config/mcp.json");
}
/**
* 验证 targetName 和 targetRevision 拒绝凭据 URI、认证查询参数及绝对路径。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsUnsafeTargetNameAndRevision() throws Exception {
assertRejectedField("targetName", "https://user:password@example.test/service");
assertRejectedField("targetName", "C:\\Users\\operator\\mcp.json");
assertRejectedField("targetRevision", "https://example.test/revision?token=must-not-enter");
assertRejectedField("targetRevision", "%2FUsers%2Foperator%2Fcredential.json");
assertRejectedField("targetRevision", "https%253A%252F%252Fexample.test%253Ftoken%253Dencoded-secret");
}
/**
* 验证增强导入拒绝允许字段字符串内部的实际凭据,并保留精确问题路径。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsCredentialInsideAllowedHitlString() throws Exception {
Map<String, Object> binding = validManifestBinding();
binding.put("hitlConfig", Map.of("prompt", "Authorization: Bearer actual-secret-value"));
byte[] manifest = manifestWithBinding(binding);
SkillManifestValidationException exception = assertThrows(
SkillManifestValidationException.class, () -> codec.decode(manifest));
assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode());
assertEquals("skills[0].capabilities[0].hitlConfig.prompt", exception.getPath());
assertFalse(exception.getMessage().contains("actual-secret-value"));
}
/**
* 验证增强导入会扫描运行时名称、工具名和目标逻辑引用等全部字符串面。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsCredentialsAcrossAllBindingStrings() throws Exception {
Map<String, Object> runtimeBinding = validManifestBinding();
runtimeBinding.put("runtimeName", "sk-proj-abcdefghijklmnopqrstuvwxyz123456");
assertSensitivePath(runtimeBinding, "skills[0].capabilities[0].runtimeName");
Map<String, Object> toolBinding = validManifestBinding();
toolBinding.put("selectedToolNames", List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456"));
assertSensitivePath(toolBinding, "skills[0].capabilities[0].selectedToolNames[0]");
Map<String, Object> targetBinding = validManifestBinding();
targetBinding.put("targetLogicalRef", "mcp:sk-proj-abcdefghijklmnopqrstuvwxyz123456");
assertSensitivePath(targetBinding, "skills[0].capabilities[0].targetLogicalRef");
}
/**
* 验证 options 只接受协议定义的数值和布尔类型。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsStringTypedOptionsWithExactPath() throws Exception {
Map<String, Object> binding = validManifestBinding();
binding.put("options", Map.of("timeoutMs", "3000"));
SkillManifestValidationException exception = assertThrows(
SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding)));
assertEquals("CAPABILITY_OPTION_VALUE_INVALID", exception.getValidationCode());
assertEquals("skills[0].capabilities[0].options.timeoutMs", exception.getPath());
}
/**
* 验证非法枚举错误使用稳定消息且不回显原始输入。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeDoesNotEchoInvalidEnumValue() throws Exception {
Map<String, Object> binding = validManifestBinding();
binding.put("capabilityType", "UNSUPPORTED_PRIVATE_VALUE");
SkillManifestValidationException exception = assertThrows(
SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding)));
assertEquals("CAPABILITY_TYPE_INVALID", exception.getValidationCode());
assertEquals("skills[0].capabilities[0].capabilityType", exception.getPath());
assertFalse(exception.getMessage().contains("UNSUPPORTED_PRIVATE_VALUE"));
}
/**
* 验证增强导出遇到遗留脏 HITL 配置时直接失败且不回显凭据。
*/
@Test
public void encodeRejectsDirtyHitlCredentialInsteadOfExportingIt() {
SkillCapabilityBinding binding = unresolvedBinding();
binding.setHitlConfigJson(Map.of("description", "token=actual-secret-value"));
SkillManifestValidationException exception = assertThrows(
SkillManifestValidationException.class,
() -> codec.encode(List.of(skillWithBindings(List.of(binding)))));
assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode());
assertFalse(exception.getMessage().contains("actual-secret-value"));
}
/**
* 验证增强包输入不能携带当前环境数据库目标 ID。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsInternalTargetId() throws Exception {
assertRejectedField("targetId", 99887766);
}
/**
* 验证 Codec 边界拒绝超过导入上限的 Skill 数量。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsMoreThanOneHundredSkills() throws Exception {
List<Map<String, Object>> skills = new ArrayList<>();
for (int index = 0; index < 101; index++) {
skills.add(Map.of("packageRoot", "skill-" + index, "capabilities", List.of()));
}
byte[] bytes = objectMapper.writeValueAsBytes(Map.of("schemaVersion", "1.0", "skills", skills));
assertThrows(BusinessException.class, () -> codec.decode(bytes));
}
/**
* 验证 Codec 边界拒绝超长 packageRoot 字段。
*
* @throws Exception JSON 生成失败
*/
@Test
public void decodeRejectsOversizedPackageRoot() throws Exception {
Map<String, Object> skill = Map.of(
"packageRoot", "s".repeat(129),
"capabilities", List.of());
byte[] bytes = objectMapper.writeValueAsBytes(
Map.of("schemaVersion", "1.0", "skills", List.of(skill)));
assertThrows(BusinessException.class, () -> codec.decode(bytes));
}
/**
* 创建包含未解析能力的 Skill。
*
* @param bindings 能力绑定
* @return Skill
*/
private Skill skillWithBindings(List<SkillCapabilityBinding> bindings) {
Skill skill = new Skill();
skill.setName("demo-skill");
skill.setPackageHash("package-hash");
skill.setCapabilityBindings(bindings);
return skill;
}
/**
* 创建无需读取目标资源的未解析能力绑定。
*
* @return 能力绑定
*/
private SkillCapabilityBinding unresolvedBinding() {
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType("MCP");
binding.setTargetLogicalRef("mcp://demo");
binding.setRuntimeName("demo_mcp");
binding.setEnabled(false);
binding.setSelectionMode("SELECTED");
binding.setSelectedToolNamesJson(List.of("search"));
binding.setHitlEnabled(true);
binding.setSortNo(0);
return binding;
}
/**
* 构造单绑定 manifest 并断言指定覆盖字段被拒绝。
*
* @param field 覆盖字段
* @param value 覆盖值
* @throws Exception JSON 生成失败
*/
private void assertRejectedField(String field, Object value) throws Exception {
Map<String, Object> binding = validManifestBinding();
binding.put(field, value);
byte[] manifest = manifestWithBinding(binding);
assertThrows(BusinessException.class, () -> codec.decode(manifest));
}
/**
* 断言单绑定中的凭据值被拒绝并保留精确路径。
*
* @param binding 待编码能力对象
* @param expectedPath 预期问题路径
* @throws Exception JSON 生成失败
*/
private void assertSensitivePath(Map<String, Object> binding, String expectedPath) throws Exception {
SkillManifestValidationException exception = assertThrows(
SkillManifestValidationException.class, () -> codec.decode(manifestWithBinding(binding)));
assertEquals("SENSITIVE_VALUE_DETECTED", exception.getValidationCode());
assertEquals(expectedPath, exception.getPath());
assertFalse(exception.getMessage().contains("sk-proj-"));
}
/**
* 创建单绑定的合法 manifest 能力对象。
*
* @return 可按测试覆盖字段的能力对象
*/
private Map<String, Object> validManifestBinding() {
Map<String, Object> binding = new LinkedHashMap<>();
binding.put("bindingKey", "demo-skill:0");
binding.put("capabilityType", "MCP");
binding.put("runtimeName", "demo_mcp");
binding.put("enabled", false);
binding.put("selectionMode", "SELECTED");
binding.put("selectedToolNames", List.of("search"));
binding.put("targetLogicalRef", "mcp:demo");
return binding;
}
/**
* 将单个能力对象封装为合法 manifest JSON。
*
* @param binding 能力对象
* @return manifest JSON 字节
* @throws Exception JSON 生成失败
*/
private byte[] manifestWithBinding(Map<String, Object> binding) throws Exception {
Map<String, Object> skill = new LinkedHashMap<>();
skill.put("packageRoot", "demo-skill");
skill.put("packageHash", "a".repeat(64));
skill.put("capabilities", List.of(binding));
return objectMapper.writeValueAsBytes(
Map.of("schemaVersion", "1.0", "skills", List.of(skill)));
}
/**
* 获取编码结果中的首个能力绑定。
*
* @param manifest manifest
* @return 能力绑定映射
*/
@SuppressWarnings("unchecked")
private Map<String, Object> firstBinding(Map<String, Object> manifest) {
List<Map<String, Object>> skills = (List<Map<String, Object>>) manifest.get("skills");
List<Map<String, Object>> bindings = (List<Map<String, Object>>) skills.get(0).get("capabilities");
assertTrue(!bindings.isEmpty());
return bindings.get(0);
}
}

View File

@@ -1,192 +0,0 @@
package tech.easyflow.skill.imports;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.easyagents.skill.util.SkillHashes;
import org.junit.Before;
import org.junit.Test;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 标准 Skill 包与 EasyFlow 增强包的格式隔离回归测试。
*/
public class SkillExportFormatIsolationTest {
private static final String SKILL_ID = "987654321012345678";
private static final String TARGET_ID = "998877665544332211";
private static final byte[] BINARY_BYTES = new byte[]{0, 1, 2, 3, 127, -1};
private SkillExportServiceImpl exportService;
private SkillService skillService;
/**
* 初始化包含平台能力绑定的 Skill。
*/
@Before
public void setUp() {
Skill skill = new Skill();
skill.setId(new BigInteger(SKILL_ID));
skill.setTenantId(BigInteger.valueOf(55667788));
skill.setCategoryId(BigInteger.valueOf(66778899));
skill.setCurrentApprovalInstanceId(BigInteger.valueOf(77889900));
skill.setName("demo-skill");
skill.setDescription("Demo skill");
skill.setSkillContent("""
---
name: demo-skill
description: Demo skill
---
# Demo
""");
skill.setPackageHash("a".repeat(64));
String binaryHash = SkillHashes.sha256Hex(BINARY_BYTES);
String binaryRef = "sha256:" + binaryHash;
SkillResource reference = new SkillResource();
reference.setPath("references/guide.md");
reference.setNormalizedPath("references/guide.md");
reference.setKind("REFERENCE");
reference.setMediaType("text/markdown");
reference.setIsText(true);
reference.setTextContent("# Guide\nportable text\n");
reference.setContentHash(SkillHashes.sha256Hex(
reference.getTextContent().getBytes(StandardCharsets.UTF_8)));
reference.setSize((long) reference.getTextContent().getBytes(StandardCharsets.UTF_8).length);
SkillResource binary = new SkillResource();
binary.setPath("assets/data.bin");
binary.setNormalizedPath("assets/data.bin");
binary.setKind("ASSET");
binary.setMediaType("application/octet-stream");
binary.setIsText(false);
binary.setContentRef(binaryRef);
binary.setContentHash(binaryHash);
binary.setSize((long) BINARY_BYTES.length);
skill.setResources(List.of(reference, binary));
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType("MCP");
binding.setTargetId(new BigInteger(TARGET_ID));
binding.setRuntimeName("demo");
binding.setTargetLogicalRef("mcp:demo");
binding.setEnabled(false);
binding.setSelectionMode("SELECTED");
binding.setSelectedToolNamesJson(List.of("search"));
binding.setOptionsJson(Map.of("timeoutMs", 3000, "token", "must-not-leak"));
skill.setCapabilityBindings(List.of(binding));
skillService = mock(SkillService.class);
when(skillService.getPackageDetail(skill.getId())).thenReturn(skill);
when(skillService.getDetail(skill.getId())).thenReturn(skill);
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(
new ObjectMapper(), mock(SkillCapabilityTargetAccessService.class));
DBSkillContentStore contentStore = mock(DBSkillContentStore.class);
when(contentStore.exists(binaryRef)).thenReturn(true);
when(contentStore.open(binaryRef)).thenAnswer(ignored -> new ByteArrayInputStream(BINARY_BYTES));
exportService = new SkillExportServiceImpl(skillService, contentStore, manifestCodec);
}
/**
* 验证标准 ZIP 只包含标准 Skill 内容,不携带 EasyFlow manifest 或平台能力配置。
*/
@Test
public void standardExportShouldExcludePlatformManifestAndBindings() {
when(skillService.getDetail(new BigInteger(SKILL_ID)))
.thenThrow(new IllegalStateException("capability target unavailable"));
Map<String, byte[]> entries = export(SkillImportFormat.STANDARD);
String allText = text(entries);
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/SKILL.md")));
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/references/")));
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/scripts/")));
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/assets/")));
assertTrue(entries.keySet().stream().anyMatch(path -> path.endsWith("/references/guide.md")));
assertTrue(entries.entrySet().stream().anyMatch(entry -> entry.getKey().endsWith("/assets/data.bin")
&& java.util.Arrays.equals(BINARY_BYTES, entry.getValue())));
assertFalse(entries.containsKey(EasyFlowSkillManifestCodec.MANIFEST_PATH));
assertFalse(allText.contains("targetLogicalRef"));
assertFalse(allText.contains("must-not-leak"));
assertFalse(allText.contains(SKILL_ID));
assertFalse(allText.contains(TARGET_ID));
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(SKILL_ID)));
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(TARGET_ID)));
verify(skillService).getPackageDetail(new BigInteger(SKILL_ID));
verify(skillService, never()).getDetail(new BigInteger(SKILL_ID));
}
/**
* 验证增强包具有独立 manifest且敏感 options 不会进入导出内容。
*/
@Test
public void easyFlowExportShouldContainSafeManifestAndStandardSkillTree() {
Map<String, byte[]> entries = export(SkillImportFormat.EASYFLOW);
String manifest = new String(entries.get(EasyFlowSkillManifestCodec.MANIFEST_PATH), StandardCharsets.UTF_8);
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/") && path.endsWith("/SKILL.md")));
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/")
&& path.endsWith("/references/")));
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/")
&& path.endsWith("/scripts/")));
assertTrue(entries.keySet().stream().anyMatch(path -> path.startsWith("skills/")
&& path.endsWith("/assets/")));
assertTrue(entries.entrySet().stream().anyMatch(entry -> entry.getKey().startsWith("skills/")
&& entry.getKey().endsWith("/assets/data.bin")
&& java.util.Arrays.equals(BINARY_BYTES, entry.getValue())));
assertTrue(manifest.contains("targetLogicalRef"));
assertTrue(manifest.contains("timeoutMs"));
assertFalse(manifest.contains("must-not-leak"));
assertFalse(manifest.contains("targetId"));
assertFalse(manifest.contains(SKILL_ID));
assertFalse(manifest.contains(TARGET_ID));
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(SKILL_ID)));
assertFalse(entries.keySet().stream().anyMatch(path -> path.contains(TARGET_ID)));
}
private Map<String, byte[]> export(SkillImportFormat format) {
try (SkillExportArtifact artifact = exportService.prepare(List.of(new BigInteger(SKILL_ID)), format)) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
artifact.transferTo(bytes);
return unzip(bytes.toByteArray());
}
}
private Map<String, byte[]> unzip(byte[] bytes) {
try {
Map<String, byte[]> entries = new LinkedHashMap<>();
try (ZipInputStream zip = new ZipInputStream(
new ByteArrayInputStream(bytes), StandardCharsets.UTF_8)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
entries.put(entry.getName(), entry.isDirectory() ? new byte[0] : zip.readAllBytes());
}
}
return entries;
} catch (Exception exception) {
throw new IllegalStateException("读取测试导出包失败", exception);
}
}
private String text(Map<String, byte[]> entries) {
StringBuilder result = new StringBuilder();
entries.values().forEach(bytes -> result.append(new String(bytes, StandardCharsets.UTF_8)));
return result.toString();
}
}

View File

@@ -1,227 +0,0 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.util.SkillHashes;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillImportStage;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.skill.entity.SkillResource;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* 多 Skill 增强导出的可移植路径与 preview round-trip 测试。
*/
public class SkillExportRoundTripTest {
/**
* 验证多 Skill `.efskill` 不在路径中暴露数据库 ID且可被增强导入 preview 完整解析。
*/
@Test
public void multiSkillEasyFlowBundleShouldRoundTripWithoutDatabaseIds() throws IOException {
BigInteger firstId = new BigInteger("987654321012345678");
BigInteger secondId = new BigInteger("887766554433221100");
Skill first = portableSkill(firstId, "alpha-skill");
Skill second = portableSkill(secondId, "beta-skill");
SkillService exportSkillService = mock(SkillService.class);
when(exportSkillService.getPackageDetail(firstId)).thenReturn(first);
when(exportSkillService.getPackageDetail(secondId)).thenReturn(second);
when(exportSkillService.getDetail(firstId)).thenReturn(first);
when(exportSkillService.getDetail(secondId)).thenReturn(second);
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(
new ObjectMapper(), targetAccessService);
SkillExportServiceImpl exportService = new SkillExportServiceImpl(
exportSkillService, mock(DBSkillContentStore.class), manifestCodec);
byte[] standard = export(exportService, List.of(firstId, secondId), SkillImportFormat.STANDARD);
Set<String> standardPaths = paths(standard);
assertTrue(standardPaths.stream().anyMatch(path -> path.endsWith("alpha-skill/SKILL.md")));
assertTrue(standardPaths.stream().anyMatch(path -> path.endsWith("beta-skill/SKILL.md")));
assertFalse(standardPaths.stream().anyMatch(path -> path.contains(firstId.toString())));
assertFalse(standardPaths.stream().anyMatch(path -> path.contains(secondId.toString())));
byte[] bundle = export(exportService, List.of(firstId, secondId), SkillImportFormat.EASYFLOW);
Set<String> paths = paths(bundle);
assertTrue(paths.contains(EasyFlowSkillManifestCodec.MANIFEST_PATH));
assertTrue(paths.stream().anyMatch(path -> path.endsWith("alpha-skill/SKILL.md")));
assertTrue(paths.stream().anyMatch(path -> path.endsWith("beta-skill/SKILL.md")));
assertFalse(paths.stream().anyMatch(path -> path.contains(firstId.toString())));
assertFalse(paths.stream().anyMatch(path -> path.contains(secondId.toString())));
FileStorageService fileStorageService = mock(FileStorageService.class);
String storedPath = "skill-imports/round-trip.efskill";
when(fileStorageService.save(any(MultipartFile.class), anyString())).thenReturn(storedPath);
when(fileStorageService.readStream(storedPath))
.thenAnswer(ignored -> new ByteArrayInputStream(bundle));
SkillImportStage stage = new SkillImportStage();
stage.setImportToken("a".repeat(32));
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
when(stageStore.create(anyString(), anyString(), any(SkillImportFormat.class))).thenReturn(stage);
SkillService importSkillService = mock(SkillService.class);
when(importSkillService.list(any(QueryWrapper.class))).thenReturn(List.of());
SkillImportServiceImpl importService = new SkillImportServiceImpl(
importSkillService,
mock(SkillCapabilityBindingService.class),
targetAccessService,
mock(DBSkillContentStore.class),
fileStorageService,
stageStore,
new EasyFlowBundleReader(manifestCodec),
mock(ResourceAccessService.class));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
preview = importService.preview(new TestMultipartFile("skills.efskill", bundle));
}
assertEquals(SkillImportFormat.EASYFLOW.name(), preview.getFormat());
assertEquals(2, preview.getSkills().size());
assertEquals(Set.of("alpha-skill", "beta-skill"), preview.getSkills().stream()
.map(SkillImportPreviewItem::getPackageRoot).collect(java.util.stream.Collectors.toSet()));
assertTrue(preview.getSkills().stream().allMatch(item -> item.getFiles().stream()
.anyMatch(file -> "SKILL.md".equals(file.getPath()) && file.isText())));
assertTrue(preview.getSkills().stream().allMatch(item -> item.getFiles().stream()
.anyMatch(file -> "examples/readme.md".equals(file.getPath())
&& "EXAMPLE".equals(file.getKind()) && file.isText())));
}
/**
* 创建仅含入口文档的可移植 Skill。
*
* @param id 数据库 ID
* @param name 标准 Skill 名称
* @return Skill 实体
*/
private Skill portableSkill(BigInteger id, String name) {
String content = "---\nname: " + name + "\ndescription: Portable " + name + "\n---\n# " + name + "\n";
String exampleContent = "# Example\n";
String exampleHash = SkillHashes.sha256Hex(exampleContent.getBytes(StandardCharsets.UTF_8));
String canonical = "SKILL.md\n"
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n"
+ "examples/readme.md\n" + exampleHash + "\n";
SkillResource example = new SkillResource();
example.setPath("examples/readme.md");
example.setNormalizedPath("examples/readme.md");
example.setKind("EXAMPLE");
example.setMediaType("text/markdown");
example.setIsText(true);
example.setTextContent(exampleContent);
example.setContentHash(exampleHash);
example.setSize((long) exampleContent.getBytes(StandardCharsets.UTF_8).length);
Skill skill = new Skill();
skill.setId(id);
skill.setName(name);
skill.setDescription("Portable " + name);
skill.setSkillContent(content);
skill.setPackageHash(SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8)));
skill.setResources(List.of(example));
skill.setCapabilityBindings(List.of());
return skill;
}
/**
* 导出指定格式的包字节。
*
* @param service 导出服务
* @param ids Skill ID
* @param format 包格式
* @return 导出包字节
*/
private byte[] export(SkillExportServiceImpl service,
List<BigInteger> ids,
SkillImportFormat format) {
try (SkillExportArtifact artifact = service.prepare(ids, format)) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
artifact.transferTo(output);
return output.toByteArray();
}
}
/**
* 读取 ZIP 非目录项路径。
*
* @param bytes ZIP 字节
* @return 条目路径
*/
private Set<String> paths(byte[] bytes) {
try {
Set<String> result = new LinkedHashSet<>();
try (ZipInputStream zip = new ZipInputStream(
new ByteArrayInputStream(bytes), StandardCharsets.UTF_8)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (!entry.isDirectory()) {
result.add(entry.getName());
}
}
}
return result;
} catch (IOException exception) {
throw new IllegalStateException("读取 round-trip 导出包失败", exception);
}
}
/**
* 内存 MultipartFile 测试替身。
*/
private static final class TestMultipartFile implements MultipartFile {
private final String filename;
private final byte[] bytes;
private TestMultipartFile(String filename, byte[] bytes) {
this.filename = filename;
this.bytes = bytes;
}
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return filename; }
@Override public String getContentType() { return "application/vnd.easyflow.skill+zip"; }
@Override public boolean isEmpty() { return bytes.length == 0; }
@Override public long getSize() { return bytes.length; }
@Override public byte[] getBytes() { return bytes.clone(); }
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
@Override public void transferTo(File destination) throws IOException {
org.springframework.util.FileCopyUtils.copy(bytes, destination);
}
}
}

View File

@@ -1,258 +0,0 @@
package tech.easyflow.skill.imports;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillImportStage;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
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;
/**
* {@link SkillImportServiceImpl} 同名冲突隐私回归测试。
*/
public class SkillImportConflictPrivacyTest {
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
private static final BigInteger TENANT_ID = BigInteger.ONE;
private static final String IMPORT_TOKEN = "c".repeat(32);
private static final String SKILL_NAME = "private-skill";
private static final String STORED_PATH = "skill-imports/private-skill.zip";
/**
* 验证无管理权的草稿和已发布 Skill 在预览中完全使用相同冲突结果。
*/
@Test
public void previewRedactsUnauthorizedDraftAndPublishedConflicts() {
Skill draft = existing("private-draft", PublishStatus.DRAFT);
Skill published = existing("private-published", PublishStatus.PUBLISHED);
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(draft, published));
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillImportServiceImpl service = service(skillService, accessService,
mock(FileStorageService.class), mock(SkillImportStageStore.class));
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> ignored = login()) {
preview = service.previewStandardForTest(new ByteArrayInputStream(
standardPackage(List.of(draft.getName(), published.getName()))));
}
assertEquals(2, preview.getSkills().size());
for (SkillImportPreviewItem item : preview.getSkills()) {
assertEquals("NAME_UNAVAILABLE", item.getConflictReason());
assertFalse(item.getOverwriteAllowed());
}
}
/**
* 验证确认阶段不会根据无权 Skill 的草稿或发布状态返回不同结果。
*/
@Test
public void confirmRejectsUnauthorizedDraftAndPublishedWithSameResult() {
BusinessException draft = confirmAgainstUnauthorized(PublishStatus.DRAFT);
BusinessException published = confirmAgainstUnauthorized(PublishStatus.PUBLISHED);
assertNameUnavailable(draft);
assertNameUnavailable(published);
assertEquals(draft.getMessage(), published.getMessage());
}
/**
* 验证预查后发生的并发唯一键冲突与无权同名冲突使用同一公开结果。
*/
@Test
public void confirmMapsConcurrentUniqueNameConflictToNameUnavailable() {
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
when(skillService.saveDraft(any(Skill.class)))
.thenThrow(new BusinessException(409, 4092, "当前租户已存在同名 Skill"));
FileStorageService storage = storedPackage();
SkillImportStageStore stageStore = stageStore();
SkillImportServiceImpl service = service(skillService, mock(ResourceAccessService.class), storage, stageStore);
SkillImportConfirmRequest request = confirmRequest(SkillImportConflictStrategy.REJECT);
BusinessException exception;
try (MockedStatic<SaTokenUtil> ignored = login()) {
exception = assertThrows(BusinessException.class, () -> service.confirm(request));
}
assertNameUnavailable(exception);
}
/**
* 针对指定发布状态执行一次无管理权限的覆盖确认。
*
* @param status 已存在 Skill 的发布状态
* @return 确认阶段抛出的名称不可用异常
*/
private BusinessException confirmAgainstUnauthorized(PublishStatus status) {
Skill existing = existing(SKILL_NAME, status);
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(existing));
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillImportServiceImpl service = service(skillService, accessService, storedPackage(), stageStore());
BusinessException exception;
try (MockedStatic<SaTokenUtil> ignored = login()) {
exception = assertThrows(BusinessException.class,
() -> service.confirm(confirmRequest(SkillImportConflictStrategy.OVERWRITE)));
}
verify(skillService, never()).overwriteImportedDraft(any(Skill.class));
return exception;
}
/**
* 创建仅包含当前测试所需依赖的导入服务。
*
* @param skillService Skill 管理服务
* @param accessService 资源访问服务
* @param storage 文件存储服务
* @param stageStore 导入暂存服务
* @return 导入服务实例
*/
private SkillImportServiceImpl service(SkillService skillService,
ResourceAccessService accessService,
FileStorageService storage,
SkillImportStageStore stageStore) {
return new SkillImportServiceImpl(skillService, mock(SkillCapabilityBindingService.class),
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class), storage, stageStore,
mock(EasyFlowBundleReader.class), accessService);
}
/**
* 创建指定名称和发布状态的既有 Skill。
*
* @param name Skill 名称
* @param status 发布状态
* @return Skill 测试数据
*/
private Skill existing(String name, PublishStatus status) {
Skill skill = new Skill();
skill.setId(BigInteger.valueOf(Math.abs(name.hashCode())));
skill.setTenantId(TENANT_ID);
skill.setName(name);
skill.setPublishStatus(status.getCode());
return skill;
}
/**
* 创建导入确认请求。
*
* @param strategy 名称冲突处理策略
* @return 导入确认请求
*/
private SkillImportConfirmRequest confirmRequest(SkillImportConflictStrategy strategy) {
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
request.setImportToken(IMPORT_TOKEN);
request.setConflictStrategy(strategy.name());
return request;
}
/**
* 创建可消费固定导入记录的暂存服务替身。
*
* @return 导入暂存服务替身
*/
private SkillImportStageStore stageStore() {
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
SkillImportStage stage = new SkillImportStage();
stage.setImportToken(IMPORT_TOKEN);
stage.setFilePath(STORED_PATH);
stage.setFormat(SkillImportFormat.STANDARD.name());
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage);
return stageStore;
}
/**
* 创建返回标准 Skill 测试包的文件存储替身。
*
* @return 文件存储服务替身
*/
private FileStorageService storedPackage() {
byte[] bytes = standardPackage(List.of(SKILL_NAME));
FileStorageService storage = mock(FileStorageService.class);
try {
when(storage.readStream(STORED_PATH)).thenAnswer(ignored -> new ByteArrayInputStream(bytes));
} catch (java.io.IOException exception) {
throw new IllegalStateException("创建导入包存储测试替身失败", exception);
}
return storage;
}
/**
* 构造包含指定 Skill 名称的标准 ZIP 包。
*
* @param names Skill 名称列表
* @return ZIP 包字节
*/
private byte[] standardPackage(List<String> names) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
for (String name : names) {
zip.putNextEntry(new ZipEntry(name + "/SKILL.md"));
zip.write(("---\nname: " + name + "\ndescription: Privacy fixture\n---\n# Privacy\n")
.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建导入冲突测试包失败", exception);
}
}
/**
* 创建固定租户与账号的登录上下文。
*
* @return 可自动关闭的静态方法替身
*/
private MockedStatic<SaTokenUtil> login() {
LoginAccount account = new LoginAccount();
account.setId(ACCOUNT_ID);
account.setTenantId(TENANT_ID);
MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
return saToken;
}
/**
* 断言异常为稳定、无资源细节的名称不可用结果。
*
* @param exception 待校验的业务异常
*/
private void assertNameUnavailable(BusinessException exception) {
assertEquals(409, exception.getHttpStatus());
assertEquals(4092, exception.getErrorCode());
assertEquals("Skill 名称不可用:" + SKILL_NAME, exception.getMessage());
}
}

View File

@@ -1,430 +0,0 @@
package tech.easyflow.skill.imports;
import com.mybatisflex.core.query.QueryWrapper;
import com.easyagents.skill.util.SkillHashes;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.capability.SkillCapabilityBindingServiceImpl;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.SkillImportStage;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
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;
/**
* {@link SkillImportServiceImpl} 只读导入预检契约测试。
*/
public class SkillImportServiceImplPreviewTest {
/**
* 验证可解析但校验失败的包返回完整问题列表,二进制资源不会触发正式提交。
*/
@Test
public void previewReturnsValidationReportForParseableInvalidPackage() {
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
SkillImportServiceImpl service = new SkillImportServiceImpl(
skillService,
mock(SkillCapabilityBindingService.class),
mock(SkillCapabilityTargetAccessService.class),
mock(DBSkillContentStore.class),
mock(FileStorageService.class),
mock(SkillImportStageStore.class),
mock(EasyFlowBundleReader.class),
mock(ResourceAccessService.class));
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
preview = service.previewStandardForTest(new ByteArrayInputStream(invalidPackage()));
}
assertEquals("STANDARD", preview.getFormat());
assertEquals(1, preview.getSkills().size());
assertEquals(1, preview.getSkills().get(0).getAssetCount());
assertTrue(preview.getIssues().stream().anyMatch(issue -> "INVALID_NAME".equals(issue.getCode())));
}
/**
* 验证非法 EasyFlow Bundle 也返回结构化预览问题,不生成一次性导入令牌。
*/
@Test
public void previewReturnsStructuredIssueForInvalidEasyFlowBundle() throws Exception {
FileStorageService fileStorageService = mock(FileStorageService.class);
EasyFlowBundleReader bundleReader = mock(EasyFlowBundleReader.class);
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(128L);
when(file.getOriginalFilename()).thenReturn("invalid.efskill");
when(fileStorageService.save(file, "skill-imports/1")).thenReturn("stored-invalid-bundle");
when(fileStorageService.readStream("stored-invalid-bundle"))
.thenAnswer(invocation -> new ByteArrayInputStream(new byte[]{1, 2, 3}));
when(bundleReader.containsManifest(any())).thenReturn(true);
when(bundleReader.prepare(any())).thenThrow(new BusinessException("EasyFlow manifest 包含敏感字段"));
SkillImportServiceImpl service = new SkillImportServiceImpl(
mock(SkillService.class), mock(SkillCapabilityBindingService.class),
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class),
fileStorageService, stageStore, bundleReader, mock(ResourceAccessService.class));
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
preview = service.preview(file);
}
assertEquals("EASYFLOW", preview.getFormat());
assertTrue(preview.getSkills().isEmpty());
assertTrue(preview.getImportToken() == null);
assertTrue(preview.getIssues().stream()
.anyMatch(issue -> "EASYFLOW_BUNDLE_INVALID".equals(issue.getCode())));
}
/**
* 验证敏感值 manifest 异常保留稳定问题码和精确路径,且响应不回显凭据。
*
* @throws Exception 测试输入流构造失败
*/
@Test
public void previewKeepsSensitiveManifestIssueCodeAndPath() throws Exception {
FileStorageService fileStorageService = mock(FileStorageService.class);
EasyFlowBundleReader bundleReader = mock(EasyFlowBundleReader.class);
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(128L);
when(file.getOriginalFilename()).thenReturn("unsafe.efskill");
when(fileStorageService.save(file, "skill-imports/1")).thenReturn("stored-unsafe-bundle");
when(fileStorageService.readStream("stored-unsafe-bundle"))
.thenAnswer(invocation -> new ByteArrayInputStream(new byte[]{1, 2, 3}));
when(bundleReader.containsManifest(any())).thenReturn(true);
when(bundleReader.prepare(any())).thenThrow(new SkillManifestValidationException(
"SENSITIVE_VALUE_DETECTED", "skills[0].capabilities[0].hitlConfig.prompt",
"EasyFlow Skill manifest 不能包含认证凭据"));
SkillImportServiceImpl service = new SkillImportServiceImpl(
mock(SkillService.class), mock(SkillCapabilityBindingService.class),
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class),
fileStorageService, mock(SkillImportStageStore.class), bundleReader,
mock(ResourceAccessService.class));
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
preview = service.preview(file);
}
assertEquals("SENSITIVE_VALUE_DETECTED", preview.getIssues().get(0).getCode());
assertEquals("skills[0].capabilities[0].hitlConfig.prompt", preview.getIssues().get(0).getPath());
assertTrue(preview.getIssues().get(0).getMessage().contains("不能包含认证凭据"));
}
/**
* 验证正式服务契约只暴露上传预览、单次确认和取消,不保留无令牌直导入口。
*/
@Test
public void publicImportContractHasNoTokenBypass() {
List<String> declaredMethods = Arrays.stream(SkillImportService.class.getDeclaredMethods())
.map(java.lang.reflect.Method::getName)
.sorted()
.toList();
assertEquals(List.of("cancel", "confirm", "preview"), declaredMethods);
}
/**
* 损坏的标准包与增强包都应通过 preview 返回结构化问题,而非中断为服务器错误。
*
* @throws Exception 模拟文件存储流配置失败
*/
@Test
public void corruptedArchivesReturnStructuredPreviewIssues() throws Exception {
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
SkillImportPreview standard = previewBrokenArchive("broken.zip");
SkillImportPreview enhanced = previewBrokenArchive("broken.efskill");
assertTrue(standard.getIssues().stream()
.anyMatch(issue -> "STANDARD_PACKAGE_INVALID".equals(issue.getCode())));
assertTrue(enhanced.getIssues().stream()
.anyMatch(issue -> "EASYFLOW_BUNDLE_INVALID".equals(issue.getCode())));
}
}
/**
* 构建损坏归档的上传预览。
*
* @param filename 上传文件名
* @return 结构化失败预览
* @throws Exception 模拟文件存储流配置失败
*/
private SkillImportPreview previewBrokenArchive(String filename) throws Exception {
byte[] bytes = "not-a-zip".getBytes(StandardCharsets.UTF_8);
String storedPath = "skill-imports/" + filename;
FileStorageService fileStorageService = mock(FileStorageService.class);
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn((long) bytes.length);
when(file.getOriginalFilename()).thenReturn(filename);
when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath);
when(fileStorageService.readStream(storedPath))
.thenAnswer(invocation -> new ByteArrayInputStream(bytes));
SkillImportServiceImpl service = new SkillImportServiceImpl(
mock(SkillService.class), mock(SkillCapabilityBindingService.class),
mock(SkillCapabilityTargetAccessService.class), mock(DBSkillContentStore.class),
fileStorageService, mock(SkillImportStageStore.class),
new EasyFlowBundleReader(mock(EasyFlowSkillManifestCodec.class)),
mock(ResourceAccessService.class));
return service.preview(file);
}
/**
* 验证增强包预览聚合能力绑定静态问题,并保留未解析目标供后续映射。
*/
@Test
public void enhancedPreviewReturnsStructuredCapabilityIssuesWithoutRejectingUnresolvedTargets() throws Exception {
byte[] bundle = invalidCapabilityBundle();
String storedPath = "skill-imports/static-capability-preview.efskill";
FileStorageService fileStorageService = mock(FileStorageService.class);
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn((long) bundle.length);
when(file.getOriginalFilename()).thenReturn("static-capability-preview.efskill");
when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath);
when(fileStorageService.readStream(storedPath))
.thenAnswer(invocation -> new ByteArrayInputStream(bundle));
SkillImportStage stage = new SkillImportStage();
stage.setImportToken("b".repeat(32));
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
when(stageStore.create(storedPath, "static-capability-preview.efskill", SkillImportFormat.EASYFLOW))
.thenReturn(stage);
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
ObjectMapper objectMapper = new ObjectMapper();
SkillCapabilityBindingService capabilityService = new SkillCapabilityBindingServiceImpl(
mock(SkillMapper.class), targetAccessService, mock(McpAccessPermissionChecker.class),
resourceAccessService, objectMapper);
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(objectMapper, targetAccessService);
SkillImportServiceImpl service = new SkillImportServiceImpl(
skillService, capabilityService, targetAccessService, mock(DBSkillContentStore.class),
fileStorageService, stageStore, new EasyFlowBundleReader(manifestCodec), resourceAccessService);
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
preview = service.preview(file);
}
assertEquals("EASYFLOW", preview.getFormat());
assertEquals("b".repeat(32), preview.getImportToken());
assertEquals(3, preview.getCapabilityMappings().size());
assertTrue(preview.getCapabilityMappings().stream()
.allMatch(mapping -> "UNRESOLVED".equals(mapping.getStatus())));
assertTrue(preview.getIssues().stream()
.anyMatch(issue -> "CAPABILITY_OPTION_VALUE_INVALID".equals(issue.getCode())));
assertTrue(preview.getIssues().stream()
.anyMatch(issue -> "RUNTIME_NAME_DUPLICATE".equals(issue.getCode())));
assertTrue(preview.getIssues().stream()
.anyMatch(issue -> "MCP_SELECTION_MODE_NOT_ALLOWED".equals(issue.getCode())));
assertTrue(preview.getIssues().stream()
.anyMatch(issue -> "MCP_EXECUTION_MODE_NOT_ALLOWED".equals(issue.getCode())));
assertTrue(preview.getIssues().stream()
.anyMatch(issue -> "MCP_TOOL_SELECTION_EMPTY".equals(issue.getCode())));
assertTrue(preview.getIssues().stream()
.filter(issue -> (issue.getCode() != null && issue.getCode().startsWith("MCP_"))
|| "CAPABILITY_OPTION_VALUE_INVALID".equals(issue.getCode())
|| "RUNTIME_NAME_DUPLICATE".equals(issue.getCode()))
.allMatch(issue -> issue.getPath().startsWith("skills[demo-skill].capabilities[")));
assertTrue(preview.getIssues().stream()
.noneMatch(issue -> "TARGET_UNRESOLVED".equals(issue.getCode())));
}
/**
* 增强导入自动映射 MCP 时必须传播权限拒绝,不能降级为未解析映射或包结构问题。
*
* @throws Exception 模拟文件存储流配置失败
*/
@Test
public void enhancedPreviewPropagatesMcpPermissionDenial() throws Exception {
byte[] bundle = invalidCapabilityBundle();
String storedPath = "skill-imports/mcp-permission-preview.efskill";
FileStorageService fileStorageService = mock(FileStorageService.class);
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn((long) bundle.length);
when(file.getOriginalFilename()).thenReturn("mcp-permission-preview.efskill");
when(fileStorageService.save(file, "skill-imports/1")).thenReturn(storedPath);
when(fileStorageService.readStream(storedPath))
.thenAnswer(invocation -> new ByteArrayInputStream(bundle));
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
when(targetAccessService.resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo"))
.thenThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"));
ObjectMapper objectMapper = new ObjectMapper();
SkillImportServiceImpl service = new SkillImportServiceImpl(
skillService, mock(SkillCapabilityBindingService.class), targetAccessService,
mock(DBSkillContentStore.class), fileStorageService, stageStore,
new EasyFlowBundleReader(new EasyFlowSkillManifestCodec(objectMapper, targetAccessService)),
mock(ResourceAccessService.class));
BusinessException exception;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
exception = assertThrows(BusinessException.class, () -> service.preview(file));
}
assertEquals(403, exception.getHttpStatus());
verify(targetAccessService).resolveLogicalRef(SkillCapabilityType.MCP, "mcp:demo");
verify(stageStore, never()).create(any(), any(), any());
verify(fileStorageService).delete(storedPath);
}
/**
* 创建包含二进制资源和非法名称的可解析标准包。
*
* @return ZIP 字节
*/
private byte[] invalidPackage() {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
writeEntry(zip, "invalid-skill/SKILL.md", ("---\n"
+ "name: Invalid Name\n"
+ "description: Invalid preview fixture\n"
+ "---\n# Invalid\n").getBytes(StandardCharsets.UTF_8));
writeEntry(zip, "invalid-skill/assets/data.bin", new byte[]{0, 1, 2});
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建导入预检测试包失败", exception);
}
}
/**
* 创建包含多类能力静态配置错误、但结构与安全边界合法的增强包。
*
* @return `.efskill` 字节
*/
private byte[] invalidCapabilityBundle() {
try {
String name = "demo-skill";
String content = "---\nname: demo-skill\ndescription: Capability preview fixture\n---\n# Demo\n";
Map<String, Object> first = new java.util.LinkedHashMap<>();
first.put("bindingKey", "demo-skill:0");
first.put("capabilityType", "WORKFLOW");
first.put("runtimeName", "sharedTool");
first.put("selectionMode", "SELECTED");
first.put("selectedToolNames", List.of("search"));
first.put("targetLogicalRef", "workflow:first");
first.put("options", Map.of("timeoutMs", 99));
Map<String, Object> second = new java.util.LinkedHashMap<>();
second.put("bindingKey", "demo-skill:1");
second.put("capabilityType", "PLUGIN_ITEM");
second.put("runtimeName", "sharedTool");
second.put("targetLogicalRef", "plugin-item:demo/tool");
second.put("options", Map.of("retryCount", 11));
Map<String, Object> third = new java.util.LinkedHashMap<>();
third.put("bindingKey", "demo-skill:2");
third.put("capabilityType", "MCP");
third.put("runtimeName", "mcpTools");
third.put("selectionMode", "SELECTED");
third.put("selectedToolNames", List.of());
third.put("executionMode", "SYNC");
third.put("targetLogicalRef", "mcp:demo");
Map<String, Object> manifest = Map.of(
"schemaVersion", "1.0",
"skills", List.of(Map.of(
"packageRoot", name,
"packageHash", packageHash(content),
"capabilities", List.of(first, second, third))));
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
writeEntry(zip, EasyFlowSkillManifestCodec.MANIFEST_PATH,
new ObjectMapper().writeValueAsBytes(manifest));
writeEntry(zip, "skills/" + name + "/SKILL.md", content.getBytes(StandardCharsets.UTF_8));
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建增强能力预检测试包失败", exception);
}
}
/**
* 计算仅含入口文档的 Skill 包 hash。
*
* @param content SKILL.md 内容
* @return 包 hash
*/
private String packageHash(String content) {
String canonical = "SKILL.md\n"
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n";
return SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8));
}
/**
* 写入 ZIP 文件项。
*
* @param zip ZIP 输出流
* @param path 包内路径
* @param content 文件内容
* @throws Exception 写入失败
*/
private void writeEntry(ZipOutputStream zip, String path, byte[] content) throws Exception {
zip.putNextEntry(new ZipEntry(path));
zip.write(content);
zip.closeEntry();
}
}

View File

@@ -1,389 +0,0 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.util.SkillHashes;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillImportStage;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SkillImportServiceImpl} 批量查询与请求内能力映射缓存测试。
*/
public class SkillImportServiceImplQueryEfficiencyTest {
private static final BigInteger TENANT_ID = BigInteger.ONE;
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
private static final String STORED_PATH = "skill-imports/query-efficiency.efskill";
private static final String IMPORT_TOKEN = "a".repeat(32);
private static final String SHARED_LOGICAL_REF = "workflow:shared-flow";
/**
* 验证多 Skill 预览只执行一次名称批量查询,同时保留逐项冲突标记语义。
*/
@Test
public void previewLoadsAllNameConflictsWithOneQuery() {
SkillService skillService = mock(SkillService.class);
Skill existing = new Skill();
existing.setName("beta-skill");
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of(existing));
SkillImportServiceImpl service = service(skillService,
mock(SkillCapabilityTargetAccessService.class),
mock(SkillCapabilityBindingService.class),
mock(FileStorageService.class),
mock(SkillImportStageStore.class));
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> saToken = login()) {
preview = service.previewStandardForTest(new ByteArrayInputStream(standardPackage(List.of(
"alpha-skill", "beta-skill", "gamma-skill"))));
}
verify(skillService, times(1)).list(any(QueryWrapper.class));
assertEquals(3, preview.getSkills().size());
assertFalse(preview.getSkills().get(0).isConflict());
assertTrue(preview.getSkills().get(1).isConflict());
assertFalse(preview.getSkills().get(2).isConflict());
}
/**
* 验证增强导入预览会缓存未匹配的逻辑引用,重复绑定不会重复访问目标解析服务。
*/
@Test
public void enhancedPreviewCachesUnresolvedLogicalRefWithinRequest() {
byte[] bundle = enhancedPackage();
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
FileStorageService fileStorageService = storedBundle(bundle);
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
when(stageStore.create(anyString(), anyString(), any(SkillImportFormat.class)))
.thenReturn(stage(STORED_PATH));
SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class);
SkillValidationResult validBindings = new SkillValidationResult();
validBindings.setValid(true);
when(bindingService.validateImportBindings(any())).thenReturn(validBindings);
SkillImportServiceImpl service = service(skillService, targetAccessService,
bindingService, fileStorageService, stageStore);
MultipartFile file = upload(bundle);
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> saToken = login()) {
preview = service.preview(file);
}
verify(targetAccessService, times(1))
.resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF);
assertEquals(2, preview.getCapabilityMappings().size());
assertTrue(preview.getCapabilityMappings().stream()
.allMatch(mapping -> "UNRESOLVED".equals(mapping.getStatus())));
}
/**
* 验证多 Skill 导入确认也只执行一次名称批量查询。
*/
@Test
public void confirmLoadsAllNameConflictsWithOneQuery() {
byte[] skillPackage = standardPackage(List.of("alpha-skill", "beta-skill", "gamma-skill"));
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
AtomicInteger nextId = new AtomicInteger(40);
when(skillService.saveDraft(any(Skill.class))).thenAnswer(invocation -> {
Skill skill = invocation.getArgument(0);
skill.setId(BigInteger.valueOf(nextId.incrementAndGet()));
return skill;
});
FileStorageService fileStorageService = storedBundle(skillPackage);
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage(STORED_PATH, SkillImportFormat.STANDARD));
SkillImportServiceImpl service = service(skillService,
mock(SkillCapabilityTargetAccessService.class),
mock(SkillCapabilityBindingService.class), fileStorageService, stageStore);
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
request.setImportToken(IMPORT_TOKEN);
request.setConflictStrategy(SkillImportConflictStrategy.REJECT.name());
List<Skill> imported;
try (MockedStatic<SaTokenUtil> saToken = login()) {
imported = service.confirm(request);
}
verify(skillService, times(1)).list(any(QueryWrapper.class));
verify(skillService, times(3)).saveDraft(any(Skill.class));
assertEquals(3, imported.size());
}
/**
* 验证增强导入确认会缓存成功匹配的逻辑引用,并将同一结果用于全部重复绑定。
*/
@Test
public void enhancedConfirmCachesResolvedLogicalRefWithinRequest() {
byte[] bundle = enhancedPackage();
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
AtomicReference<Skill> savedSkill = new AtomicReference<>();
BigInteger skillId = BigInteger.valueOf(41);
when(skillService.saveDraft(any(Skill.class))).thenAnswer(invocation -> {
Skill skill = invocation.getArgument(0);
skill.setId(skillId);
savedSkill.set(skill);
return skill;
});
when(skillService.getDetail(skillId)).thenAnswer(ignored -> savedSkill.get());
SkillCapabilityTargetAccessService targetAccessService = mock(SkillCapabilityTargetAccessService.class);
BigInteger targetId = BigInteger.valueOf(73);
when(targetAccessService.resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF))
.thenReturn(targetId);
SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class);
FileStorageService fileStorageService = storedBundle(bundle);
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
when(stageStore.consume(IMPORT_TOKEN)).thenReturn(stage(STORED_PATH, SkillImportFormat.EASYFLOW));
SkillImportServiceImpl service = service(skillService, targetAccessService,
bindingService, fileStorageService, stageStore);
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
request.setImportToken(IMPORT_TOKEN);
request.setConflictStrategy(SkillImportConflictStrategy.REJECT.name());
List<Skill> imported;
try (MockedStatic<SaTokenUtil> saToken = login()) {
imported = service.confirm(request);
}
verify(targetAccessService, times(1))
.resolveLogicalRef(SkillCapabilityType.WORKFLOW, SHARED_LOGICAL_REF);
verify(bindingService, times(1)).replaceBindings(eq(skillId),
org.mockito.ArgumentMatchers.argThat(bindings -> bindings.size() == 2
&& bindings.stream().allMatch(binding -> targetId.equals(binding.getTargetId()))));
assertEquals(1, imported.size());
}
/**
* 创建待测服务。
*
* @param skillService Skill 服务
* @param targetAccessService 能力目标服务
* @param bindingService 能力绑定服务
* @param fileStorageService 文件存储服务
* @param stageStore 导入会话仓库
* @return 待测导入服务
*/
private SkillImportServiceImpl service(SkillService skillService,
SkillCapabilityTargetAccessService targetAccessService,
SkillCapabilityBindingService bindingService,
FileStorageService fileStorageService,
SkillImportStageStore stageStore) {
EasyFlowSkillManifestCodec manifestCodec = new EasyFlowSkillManifestCodec(
new ObjectMapper(), targetAccessService);
return new SkillImportServiceImpl(skillService, bindingService, targetAccessService,
mock(DBSkillContentStore.class), fileStorageService, stageStore,
new EasyFlowBundleReader(manifestCodec), mock(ResourceAccessService.class));
}
/**
* 创建标准 Skill ZIP。
*
* @param names Skill 名称
* @return ZIP 字节
*/
private byte[] standardPackage(List<String> names) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
for (String name : names) {
writeEntry(zip, name + "/SKILL.md", skillContent(name).getBytes(StandardCharsets.UTF_8));
}
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建标准 Skill 测试包失败", exception);
}
}
/**
* 创建包含两个相同逻辑引用绑定的增强 Skill 包。
*
* @return `.efskill` 字节
*/
private byte[] enhancedPackage() {
try {
String name = "alpha-skill";
String content = skillContent(name);
Map<String, Object> firstBinding = binding("alpha-workflow-one", "alphaFlowOne");
Map<String, Object> secondBinding = binding("alpha-workflow-two", "alphaFlowTwo");
Map<String, Object> manifest = Map.of(
"schemaVersion", "1.0",
"skills", List.of(Map.of(
"packageRoot", name,
"packageHash", packageHash(content),
"capabilities", List.of(firstBinding, secondBinding))));
byte[] manifestBytes = new ObjectMapper().writeValueAsBytes(manifest);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
writeEntry(zip, EasyFlowSkillManifestCodec.MANIFEST_PATH, manifestBytes);
writeEntry(zip, "skills/" + name + "/SKILL.md", content.getBytes(StandardCharsets.UTF_8));
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建增强 Skill 测试包失败", exception);
}
}
/**
* 创建能力绑定 manifest 项。
*
* @param bindingKey 绑定键
* @param runtimeName 运行时名称
* @return manifest 项
*/
private Map<String, Object> binding(String bindingKey, String runtimeName) {
return Map.of(
"bindingKey", bindingKey,
"capabilityType", SkillCapabilityType.WORKFLOW.name(),
"runtimeName", runtimeName,
"targetLogicalRef", SHARED_LOGICAL_REF);
}
/**
* 创建标准入口文档。
*
* @param name Skill 名称
* @return Markdown 内容
*/
private String skillContent(String name) {
return "---\nname: " + name + "\ndescription: Query efficiency fixture for "
+ name + "\n---\n# " + name + "\n";
}
/**
* 计算仅含入口文档的标准包 hash。
*
* @param content 入口文档
* @return 包 hash
*/
private String packageHash(String content) {
String canonical = "SKILL.md\n"
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n";
return SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8));
}
/**
* 创建每次都返回新输入流的文件存储 mock。
*
* @param bundle 增强包字节
* @return 文件存储 mock
*/
private FileStorageService storedBundle(byte[] bundle) {
FileStorageService storage = mock(FileStorageService.class);
when(storage.save(any(MultipartFile.class), anyString())).thenReturn(STORED_PATH);
try {
when(storage.readStream(STORED_PATH)).thenAnswer(ignored -> new ByteArrayInputStream(bundle));
} catch (java.io.IOException exception) {
throw new IllegalStateException("创建文件存储测试替身失败", exception);
}
return storage;
}
/**
* 创建上传文件 mock。
*
* @param bundle 增强包字节
* @return 上传文件
*/
private MultipartFile upload(byte[] bundle) {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn((long) bundle.length);
when(file.getOriginalFilename()).thenReturn("skills.efskill");
return file;
}
/**
* 创建增强导入会话。
*
* @param path 文件路径
* @return 导入会话
*/
private SkillImportStage stage(String path) {
return stage(path, SkillImportFormat.EASYFLOW);
}
/**
* 创建指定格式的导入会话。
*
* @param path 文件路径
* @param format 包格式
* @return 导入会话
*/
private SkillImportStage stage(String path, SkillImportFormat format) {
SkillImportStage stage = new SkillImportStage();
stage.setImportToken(IMPORT_TOKEN);
stage.setFilePath(path);
stage.setFormat(format.name());
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
return stage;
}
/**
* 建立当前租户登录态静态 mock。
*
* @return 静态 mock 句柄
*/
private MockedStatic<SaTokenUtil> login() {
LoginAccount account = new LoginAccount();
account.setId(ACCOUNT_ID);
account.setTenantId(TENANT_ID);
MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
return saToken;
}
/**
* 写入 ZIP 条目。
*
* @param zip ZIP 输出流
* @param path 条目路径
* @param content 条目内容
* @throws Exception 写入失败
*/
private void writeEntry(ZipOutputStream zip, String path, byte[] content) throws Exception {
zip.putNextEntry(new ZipEntry(path));
zip.write(content);
zip.closeEntry();
}
}

View File

@@ -188,7 +188,7 @@ public class SkillImportStageStoreTest {
BusinessException exception;
try (MockedStatic<SaTokenUtil> login = login()) {
exception = assertThrows(BusinessException.class,
() -> fixture.store.create("skill-imports/demo.zip", "demo.zip", SkillImportFormat.STANDARD));
() -> fixture.store.create("skill-imports/demo.zip", "demo.zip"));
}
assertEquals(500, exception.getHttpStatus());
@@ -212,7 +212,6 @@ public class SkillImportStageStoreTest {
stage.setTenantId(TENANT_ID);
stage.setAccountId(ACCOUNT_ID);
stage.setFilePath("skill-imports/demo.zip");
stage.setFormat(SkillImportFormat.STANDARD.name());
stage.setStatus("PENDING");
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
return stage;

View File

@@ -0,0 +1,192 @@
package tech.easyflow.skill.imports;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* 标准 Skill ZIP 的导入导出契约测试。
*/
public class StandardSkillPackageContractTest {
/**
* 单个标准目录包可预检,并保留任意自定义资源目录。
*/
@Test
public void previewAcceptsOneStandardSkillWithCustomResources() {
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
SkillImportServiceImpl service = importService(skillService);
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> ignored = login()) {
preview = service.previewStandardForTest(new ByteArrayInputStream(skillZip(
"demo-skill", "custom/prompts/system.txt")));
}
assertEquals(1, preview.getSkills().size());
assertEquals("demo-skill", preview.getSkills().get(0).getName());
assertTrue(preview.getSkills().get(0).getFiles().stream()
.anyMatch(file -> "custom/prompts/system.txt".equals(file.getPath())));
}
/**
* 一个上传 ZIP 只能承载一个 Skill批量导入由多个 token 独立完成。
*/
@Test
public void previewRejectsMultipleSkillsInOneZip() {
SkillService skillService = mock(SkillService.class);
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
SkillImportServiceImpl service = importService(skillService);
SkillImportPreview preview;
try (MockedStatic<SaTokenUtil> ignored = login()) {
preview = service.previewStandardForTest(new ByteArrayInputStream(multiSkillZip()));
}
assertTrue(preview.getSkills().isEmpty());
assertTrue(preview.getIssues().stream()
.anyMatch(issue -> "STANDARD_PACKAGE_SKILL_COUNT".equals(issue.getCode())));
}
/**
* 私有 efskill 扩展名必须在读取前被拒绝。
*/
@Test
public void previewRejectsEfskillExtension() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(10L);
when(file.getOriginalFilename()).thenReturn("legacy.efskill");
BusinessException exception = assertThrows(BusinessException.class,
() -> importService(mock(SkillService.class)).preview(file));
assertTrue(exception.getMessage().contains(".efskill 已停止支持"));
}
/**
* 导出始终生成标准 ZIP路径不暴露数据库 ID。
*/
@Test
public void exportProducesPortableStandardZip() {
BigInteger id = new BigInteger("987654321012345678");
Skill skill = new Skill();
skill.setId(id);
skill.setName("portable-skill");
skill.setDescription("Portable skill");
skill.setSkillContent(skillContent("portable-skill"));
skill.setResources(List.of());
SkillService skillService = mock(SkillService.class);
when(skillService.getPackageDetail(id)).thenReturn(skill);
SkillExportServiceImpl service = new SkillExportServiceImpl(
skillService, mock(DBSkillContentStore.class));
byte[] bytes;
try (SkillExportArtifact artifact = service.prepare(List.of(id))) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
artifact.transferTo(output);
bytes = output.toByteArray();
}
Set<String> paths = zipPaths(bytes);
assertTrue(paths.stream().anyMatch(path -> path.endsWith("portable-skill/SKILL.md")));
assertTrue(paths.stream().noneMatch(path -> path.contains(id.toString())));
}
private SkillImportServiceImpl importService(SkillService skillService) {
return new SkillImportServiceImpl(skillService, mock(DBSkillContentStore.class),
mock(FileStorageService.class), mock(SkillImportStageStore.class),
mock(ResourceAccessService.class));
}
private MockedStatic<SaTokenUtil> login() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
return saToken;
}
private byte[] skillZip(String name, String resourcePath) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
write(zip, name + "/SKILL.md", skillContent(name));
write(zip, name + "/" + resourcePath, "Use concise language.");
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建标准 Skill 测试包失败", exception);
}
}
private byte[] multiSkillZip() {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes, StandardCharsets.UTF_8)) {
write(zip, "alpha-skill/SKILL.md", skillContent("alpha-skill"));
write(zip, "beta-skill/SKILL.md", skillContent("beta-skill"));
}
return bytes.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("创建多 Skill 测试包失败", exception);
}
}
private void write(ZipOutputStream zip, String path, String content) throws Exception {
zip.putNextEntry(new ZipEntry(path));
zip.write(content.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
private String skillContent(String name) {
return "---\nname: " + name + "\ndescription: Standard package fixture\n---\n# Instructions\n";
}
private Set<String> zipPaths(byte[] bytes) {
try (InputStream input = new ByteArrayInputStream(bytes);
ZipInputStream zip = new ZipInputStream(input, StandardCharsets.UTF_8)) {
java.util.LinkedHashSet<String> paths = new java.util.LinkedHashSet<>();
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (!entry.isDirectory()) {
paths.add(entry.getName());
}
}
return paths.stream().collect(Collectors.toCollection(java.util.LinkedHashSet::new));
} catch (Exception exception) {
throw new IllegalStateException("读取标准 Skill 测试包失败", exception);
}
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.skill.mapper;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* 标准 Skill 持久化收敛迁移的不可逆操作守卫测试。
*/
public class SkillStandardCleanupMigrationContractTest {
/**
* 数据完整性与私有包守卫必须先于任何旧表删除。
*
* @throws Exception 读取迁移文件失败时抛出
*/
@Test
public void guardsRunBeforeDestructiveCleanup() throws Exception {
String sql = migrationSql();
int firstDrop = sql.indexOf("DROP TABLE IF EXISTS `tb_skill_capability_binding`");
assertTrue(firstDrop > 0);
assertTrue(sql.indexOf("tmp_skill_standard_cleanup_guard") < firstDrop);
assertTrue(sql.indexOf("resource.`tenant_id` <> skill.`tenant_id`") < firstDrop);
assertTrue(sql.indexOf("WHERE `format` <> ''STANDARD''") < firstDrop);
}
/**
* 迁移仅删除旧包表和派生字段,保留六张标准 Skill 表。
*
* @throws Exception 读取迁移文件失败时抛出
*/
@Test
public void keepsOnlyStandardPackagePersistence() throws Exception {
String sql = migrationSql();
for (String legacyTable : new String[]{
"tb_skill_capability_binding",
"tb_skill_reference",
"tb_skill_script",
"tb_skill_asset",
"tb_skill_asset_content"
}) {
assertTrue(sql.contains("DROP TABLE IF EXISTS `" + legacyTable + "`"));
}
for (String standardTable : new String[]{
"tb_skill_category",
"tb_skill_resource",
"tb_skill_content",
"tb_skill_content_write_intent",
"tb_skill_import_stage"
}) {
assertFalse(sql.contains("DROP TABLE IF EXISTS `" + standardTable + "`"));
}
assertTrue(sql.contains("'metadata_json', 'enabled', 'source_type', 'capability_hash'"));
assertTrue(sql.contains("'kind', 'language', 'metadata_json', 'sort_no'"));
assertTrue(sql.contains("ALTER TABLE `tb_skill_import_stage` DROP COLUMN `format`"));
}
private String migrationSql() throws Exception {
Path root = Path.of("").toAbsolutePath();
for (int level = 0; level < 5 && root != null; level++, root = root.getParent()) {
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
+ "db/migration/mysql/V55__mysql_skill_standard_package_cleanup.sql");
if (Files.isRegularFile(migration)) {
return Files.readString(migration, StandardCharsets.UTF_8);
}
}
throw new IllegalStateException("未找到 V55 Skill 标准化迁移脚本");
}
}

View File

@@ -1,62 +0,0 @@
package tech.easyflow.skill.mapper;
import org.apache.ibatis.annotations.Update;
import org.junit.Test;
import java.lang.reflect.Method;
import java.math.BigInteger;
import static org.junit.Assert.assertTrue;
/**
* {@link SkillMapper} 迁移摘要回填的并发与审计 SQL 契约测试。
*/
public class SkillSummaryBackfillSqlTest {
/**
* 验证 package hash 只回填空值旧记录,并显式保持业务修改审计列。
*
* @throws Exception 反射读取 Mapper 方法失败
*/
@Test
public void packageBackfillShouldBeConditionalAndAuditNeutral() throws Exception {
Method method = SkillMapper.class.getMethod(
"backfillPackageSummary", BigInteger.class, BigInteger.class, String.class,
Integer.class, Integer.class, Integer.class, Integer.class);
String sql = sql(method);
assertTrue(sql.contains("tenant_id=#{tenantId}"));
assertTrue(sql.contains("package_hash IS NULL"));
assertTrue(sql.contains("modified=modified"));
assertTrue(sql.contains("modified_by=modified_by"));
}
/**
* 验证 capability hash 首次回填只作用于空值旧记录,并保持业务修改审计列。
*
* @throws Exception 反射读取 Mapper 方法失败
*/
@Test
public void capabilityBackfillShouldBeConditionalAndAuditNeutral() throws Exception {
Method method = SkillMapper.class.getMethod(
"backfillCapabilityHash", BigInteger.class, BigInteger.class, String.class);
String sql = sql(method);
assertTrue(sql.contains("tenant_id=#{tenantId}"));
assertTrue(sql.contains("capability_hash IS NULL"));
assertTrue(sql.contains("modified=modified"));
assertTrue(sql.contains("modified_by=modified_by"));
}
/**
* 读取 Mapper 方法声明的更新 SQL。
*
* @param method Mapper 方法
* @return 合并后的 SQL
*/
private String sql(Method method) {
return String.join(" ", method.getAnnotation(Update.class).value());
}
}

View File

@@ -11,6 +11,8 @@ import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -68,6 +70,9 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
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.markOfflineApproved(any(), any(), any())).thenReturn(1);
when(skillMapper.restoreApprovalState(any(), any(), any(), any())).thenReturn(1);
handler = new SkillApprovalSubjectHandler(
approvalInstanceService,
new ObjectMapper(),
@@ -85,10 +90,10 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
}
/**
* 验证发布候选在提交审批请求时立即持有自己的内容引用。
* 验证只构建审批请求不会持有内容引用,避免预检产生副作用。
*/
@Test
public void publishCandidateRetainsSnapshotContentsOnSubmit() {
public void buildPublishRequestDoesNotRetainSnapshotContents() {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> candidate = snapshot("sha256:candidate");
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
@@ -100,7 +105,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(skillMapper).selectOneByQuery(queryCaptor.capture());
assertTrue(queryCaptor.getValue().toSQL().toLowerCase().contains("for update"));
verify(skillService).retainSnapshotContents(candidate);
verify(skillService, never()).retainSnapshotContents(candidate);
assertSame(candidate, request.getSnapshotJson().get("resourceSnapshot"));
assertEquals(PublishStatus.DRAFT.getCode(), request.getSnapshotJson().get("previousPublishStatus"));
}
@@ -157,8 +162,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
Skill draft = skill(PublishStatus.DRAFT, Map.of());
Map<String, Object> governance = Map.of(
"id", SKILL_ID,
"name", "demo-skill",
"capabilityCount", 1);
"name", "demo-skill");
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft);
when(skillService.buildGovernanceSnapshot(draft)).thenReturn(governance);
@@ -198,6 +202,160 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
verify(skillService, never()).removeAggregate(SKILL_ID);
}
/**
* 审批发布仅允许当前审批实例写入冻结快照。
*/
@Test
public void approvedPublishUsesApprovalInstanceCompareAndSet() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> candidate = Map.of("snapshotHash", "candidate-hash");
Skill pending = skill(PublishStatus.PUBLISH_PENDING, Map.of());
pending.setCurrentApprovalInstanceId(instanceId);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(),
SKILL_ID,
candidate,
OPERATOR_ID,
instanceId);
verify(skillService).assertSnapshotHash(candidate);
verify(skillMapper).publishApproved(
eq(SKILL_ID),
eq(BigInteger.ONE),
eq(instanceId),
same(candidate),
any(Date.class),
eq(OPERATOR_ID),
eq("candidate-hash"));
}
/**
* 过期审批实例不得覆盖新的 Skill 状态。
*/
@Test
public void staleApprovalCallbackIsRejected() {
Skill pending = skill(PublishStatus.PUBLISH_PENDING, Map.of());
pending.setCurrentApprovalInstanceId(BigInteger.valueOf(100));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending);
when(approvalInstanceService.getById(BigInteger.valueOf(99)))
.thenReturn(approvalInstance(
BigInteger.valueOf(99), ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
BusinessException exception = assertThrows(BusinessException.class, () -> handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(),
SKILL_ID,
Map.of("snapshotHash", "candidate-hash"),
OPERATOR_ID,
BigInteger.valueOf(99)));
assertEquals(409, exception.getHttpStatus());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
}
/**
* 同一发布申请的重复通过回调应幂等成功且不重复释放内容。
*/
@Test
public void repeatedPublishApprovalIsNoOp() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> candidate = Map.of("snapshotHash", "candidate-hash");
Skill published = skill(PublishStatus.PUBLISHED, candidate);
published.setSnapshotHash("candidate-hash");
published.setCurrentApprovalInstanceId(instanceId);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(published);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.APPROVED));
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID, instanceId);
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
verify(skillService, never()).releaseSnapshotContents(any());
}
/**
* 同一下线申请的重复通过回调应幂等成功。
*/
@Test
public void repeatedOfflineApprovalIsNoOp() {
BigInteger instanceId = BigInteger.valueOf(99);
Skill offline = skill(PublishStatus.OFFLINE, snapshot("sha256:published"));
offline.setCurrentApprovalInstanceId(instanceId);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(offline);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.OFFLINE, ApprovalInstanceStatus.APPROVED));
handler.applyApprovedAction(
ApprovalActionType.OFFLINE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId);
verify(skillMapper, never()).markOfflineApproved(any(), any(), any());
}
/**
* 同一删除申请在资源已经删除后重复回调应幂等成功。
*/
@Test
public void repeatedDeleteApprovalForMissingSkillIsNoOp() {
BigInteger instanceId = BigInteger.valueOf(99);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.DELETE, ApprovalInstanceStatus.APPROVED));
when(approvalInstanceService.isLatestResourceInstance(
instanceId, ApprovalResourceType.SKILL.getCode(), SKILL_ID)).thenReturn(true);
handler.applyApprovedAction(
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId);
verify(skillService, never()).removeLifecycleAggregate(any());
}
/**
* 较旧删除申请不能把资源缺失误判为自身已完成。
*/
@Test
public void staleDeleteApprovalForMissingSkillIsRejected() {
BigInteger instanceId = BigInteger.valueOf(99);
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
when(approvalInstanceService.getById(instanceId))
.thenReturn(approvalInstance(instanceId, ApprovalActionType.DELETE, ApprovalInstanceStatus.APPROVED));
BusinessException exception = assertThrows(BusinessException.class, () -> handler.applyApprovedAction(
ApprovalActionType.DELETE.getCode(), SKILL_ID, Map.of(), OPERATOR_ID, instanceId));
assertEquals(409, exception.getHttpStatus());
verify(skillService, never()).removeLifecycleAggregate(any());
}
/**
* 驳回回调首次释放候选引用,之后同一实例重放保持无副作用。
*/
@Test
public void repeatedRejectRestoreIsNoOpAfterFirstApplication() {
BigInteger instanceId = BigInteger.valueOf(99);
Map<String, Object> candidate = snapshot("sha256:candidate");
Skill pending = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
pending.setCurrentApprovalInstanceId(instanceId);
Skill restored = skill(PublishStatus.PUBLISHED, snapshot("sha256:published"));
ApprovalInstance instance = approvalInstance(
instanceId, ApprovalActionType.PUBLISH, ApprovalInstanceStatus.REJECTED);
instance.setSnapshotJson(Map.of("resourceSnapshot", candidate));
when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pending, restored);
when(approvalInstanceService.getById(instanceId)).thenReturn(instance);
when(approvalInstanceService.isLatestResourceInstance(
instanceId, ApprovalResourceType.SKILL.getCode(), SKILL_ID)).thenReturn(true);
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED, instanceId);
handler.restoreState(SKILL_ID, PublishStatus.PUBLISHED, instanceId);
verify(skillService).releaseSnapshotContents(candidate);
verify(skillMapper).restoreApprovalState(
SKILL_ID, BigInteger.ONE, instanceId, PublishStatus.PUBLISHED.getCode());
}
/**
* 创建指定生命周期状态的 Skill。
*
@@ -227,4 +385,24 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
"path", "assets/file.bin",
"contentRef", contentRef)));
}
/**
* 创建与当前 Skill 回调匹配的审批实例。
*
* @param instanceId 实例 ID
* @param action 动作
* @param status 实例状态
* @return 审批实例
*/
private ApprovalInstance approvalInstance(BigInteger instanceId,
ApprovalActionType action,
ApprovalInstanceStatus status) {
ApprovalInstance instance = new ApprovalInstance();
instance.setId(instanceId);
instance.setResourceType(ApprovalResourceType.SKILL.getCode());
instance.setResourceId(SKILL_ID);
instance.setActionType(action.getCode());
instance.setStatus(status.getCode());
return instance;
}
}

View File

@@ -0,0 +1,74 @@
package tech.easyflow.skill.publish;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.ai.publish.AiResourceLifecycleService;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
/**
* {@link SkillPublishAppService} 发布说明契约测试。
*/
public class SkillPublishAppServiceTest {
/**
* 发布说明必须包含可见字符。
*/
@Test
public void rejectsBlankPublishReason() {
SkillPublishAppService service = new SkillPublishAppService(mock(AiResourceLifecycleService.class));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.submitPublishApproval(BigInteger.ONE, " \n "));
assertEquals("发布说明不能为空", exception.getMessage());
}
/**
* 发布说明最长为 500 个字符。
*/
@Test
public void rejectsPublishReasonLongerThanLimit() {
SkillPublishAppService service = new SkillPublishAppService(mock(AiResourceLifecycleService.class));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.submitPublishApproval(BigInteger.ONE, "a".repeat(501)));
assertEquals("发布说明不能超过 500 个字符", exception.getMessage());
}
/**
* 提交发布时会规范化说明并透传登录身份。
*/
@Test
public void trimsAndForwardsPublishReason() {
AiResourceLifecycleService lifecycleService = mock(AiResourceLifecycleService.class);
SkillPublishAppService service = new SkillPublishAppService(lifecycleService);
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
service.submitPublishApproval(BigInteger.valueOf(101), " 首次发布 ");
}
verify(lifecycleService).submitAction(
ApprovalResourceType.SKILL.getCode(),
BigInteger.valueOf(101),
ApprovalActionType.PUBLISH.getCode(),
BigInteger.valueOf(7),
"首次发布");
}
}

View File

@@ -1,218 +0,0 @@
package tech.easyflow.skill.repository;
import com.easyagents.skill.factory.SkillFactory;
import com.easyagents.skill.model.SkillResourceKind;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link DBSkillRepository} 二进制内容引用所有权转移测试。
*/
public class DBSkillRepositoryContentOwnershipTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private static final String REF_A = "sha256:" + "a".repeat(64);
private static final String REF_B = "sha256:" + "b".repeat(64);
private SkillService skillService;
private DBSkillContentStore contentStore;
private DBSkillRepository repository;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化仓储及登录态。
*/
@Before
public void setUp() {
skillService = mock(SkillService.class);
contentStore = mock(DBSkillContentStore.class);
repository = new DBSkillRepository(
skillService, contentStore, mock(SkillVisibilityQueryHelper.class));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 验证新增 Skill 直接接管调用方已取得的所有二进制引用。
*/
@Test
public void newSkillTransfersIncomingReferencesWithoutRetain() {
com.easyagents.skill.model.Skill incoming = incoming(null, REF_A, REF_A);
repository.save(incoming);
verify(skillService).saveDraft(any(Skill.class));
verify(contentStore, never()).retain(anyString());
}
/**
* 验证更新时仅出现在新聚合中的引用直接转移,不额外 retain。
*/
@Test
public void updateTransfersNewOnlyReferenceWithoutRetain() {
prepareExisting(List.of(resource("assets/old.bin", REF_A)));
repository.save(incoming(SKILL_ID.toString(), REF_B));
verify(skillService).updateDraft(any(Skill.class));
verify(contentStore, never()).retain(anyString());
}
/**
* 验证旧、新聚合重叠的引用会 retain 一次,以抵消旧聚合替换时的 release。
*/
@Test
public void updateRetainsOverlappingReference() {
prepareExisting(List.of(resource("assets/old.bin", REF_A)));
repository.save(incoming(SKILL_ID.toString(), REF_A));
verify(contentStore).retain(REF_A);
verify(contentStore, never()).retain(REF_B);
}
/**
* 验证仅存在于旧聚合的引用不 retain由资源替换流程负责释放。
*/
@Test
public void updateDoesNotRetainRemovedReference() {
prepareExisting(List.of(resource("assets/old.bin", REF_A)));
repository.save(incoming(SKILL_ID.toString()));
verify(skillService).updateDraft(any(Skill.class));
verify(contentStore, never()).retain(anyString());
}
/**
* 验证共享 contentRef 按资源出现次数计算交集,不因 hash 去重而少持有或多持有。
*/
@Test
public void updateRetainsSharedReferenceByMultisetIntersection() {
prepareExisting(List.of(
resource("assets/old-a.bin", REF_A),
resource("assets/old-b.bin", REF_A),
resource("assets/old-c.bin", REF_B)));
repository.save(incoming(SKILL_ID.toString(), REF_A, REF_A, REF_A, REF_B, REF_B));
verify(contentStore, times(2)).retain(REF_A);
verify(contentStore).retain(REF_B);
}
/**
* 验证缺失或不可读 Skill 按仓储契约返回 empty不把详情服务的 404 泄漏给调用方。
*/
@Test
public void getReturnsEmptyWhenSkillIsNotReadable() {
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(null);
assertTrue(repository.get(SKILL_ID.toString()).isEmpty());
verify(skillService, never()).getDetail(SKILL_ID);
}
/**
* 仓储删除必须走带生命周期状态约束的普通聚合删除入口。
*/
@Test
public void deleteUsesGuardedAggregateRemoval() {
repository.delete(SKILL_ID.toString());
verify(skillService).removeAggregate(SKILL_ID);
verify(skillService, never()).removeLifecycleAggregate(SKILL_ID);
}
/**
* 准备一个可更新的已存在 Skill。
*
* @param resources 已持久化资源
*/
private void prepareExisting(List<SkillResource> resources) {
Skill header = new Skill();
header.setId(SKILL_ID);
header.setTenantId(BigInteger.ONE);
Skill detail = new Skill();
detail.setId(SKILL_ID);
detail.setTenantId(BigInteger.ONE);
detail.setResources(resources);
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(header);
when(skillService.getDetail(SKILL_ID)).thenReturn(detail);
}
/**
* 创建 M18 Skill 聚合。
*
* @param id 仓储 ID可为空
* @param refs 二进制引用多重集
* @return M18 Skill
*/
private com.easyagents.skill.model.Skill incoming(String id, String... refs) {
List<com.easyagents.skill.model.SkillResource> resources = new ArrayList<>();
for (int index = 0; index < refs.length; index++) {
com.easyagents.skill.model.SkillResource resource = new com.easyagents.skill.model.SkillResource();
resource.setPath("assets/incoming-" + index + ".bin");
resource.setKind(SkillResourceKind.ASSET);
resource.setMediaType("application/octet-stream");
resource.setContentRef(refs[index]);
resource.setContentHash(refs[index].substring("sha256:".length()));
resource.setSize(1);
resources.add(resource);
}
return SkillFactory.createWithResources(id,
"---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo\n", resources);
}
/**
* 创建已持久化二进制资源。
*
* @param path 包内路径
* @param contentRef 内容引用
* @return 资源实体
*/
private SkillResource resource(String path, String contentRef) {
SkillResource resource = new SkillResource();
resource.setPath(path);
resource.setNormalizedPath(path);
resource.setIsText(false);
resource.setContentRef(contentRef);
resource.setContentHash(contentRef.substring("sha256:".length()));
resource.setSize(1L);
return resource;
}
}

View File

@@ -1,89 +0,0 @@
package tech.easyflow.skill.security;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* {@link SkillCredentialValueGuard} 结构化凭据检测测试。
*/
public class SkillCredentialValueGuardTest {
/**
* 验证认证头、赋值、URI userinfo、私钥和常见 Token 前缀被识别。
*/
@Test
public void detectsHighConfidenceCredentialStructures() {
List<String> credentials = List.of(
"Authorization: Bearer actual-secret-value",
"{\"token\":\"actual-secret-value\"}",
"clientSecret=actual-secret-value",
"https://operator:actual-password@example.test/service",
"-----BEGIN RSA PRIVATE KEY-----",
"key=sk-proj-abcdefghijklmnopqrstuvwxyz123456",
"actual-secret-value",
"token%253Dactual-secret-value",
"token=${TOKEN}actual-secret",
"token=${TOKEN} actual-secret",
"Authorization: Bearer ${TOKEN} actual-secret",
"https://example.test/service?token=actual-secret-value",
"https://example.test/service?mode=read&client_secret=actual-secret-value",
"https://example.test/callback#access_token=actual-secret-value",
"token%2525253Dactual-secret-value",
"token%3Dactual-secret-value%ZZ",
"token%252525252525253Dactual-secret-value",
"to\u200Bken=actual-secret-value",
"to\u0000ken=actual-secret-value",
"spring.datasource.password=actual-secret-value",
"headers[Authorization]=Bearer actual-secret-value",
"OPENAI_API_KEY=actual-secret-value",
"AWS_SECRET_ACCESS_KEY=actual-secret-value",
"Cookie=session-value-actual-secret",
"X-Auth-Token=actual-secret-value",
"session=actual-secret-value",
"Bearer actual-secret-value",
"Basic dXNlcjphY3R1YWwtc2VjcmV0",
"glpat-abcdefghijklmnopqrstuvwxyz123456",
"hf_abcdefghijklmnopqrstuvwxyz123456",
"sk_live_abcdefghijklmnopqrstuvwxyz123456",
"AIzaSyabcdefghijklmnopqrstuvwxyz1234567890",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature-value-123456");
for (String credential : credentials) {
assertTrue(credential, SkillCredentialValueGuard.containsCredential(credential));
}
}
/**
* 验证明确占位符和普通展示文案不会被当作真实凭据。
*/
@Test
public void allowsPlaceholdersAndOrdinaryDisplayCopy() {
List<String> safeValues = List.of(
"是否继续执行当前工作流?",
"请确认操作,运行时会从安全配置读取认证信息",
"token=${TOKEN}",
"Authorization: Bearer {{ token }}",
"apiKey=<API_KEY>",
"password=[REDACTED]",
"secret=***",
"https://operator:${PASSWORD}@example.test/service",
"token=none",
"client_secret=not-set",
"Bearer ${TOKEN}",
"Basic {{ basic_auth }}",
"Basic information",
"Bearer authentication",
"Authorization: Bearer",
"请将 Authorization: Bearer 写入请求头",
"sk-project-management-service",
"https://operator@example.test/service");
for (String safeValue : safeValues) {
assertFalse(safeValue, SkillCredentialValueGuard.containsCredential(safeValue));
}
}
}

View File

@@ -1,75 +0,0 @@
package tech.easyflow.skill.security;
import org.junit.Test;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
/**
* {@link SkillSensitiveConfigSanitizer} 字段与值类型白名单测试。
*/
public class SkillSensitiveConfigSanitizerTest {
/**
* 验证执行选项仅保留允许的 JSON 标量,并移除常见凭据和复杂值。
*/
@Test
public void optionsKeepAllowedScalarsAndDropCredentialsOrComplexValues() {
Map<String, Object> source = new LinkedHashMap<>();
source.put("timeoutMs", 5_000);
source.put("retryCount", 3);
source.put("async", true);
source.put("readOnly", List.of("complex"));
source.put("token", "secret-token");
source.put("apiKey", "secret-key");
source.put("headers", Map.of("Authorization", "Bearer secret"));
Map<String, Object> sanitized = SkillSensitiveConfigSanitizer.sanitizeOptions(source);
assertEquals(Map.of("timeoutMs", 5_000, "retryCount", 3, "async", true), sanitized);
assertFalse(sanitized.containsKey("token"));
assertFalse(sanitized.containsKey("apiKey"));
assertFalse(sanitized.containsKey("headers"));
assertEquals("secret-token", source.get("token"));
}
/**
* 验证 HITL 只保留展示字段,认证信息和复杂值不会穿透。
*/
@Test
public void hitlKeepsDisplayScalarsOnly() {
Map<String, Object> source = new LinkedHashMap<>();
source.put("prompt", "是否继续");
source.put("title", "人工确认");
source.put("confirmLabel", "继续");
source.put("cancelLabel", "取消");
source.put("description", Map.of("token", "nested-secret"));
source.put("authorization", "Bearer secret");
Map<String, Object> sanitized = SkillSensitiveConfigSanitizer.sanitizeHitl(source);
assertEquals(Map.of(
"prompt", "是否继续",
"title", "人工确认",
"confirmLabel", "继续",
"cancelLabel", "取消"), sanitized);
assertFalse(sanitized.containsKey("authorization"));
assertFalse(sanitized.containsKey("description"));
}
/**
* 验证空输入返回可安全修改的独立映射。
*/
@Test
public void nullInputReturnsMutableEmptyMap() {
Map<String, Object> sanitized = SkillSensitiveConfigSanitizer.sanitizeOptions(null);
sanitized.put("timeoutMs", 1_000);
assertEquals(1_000, sanitized.get("timeoutMs"));
}
}

View File

@@ -15,6 +15,7 @@ import java.math.BigInteger;
import java.util.Locale;
import java.util.Set;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
@@ -50,10 +51,10 @@ public class SkillVisibilityQueryHelperTenantTest {
}
/**
* 验证分类 ALL 范围的列表查询显式包含未分类 Skill。
* 验证分类 ALL 范围仍按创建人和可见范围过滤,不放行未分类私有 Skill。
*/
@Test
public void allCategoryScopeQueryShouldIncludeUnclassifiedSkills() {
public void allCategoryScopeQueryShouldNotBypassPrivateScopeForUnclassifiedSkills() {
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysDeptService sysDeptService = mock(SysDeptService.class);
SkillVisibilityQueryHelper helper = new SkillVisibilityQueryHelper(
@@ -70,7 +71,9 @@ public class SkillVisibilityQueryHelperTenantTest {
}
String sql = query.toSQL().toLowerCase(Locale.ROOT);
assertTrue("ALL 分类查询缺少未分类分支: " + sql,
assertTrue("ALL 分类查询缺少创建人边界: " + sql, sql.contains("created_by"));
assertTrue("ALL 分类查询缺少可见范围边界: " + sql, sql.contains("visibility_scope"));
assertFalse("ALL 分类查询不应包含未分类越权分支: " + sql,
sql.contains("category_id") && sql.contains("is null"));
}

View File

@@ -1,163 +0,0 @@
package tech.easyflow.skill.service.impl;
import com.easyagents.skill.util.SkillHashes;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* V27 旧 Skill 在只读详情路径中的摘要回填测试。
*/
public class SkillLegacySummaryBackfillTest {
/**
* 验证 READ 权限即可获得完整 hash并通过专用 Mapper 条件回填且不改内存审计值。
*/
@Test
public void readDetailShouldBackfillMissingHashesWithoutManagePermission() {
BigInteger skillId = BigInteger.valueOf(101);
BigInteger tenantId = BigInteger.valueOf(42);
BigInteger accountId = BigInteger.valueOf(7);
Date originalModified = new Date(1_700_000_000_000L);
String content = "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n";
String referenceText = "# Reference\n";
String referenceHash = SkillHashes.sha256Hex(referenceText.getBytes(StandardCharsets.UTF_8));
String expectedCapabilityHash = SkillHashes.sha256Hex("[]".getBytes(StandardCharsets.UTF_8));
Skill skill = new Skill();
skill.setId(skillId);
skill.setTenantId(tenantId);
skill.setCreatedBy(accountId);
skill.setSkillContent(content);
skill.setModified(originalModified);
skill.setModifiedBy(accountId);
SkillResource resource = new SkillResource();
resource.setPath("references/guide.md");
resource.setNormalizedPath("references/guide.md");
resource.setKind("REFERENCE");
resource.setIsText(true);
resource.setTextContent(referenceText);
resource.setContentHash(referenceHash);
resource.setSize((long) referenceText.getBytes(StandardCharsets.UTF_8).length);
SkillMapper mapper = mock(SkillMapper.class);
SkillResourceService resourceService = mock(SkillResourceService.class);
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillServiceImpl service = spy(new SkillServiceImpl(
mock(SkillCategoryService.class), resourceService, capabilityService,
mock(DBSkillContentStore.class), accessService,
mock(CategoryPermissionService.class), new ObjectMapper()));
doReturn(mapper).when(service).getMapper();
doReturn(skill).when(service).getOne(any(QueryWrapper.class));
when(resourceService.list(any(QueryWrapper.class))).thenReturn(List.of(resource));
when(capabilityService.listBindings(skillId)).thenReturn(List.of());
when(capabilityService.calculateStoredHash(skillId)).thenReturn(expectedCapabilityHash);
LoginAccount account = new LoginAccount();
account.setId(accountId);
account.setTenantId(tenantId);
Skill detail;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
detail = service.getDetail(skillId);
}
String canonical = "SKILL.md\n"
+ SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)) + "\n"
+ "references/guide.md\n" + referenceHash + "\n";
String expectedPackageHash = SkillHashes.sha256Hex(canonical.getBytes(StandardCharsets.UTF_8));
assertEquals(expectedPackageHash, detail.getPackageHash());
assertEquals(expectedCapabilityHash, detail.getCapabilityHash());
assertSame(originalModified, detail.getModified());
assertEquals(accountId, detail.getModifiedBy());
verify(mapper).backfillPackageSummary(
skillId, tenantId, expectedPackageHash, 1, 1, 0, 0);
verify(mapper).backfillCapabilityHash(skillId, tenantId, expectedCapabilityHash);
verify(accessService).assertAccess(
CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
verify(accessService, never()).assertAccess(
eq(CategoryResourceType.SKILL), any(Skill.class), eq(ResourceAction.MANAGE), anyString());
}
/**
* 验证目标权限不足导致绑定脱敏时,不会用脱敏数据回填错误的能力 hash。
*/
@Test
public void redactedCapabilityShouldNotBackfillMissingHash() {
BigInteger skillId = BigInteger.valueOf(102);
BigInteger tenantId = BigInteger.valueOf(42);
BigInteger accountId = BigInteger.valueOf(7);
Skill skill = new Skill();
skill.setId(skillId);
skill.setTenantId(tenantId);
skill.setCreatedBy(accountId);
skill.setSkillContent("---\nname: private-skill\ndescription: Private\n---\n# Private\n");
skill.setPackageHash("existing-package-hash");
SkillCapabilityBinding redacted = new SkillCapabilityBinding();
redacted.setCapabilityType("MCP");
redacted.setRuntimeName("private_mcp");
redacted.setTargetStatus("NO_PERMISSION");
SkillMapper mapper = mock(SkillMapper.class);
SkillResourceService resourceService = mock(SkillResourceService.class);
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillServiceImpl service = spy(new SkillServiceImpl(
mock(SkillCategoryService.class), resourceService, capabilityService,
mock(DBSkillContentStore.class), accessService,
mock(CategoryPermissionService.class), new ObjectMapper()));
doReturn(mapper).when(service).getMapper();
doReturn(skill).when(service).getOne(any(QueryWrapper.class));
when(resourceService.listDescriptors(skillId, tenantId)).thenReturn(List.of());
when(capabilityService.listBindings(skillId)).thenReturn(List.of(redacted));
LoginAccount account = new LoginAccount();
account.setId(accountId);
account.setTenantId(tenantId);
Skill detail;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
detail = service.getManagementDetail(skillId);
}
assertNull(detail.getCapabilityHash());
verify(capabilityService, never()).calculateStoredHash(any());
verify(mapper, never()).backfillCapabilityHash(any(), any(), anyString());
}
}

View File

@@ -26,7 +26,10 @@ public class SkillResourceServiceImplProjectionTest {
assertTrue(sql.contains("normalized_path"));
assertTrue(sql.contains("content_hash"));
assertTrue(sql.contains("metadata_json"));
assertTrue(sql.contains("media_type"));
assertTrue(sql.contains("is_text"));
assertTrue(sql.contains("size"));
assertFalse(sql.contains("metadata_json"));
assertFalse(sql.contains("text_content"));
assertFalse(sql.contains("content_ref"));
}

View File

@@ -1,155 +0,0 @@
package tech.easyflow.skill.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* {@link SkillServiceImpl} 发布候选和已发布快照内容引用计数测试。
*/
public class SkillServiceImplContentReferenceTest {
private DBSkillContentStore contentStore;
private SkillServiceImpl service;
/**
* 初始化只关注内容引用的 Skill 服务。
*/
@Before
public void setUp() {
contentStore = mock(DBSkillContentStore.class);
service = new SkillServiceImpl(
mock(SkillCategoryService.class),
mock(SkillResourceService.class),
mock(SkillCapabilityBindingService.class),
contentStore,
mock(ResourceAccessService.class),
mock(CategoryPermissionService.class),
new ObjectMapper());
}
/**
* 验证发布候选按资源出现次数 retain同一 hash 被多个资源引用时必须持有多份引用。
*/
@Test
public void retainSnapshotContentsPreservesDuplicateResourceReferences() {
Map<String, Object> snapshot = snapshot("sha256:a", "sha256:a", "sha256:b", null, "");
service.retainSnapshotContents(snapshot);
verify(contentStore, times(2)).retain("sha256:a");
verify(contentStore).retain("sha256:b");
verify(contentStore, never()).retain("");
}
/**
* 验证候选驳回、旧快照替换或聚合删除时按相同出现次数 release。
*/
@Test
public void releaseSnapshotContentsBalancesEveryHeldReference() {
Map<String, Object> snapshot = snapshot("sha256:a", "sha256:a", "sha256:b", null, "");
service.releaseSnapshotContents(snapshot);
verify(contentStore, times(2)).release("sha256:a");
verify(contentStore).release("sha256:b");
verify(contentStore, never()).release("");
}
/**
* 验证 V24 assets 快照仍按资源出现次数释放内容引用。
*/
@Test
public void legacyAssetSnapshotBalancesEveryHeldReference() {
Map<String, Object> snapshot = Map.of(
"assets", List.of(
Map.of("contentRef", "sha256:legacy"),
Map.of("contentRef", "sha256:legacy"),
Map.of("contentRef", "sha256:other")));
service.retainSnapshotContents(snapshot);
service.releaseSnapshotContents(snapshot);
verify(contentStore, times(2)).retain("sha256:legacy");
verify(contentStore).retain("sha256:other");
verify(contentStore, times(2)).release("sha256:legacy");
verify(contentStore).release("sha256:other");
}
/**
* 验证空快照和非列表 resources 不触发引用变化。
*/
@Test
public void malformedOrEmptySnapshotDoesNotChangeReferences() {
service.retainSnapshotContents(null);
service.releaseSnapshotContents(Map.of("resources", "invalid"));
verify(contentStore, never()).retain(org.mockito.ArgumentMatchers.anyString());
verify(contentStore, never()).release(org.mockito.ArgumentMatchers.anyString());
}
/**
* 验证删除治理快照仅包含审计所需字段,不携带提示词、资源、能力配置或自定义元数据。
*/
@Test
public void governanceSnapshotExcludesExecutableAndSensitivePayloads() {
Skill skill = new Skill();
skill.setId(java.math.BigInteger.valueOf(101));
skill.setTenantId(java.math.BigInteger.ONE);
skill.setName("demo-skill");
skill.setDisplayName("Demo Skill");
skill.setSkillContent("secret prompt");
skill.setMetadataJson(Map.of("apiKey", "secret"));
skill.setPublishStatus("DRAFT");
skill.setResourceCount(2);
skill.setCapabilityCount(1);
Map<String, Object> snapshot = service.buildGovernanceSnapshot(skill);
assertEquals(skill.getId(), snapshot.get("id"));
assertEquals("demo-skill", snapshot.get("name"));
assertEquals(2, snapshot.get("resourceCount"));
assertEquals(1, snapshot.get("capabilityCount"));
assertFalse(snapshot.containsKey("skillContent"));
assertFalse(snapshot.containsKey("metadataJson"));
assertFalse(snapshot.containsKey("resources"));
assertFalse(snapshot.containsKey("capabilities"));
assertFalse(snapshot.toString().contains("secret"));
}
/**
* 创建包含指定内容引用序列的快照。
*
* @param refs 内容引用,可含空值
* @return 发布快照
*/
private Map<String, Object> snapshot(String... refs) {
List<Map<String, Object>> resources = new ArrayList<>();
for (String ref : refs) {
Map<String, Object> resource = new LinkedHashMap<>();
resource.put("path", "assets/" + resources.size());
resource.put("contentRef", ref);
resources.add(resource);
}
return Map.of("resources", resources);
}
}

View File

@@ -1,154 +0,0 @@
package tech.easyflow.skill.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
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.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SkillServiceImpl} 聚合删除状态与主行锁约束测试。
*/
public class SkillServiceImplDeletionGuardTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private SkillMapper mapper;
private SkillServiceImpl service;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化 Skill 删除服务与当前租户登录态。
*/
@Before
public void setUp() {
mapper = mock(SkillMapper.class);
service = spy(new SkillServiceImpl(
mock(SkillCategoryService.class),
mock(SkillResourceService.class),
mock(SkillCapabilityBindingService.class),
mock(DBSkillContentStore.class),
mock(ResourceAccessService.class),
mock(CategoryPermissionService.class),
new ObjectMapper()));
doReturn(mapper).when(service).getMapper();
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 普通仓储删除不得绕过已发布状态约束,且检查状态前必须锁定 Skill 主行。
*/
@Test
public void ordinaryDeleteRejectsPublishedSkillAfterRowLock() {
doReturn(skill(PublishStatus.PUBLISHED)).when(service).getOne(any(QueryWrapper.class));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.removeAggregate(SKILL_ID));
assertEquals(409, exception.getHttpStatus());
assertTrue(exception.getMessage().contains("先下线"));
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(service).getOne(queryCaptor.capture());
assertTrue(queryCaptor.getValue().toSQL().toUpperCase().contains("FOR UPDATE"));
verify(mapper, never()).deleteByQuery(any(QueryWrapper.class));
}
/**
* 普通仓储删除不得删除处于删除审批中的 Skill。
*/
@Test
public void ordinaryDeleteRejectsDeletePendingSkill() {
doReturn(skill(PublishStatus.DELETE_PENDING)).when(service).getOne(any(QueryWrapper.class));
BusinessException exception = assertThrows(BusinessException.class,
() -> service.removeAggregate(SKILL_ID));
assertEquals(409, exception.getHttpStatus());
assertTrue(exception.getMessage().contains("进行中的审批"));
verify(mapper, never()).deleteByQuery(any(QueryWrapper.class));
}
/**
* 审批通过后的生命周期入口应允许删除 DELETE_PENDING并仍通过已锁定聚合执行删除。
*/
@Test
public void lifecycleDeleteAllowsDeletePendingSkill() {
SkillResourceService resourceService = mock(SkillResourceService.class);
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillServiceImpl lifecycleService = spy(new SkillServiceImpl(
mock(SkillCategoryService.class),
resourceService,
capabilityService,
mock(DBSkillContentStore.class),
accessService,
mock(CategoryPermissionService.class),
new ObjectMapper()));
doReturn(mapper).when(lifecycleService).getMapper();
doReturn(skill(PublishStatus.DELETE_PENDING)).when(lifecycleService).getOne(any(QueryWrapper.class));
when(resourceService.list(any(QueryWrapper.class))).thenReturn(List.of());
when(mapper.deleteByQuery(any(QueryWrapper.class))).thenReturn(1);
lifecycleService.removeLifecycleAggregate(SKILL_ID);
verify(capabilityService).removeBySkillId(SKILL_ID);
verify(mapper).deleteByQuery(any(QueryWrapper.class));
}
/**
* 创建指定发布状态的最小 Skill。
*
* @param status 发布状态
* @return Skill 实体
*/
private Skill skill(PublishStatus status) {
Skill skill = new Skill();
skill.setId(SKILL_ID);
skill.setTenantId(BigInteger.ONE);
skill.setName("demo-skill");
skill.setPublishStatus(status.getCode());
return skill;
}
}

View File

@@ -1,305 +0,0 @@
package tech.easyflow.skill.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
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.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SkillServiceImpl} 管理操作测试,覆盖复制、更新与发布校验语义。
*/
public class SkillServiceImplManagementTest {
/**
* 复制 Skill 时应改写标准名称、保留未知 frontmatter并为二进制资源建立独立引用。
*/
@Test
public void copyDraftPreservesPortableContentAndOwnsBinaryReferences() {
BigInteger sourceId = BigInteger.valueOf(101);
BigInteger copiedId = BigInteger.valueOf(202);
DBSkillContentStore contentStore = mock(DBSkillContentStore.class);
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
SkillServiceImpl service = spy(service(contentStore, capabilityService,
mock(SkillCategoryService.class), mock(ResourceAccessService.class),
mock(CategoryPermissionService.class)));
Skill source = sourceSkill(sourceId);
Skill copied = new Skill();
copied.setId(copiedId);
copied.setCapabilityHash("empty-capability-hash");
doAnswer(invocation -> sourceId.equals(invocation.getArgument(0)) ? source : copied)
.when(service).getDetail(any(BigInteger.class));
ArgumentCaptor<Skill> draftCaptor = ArgumentCaptor.forClass(Skill.class);
doReturn(copied).when(service).saveDraft(draftCaptor.capture());
when(capabilityService.replaceBindings(eq(copiedId), any(), eq("empty-capability-hash")))
.thenReturn(List.of());
Skill result = service.copyDraft(sourceId, "demo-skill-copy", "演示副本", BigInteger.valueOf(9));
assertEquals(copiedId, result.getId());
Skill draft = draftCaptor.getValue();
assertEquals(BigInteger.valueOf(9), draft.getCategoryId());
assertEquals("演示副本", draft.getDisplayName());
assertTrue(draft.getSkillContent().contains("name: demo-skill-copy"));
assertTrue(draft.getSkillContent().contains("nested:"));
assertTrue(draft.getSkillContent().contains("keep-me"));
assertEquals(2, draft.getResources().size());
verify(contentStore).retain("sha256:" + "a".repeat(64));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<SkillCapabilityBinding>> bindingsCaptor = ArgumentCaptor.forClass(List.class);
verify(capabilityService).replaceBindings(eq(copiedId), bindingsCaptor.capture(),
eq("empty-capability-hash"));
assertEquals(BigInteger.valueOf(77), bindingsCaptor.getValue().get(0).getTargetId());
assertEquals("demo_tool", bindingsCaptor.getValue().get(0).getRuntimeName());
}
/**
* 更新草稿可能同时移动分类,必须先锁分类树再锁 Skill 行。
*/
@Test
public void updateDraftLocksCategoryTreeBeforeSkillRow() {
BigInteger tenantId = BigInteger.valueOf(10);
BigInteger categoryId = BigInteger.valueOf(30);
SkillCategoryService categoryService = mock(SkillCategoryService.class);
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class),
mock(SkillCapabilityBindingService.class), categoryService, accessService,
mock(CategoryPermissionService.class)));
Skill existing = skill(BigInteger.ONE, tenantId, "demo-skill");
existing.setDescription("Demo skill");
existing.setSkillContent("---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo\n");
existing.setPublishStatus(PublishStatus.DRAFT.getCode());
Skill incoming = skill(existing.getId(), tenantId, existing.getName());
incoming.setCategoryId(categoryId);
doReturn(existing).when(service).getOne(any(QueryWrapper.class));
doThrow(new BusinessException("stop after row lock")).when(accessService)
.assertAccess(eq(CategoryResourceType.SKILL), eq(existing), eq(ResourceAction.MANAGE), anyString());
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(20));
account.setTenantId(tenantId);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
assertThrows(BusinessException.class, () -> service.updateDraft(incoming));
}
InOrder lockOrder = inOrder(categoryService, service);
lockOrder.verify(categoryService).lockAndValidateUsableCategory(categoryId);
lockOrder.verify(service).getOne(any(QueryWrapper.class));
}
/**
* 覆盖导入必须在取得行锁后重验状态,禁止覆盖并发完成发布的 Skill。
*/
@Test
public void overwriteImportRechecksDraftStatusAfterLock() {
BigInteger tenantId = BigInteger.valueOf(10);
BigInteger accountId = BigInteger.valueOf(20);
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class),
mock(SkillCapabilityBindingService.class), mock(SkillCategoryService.class),
accessService, mock(CategoryPermissionService.class)));
Skill published = skill(BigInteger.valueOf(1), tenantId, "published-skill");
published.setPublishStatus(PublishStatus.PUBLISHED.getCode());
doReturn(published).when(service).getOne(any(QueryWrapper.class));
Skill imported = skill(published.getId(), tenantId, published.getName());
LoginAccount account = new LoginAccount();
account.setId(accountId);
account.setTenantId(tenantId);
BusinessException exception;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
exception = assertThrows(BusinessException.class,
() -> service.overwriteImportedDraft(imported));
}
assertEquals(409, exception.getHttpStatus());
assertTrue(exception.getMessage().contains("仅允许覆盖草稿状态"));
verify(accessService).assertAccess(CategoryResourceType.SKILL, published,
ResourceAction.MANAGE, "无权限管理该 Skill");
}
/**
* 发布级校验必须在解析实时能力前重新校验 Skill 管理权限。
*/
@Test
public void publishValidationRequiresManagePermission() {
BigInteger skillId = BigInteger.valueOf(101);
ResourceAccessService accessService = mock(ResourceAccessService.class);
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), capabilityService,
mock(SkillCategoryService.class), accessService, mock(CategoryPermissionService.class)));
Skill detail = skill(skillId, BigInteger.TEN, "demo-skill");
detail.setSkillContent("""
---
name: demo-skill
description: Demonstration skill
---
# Instructions
""");
doReturn(detail).when(service).getDetail(skillId);
SkillValidationResult capabilityResult = new SkillValidationResult();
capabilityResult.setValid(true);
when(capabilityService.validateBindings(eq(skillId), isNull(), eq(true)))
.thenReturn(capabilityResult);
service.validateSkill(skillId, true);
verify(accessService).assertAccess(CategoryResourceType.SKILL, detail,
ResourceAction.MANAGE, "无权限管理该 Skill");
verify(capabilityService).validateBindings(skillId, null, true);
}
/**
* 数据库拒绝创建草稿属于服务端持久化故障,不能返回客户端输入错误。
*/
@Test
public void saveDraftPersistenceFailureUsesServerErrorStatus() {
SkillCapabilityBindingService capabilityService = mock(SkillCapabilityBindingService.class);
SkillCategoryService categoryService = mock(SkillCategoryService.class);
SkillServiceImpl service = spy(service(mock(DBSkillContentStore.class), capabilityService,
categoryService, mock(ResourceAccessService.class),
mock(CategoryPermissionService.class)));
doReturn(0L).when(service).count(any(QueryWrapper.class));
doReturn(false).when(service).save(any(Skill.class));
when(capabilityService.calculateHash(List.of())).thenReturn("4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945");
Skill draft = new Skill();
draft.setDisplayName("演示 Skill");
draft.setSkillContent("""
---
name: demo-skill
description: Demonstration skill
---
# Instructions
""");
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(20));
account.setTenantId(BigInteger.valueOf(10));
BusinessException exception;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
exception = assertThrows(BusinessException.class, () -> service.saveDraft(draft));
}
assertEquals(500, exception.getHttpStatus());
verify(categoryService).lockAndValidateUsableCategory(null);
}
/**
* 创建仅注入当前测试依赖的服务实例。
*
* @param contentStore 内容仓库
* @param capabilityService 能力服务
* @param categoryService 分类服务
* @param accessService 资源权限服务
* @param categoryPermissionService 分类权限服务
* @return Skill 服务
*/
private SkillServiceImpl service(DBSkillContentStore contentStore,
SkillCapabilityBindingService capabilityService,
SkillCategoryService categoryService,
ResourceAccessService accessService,
CategoryPermissionService categoryPermissionService) {
return new SkillServiceImpl(categoryService, mock(SkillResourceService.class), capabilityService,
contentStore, accessService, categoryPermissionService, new ObjectMapper());
}
/**
* 创建含未知 frontmatter、文本、二进制和能力配置的源 Skill。
*
* @param id Skill ID
* @return 源 Skill
*/
private Skill sourceSkill(BigInteger id) {
Skill source = skill(id, BigInteger.ONE, "demo-skill");
source.setSkillContent("""
---
name: demo-skill
description: Demonstrates copying
nested:
value: keep-me
---
# Demo
""");
SkillResource text = new SkillResource();
text.setPath("references/guide.md");
text.setIsText(true);
text.setTextContent("guide");
text.setMetadataJson(Map.of());
SkillResource binary = new SkillResource();
binary.setPath("assets/image.png");
binary.setIsText(false);
binary.setContentRef("sha256:" + "a".repeat(64));
binary.setContentHash("a".repeat(64));
binary.setMetadataJson(Map.of());
source.setResources(List.of(text, binary));
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType("WORKFLOW");
binding.setTargetId(BigInteger.valueOf(77));
binding.setTargetLogicalRef("workflow:demo");
binding.setRuntimeName("demo_tool");
binding.setEnabled(true);
binding.setOptionsJson(Map.of("timeoutMs", 2_000));
source.setCapabilityBindings(List.of(binding));
return source;
}
/**
* 创建最小 Skill 实体。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param name Skill 名称
* @return Skill 实体
*/
private Skill skill(BigInteger id, BigInteger tenantId, String name) {
Skill skill = new Skill();
skill.setId(id);
skill.setTenantId(tenantId);
skill.setName(name);
return skill;
}
}

View File

@@ -90,12 +90,13 @@ public class DBSkillContentStoreTest {
* 验证新内容先提交恢复意图,再在业务事务中写文件、激活索引并原子删除意图。
*/
@Test
public void putInputStreamCommitsRecoverableWriteIntentAndActiveIndex() {
public void stagedContentCommitsRecoverableWriteIntentAndActiveIndex() {
byte[] bytes = "stream-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentRef = SkillHashes.sha256Ref(bytes);
FileStorageWriteHandle handle = stubNewContentWrite(bytes, "/attachment/stream.bin");
String actual = contentStore.put(new ByteArrayInputStream(bytes), bytes.length);
SkillContentStage stage = contentStore.stage(new ByteArrayInputStream(bytes), bytes.length);
String actual = contentStore.commit(stage);
assertEquals(contentRef, actual);
verify(transactionManager, atLeastOnce()).getTransaction(any(TransactionDefinition.class));