diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java index 6538a457..4d715a74 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java @@ -1,12 +1,14 @@ package tech.easyflow.admin.controller.ai.support; import org.springframework.stereotype.Component; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.agent.entity.Agent; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.skill.entity.Skill; import tech.easyflow.system.service.SysAccountService; +import tech.easyflow.system.entity.SysAccount; import javax.annotation.Resource; import java.math.BigInteger; @@ -73,7 +75,38 @@ public class AiResourceCreatorNameSupport { * @param skills Skill 集合 */ public void fillSkillCreatorNames(Collection skills) { - fillCreatorNames(skills, Skill::getCreatedBy, Skill::setCreatedByName); + if (skills == null || skills.isEmpty()) { + return; + } + LinkedHashSet creatorIds = skills.stream().map(Skill::getCreatedBy) + .filter(Objects::nonNull) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + if (creatorIds.isEmpty()) { + return; + } + Map labels = sysAccountService.list(QueryWrapper.create() + .select(SysAccount::getId, SysAccount::getNickname, SysAccount::getLoginName) + .in(SysAccount::getId, creatorIds)).stream() + .collect(java.util.stream.Collectors.toMap(SysAccount::getId, this::skillCreatorLabel, + (left, right) -> left, java.util.LinkedHashMap::new)); + skills.forEach(skill -> skill.setCreatedByName( + labels.getOrDefault(skill.getCreatedBy(), String.valueOf(skill.getCreatedBy())))); + } + + /** + * 将 Skill 创建人格式化为“昵称(账号)”。 + * + * @param account 创建人账号 + * @return 创建人展示标签 + */ + private String skillCreatorLabel(SysAccount account) { + String nickname = account.getNickname() == null ? "" : account.getNickname().trim(); + String loginName = account.getLoginName() == null ? "" : account.getLoginName().trim(); + if (nickname.isBlank()) { + return loginName.isBlank() ? String.valueOf(account.getId()) : loginName; + } + return loginName.isBlank() || nickname.equals(loginName) + ? nickname : nickname + "(" + loginName + ")"; } /** diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java index 5418a52d..37cea67d 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java @@ -21,7 +21,6 @@ import tech.easyflow.system.service.CategoryPermissionService; import java.io.Serializable; import java.math.BigInteger; -import java.util.Collections; import java.util.List; import java.util.LinkedHashMap; import java.util.Locale; @@ -77,14 +76,17 @@ public class SkillCategoryController { } } RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()); - if (access.isRestricted()) { - if (access.getCategoryIds().isEmpty()) { - return Result.ok(Collections.emptyList()); - } - queryWrapper.in("id", access.getCategoryIds()); - } queryWrapper.orderBy(resolveOrderBy(sortKey, sortType)); List categories = service.list(queryWrapper); + if (access.isRestricted()) { + Set visibleIds = new java.util.LinkedHashSet<>(access.getCategoryIds()); + categories.stream().filter(category -> access.getCategoryIds().contains(category.getId())) + .map(SkillCategory::getAncestors).filter(value -> value != null && !value.isBlank()) + .flatMap(value -> java.util.Arrays.stream(value.split(","))) + .map(String::trim).filter(value -> !value.isBlank() && !"0".equals(value)) + .map(BigInteger::new).forEach(visibleIds::add); + categories = categories.stream().filter(category -> visibleIds.contains(category.getId())).toList(); + } return Result.ok(Boolean.FALSE.equals(asTree) ? categories : toTree(categories)); } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java index d21bdc80..7d4f0c26 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java @@ -1,8 +1,6 @@ package tech.easyflow.admin.controller.skill; import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.dev33.satoken.annotation.SaMode; -import cn.dev33.satoken.stp.StpUtil; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; import jakarta.servlet.http.HttpServletResponse; @@ -12,12 +10,12 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; -import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest; -import tech.easyflow.admin.controller.skill.vo.SkillCapabilityReplaceView; import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest; import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest; +import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView; import tech.easyflow.admin.controller.skill.vo.SkillView; import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView; import tech.easyflow.approval.entity.vo.ApprovalActionResult; @@ -27,11 +25,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.util.SearchKeywordUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; -import tech.easyflow.skill.capability.SkillCapabilityBindingService; -import tech.easyflow.skill.capability.SkillCapabilityCandidate; import tech.easyflow.skill.entity.Skill; -import tech.easyflow.skill.entity.SkillCapabilityBinding; -import tech.easyflow.skill.enums.SkillCapabilityType; import tech.easyflow.skill.file.SkillFileContent; import tech.easyflow.skill.file.SkillFileNode; import tech.easyflow.skill.file.SkillFileRenameRequest; @@ -41,7 +35,6 @@ import tech.easyflow.skill.imports.SkillExportRequest; import tech.easyflow.skill.imports.SkillExportArtifact; import tech.easyflow.skill.imports.SkillExportService; import tech.easyflow.skill.imports.SkillImportConfirmRequest; -import tech.easyflow.skill.imports.SkillImportFormat; import tech.easyflow.skill.imports.SkillImportPreview; import tech.easyflow.skill.imports.SkillImportService; import tech.easyflow.skill.publish.SkillPublishAppService; @@ -65,14 +58,14 @@ import java.util.Objects; import java.util.Set; /** - * Skill 管理端 API,统一负责轻量查询、白名单写入、文件工作台、能力绑定和导入导出。 + * Skill 管理端 API,统一负责轻量查询、白名单写入、文件工作台和标准包导入导出。 */ @RestController @RequestMapping("/api/v1/skill") public class SkillController { private static final Set PAGE_SORT_COLUMNS = Set.of( - "id", "name", "display_name", "created", "modified", "publish_status", "resource_count", "capability_count"); + "id", "name", "display_name", "created", "modified", "publish_status"); private final SkillService skillService; private final SkillApprovalStateService skillApprovalStateService; @@ -80,7 +73,6 @@ public class SkillController { private final SkillImportService skillImportService; private final SkillExportService skillExportService; private final SkillFileService skillFileService; - private final SkillCapabilityBindingService capabilityBindingService; private final ResourceAccessService resourceAccessService; private final CategoryPermissionService categoryPermissionService; private final SkillVisibilityQueryHelper visibilityQueryHelper; @@ -95,7 +87,6 @@ public class SkillController { * @param skillImportService 导入服务 * @param skillExportService 导出服务 * @param skillFileService 文件服务 - * @param capabilityBindingService 能力绑定服务 * @param resourceAccessService 资源权限服务 * @param categoryPermissionService 分类权限服务 * @param visibilityQueryHelper 可见性查询助手 @@ -107,7 +98,6 @@ public class SkillController { SkillImportService skillImportService, SkillExportService skillExportService, SkillFileService skillFileService, - SkillCapabilityBindingService capabilityBindingService, ResourceAccessService resourceAccessService, CategoryPermissionService categoryPermissionService, SkillVisibilityQueryHelper visibilityQueryHelper, @@ -118,7 +108,6 @@ public class SkillController { this.skillImportService = skillImportService; this.skillExportService = skillExportService; this.skillFileService = skillFileService; - this.capabilityBindingService = capabilityBindingService; this.resourceAccessService = resourceAccessService; this.categoryPermissionService = categoryPermissionService; this.visibilityQueryHelper = visibilityQueryHelper; @@ -134,9 +123,9 @@ public class SkillController { * @param categoryScope 分类范围,UNCATEGORIZED 表示未分类 * @param name 名称关键词 * @param displayName 展示名称关键词 + * @param keyword 名称、用途或创建人模糊关键词 * @param publishStatus 发布状态 - * @param sourceType 来源类型 - * @param capabilityType 能力类型 + * @param visibilityScope 使用范围 * @param sortKey 排序字段 * @param sortType 排序方向 * @return 轻量分页结果 @@ -144,8 +133,8 @@ public class SkillController { @GetMapping("/page") @SaCheckPermission("/api/v1/skill/query") public Result> page(Long pageNumber, Long pageSize, BigInteger categoryId, String categoryScope, - String name, String displayName, String publishStatus, String sourceType, - String capabilityType, String sortKey, String sortType) { + String name, String displayName, String keyword, String publishStatus, + String visibilityScope, String sortKey, String sortType) { long normalizedPage = pageNumber == null || pageNumber < 1 ? 1 : pageNumber; long normalizedSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100); QueryWrapper query = descriptorQuery(); @@ -155,19 +144,16 @@ public class SkillController { } else { query.eq("category_id", categoryId, categoryId != null); } - query - .eq("publish_status", publishStatus, hasText(publishStatus)) - .eq("source_type", sourceType, hasText(sourceType)); - String keyword = hasText(displayName) ? displayName : name; - if (hasText(keyword)) { - String pattern = SearchKeywordUtil.literalContainsPattern(keyword); - query.and("(name LIKE ? OR display_name LIKE ? OR description LIKE ?)", pattern, pattern, pattern); - } - if (hasText(capabilityType)) { - SkillCapabilityType normalizedCapabilityType = SkillCapabilityType.from(capabilityType); - query.and("EXISTS (SELECT 1 FROM tb_skill_capability_binding b " - + "WHERE b.skill_id = tb_skill.id AND b.tenant_id = tb_skill.tenant_id " - + "AND b.capability_type = ?)", normalizedCapabilityType.name()); + query.eq("publish_status", publishStatus, hasText(publishStatus)) + .eq("visibility_scope", visibilityScope, hasText(visibilityScope)); + String effectiveKeyword = hasText(keyword) ? keyword : hasText(displayName) ? displayName : name; + if (hasText(effectiveKeyword)) { + String pattern = SearchKeywordUtil.literalContainsPattern(effectiveKeyword); + query.and("(name LIKE ? ESCAPE '\\\\' OR display_name LIKE ? ESCAPE '\\\\' " + + "OR description LIKE ? ESCAPE '\\\\' OR EXISTS (SELECT 1 FROM tb_sys_account a " + + "WHERE a.id = tb_skill.created_by AND a.tenant_id = tb_skill.tenant_id " + + "AND (a.nickname LIKE ? ESCAPE '\\\\' OR a.login_name LIKE ? ESCAPE '\\\\'))) ", + pattern, pattern, pattern, pattern, pattern); } query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc")); Page source = skillService.page(new Page<>(normalizedPage, normalizedSize), query); @@ -189,10 +175,6 @@ public class SkillController { @SaCheckPermission("/api/v1/skill/getDetail") public Result detail(BigInteger id) { Skill skill = skillService.getManagementDetail(id); - if (!StpUtil.hasPermission("/api/v1/skill/capability")) { - skill.setCapabilityBindings(null); - skill.setCapabilityHash(null); - } fillListState(List.of(skill)); return Result.ok(toView(skill)); } @@ -234,7 +216,7 @@ public class SkillController { * @return 新建的 Skill 草稿 */ @PostMapping("/copy") - @SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/capability"}) + @SaCheckPermission("/api/v1/skill/save") public Result copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) { if (request == null) { throw new BusinessException("复制参数不能为空"); @@ -247,7 +229,7 @@ public class SkillController { * 在展示发布确认前执行发布级全量校验。 * * @param id Skill ID - * @return 包含实时能力解析的结构化校验结果 + * @return 标准包结构化校验结果 */ @PostMapping("/validatePublish") @SaCheckPermission("/api/v1/skill/submitPublishApproval") @@ -343,6 +325,7 @@ public class SkillController { * * @param skillId Skill ID * @param path 文件路径 + * @param expectedContentHash 目标文件预期内容哈希,替换时必填 * @param file 上传文件 * @return 文件摘要 */ @@ -384,77 +367,28 @@ public class SkillController { } /** - * 查询 Skill 能力绑定。 + * 批量预览标准 ZIP 导入内容。 * - * @param skillId Skill ID - * @return 绑定列表 - */ - @GetMapping("/capability/list") - @SaCheckPermission(value = {"/api/v1/skill/getDetail", "/api/v1/skill/capability"}) - public Result> capabilityList(BigInteger skillId) { - return Result.ok(capabilityBindingService.listVisibleBindings(skillId).stream() - .map(SkillView.CapabilityView::from).toList()); - } - - /** - * 查询当前用户可绑定的能力候选。 - * - * @param type 能力类型 - * @param keyword 关键词 - * @return 候选列表 - */ - @GetMapping("/capability/candidates") - @SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR) - public Result> capabilityCandidates(String type, String keyword) { - return Result.ok(capabilityBindingService.listCandidates(SkillCapabilityType.from(type), keyword)); - } - - /** - * 获取 MCP 工具名清单。 - * - * @param targetId MCP ID - * @return MCP 候选详情 - */ - @GetMapping("/capability/tools") - @SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR) - public Result capabilityTools(BigInteger targetId) { - return Result.ok(capabilityBindingService.getMcpTools(targetId)); - } - - /** - * 原子替换 Skill 能力绑定。 - * - * @param skillId Skill ID - * @param requests 绑定白名单请求 - * @return 保存后的绑定 - */ - @PostMapping("/capability/replace") - @SaCheckPermission("/api/v1/skill/capability") - public Result replaceCapabilities( - @JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId, - @JsonBody(value = "expectedCapabilityHash", required = true, skipConvertError = false) - String expectedCapabilityHash, - @JsonBody(value = "bindings", required = true, skipConvertError = false) - List requests) { - List bindings = requests == null ? List.of() - : requests.stream().map(SkillCapabilityBindingRequest::toEntity).toList(); - List saved = capabilityBindingService.replaceBindings( - skillId, bindings, expectedCapabilityHash); - return Result.ok(new SkillCapabilityReplaceView( - saved.stream().map(SkillView.CapabilityView::from).toList(), - capabilityBindingService.calculateHash(saved))); - } - - /** - * 预览标准 ZIP 或 EasyFlow Bundle 导入内容。 - * - * @param file 导入文件 - * @return token 化预览 + * @param files 导入文件 + * @param file 兼容单文件字段 + * @return 每个文件的 token 化预览 */ @PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @SaCheckPermission("/api/v1/skill/import") - public Result importPreview(MultipartFile file) { - return Result.ok(skillImportService.preview(file)); + public Result> importPreview( + @RequestPart(value = "files", required = false) List files, + @RequestPart(value = "file", required = false) MultipartFile file) { + List uploads = new java.util.ArrayList<>(files == null ? List.of() : files); + if (file != null) { + uploads.add(file); + } + if (uploads.isEmpty()) { + throw new BusinessException("请选择要导入的标准 Skill ZIP"); + } + if (uploads.size() > 20) { + throw new BusinessException("单次最多预检 20 个 Skill ZIP"); + } + return Result.ok(uploads.stream().map(skillImportService::preview).toList()); } /** @@ -470,6 +404,35 @@ public class SkillController { return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList()); } + /** + * 独立确认多个已预检的标准 Skill ZIP;单包业务失败不回滚其他包。 + * + * @param requests 导入确认请求,按预检 token 一一对应 + * @return 各包独立导入结果 + */ + @PostMapping("/import/confirmBatch") + @SaCheckPermission("/api/v1/skill/import") + public Result> importConfirmBatch( + @JsonBody(required = true, skipConvertError = false) List requests) { + if (requests == null || requests.isEmpty()) { + throw new BusinessException("请选择要确认导入的 Skill"); + } + if (requests.size() > 20) { + throw new BusinessException("单次最多确认导入 20 个 Skill"); + } + List results = new java.util.ArrayList<>(requests.size()); + for (SkillImportConfirmRequest request : requests) { + String token = request == null ? null : request.getImportToken(); + try { + List skills = skillImportService.confirm(request).stream().map(this::toView).toList(); + results.add(SkillImportBatchResultView.succeeded(token, skills)); + } catch (BusinessException exception) { + results.add(SkillImportBatchResultView.failed(token, exception.getMessage())); + } + } + return Result.ok(results); + } + /** * 取消导入并清理临时包。 * @@ -485,7 +448,7 @@ public class SkillController { } /** - * 导出标准 Skill ZIP 或 EasyFlow 增强包。 + * 导出标准 Skill ZIP。 * * @param request 导出请求 * @param response HTTP 响应 @@ -500,9 +463,7 @@ public class SkillController { if (request.getIds().size() > 100) { throw new BusinessException("单次最多导出 100 个 Skill"); } - SkillImportFormat format = SkillImportFormat.from(request.getFormat()); - assertEnhancedExportPermission(format); - try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds(), format)) { + try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds())) { response.setContentType(artifact.getMediaType()); response.setHeader("Content-Disposition", attachment(artifact.getFileName())); artifact.transferTo(output(response)); @@ -510,32 +471,35 @@ public class SkillController { } /** - * 导出单个标准或增强 Skill 包。 + * 导出单个标准 Skill 包。 * * @param id Skill ID - * @param format 导出格式 * @param response HTTP 响应 */ @GetMapping("/export") @SaCheckPermission("/api/v1/skill/export") - public void exportOne(BigInteger id, String format, HttpServletResponse response) { + public void exportOne(BigInteger id, HttpServletResponse response) { if (id == null) { throw new BusinessException("Skill ID 不能为空"); } - writeExport(List.of(id), SkillImportFormat.from(format), response); + writeExport(List.of(id), response); } /** * 提交发布审批。 * * @param id Skill ID + * @param applicationReason 发布说明 * @return 审批实例 ID */ @PostMapping("/submitPublishApproval") @SaCheckPermission("/api/v1/skill/submitPublishApproval") public Result submitPublishApproval( - @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) { - return approvalResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布"); + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id, + @JsonBody(value = "applicationReason", required = true, skipConvertError = false) + String applicationReason) { + return approvalResult(skillPublishAppService.submitPublishApproval(id, applicationReason), + "已提交发布审批", "已直接发布"); } /** @@ -587,31 +551,18 @@ public class SkillController { private QueryWrapper descriptorQuery() { return QueryWrapper.create().select("id", "tenant_id", "dept_id", "category_id", "name", "display_name", "description", - "enabled", "visibility_scope", "source_type", "package_hash", "capability_hash", "snapshot_hash", - "resource_count", "capability_count", "reference_count", "script_count", "asset_count", + "visibility_scope", "package_hash", "snapshot_hash", "publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by"); } - private void writeExport(List ids, SkillImportFormat format, HttpServletResponse response) { - assertEnhancedExportPermission(format); - try (SkillExportArtifact artifact = skillExportService.prepare(ids, format)) { + private void writeExport(List ids, HttpServletResponse response) { + try (SkillExportArtifact artifact = skillExportService.prepare(ids)) { response.setContentType(artifact.getMediaType()); response.setHeader("Content-Disposition", attachment(artifact.getFileName())); artifact.transferTo(output(response)); } } - /** - * EasyFlow 增强包包含能力配置,导出时额外校验能力绑定查看权限。 - * - * @param format 导出格式 - */ - void assertEnhancedExportPermission(SkillImportFormat format) { - if (SkillImportFormat.EASYFLOW == format) { - StpUtil.checkPermission("/api/v1/skill/capability"); - } - } - private void fillListState(List skills) { skillApprovalStateService.fillSkillApprovalState(skills); creatorNameSupport.fillSkillCreatorNames(skills); diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityBindingRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityBindingRequest.java deleted file mode 100644 index 1c61d59d..00000000 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityBindingRequest.java +++ /dev/null @@ -1,59 +0,0 @@ -package tech.easyflow.admin.controller.skill.vo; - -import tech.easyflow.skill.entity.SkillCapabilityBinding; - -import java.math.BigInteger; -import java.util.List; -import java.util.Map; - -/** - * Skill 能力绑定写入白名单。 - * - * @param capabilityType 能力类型 - * @param targetId 当前环境目标 ID - * @param targetLogicalRef 跨环境逻辑引用 - * @param runtimeName 运行时名称 - * @param enabled 是否启用 - * @param selectionMode MCP 工具选择模式 - * @param selectedToolNamesJson 已选 MCP 工具 - * @param executionMode 执行模式 - * @param hitlEnabled 是否需要人工确认 - * @param hitlConfigJson 人工确认安全配置 - * @param optionsJson 执行安全配置 - * @param sortNo 排序号 - */ -public record SkillCapabilityBindingRequest(String capabilityType, - BigInteger targetId, - String targetLogicalRef, - String runtimeName, - Boolean enabled, - String selectionMode, - List selectedToolNamesJson, - String executionMode, - Boolean hitlEnabled, - Map hitlConfigJson, - Map optionsJson, - Integer sortNo) { - - /** - * 转换为能力绑定业务实体。 - * - * @return 仅包含可写字段的绑定实体 - */ - public SkillCapabilityBinding toEntity() { - SkillCapabilityBinding binding = new SkillCapabilityBinding(); - binding.setCapabilityType(capabilityType); - binding.setTargetId(targetId); - binding.setTargetLogicalRef(targetLogicalRef); - binding.setRuntimeName(runtimeName); - binding.setEnabled(enabled); - binding.setSelectionMode(selectionMode); - binding.setSelectedToolNamesJson(selectedToolNamesJson); - binding.setExecutionMode(executionMode); - binding.setHitlEnabled(hitlEnabled); - binding.setHitlConfigJson(hitlConfigJson); - binding.setOptionsJson(optionsJson); - binding.setSortNo(sortNo); - return binding; - } -} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityReplaceView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityReplaceView.java deleted file mode 100644 index 9b11fb43..00000000 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillCapabilityReplaceView.java +++ /dev/null @@ -1,13 +0,0 @@ -package tech.easyflow.admin.controller.skill.vo; - -import java.util.List; - -/** - * 能力绑定原子替换结果。 - * - * @param bindings 保存后的白名单绑定视图 - * @param capabilityHash 新能力配置哈希 - */ -public record SkillCapabilityReplaceView(List bindings, - String capabilityHash) { -} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java index 547878e1..5018790d 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillDraftRequest.java @@ -11,14 +11,12 @@ import java.math.BigInteger; * @param categoryId 分类 ID * @param displayName 展示名称 * @param skillContent SKILL.md 内容,仅创建时使用;已有草稿正文通过文件接口原子保存 - * @param enabled 是否启用 * @param visibilityScope 可见范围 */ public record SkillDraftRequest(BigInteger id, BigInteger categoryId, String displayName, String skillContent, - Boolean enabled, String visibilityScope) { /** @@ -32,7 +30,6 @@ public record SkillDraftRequest(BigInteger id, skill.setCategoryId(categoryId); skill.setDisplayName(displayName); skill.setSkillContent(skillContent); - skill.setEnabled(enabled); skill.setVisibilityScope(visibilityScope); return skill; } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillImportBatchResultView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillImportBatchResultView.java new file mode 100644 index 00000000..c31fb508 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillImportBatchResultView.java @@ -0,0 +1,41 @@ +package tech.easyflow.admin.controller.skill.vo; + +import java.util.List; + +/** + * 单个标准 Skill ZIP 的独立导入结果。 + * + * @param importToken 预检 token + * @param success 是否成功 + * @param message 失败原因,成功时为空 + * @param skills 导入成功的 Skill + */ +public record SkillImportBatchResultView( + String importToken, + boolean success, + String message, + List skills +) { + + /** + * 构造成功结果。 + * + * @param importToken 预检 token + * @param skills 导入的 Skill + * @return 成功结果 + */ + public static SkillImportBatchResultView succeeded(String importToken, List skills) { + return new SkillImportBatchResultView(importToken, true, null, List.copyOf(skills)); + } + + /** + * 构造失败结果。 + * + * @param importToken 预检 token + * @param message 失败原因 + * @return 失败结果 + */ + public static SkillImportBatchResultView failed(String importToken, String message) { + return new SkillImportBatchResultView(importToken, false, message, List.of()); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java index 5b92391c..acc44484 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java @@ -1,66 +1,46 @@ package tech.easyflow.admin.controller.skill.vo; +import com.easyagents.skill.util.SkillResources; import tech.easyflow.skill.entity.Skill; -import tech.easyflow.skill.entity.SkillCapabilityBinding; import tech.easyflow.skill.entity.SkillResource; import java.math.BigInteger; import java.util.Date; import java.util.List; -import java.util.Map; /** - * 管理端 Skill 视图,不暴露租户字段、二进制内部引用和发布快照。 + * 管理端 Skill 安全视图。 * * @param id Skill ID * @param categoryId 分类 ID - * @param name 规范名称 + * @param name 标准名称 * @param displayName 展示名称 - * @param description 描述 - * @param metadataJson frontmatter 扩展元数据 + * @param description 用途描述 * @param skillContent SKILL.md 内容 - * @param enabled 是否启用 - * @param visibilityScope 可见范围 - * @param sourceType 来源类型 - * @param packageHash 包 hash - * @param capabilityHash 能力 hash - * @param snapshotHash 发布快照 hash - * @param resourceCount 资源数 - * @param capabilityCount 能力数 - * @param referenceCount 参考文档数 - * @param scriptCount 脚本数 - * @param assetCount 二进制资源数 + * @param visibilityScope 使用范围 + * @param packageHash 标准包哈希 + * @param snapshotHash 发布快照哈希 * @param publishStatus 发布状态 * @param currentApprovalInstanceId 当前审批实例 ID * @param approvalPending 是否审批中 * @param currentApprovalActionType 当前审批动作 - * @param displayPublishStatus 展示发布状态 + * @param displayPublishStatus 展示状态 * @param created 创建时间 * @param modified 修改时间 - * @param createdByName 创建人名称 - * @param readable 当前用户是否可读 - * @param manageable 当前用户是否可管理 - * @param resources 包内资源摘要 - * @param bindings 能力绑定 + * @param createdByName 创建人昵称与账号 + * @param readable 是否可读 + * @param manageable 是否可管理 + * @param resources 资源摘要 */ public record SkillView(BigInteger id, BigInteger categoryId, String name, String displayName, String description, - Map metadataJson, String skillContent, - Boolean enabled, String visibilityScope, - String sourceType, String packageHash, - String capabilityHash, String snapshotHash, - Integer resourceCount, - Integer capabilityCount, - Integer referenceCount, - Integer scriptCount, - Integer assetCount, String publishStatus, BigInteger currentApprovalInstanceId, Boolean approvalPending, @@ -71,114 +51,50 @@ public record SkillView(BigInteger id, String createdByName, boolean readable, boolean manageable, - List resources, - List bindings) { + List resources) { /** - * 从业务实体创建安全视图。 + * 从领域实体构造管理端视图。 * * @param skill Skill 实体 * @param readable 是否可读 * @param manageable 是否可管理 - * @return Skill 管理视图 + * @return 管理端视图 */ public static SkillView from(Skill skill, boolean readable, boolean manageable) { List resources = skill.getResources() == null ? null : skill.getResources().stream().map(ResourceView::from).toList(); - List bindings = skill.getCapabilityBindings() == null ? null - : skill.getCapabilityBindings().stream() - .map(binding -> CapabilityView.from(binding, manageable)).toList(); return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(), - skill.getDescription(), skill.getMetadataJson(), skill.getSkillContent(), skill.getEnabled(), - skill.getVisibilityScope(), skill.getSourceType(), skill.getPackageHash(), skill.getCapabilityHash(), - skill.getSnapshotHash(), skill.getResourceCount(), skill.getCapabilityCount(), skill.getReferenceCount(), - skill.getScriptCount(), skill.getAssetCount(), skill.getPublishStatus(), - skill.getCurrentApprovalInstanceId(), skill.getApprovalPending(), skill.getCurrentApprovalActionType(), - skill.getDisplayPublishStatus(), skill.getCreated(), skill.getModified(), skill.getCreatedByName(), - readable, manageable, resources, bindings); + skill.getDescription(), skill.getSkillContent(), skill.getVisibilityScope(), skill.getPackageHash(), + skill.getSnapshotHash(), skill.getPublishStatus(), skill.getCurrentApprovalInstanceId(), + skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(), + skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources); } /** * Skill 包内资源摘要。 * * @param id 资源 ID - * @param path 路径 - * @param kind 类型 - * @param language 脚本语言 + * @param path 标准相对路径 + * @param kind 按路径派生的语义类型 * @param mediaType 媒体类型 * @param isText 是否文本 - * @param contentHash 内容 hash + * @param contentHash 内容哈希 * @param size 字节数 - * @param metadataJson 扩展元数据 */ - public record ResourceView(BigInteger id, String path, String kind, String language, String mediaType, - Boolean isText, String contentHash, Long size, Map metadataJson) { + public record ResourceView(BigInteger id, String path, String kind, String mediaType, + Boolean isText, String contentHash, Long size) { /** * 转换资源实体。 * * @param resource 资源实体 - * @return 资源视图 + * @return 资源摘要 */ public static ResourceView from(SkillResource resource) { - return new ResourceView(resource.getId(), resource.getNormalizedPath(), resource.getKind(), - resource.getLanguage(), resource.getMediaType(), resource.getIsText(), resource.getContentHash(), - resource.getSize(), resource.getMetadataJson()); - } - } - - /** - * Skill 能力绑定视图。 - * - * @param id 绑定 ID - * @param capabilityType 能力类型 - * @param targetId 目标 ID - * @param targetLogicalRef 跨环境逻辑引用 - * @param runtimeName 运行时名称 - * @param enabled 是否启用 - * @param selectionMode 工具选择模式 - * @param selectedToolNamesJson 已选工具 - * @param executionMode 执行模式 - * @param hitlEnabled 是否人工确认 - * @param hitlConfigJson 人工确认安全配置 - * @param optionsJson 执行安全配置 - * @param sortNo 排序号 - * @param targetName 目标名称 - * @param targetStatus 目标状态 - * @param resolvedToolNames 已解析工具 - */ - public record CapabilityView(BigInteger id, String capabilityType, BigInteger targetId, String targetLogicalRef, - String runtimeName, Boolean enabled, String selectionMode, - List selectedToolNamesJson, String executionMode, Boolean hitlEnabled, - Map hitlConfigJson, Map optionsJson, Integer sortNo, - String targetName, String targetStatus, List resolvedToolNames) { - - /** - * 转换绑定实体。 - * - * @param binding 绑定实体 - * @return 绑定视图 - */ - public static CapabilityView from(SkillCapabilityBinding binding) { - return from(binding, true); - } - - /** - * 按管理权限转换绑定实体,READ 用户看不到当前环境内部目标 ID。 - * - * @param binding 绑定实体 - * @param includeTargetId 是否包含目标 ID - * @return 绑定视图 - */ - public static CapabilityView from(SkillCapabilityBinding binding, boolean includeTargetId) { - boolean hideUnavailableTarget = !includeTargetId && "NO_PERMISSION".equals(binding.getTargetStatus()); - return new CapabilityView(binding.getId(), binding.getCapabilityType(), - includeTargetId ? binding.getTargetId() : null, - binding.getTargetLogicalRef(), binding.getRuntimeName(), binding.getEnabled(), - binding.getSelectionMode(), binding.getSelectedToolNamesJson(), binding.getExecutionMode(), - binding.getHitlEnabled(), binding.getHitlConfigJson(), binding.getOptionsJson(), binding.getSortNo(), - hideUnavailableTarget ? null : binding.getTargetName(), binding.getTargetStatus(), - hideUnavailableTarget ? List.of() : binding.getResolvedToolNames()); + String path = resource.getNormalizedPath(); + return new ResourceView(resource.getId(), path, SkillResources.classify(path).name(), + resource.getMediaType(), resource.getIsText(), resource.getContentHash(), resource.getSize()); } } } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java index 488ba3dc..064f9cb6 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java @@ -1,66 +1,62 @@ package tech.easyflow.admin.controller.skill; +import cn.dev33.satoken.annotation.SaCheckPermission; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; -import cn.dev33.satoken.annotation.SaCheckPermission; -import cn.dev33.satoken.stp.StpUtil; import org.testng.Assert; import org.testng.annotations.Test; -import org.mockito.MockedStatic; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; -import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest; import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest; import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest; +import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView; import tech.easyflow.admin.controller.skill.vo.SkillView; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBodyParser; -import tech.easyflow.skill.capability.SkillCapabilityBindingService; import tech.easyflow.skill.entity.Skill; -import tech.easyflow.skill.entity.SkillCapabilityBinding; import tech.easyflow.skill.file.SkillFileService; import tech.easyflow.skill.imports.SkillExportService; -import tech.easyflow.skill.imports.SkillImportFormat; +import tech.easyflow.skill.imports.SkillImportConfirmRequest; import tech.easyflow.skill.imports.SkillImportService; import tech.easyflow.skill.publish.SkillPublishAppService; import tech.easyflow.skill.security.SkillVisibilityQueryHelper; import tech.easyflow.skill.service.SkillApprovalStateService; import tech.easyflow.skill.service.SkillService; -import tech.easyflow.skill.validation.SkillValidationResult; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.ResourceAccessService; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.math.BigInteger; +import java.util.Arrays; import java.util.List; +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.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * {@link SkillController} 写入 DTO 与返回视图静态契约测试。 + * {@link SkillController} 标准包管理 API 契约测试。 */ public class SkillControllerContractTest { /** - * 验证当前 Fastjson 与 JsonBody 解析链路支持 Skill 草稿 record。 + * 草稿白名单 DTO 只接受标准包治理字段。 * - * @throws Exception DTO 反序列化失败 + * @throws Exception 反序列化失败 */ @Test - public void jsonBodyParserDeserializesSkillDraftRecord() throws Exception { + public void jsonBodyParserDeserializesStandardDraftRecord() throws Exception { JSONObject json = JSON.parseObject(""" { "id": 101, "categoryId": 9, "displayName": "演示 Skill", - "skillContent": "---\\nname: demo-skill\\ndescription: Demo\\n---\\n# Demo\\n", - "enabled": true, + "skillContent": "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n", "visibilityScope": "PRIVATE" } """); @@ -71,48 +67,15 @@ public class SkillControllerContractTest { Assert.assertEquals(request.id(), BigInteger.valueOf(101)); Assert.assertEquals(request.categoryId(), BigInteger.valueOf(9)); Assert.assertEquals(request.displayName(), "演示 Skill"); - Assert.assertTrue(request.enabled()); Assert.assertEquals(request.visibilityScope(), "PRIVATE"); + Assert.assertFalse(Arrays.stream(SkillDraftRequest.class.getRecordComponents()) + .anyMatch(component -> "enabled".equals(component.getName()))); } /** - * 验证当前 Fastjson 与 JsonBody 解析链路支持含集合和映射的能力绑定 record。 + * 保存入口使用白名单 DTO 并返回 Skill 视图。 * - * @throws Exception DTO 反序列化失败 - */ - @Test - public void jsonBodyParserDeserializesCapabilityBindingRecord() throws Exception { - JSONObject json = JSON.parseObject(""" - { - "capabilityType": "MCP", - "targetId": 77, - "targetLogicalRef": "mcp://demo", - "runtimeName": "demo_mcp", - "enabled": true, - "selectionMode": "SELECTED", - "selectedToolNamesJson": ["search", "fetch"], - "executionMode": "SYNC", - "hitlEnabled": true, - "hitlConfigJson": {"prompt": "确认执行"}, - "optionsJson": {"timeoutMs": 3000}, - "sortNo": 2 - } - """); - - SkillCapabilityBindingRequest request = (SkillCapabilityBindingRequest) JsonBodyParser.parseJsonBody( - json, SkillCapabilityBindingRequest.class, SkillCapabilityBindingRequest.class, ""); - - Assert.assertEquals(request.capabilityType(), "MCP"); - Assert.assertEquals(request.targetId(), BigInteger.valueOf(77)); - Assert.assertEquals(request.selectedToolNamesJson(), List.of("search", "fetch")); - Assert.assertEquals(request.hitlConfigJson().get("prompt"), "确认执行"); - Assert.assertEquals(((Number) request.optionsJson().get("timeoutMs")).intValue(), 3000); - } - - /** - * 验证草稿写入口使用 JsonBody 白名单 DTO,并返回 SkillView。 - * - * @throws Exception 控制器方法反射失败 + * @throws Exception 反射失败 */ @Test public void saveEndpointUsesDraftRequestAndSkillView() throws Exception { @@ -126,163 +89,76 @@ public class SkillControllerContractTest { } /** - * 验证详情视图按 MANAGE 权限隐藏或保留当前环境目标 ID。 - */ - @Test - public void detailViewProjectsCapabilityTargetIdByManagePermission() { - SkillCapabilityBinding binding = binding(BigInteger.valueOf(77)); - Skill skill = new Skill(); - skill.setCapabilityBindings(List.of(binding)); - - SkillView readOnly = SkillView.from(skill, true, false); - SkillView manageable = SkillView.from(skill, true, true); - - Assert.assertNull(readOnly.bindings().get(0).targetId()); - Assert.assertEquals(manageable.bindings().get(0).targetId(), BigInteger.valueOf(77)); - } - - /** - * 验证能力替换响应沿用可编辑投影并保留目标 ID。 - */ - @Test - public void replaceResponseProjectionKeepsEditableTargetId() { - SkillView.CapabilityView view = SkillView.CapabilityView.from(binding(BigInteger.valueOf(88))); - - Assert.assertEquals(view.targetId(), BigInteger.valueOf(88)); - } - - /** - * 验证能力列表端点只调用按权限脱敏的读取方法。 - */ - @Test - public void capabilityListEndpointUsesPermissionAwareBindingRead() { - BigInteger skillId = BigInteger.valueOf(101); - SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class); - SkillCapabilityBinding redacted = binding(null); - when(bindingService.listVisibleBindings(skillId)).thenReturn(List.of(redacted)); - SkillController controller = controller(bindingService); - - Result> result = controller.capabilityList(skillId); - - Assert.assertNull(result.getData().get(0).targetId()); - verify(bindingService).listVisibleBindings(skillId); - verify(bindingService, never()).listBindings(skillId); - } - - /** - * 验证复制需要新建和能力绑定双重操作权限。 + * 复制只依赖 Skill 新建权限,不再暴露能力绑定权限。 * - * @throws Exception 控制器方法反射失败 + * @throws Exception 反射失败 */ @Test - public void copyEndpointDeclaresIndependentOperationPermissions() throws Exception { - SaCheckPermission copyPermission = SkillController.class + public void copyEndpointUsesOnlySavePermission() throws Exception { + SaCheckPermission permission = SkillController.class .getMethod("copy", SkillCopyRequest.class).getAnnotation(SaCheckPermission.class); - Assert.assertEquals(copyPermission.value(), - new String[]{"/api/v1/skill/save", "/api/v1/skill/capability"}); - } - - /** - * 验证正式删除审批入口使用真实操作权限,不引用历史死权限。 - * - * @throws Exception 控制器方法反射失败 - */ - @Test - public void deleteEndpointUsesCanonicalDeletePermission() throws Exception { - SaCheckPermission submitPermission = SkillController.class - .getMethod("submitDeleteApproval", BigInteger.class).getAnnotation(SaCheckPermission.class); - - Assert.assertEquals(submitPermission.value(), - new String[]{"/api/v1/skill/submitDeleteApproval"}); - Assert.assertFalse(java.util.Arrays.stream(SkillController.class.getDeclaredMethods()) + Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/save"}); + Assert.assertFalse(Arrays.stream(SkillController.class.getDeclaredMethods()) .map(method -> method.getAnnotation(SaCheckPermission.class)) .filter(java.util.Objects::nonNull) - .flatMap(permission -> java.util.Arrays.stream(permission.value())) - .anyMatch("/api/v1/skill/remove"::equals)); + .flatMap(item -> Arrays.stream(item.value())) + .anyMatch("/api/v1/skill/capability"::equals)); } /** - * 验证发布预检复用发布权限,并明确调用发布级校验。 - * - * @throws Exception 控制器方法反射失败 + * 发布入口把必填发布说明原样交给应用服务。 */ @Test - public void publishValidationUsesPublishPermissionAndFullValidation() throws Exception { - BigInteger skillId = BigInteger.valueOf(101); - SkillService skillService = mock(SkillService.class); - SkillValidationResult validation = new SkillValidationResult(); - validation.setValid(true); - when(skillService.validateSkill(skillId, true)).thenReturn(validation); - SkillController controller = controller(skillService, mock(SkillCapabilityBindingService.class)); + public void publishEndpointForwardsRequiredReason() { + BigInteger id = BigInteger.valueOf(101); + SkillPublishAppService publishService = mock(SkillPublishAppService.class); + when(publishService.submitPublishApproval(id, "补充审核规则")) + .thenReturn(ApprovalActionResult.direct()); + SkillController controller = controller(mock(SkillImportService.class), publishService); - Result result = controller.validatePublish(skillId); - SaCheckPermission permission = SkillController.class - .getMethod("validatePublish", BigInteger.class) - .getAnnotation(SaCheckPermission.class); + controller.submitPublishApproval(id, "补充审核规则"); - Assert.assertSame(result.getData(), validation); - Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/submitPublishApproval"}); - verify(skillService).validateSkill(skillId, true); + verify(publishService).submitPublishApproval(id, "补充审核规则"); } /** - * 验证标准导出不追加能力权限,EasyFlow 增强导出必须检查能力绑定查看权限。 + * 批量确认按 token 隔离业务失败,成功项仍保留结果。 */ @Test - public void enhancedExportRequiresCapabilityPermission() { - SkillController controller = controller(mock(SkillCapabilityBindingService.class)); + public void batchConfirmKeepsIndependentResults() { + SkillImportService importService = mock(SkillImportService.class); + SkillImportConfirmRequest first = request("a".repeat(32)); + SkillImportConfirmRequest second = request("b".repeat(32)); + when(importService.confirm(first)).thenThrow(new BusinessException("名称不可用")); + Skill imported = new Skill(); + imported.setId(BigInteger.ONE); + imported.setName("demo-skill"); + imported.setPublishStatus(PublishStatus.DRAFT.getCode()); + when(importService.confirm(second)).thenReturn(List.of(imported)); - try (MockedStatic stp = mockStatic(StpUtil.class)) { - controller.assertEnhancedExportPermission(SkillImportFormat.STANDARD); - stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), never()); + List results = controller(importService, mock(SkillPublishAppService.class)) + .importConfirmBatch(List.of(first, second)).getData(); - controller.assertEnhancedExportPermission(SkillImportFormat.EASYFLOW); - stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), times(1)); - } + Assert.assertFalse(results.get(0).success()); + Assert.assertEquals(results.get(0).message(), "名称不可用"); + Assert.assertTrue(results.get(1).success()); + Assert.assertEquals(results.get(1).skills().get(0).name(), "demo-skill"); } - /** - * 创建测试能力绑定。 - * - * @param targetId 目标 ID - * @return 能力绑定 - */ - private SkillCapabilityBinding binding(BigInteger targetId) { - SkillCapabilityBinding binding = new SkillCapabilityBinding(); - binding.setId(BigInteger.ONE); - binding.setCapabilityType("MCP"); - binding.setTargetId(targetId); - binding.setTargetLogicalRef("mcp:demo"); - binding.setRuntimeName("demo_mcp"); - binding.setEnabled(true); - binding.setSelectionMode("ALL"); - binding.setHitlEnabled(false); - return binding; + private SkillImportConfirmRequest request(String token) { + SkillImportConfirmRequest request = new SkillImportConfirmRequest(); + request.setImportToken(token); + request.setVisibilityScope("PRIVATE"); + return request; } - /** - * 创建只注入能力服务的控制器测试实例。 - * - * @param bindingService 能力绑定服务 - * @return 控制器实例 - */ - private SkillController controller(SkillCapabilityBindingService bindingService) { - return controller(mock(SkillService.class), bindingService); - } - - /** - * 创建注入指定 Skill 与能力服务的控制器测试实例。 - * - * @param skillService Skill 服务 - * @param bindingService 能力绑定服务 - * @return 控制器实例 - */ - private SkillController controller(SkillService skillService, SkillCapabilityBindingService bindingService) { - return new SkillController(skillService, mock(SkillApprovalStateService.class), - mock(SkillPublishAppService.class), mock(SkillImportService.class), mock(SkillExportService.class), - mock(SkillFileService.class), bindingService, mock(ResourceAccessService.class), - mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class), + private SkillController controller(SkillImportService importService, SkillPublishAppService publishService) { + ResourceAccessService accessService = mock(ResourceAccessService.class); + when(accessService.canAccess(any(), any(), any())).thenReturn(true); + return new SkillController(mock(SkillService.class), mock(SkillApprovalStateService.class), + publishService, importService, mock(SkillExportService.class), mock(SkillFileService.class), + accessService, mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class), mock(AiResourceCreatorNameSupport.class)); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java index 14d33b6b..9fbe635f 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java @@ -47,6 +47,32 @@ public interface AiResourceLifecycleHandler { */ void applyApprovedAction(String actionType, BigInteger resourceId, Map resourceSnapshot, BigInteger operatorId); + /** + * 执行带审批实例身份的通过回调。 + * + * @param actionType 动作类型 + * @param resourceId 资源 ID + * @param resourceSnapshot 审批冻结快照 + * @param operatorId 操作人 ID + * @param approvalInstanceId 审批实例 ID + */ + default void applyApprovedAction(String actionType, + BigInteger resourceId, + Map resourceSnapshot, + BigInteger operatorId, + BigInteger approvalInstanceId) { + applyApprovedAction(actionType, resourceId, resourceSnapshot, operatorId); + } + + /** + * 在实际提交或直接执行前持有冻结快照所需资源。 + * + * @param actionType 动作类型 + * @param resourceSnapshot 冻结快照 + */ + default void retainSubmittedSnapshot(String actionType, Map resourceSnapshot) { + } + /** * 按提交前真实状态恢复资源状态。 * @@ -54,4 +80,17 @@ public interface AiResourceLifecycleHandler { * @param previousStatus 提交前状态 */ void restoreState(BigInteger resourceId, PublishStatus previousStatus); + + /** + * 按审批实例身份恢复提交前状态。 + * + * @param resourceId 资源 ID + * @param previousStatus 提交前状态 + * @param approvalInstanceId 审批实例 ID + */ + default void restoreState(BigInteger resourceId, + PublishStatus previousStatus, + BigInteger approvalInstanceId) { + restoreState(resourceId, previousStatus); + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java index dadc37e3..9a233d54 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java @@ -63,8 +63,10 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic AiResourceLifecycleHandler handler = getHandler(resourceType); ApprovalSubmitRequest request = handler.buildSubmitRequest(resourceId, actionType, operatorId); ApprovalFlowDetailVo flow = approvalMatchService.matchFlowOrNull(request); + Map resourceSnapshot = readResourceSnapshot(request.getSnapshotJson()); if (flow == null) { - handler.applyApprovedAction(actionType, resourceId, readResourceSnapshot(request.getSnapshotJson()), operatorId); + handler.retainSubmittedSnapshot(actionType, resourceSnapshot); + handler.applyApprovedAction(actionType, resourceId, resourceSnapshot, operatorId); return ApprovalActionResult.direct(); } request.setApplicationReason(ApprovalApplicationReasonPolicy.normalize( @@ -73,6 +75,7 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic applicationReason )); BigInteger instanceId = approvalInstanceService.submitApproval(request); + handler.retainSubmittedSnapshot(actionType, resourceSnapshot); handler.updatePendingState( resourceId, resolveSubmittedStatus(actionType, resolvePreviousStatus(request.getSnapshotJson())), @@ -100,12 +103,14 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic * {@inheritDoc} */ @Override + @Transactional(rollbackFor = Exception.class) public void handleApproved(ApprovalInstance instance, BigInteger operatorId, String comment) { getHandler(instance.getResourceType()).applyApprovedAction( instance.getActionType(), instance.getResourceId(), readResourceSnapshot(instance.getSnapshotJson()), - operatorId + operatorId, + instance.getId() ); } @@ -113,10 +118,12 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic * {@inheritDoc} */ @Override + @Transactional(rollbackFor = Exception.class) public void handleRejected(ApprovalInstance instance, BigInteger operatorId, String comment) { getHandler(instance.getResourceType()).restoreState( instance.getResourceId(), - resolvePreviousStatus(instance.getSnapshotJson()) + resolvePreviousStatus(instance.getSnapshotJson()), + instance.getId() ); } @@ -124,10 +131,12 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic * {@inheritDoc} */ @Override + @Transactional(rollbackFor = Exception.class) public void handleRevoked(ApprovalInstance instance, BigInteger operatorId, String comment) { getHandler(instance.getResourceType()).restoreState( instance.getResourceId(), - resolvePreviousStatus(instance.getSnapshotJson()) + resolvePreviousStatus(instance.getSnapshotJson()), + instance.getId() ); } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalInstanceService.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalInstanceService.java index 01e3248c..ec973358 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalInstanceService.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalInstanceService.java @@ -61,4 +61,14 @@ public interface ApprovalInstanceService { * @return 审批实例 */ ApprovalInstance getById(BigInteger instanceId); + + /** + * 判断给定实例是否仍是资源最近创建的审批实例。 + * + * @param instanceId 审批实例 ID + * @param resourceType 资源类型 + * @param resourceId 资源 ID + * @return 没有更新实例时返回 {@code true} + */ + boolean isLatestResourceInstance(BigInteger instanceId, String resourceType, BigInteger resourceId); } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java index 6b25f1d0..ee2bc24a 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java @@ -274,6 +274,20 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { .eq(ApprovalInstance::getTenantId, requireCurrentTenantId())); } + /** + * {@inheritDoc} + */ + @Override + public boolean isLatestResourceInstance(BigInteger instanceId, + String resourceType, + BigInteger resourceId) { + return approvalInstanceMapper.selectCountByQuery(QueryWrapper.create() + .eq(ApprovalInstance::getTenantId, requireCurrentTenantId()) + .eq(ApprovalInstance::getResourceType, resourceType) + .eq(ApprovalInstance::getResourceId, resourceId) + .gt(ApprovalInstance::getId, instanceId)) == 0; + } + private Map buildInstanceSnapshot(ApprovalSubmitRequest request, ApprovalFlowDetailVo flow, List steps, SysDept applicantDept) { Map snapshot = new LinkedHashMap<>(); diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingService.java deleted file mode 100644 index 72b86623..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingService.java +++ /dev/null @@ -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 { - - /** - * 查询当前用户可查看的 Skill 能力绑定。 - * - * @param skillId Skill ID - * @return 有序绑定列表 - */ - List listBindings(BigInteger skillId); - - /** - * 查询面向管理端读取接口的安全绑定,并按当前用户 MANAGE 权限隐藏内部目标 ID。 - * - * @param skillId Skill ID - * @return 有序安全绑定列表 - */ - List listVisibleBindings(BigInteger skillId); - - /** - * 原子替换 Skill 能力绑定。 - * - * @param skillId Skill ID - * @param bindings 新绑定列表 - * @return 保存后的绑定列表 - */ - List replaceBindings(BigInteger skillId, List bindings); - - /** - * 按客户端读取到的能力 hash 原子替换绑定,防止多标签页相互覆盖。 - * - * @param skillId Skill ID - * @param bindings 新绑定列表 - * @param expectedCapabilityHash 客户端读取到的能力 hash - * @return 保存后的绑定列表 - */ - List replaceBindings(BigInteger skillId, - List bindings, - String expectedCapabilityHash); - - /** - * 校验待保存或现有绑定。 - * - * @param skillId Skill ID - * @param bindings 可选待校验绑定,为空时校验已保存绑定 - * @param publishValidation 是否执行发布级实时工具解析 - * @return 结构化校验结果 - */ - SkillValidationResult validateBindings(BigInteger skillId, - List bindings, - boolean publishValidation); - - /** - * 校验增强包导入预览中的能力绑定。 - * - *

该入口不读取或写入 Skill 业务数据,也不要求已有 Skill 权限。未映射目标仅保留给 - * 导入映射步骤处理;已经映射的目标仍会校验当前操作者的使用权限和可用状态。

- * - * @param bindings 从增强包 manifest 还原的能力绑定 - * @return 结构化校验结果 - */ - SkillValidationResult validateImportBindings(List bindings); - - /** - * 查询可绑定能力候选项。 - * - * @param capabilityType 能力类型 - * @param keyword 关键词 - * @return 候选列表 - */ - List listCandidates(SkillCapabilityType capabilityType, String keyword); - - /** - * 按需获取 MCP 工具清单。 - * - * @param targetId MCP ID - * @return MCP 候选详情 - */ - SkillCapabilityCandidate getMcpTools(BigInteger targetId); - - /** - * 构建经过发布级校验的安全快照。 - * - * @param skillId Skill ID - * @return 不含凭据的能力快照 - */ - List> buildPublishSnapshot(BigInteger skillId); - - /** - * 计算当前能力配置 hash。 - * - * @param bindings 能力绑定 - * @return SHA-256 hash - */ - String calculateHash(List bindings); - - /** - * 基于数据库原始绑定计算 hash,不暴露可能被展示边界脱敏的历史配置。 - * - * @param skillId Skill ID - * @return SHA-256 hash - */ - String calculateStoredHash(BigInteger skillId); - - /** - * 删除 Skill 的全部能力绑定。 - * - * @param skillId Skill ID - */ - void removeBySkillId(BigInteger skillId); -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImpl.java deleted file mode 100644 index 146f832f..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImpl.java +++ /dev/null @@ -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 - 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 listBindings(BigInteger skillId) { - return listBindings(skillId, false); - } - - /** - * {@inheritDoc} - */ - @Override - public List listVisibleBindings(BigInteger skillId) { - return listBindings(skillId, true); - } - - /** - * 查询并填充绑定展示状态,可选按 MANAGE 权限移除内部目标标识。 - * - * @param skillId Skill ID - * @param redactReadOnlyTargets 是否为只读调用方脱敏 - * @return 有序绑定列表 - */ - private List 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 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 replaceBindings(BigInteger skillId, List bindings) { - return replaceBindingsInternal(skillId, bindings, null); - } - - /** - * {@inheritDoc} - */ - @Override - @Transactional(rollbackFor = Exception.class) - public List replaceBindings(BigInteger skillId, - List bindings, - String expectedCapabilityHash) { - if (expectedCapabilityHash == null || !expectedCapabilityHash.matches("^[a-f0-9]{64}$")) { - throw new BusinessException(409, 4093, "缺少或无效的能力配置版本,请重新加载后再保存"); - } - return replaceBindingsInternal(skillId, bindings, expectedCapabilityHash); - } - - private List replaceBindingsInternal(BigInteger skillId, - List 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 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 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 bindings) { - return validateInternal(bindings == null ? List.of() : bindings, ValidationMode.IMPORT_PREVIEW); - } - - /** - * {@inheritDoc} - */ - @Override - public List 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> buildPublishSnapshot(BigInteger skillId) { - List bindings = listRaw(skillId); - ValidatedBindings validated = validateInternalWithTargets(bindings, ValidationMode.PUBLISH); - assertNoErrors(validated.result()); - List> 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 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 bindings) { - List> 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 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 bindings, ValidationMode mode) { - return validateInternalWithTargets(bindings, mode).result(); - } - - /** - * 校验能力绑定并保留本次调用已授权的目标摘要,供发布快照复用。 - * - * @param bindings 能力绑定 - * @param mode 校验场景 - * @return 校验结果与按绑定序号记录的目标摘要 - */ - private ValidatedBindings validateInternalWithTargets(List bindings, - ValidationMode mode) { - assertMcpAccessWhenPresent(bindings); - List 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 runtimeNames = new HashSet<>(); - Map targetCache = new HashMap<>(); - Map 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 查询与使用权限。 - * - *

该检查先于目标映射执行,因此禁用或尚未映射的 MCP 绑定也不能绕过授权。

- * - * @param bindings 待校验能力绑定 - */ - private void assertMcpAccessWhenPresent(List 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 runtimeNames, - Map targetCache, - Map targetsByIndex, - List 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 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 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 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 issues) { - // 展示元数据与逻辑引用在快照构造时采用 fail-closed 降级;工具名会直接成为运行时名称,必须阻断。 - List 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 runtimeNames, - List 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 runtimeNames, - List issues) { - SkillCapabilitySelectionMode mode = SkillCapabilitySelectionMode.fromOrDefault(binding.getSelectionMode()); - List selected = binding.getSelectedToolNamesJson(); - if (!enabled) { - return; - } - List available = target == null ? List.of() : target.getToolNames(); - List 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 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 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 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 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 issues) { - Map originalHitl = binding.getHitlConfigJson() == null - ? Map.of() : binding.getHitlConfigJson(); - Map originalOptions = binding.getOptionsJson() == null - ? Map.of() : binding.getOptionsJson(); - Map safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()); - Map 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 hitl, - Map options, - String path, - List issues) { - for (Map.Entry 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 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 options, - String key, - int minimum, - int maximum, - String path, - List 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 hitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()); - hitl.entrySet().removeIf(entry -> !(entry.getValue() instanceof String text) - || SkillCredentialValueGuard.containsCredential(text)); - binding.setHitlConfigJson(hitl); - - Map options = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()); - options.entrySet().removeIf(entry -> !isSafeOptionForExposure(entry.getKey(), entry.getValue())); - binding.setOptionsJson(options); - } - - /** - * 过滤历史工具名中的异常或凭据式值。 - * - * @param values 原始工具名 - * @return 可安全展示的工具名 - */ - private List sanitizeToolNamesForExposure(List 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 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 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 targetsByIndex) { - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityCandidate.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityCandidate.java deleted file mode 100644 index 10e472cf..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityCandidate.java +++ /dev/null @@ -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 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 getToolNames() { return toolNames; } - public void setToolNames(List toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTarget.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTarget.java deleted file mode 100644 index 6c57f54f..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTarget.java +++ /dev/null @@ -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 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 getToolNames() { return toolNames; } - public void setToolNames(List toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessService.java deleted file mode 100644 index ace3d6d8..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessService.java +++ /dev/null @@ -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 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); -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessServiceImpl.java deleted file mode 100644 index a23a66df..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/capability/SkillCapabilityTargetAccessServiceImpl.java +++ /dev/null @@ -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 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 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 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 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 items = pluginItemService.list(query); - Map plugins = loadPlugins(items); - Map preparedPlugins = new LinkedHashMap<>(); - for (Plugin plugin : plugins.values()) { - preparedPlugins.put(plugin.getId(), pluginService.preparePluginForCurrentUser(plugin)); - } - List 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 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 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 loadPlugins(List items) { - List ids = items.stream().map(PluginItem::getPluginId).filter(Objects::nonNull).distinct().toList(); - if (ids.isEmpty()) { - return Map.of(); - } - Map 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 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 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 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 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 matches = mcpService.list(QueryWrapper.create() - .eq(Mcp::getTenantId, requireAccount().getTenantId()) - .eq(Mcp::getTitle, title).limit(2)); - List 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; - } - -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java index 2d8726b0..bc2dcdf5 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java @@ -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 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 references; - @Column(ignore = true) - private List scripts; - @Column(ignore = true) - private List assets; - @Column(ignore = true) private List resources; - @Column(ignore = true) - private List 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 getMetadataJson() { return metadataJson; } - public void setMetadataJson(Map 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 getReferences() { return references; } - public void setReferences(List references) { this.references = references; } - public List getScripts() { return scripts; } - public void setScripts(List scripts) { this.scripts = scripts; } - public List getAssets() { return assets; } - public void setAssets(List assets) { this.assets = assets; } public List getResources() { return resources; } public void setResources(List resources) { this.resources = resources; } - public List getCapabilityBindings() { return capabilityBindings; } - public void setCapabilityBindings(List capabilityBindings) { this.capabilityBindings = capabilityBindings; } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAsset.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAsset.java deleted file mode 100644 index 8b4c9192..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAsset.java +++ /dev/null @@ -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 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 getMetadataJson() { return metadataJson; } - public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCapabilityBinding.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCapabilityBinding.java deleted file mode 100644 index 310a526d..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCapabilityBinding.java +++ /dev/null @@ -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 selectedToolNamesJson = new ArrayList<>(); - private String executionMode; - private Boolean hitlEnabled; - @Column(typeHandler = FastjsonTypeHandler.class) - private Map hitlConfigJson = new LinkedHashMap<>(); - @Column(typeHandler = FastjsonTypeHandler.class) - private Map 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 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 getSelectedToolNamesJson() { return selectedToolNamesJson; } - public void setSelectedToolNamesJson(List 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 getHitlConfigJson() { return hitlConfigJson; } - public void setHitlConfigJson(Map hitlConfigJson) { this.hitlConfigJson = hitlConfigJson == null ? new LinkedHashMap<>() : hitlConfigJson; } - public Map getOptionsJson() { return optionsJson; } - public void setOptionsJson(Map 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 getResolvedToolNames() { return resolvedToolNames; } - public void setResolvedToolNames(List resolvedToolNames) { this.resolvedToolNames = resolvedToolNames == null ? new ArrayList<>() : new ArrayList<>(resolvedToolNames); } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java index 8e8b13f6..f2c55d76 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillImportStage.java @@ -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; } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillReference.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillReference.java deleted file mode 100644 index ac3ac37b..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillReference.java +++ /dev/null @@ -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 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 getMetadataJson() { return metadataJson; } - public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java index f8be26d6..d1d2582b 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillResource.java @@ -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 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 getMetadataJson() { return metadataJson; } - public void setMetadataJson(Map 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; } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillScript.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillScript.java deleted file mode 100644 index 37c763df..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillScript.java +++ /dev/null @@ -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 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 getMetadataJson() { return metadataJson; } - public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityExecutionMode.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityExecutionMode.java deleted file mode 100644 index 9de1de43..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityExecutionMode.java +++ /dev/null @@ -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("不支持的能力执行模式"); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilitySelectionMode.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilitySelectionMode.java deleted file mode 100644 index 6ac545d7..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilitySelectionMode.java +++ /dev/null @@ -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 工具选择模式"); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityType.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityType.java deleted file mode 100644 index d15ce476..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillCapabilityType.java +++ /dev/null @@ -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("不支持的能力类型"); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java index 881c8663..00d1eee5 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java @@ -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 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); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowBundleReader.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowBundleReader.java deleted file mode 100644 index 0d091460..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowBundleReader.java +++ /dev/null @@ -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 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 paths = new HashSet<>(); - Set collisionKeys = new HashSet<>(); - try (ZipFile input = new ZipFile(packageFile); - ZipOutputStream output = new ZipOutputStream( - Files.newOutputStream(standardZip, StandardOpenOption.TRUNCATE_EXISTING), - StandardCharsets.UTF_8)) { - Enumeration 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 manifest; - - private PreparedBundle(Path standardZip, Map 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 getManifest() { - return manifest; - } - - /** - * 删除转换临时文件。 - */ - @Override - public void close() { - deleteQuietly(standardZip); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodec.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodec.java deleted file mode 100644 index 693b103e..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodec.java +++ /dev/null @@ -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 ROOT_FIELDS = Set.of("schemaVersion", "skills"); - private static final Set SKILL_FIELDS = Set.of("packageRoot", "packageHash", "capabilities"); - private static final Set 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 skills) { - if (skills == null || skills.size() > MAX_SKILLS) { - throw new BusinessException("单个 EasyFlow Skill Bundle 最多包含 " + MAX_SKILLS + " 个 Skill"); - } - Map manifest = new LinkedHashMap<>(); - manifest.put("schemaVersion", "1.0"); - List> 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 item = new LinkedHashMap<>(); - item.put("packageRoot", packageRoot); - item.put("packageHash", packageHash); - List> bindings = new ArrayList<>(); - List 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 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 manifest = objectMapper.readerFor(new TypeReference>() { }) - .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 roots = new java.util.HashSet<>(); - java.util.Set 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 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 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 safe = hitl - ? SkillSensitiveConfigSanitizer.sanitizeHitl(source) - : SkillSensitiveConfigSanitizer.sanitizeOptions(source); - if (!safe.equals(source)) { - throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含未允许或敏感配置"); - } - if (hitl) { - for (Map.Entry 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 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 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 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 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 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 safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()); - Map 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 target, String field, String value) { - String safeValue = SkillPortableTargetSanitizer.safePortableMetadataOrNull(value); - if (safeValue != null) { - target.put(field, safeValue); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java index 22ea8735..61f0f780 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportRequest.java @@ -10,10 +10,7 @@ import java.util.List; public class SkillExportRequest { private List ids = new ArrayList<>(); - private String format; public List getIds() { return ids; } public void setIds(List ids) { this.ids = ids == null ? new ArrayList<>() : new ArrayList<>(ids); } - public String getFormat() { return format; } - public void setFormat(String format) { this.format = format; } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java index a7022800..f5903e01 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java @@ -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 skillIds, SkillImportFormat format); + SkillExportArtifact prepare(Collection skillIds); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java index 1eb25ebd..398b4c22 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java @@ -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 skillIds, SkillImportFormat format) { + public SkillExportArtifact prepare(Collection skillIds) { if (skillIds == null || skillIds.isEmpty()) { throw new BusinessException("请选择要导出的 Skill"); } - SkillImportFormat effectiveFormat = format == null ? SkillImportFormat.STANDARD : format; - List skills = loadAuthorizedSkills( - skillIds, effectiveFormat == SkillImportFormat.EASYFLOW); - Path standardPackage = null; - Path finalPackage = null; + List 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 loadAuthorizedSkills(Collection skillIds, boolean includeCapabilities) { + private List loadAuthorizedSkills(Collection skillIds) { List skills = new ArrayList<>(); Set 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 skills, Path target) throws IOException { - List 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 entryNames = new HashSet<>(); - List 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 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 skillIds) { + Collection skillIds) { List 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) { diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityMapping.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityMapping.java deleted file mode 100644 index 1bc41db5..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityMapping.java +++ /dev/null @@ -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; } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityOverride.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityOverride.java deleted file mode 100644 index 4fa88547..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportCapabilityOverride.java +++ /dev/null @@ -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) { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java index 6794bfcd..7ab749ad 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportConfirmRequest.java @@ -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 renames = new LinkedHashMap<>(); - private List 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 getRenames() { return renames; } public void setRenames(Map renames) { this.renames = renames == null ? new LinkedHashMap<>() : new LinkedHashMap<>(renames); } - public List getCapabilityMappings() { return capabilityMappings; } - public void setCapabilityMappings(List capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportFormat.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportFormat.java deleted file mode 100644 index c306faed..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportFormat.java +++ /dev/null @@ -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); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java index 942b1e84..22d649a6 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java @@ -12,9 +12,7 @@ public class SkillImportPreview { private List skills = new ArrayList<>(); private String importToken; - private String format; private Date expiresAt; - private List capabilityMappings = new ArrayList<>(); private List 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 getCapabilityMappings() { return capabilityMappings; } - public void setCapabilityMappings(List capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); } public List getIssues() { return issues; } public void setIssues(List issues) { this.issues = issues == null ? new ArrayList<>() : new ArrayList<>(issues); } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java index 5e63ae04..52327f01 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java @@ -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); diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java index 8c3f87d1..cbbc1675 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java @@ -6,6 +6,7 @@ import com.easyagents.skill.codec.ZipSkillPackageCodec; import com.easyagents.skill.exception.SkillPackageException; import com.easyagents.skill.model.SkillDocument; import com.easyagents.skill.model.SkillPackageLimits; +import com.easyagents.skill.model.SkillResourceKind; import com.easyagents.skill.store.SkillContentStage; import com.easyagents.skill.store.SkillContentStore; import com.easyagents.skill.util.SkillFrontmatter; @@ -21,35 +22,27 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; +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.SkillCapabilityBinding; import tech.easyflow.skill.entity.SkillImportStage; -import tech.easyflow.skill.enums.SkillCapabilityType; -import tech.easyflow.ai.enums.PublishStatus; -import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer; import tech.easyflow.skill.service.SkillService; import tech.easyflow.skill.store.DBSkillContentStore; import tech.easyflow.skill.support.SkillModelConverter; 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.enums.VisibilityScope; import tech.easyflow.system.service.ResourceAccessService; import java.io.IOException; import java.io.InputStream; import java.math.BigInteger; -import java.nio.file.Files; -import java.nio.file.Path; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -57,67 +50,53 @@ import java.util.Map; import java.util.Set; /** - * 标准 ZIP 与 EasyFlow Bundle 的 token 化导入服务。 + * 基于一次性令牌的标准 Skill ZIP 导入服务。 */ @Service public class SkillImportServiceImpl implements SkillImportService { private static final Logger LOG = LoggerFactory.getLogger(SkillImportServiceImpl.class); - private static final int MAX_SKILLS = 100; - private static final int MAX_BINDINGS_PER_SKILL = 200; - private static final int MAX_TOTAL_BINDINGS = 1_000; + private static final int MAX_RENAMES = 20; private static final int NAME_CONFLICT_ERROR_CODE = 4092; private static final String NAME_UNAVAILABLE_REASON = "NAME_UNAVAILABLE"; private final SkillService skillService; - private final SkillCapabilityBindingService capabilityBindingService; - private final SkillCapabilityTargetAccessService targetAccessService; private final DBSkillContentStore contentStore; private final FileStorageService fileStorageService; private final SkillImportStageStore stageStore; - private final EasyFlowBundleReader bundleReader; private final ResourceAccessService resourceAccessService; /** - * 创建 Skill 导入服务。 + * 创建标准 Skill ZIP 导入服务。 * * @param skillService Skill 服务 - * @param capabilityBindingService 能力绑定服务 - * @param targetAccessService 目标映射服务 * @param contentStore 二进制内容仓库 * @param fileStorageService 文件存储 - * @param stageStore 导入临时包仓库 - * @param bundleReader EasyFlow Bundle 读取器 - * @param resourceAccessService Skill 资源权限服务 + * @param stageStore 导入暂存仓库 + * @param resourceAccessService 资源权限服务 */ public SkillImportServiceImpl(SkillService skillService, - SkillCapabilityBindingService capabilityBindingService, - SkillCapabilityTargetAccessService targetAccessService, DBSkillContentStore contentStore, @Qualifier("default") FileStorageService fileStorageService, SkillImportStageStore stageStore, - EasyFlowBundleReader bundleReader, ResourceAccessService resourceAccessService) { this.skillService = skillService; - this.capabilityBindingService = capabilityBindingService; - this.targetAccessService = targetAccessService; this.contentStore = contentStore; this.fileStorageService = fileStorageService; this.stageStore = stageStore; - this.bundleReader = bundleReader; this.resourceAccessService = resourceAccessService; } /** - * 仅供同包测试直接校验标准 ZIP 的只读预览转换,不属于正式导入服务契约。 + * 供同包测试执行无持久化标准 ZIP 预览。 * * @param inputStream 标准 ZIP 输入流 - * @return 不含导入令牌的预览 + * @return 导入预览 */ SkillImportPreview previewStandardForTest(InputStream inputStream) { SkillPackageReadResult decoded = new ZipSkillPackageCodec(new PreviewContentStore()) .decode(inputStream, SkillPackageReadOptions.reportOnly()); - return buildPreview(decoded, SkillImportFormat.STANDARD, null, null); + return hasExactlyOneSkill(decoded) ? buildPreview(decoded, null) : invalidSkillCountPreview(); } /** @@ -127,45 +106,36 @@ public class SkillImportServiceImpl implements SkillImportService { public SkillImportPreview preview(MultipartFile file) { validateUpload(file); LoginAccount account = requireAccount(); - String path = null; - SkillImportFormat format = file.getOriginalFilename().toLowerCase(java.util.Locale.ROOT).endsWith(".efskill") - ? SkillImportFormat.EASYFLOW : SkillImportFormat.STANDARD; + String storedPath = null; try { - path = fileStorageService.save(file, "skill-imports/" + account.getTenantId()); - if (path == null || path.isBlank()) { + storedPath = fileStorageService.save(file, "skill-imports/" + account.getTenantId()); + if (storedPath == null || storedPath.isBlank()) { throw new BusinessException(500, 500, "Skill 导入临时包存储失败,请稍后重试"); } - format = detectFormat(path, file.getOriginalFilename()); - PreviewDecode decoded = decodePreview(path, format); - SkillImportPreview preview = buildPreview(decoded.readResult, format, null, decoded.manifest); - SkillImportStage stage = stageStore.create(path, file.getOriginalFilename(), format); - preview.setImportToken(stage.getImportToken()); - preview.setExpiresAt(stage.getExpiresAt()); + SkillPackageReadResult decoded; + try (InputStream input = fileStorageService.readStream(storedPath)) { + decoded = new ZipSkillPackageCodec(new PreviewContentStore()) + .decode(input, SkillPackageReadOptions.reportOnly()); + } + if (!hasExactlyOneSkill(decoded)) { + cleanupUnregisteredPath(storedPath); + return invalidSkillCountPreview(); + } + SkillImportStage stage = stageStore.create(storedPath, file.getOriginalFilename()); + SkillImportPreview preview = buildPreview(decoded, stage); return preview; } catch (SkillPackageException exception) { - cleanupUnregisteredPath(path); - return failedPreview(format, exception); + cleanupUnregisteredPath(storedPath); + return failedPreview(exception); } catch (BusinessException exception) { - cleanupUnregisteredPath(path); - // 授权失败属于调用方权限问题,不能伪装成可修复的包结构校验问题。 - if (exception.getHttpStatus() == 401 || exception.getHttpStatus() == 403) { - throw exception; - } - if (exception.getHttpStatus() < 500) { - if (exception instanceof SkillManifestValidationException validationException) { - return failedPreview(format, validationException.getValidationCode(), - validationException.getMessage(), validationException.getPath()); - } - return failedPreview(format, - format == SkillImportFormat.EASYFLOW - ? "EASYFLOW_BUNDLE_INVALID" : "STANDARD_PACKAGE_INVALID", - exception.getMessage(), - format == SkillImportFormat.EASYFLOW - ? EasyFlowSkillManifestCodec.MANIFEST_PATH : null); - } + cleanupUnregisteredPath(storedPath); throw exception; + } catch (IOException exception) { + cleanupUnregisteredPath(storedPath); + LOG.error("读取标准 Skill ZIP 失败,path={}", storedPath, exception); + throw new BusinessException(500, 500, "读取 Skill 导入包失败", exception); } catch (RuntimeException exception) { - cleanupUnregisteredPath(path); + cleanupUnregisteredPath(storedPath); throw exception; } } @@ -176,17 +146,16 @@ public class SkillImportServiceImpl implements SkillImportService { @Override @Transactional(rollbackFor = Exception.class) public List confirm(SkillImportConfirmRequest request) { - if (request == null) { - throw new BusinessException("Skill 导入确认参数不能为空"); - } + validateConfirmRequest(request); SkillImportStage stage = stageStore.consume(request.getImportToken()); scheduleStageCleanup(stage); - SkillImportFormat format = SkillImportFormat.from(stage.getFormat()); - DecodedImport decoded = decodeForImport(stage.getFilePath(), format); - try { - return saveDecoded(decoded, request, format); - } finally { - decoded.close(); + try (InputStream input = fileStorageService.readStream(stage.getFilePath())) { + SkillPackageReadResult decoded = new ZipSkillPackageCodec(contentStore) + .decode(input, SkillPackageReadOptions.defaults()); + return saveSkills(decoded, request); + } catch (IOException exception) { + LOG.error("读取 Skill 导入临时包失败,token={}", stage.getImportToken(), exception); + throw new BusinessException(500, 500, "读取 Skill 导入包失败", exception); } } @@ -198,114 +167,44 @@ public class SkillImportServiceImpl implements SkillImportService { stageStore.cancel(importToken); } - private PreviewDecode decodePreview(String path, SkillImportFormat format) { - if (format == SkillImportFormat.STANDARD) { - try (InputStream input = fileStorageService.readStream(path)) { - SkillPackageReadResult result = new ZipSkillPackageCodec(new PreviewContentStore()) - .decode(input, SkillPackageReadOptions.reportOnly()); - return new PreviewDecode(result, null); - } catch (IOException exception) { - LOG.error("读取标准 Skill 导入临时包失败,path={}", path, exception); - throw new BusinessException(500, 500, "读取 Skill 导入临时包失败", exception); - } + private List saveSkills(SkillPackageReadResult decoded, SkillImportConfirmRequest request) { + if (!hasExactlyOneSkill(decoded)) { + throw new BusinessException("每个标准 Skill ZIP 必须且只能包含一个 Skill"); } - try (InputStream input = fileStorageService.readStream(path); - EasyFlowBundleReader.PreparedBundle prepared = bundleReader.prepare(input); - InputStream standard = prepared.openStandardZip()) { - SkillPackageReadResult result = new ZipSkillPackageCodec(new PreviewContentStore()) - .decode(standard, SkillPackageReadOptions.reportOnly()); - return new PreviewDecode(result, prepared.getManifest()); - } catch (IOException exception) { - LOG.error("读取 EasyFlow Skill 导入临时包失败,path={}", path, exception); - throw new BusinessException(500, 500, "读取 EasyFlow Skill 导入临时包失败", exception); - } - } - - private DecodedImport decodeForImport(String path, SkillImportFormat format) { - try { - InputStream stored = fileStorageService.readStream(path); - if (format == SkillImportFormat.STANDARD) { - try (stored) { - SkillPackageReadResult result = new ZipSkillPackageCodec(contentStore) - .decode(stored, SkillPackageReadOptions.defaults()); - return new DecodedImport(result, null, null); - } - } - EasyFlowBundleReader.PreparedBundle prepared; - try (stored) { - prepared = bundleReader.prepare(stored); - } - try (InputStream standard = prepared.openStandardZip()) { - SkillPackageReadResult result = new ZipSkillPackageCodec(contentStore) - .decode(standard, SkillPackageReadOptions.defaults()); - return new DecodedImport(result, prepared.getManifest(), prepared); - } catch (RuntimeException | IOException exception) { - prepared.close(); - throw exception; - } - } catch (IOException exception) { - LOG.error("读取 Skill 导入临时包失败,path={}", path, exception); - throw new BusinessException(500, 500, "读取 Skill 导入临时包失败", exception); - } - } - - private List saveDecoded(DecodedImport decoded, - SkillImportConfirmRequest request, - SkillImportFormat format) { - return saveSkills(decoded.readResult, request, format, decoded.manifest); - } - - private List saveSkills(SkillPackageReadResult decoded, - SkillImportConfirmRequest request, - SkillImportFormat format, - Map manifest) { - if (decoded.getSkillPackage().getSkills().size() > MAX_SKILLS) { - throw new BusinessException("单次最多导入 " + MAX_SKILLS + " 个 Skill"); - } - assertImportable(decoded); - validateConfirmRequest(request); - if (format == SkillImportFormat.EASYFLOW) { - validateManifestAgainstPackage(parseManifest(manifest), decoded.getSkillPackage().getSkills()); + if (decoded.getValidationReport().getIssues().stream() + .anyMatch(issue -> "ERROR".equals(issue.getSeverity().name()))) { + throw new BusinessException("Skill 包存在校验错误,不能导入"); } SkillImportConflictStrategy strategy = SkillImportConflictStrategy.fromOrDefault(request.getConflictStrategy()); - List saved = new ArrayList<>(); - Map byPackageRoot = new LinkedHashMap<>(); Set lookupNames = new LinkedHashSet<>(); decoded.getSkillPackage().getSkills().forEach(skill -> lookupNames.add(skill.getName())); lookupNames.addAll(request.getRenames().values()); Map existingByName = findByNames(lookupNames); + List saved = new ArrayList<>(); for (com.easyagents.skill.model.Skill imported : decoded.getSkillPackage().getSkills()) { com.easyagents.skill.model.Skill effective = applyRename(imported, request, strategy, existingByName); Skill entity = SkillModelConverter.fromAgentSkill(effective); entity.setCategoryId(request.getCategoryId()); - entity.setSourceType(format == SkillImportFormat.EASYFLOW ? "EASYFLOW_BUNDLE" : "STANDARD_ZIP"); + entity.setVisibilityScope(request.getVisibilityScope()); Skill existing = existingByName.get(entity.getName()); Skill result; if (existing == null) { result = saveNewDraft(entity); } else if (!resourceAccessService.canAccess( CategoryResourceType.SKILL, existing, ResourceAction.MANAGE)) { - // 权限判断必须早于发布状态判断,避免利用确认结果探测私有 Skill 状态。 throw nameUnavailable(entity.getName()); } else if (strategy == SkillImportConflictStrategy.OVERWRITE) { if (PublishStatus.from(existing.getPublishStatus()) != PublishStatus.DRAFT) { throw new BusinessException("仅允许覆盖草稿状态的 Skill:" + entity.getName()); } entity.setId(existing.getId()); - // 服务层在 SELECT FOR UPDATE 后重验 DRAFT,封闭预览与确认之间的发布竞态。 result = overwriteExistingDraft(entity); } else { - throw new BusinessException(409, 4092, - "Skill 已存在,请选择重命名或覆盖:" + entity.getName()); + throw nameUnavailable(entity.getName()); } saved.add(result); - byPackageRoot.put(imported.getPackageRoot(), result); existingByName.put(result.getName(), result); } - if (format == SkillImportFormat.EASYFLOW) { - applyManifestBindings(manifest, request, byPackageRoot); - saved = saved.stream().map(skill -> skillService.getDetail(skill.getId())).toList(); - } return saved; } @@ -313,14 +212,12 @@ public class SkillImportServiceImpl implements SkillImportService { SkillImportConfirmRequest request, SkillImportConflictStrategy strategy, Map existingByName) { - Skill existing = existingByName.get(imported.getName()); - if (existing == null || strategy != SkillImportConflictStrategy.RENAME) { + if (!existingByName.containsKey(imported.getName()) + || strategy != SkillImportConflictStrategy.RENAME) { return imported; } - String renamed = request.getRenames().get(imported.getPackageRoot()); - if (renamed == null) { - renamed = request.getRenames().get(imported.getName()); - } + String renamed = request.getRenames().getOrDefault(imported.getPackageRoot(), + request.getRenames().get(imported.getName())); if (renamed == null || !renamed.matches("[a-z0-9]+(?:-[a-z0-9]+)*")) { throw new BusinessException("请为名称不可用的 Skill 提供规范连字符名称:" + imported.getName()); } @@ -331,194 +228,98 @@ public class SkillImportServiceImpl implements SkillImportService { Map values = new LinkedHashMap<>(document.getFrontmatter().getValues()); values.put("name", renamed); com.easyagents.skill.model.Skill renamedSkill = com.easyagents.skill.factory.SkillFactory.createWithResources( - null, SkillFrontmatter.serialize(values, document.getMarkdownBody()), imported.getResources()); + SkillFrontmatter.serialize(values, document.getMarkdownBody()), imported.getResources()); renamedSkill.setPackageRoot(renamed); return renamedSkill; } - private void applyManifestBindings(Map manifest, - SkillImportConfirmRequest request, - Map skills) { - List manifestSkills = parseManifest(manifest); - Set knownBindingKeys = new LinkedHashSet<>(); - manifestSkills.forEach(skill -> skill.bindings.forEach(binding -> knownBindingKeys.add(binding.bindingKey))); - Map overrides = new HashMap<>(); - for (SkillImportCapabilityOverride mapping : request.getCapabilityMappings()) { - validateCapabilityMapping(mapping); - if (!knownBindingKeys.contains(mapping.bindingKey())) { - throw new BusinessException("能力映射引用了未知 bindingKey:" + mapping.bindingKey()); - } - if (overrides.put(mapping.bindingKey(), mapping) != null) { - throw new BusinessException("能力映射包含重复 bindingKey:" + mapping.bindingKey()); - } - } - Map resolvedTargets = new HashMap<>(); - for (ManifestSkill manifestSkill : manifestSkills) { - Skill skill = skills.get(manifestSkill.packageRoot); - if (skill == null) { - throw new BusinessException("EasyFlow manifest 引用了包内不存在的 Skill:" + manifestSkill.packageRoot); - } - List bindings = new ArrayList<>(); - for (ManifestBinding source : manifestSkill.bindings) { - SkillImportCapabilityOverride override = overrides.get(source.bindingKey); - BigInteger targetId = override == null ? null : override.targetId(); - boolean disabled = override != null && override.disabled(); - if (targetId == null && !disabled) { - targetId = resolveTargetCached(resolvedTargets, source.type, source.targetLogicalRef); - } - if (targetId == null && !disabled) { - throw new BusinessException("能力目标尚未映射:" + source.targetLogicalRef); - } - SkillCapabilityBinding binding = source.toBinding(); - binding.setTargetId(targetId); - binding.setTargetLogicalRef(source.targetLogicalRef); - binding.setEnabled(!disabled && source.enabled); - bindings.add(binding); - } - capabilityBindingService.replaceBindings(skill.getId(), bindings); - } - } - - private SkillImportPreview buildPreview(SkillPackageReadResult decoded, - SkillImportFormat format, - SkillImportStage stage, - Map manifest) { + private SkillImportPreview buildPreview(SkillPackageReadResult decoded, SkillImportStage stage) { SkillImportPreview preview = new SkillImportPreview(); - preview.setFormat(format.name()); if (stage != null) { preview.setImportToken(stage.getImportToken()); preview.setExpiresAt(stage.getExpiresAt()); } - Map conflicts = findByNames(decoded.getSkillPackage().getSkills().stream() - .map(com.easyagents.skill.model.Skill::getName).collect(java.util.stream.Collectors.toSet())); - preview.setSkills(decoded.getSkillPackage().getSkills().stream() - .map(skill -> toPreviewItem(skill, conflicts.get(skill.getName()))) - .toList()); - List issues = decoded.getValidationReport().getIssues().stream().map(source -> { + preview.setIssues(decoded.getValidationReport().getIssues().stream().map(source -> { SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(), source.getMessage(), source.getPath()); issue.setLine(source.getLine()); issue.setColumn(source.getColumn()); issue.setSuggestion(source.getSuggestion()); return issue; - }).collect(java.util.stream.Collectors.toCollection(ArrayList::new)); - if (format == SkillImportFormat.EASYFLOW) { - List manifestSkills = parseManifest(manifest); - validateManifestAgainstPackage(manifestSkills, decoded.getSkillPackage().getSkills()); - List mappings = buildCapabilityMappings(manifestSkills); - preview.setCapabilityMappings(mappings); - issues.addAll(validatePreviewBindings(manifestSkills, mappings)); - } - preview.setIssues(issues); + }).toList()); + Map existing = findByNames(decoded.getSkillPackage().getSkills().stream() + .map(com.easyagents.skill.model.Skill::getName).toList()); + preview.setSkills(decoded.getSkillPackage().getSkills().stream() + .map(skill -> toPreviewItem(skill, existing.get(skill.getName()))).toList()); return preview; } - /** - * 校验增强包中可由 manifest 和当前已解析目标确定的能力配置。 - * - * @param skills manifest Skill 项 - * @param mappings 已完成自动解析的映射项 - * @return 带 Skill 根路径的结构化问题 - */ - private List validatePreviewBindings(List skills, - List mappings) { - Map targetIds = new HashMap<>(); - for (SkillImportCapabilityMapping mapping : mappings) { - targetIds.put(mapping.getBindingKey(), mapping.getTargetId()); - } - List issues = new ArrayList<>(); - for (ManifestSkill skill : skills) { - if (skill.bindings.isEmpty()) { - continue; - } - List bindings = new ArrayList<>(); - for (ManifestBinding source : skill.bindings) { - SkillCapabilityBinding binding = source.toBinding(); - binding.setTargetId(targetIds.get(source.bindingKey)); - binding.setTargetLogicalRef(source.targetLogicalRef); - bindings.add(binding); - } - SkillValidationResult result = capabilityBindingService.validateImportBindings(bindings); - for (SkillValidationIssue issue : result.getIssues()) { - issues.add(prefixPreviewIssue(skill.packageRoot, issue)); - } - } - return issues; - } - - /** - * 为能力校验问题补充包内 Skill 定位路径。 - * - * @param packageRoot Skill 包根路径 - * @param source 原始能力校验问题 - * @return 可在导入预览中定位的问题副本 - */ - private SkillValidationIssue prefixPreviewIssue(String packageRoot, SkillValidationIssue source) { - String suffix = source.getPath() == null || source.getPath().isBlank() - ? "capabilities" : source.getPath(); - SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity(), source.getCode(), - source.getMessage(), "skills[" + packageRoot + "]." + suffix); - issue.setLine(source.getLine()); - issue.setColumn(source.getColumn()); - issue.setSuggestion(source.getSuggestion()); - return issue; - } - private SkillImportPreviewItem toPreviewItem(com.easyagents.skill.model.Skill skill, Skill existing) { SkillImportPreviewItem item = new SkillImportPreviewItem(); item.setPackageId(skill.getPackageRoot()); item.setPackageRoot(skill.getPackageRoot()); item.setName(skill.getName()); item.setDescription(skill.getDescription()); - item.setReferenceCount(skill.getReferences().size()); - item.setScriptCount(skill.getScripts().size()); - item.setAssetCount(skill.getAssets().size()); + item.setReferenceCount(countResources(skill, SkillResourceKind.REFERENCE)); + item.setScriptCount(countResources(skill, SkillResourceKind.SCRIPT)); + item.setAssetCount(countResources(skill, SkillResourceKind.ASSET)); item.setResourceCount(skill.getResources().size()); - item.setPackageHash(calculateSkillPackageHash(skill)); + item.setPackageHash(calculatePackageHash(skill)); item.setConflict(existing != null); if (existing != null) { boolean manageable = resourceAccessService.canAccess( CategoryResourceType.SKILL, existing, ResourceAction.MANAGE); - if (!manageable) { - item.setOverwriteAllowed(false); - item.setConflictReason(NAME_UNAVAILABLE_REASON); - } else { - boolean draft = PublishStatus.from(existing.getPublishStatus()) == PublishStatus.DRAFT; - item.setOverwriteAllowed(draft); - item.setConflictReason(draft ? null : "NOT_DRAFT"); - } + boolean draft = manageable && PublishStatus.from(existing.getPublishStatus()) == PublishStatus.DRAFT; + item.setOverwriteAllowed(draft); + item.setConflictReason(!manageable ? NAME_UNAVAILABLE_REASON : draft ? null : "NOT_DRAFT"); } List files = new ArrayList<>(); - SkillImportPreviewFile skillFile = new SkillImportPreviewFile(); - skillFile.setPath("SKILL.md"); - skillFile.setKind("SKILL"); - skillFile.setMediaType("text/markdown"); - skillFile.setText(true); - skillFile.setSize((skill.getSkillContent() == null ? "" : skill.getSkillContent()) - .getBytes(java.nio.charset.StandardCharsets.UTF_8).length); - files.add(skillFile); - SkillResources.canonicalResources(skill).stream() + files.add(previewFile("SKILL.md", "SKILL", "text/markdown", true, + (skill.getSkillContent() == null ? "" : skill.getSkillContent()) + .getBytes(StandardCharsets.UTF_8).length)); + skill.getResources().stream() .sorted(java.util.Comparator.comparing(com.easyagents.skill.model.SkillResource::getPath)) - .forEach(resource -> { - SkillImportPreviewFile file = new SkillImportPreviewFile(); - file.setPath(resource.getPath()); - file.setKind(resource.getKind().name()); - file.setMediaType(resource.getMediaType()); - file.setText(resource.isText()); - file.setSize(resource.getSize()); - files.add(file); - }); + .map(resource -> previewFile(resource.getPath(), resource.getKind().name(), + resource.getMediaType(), resource.isText(), resource.getSize())) + .forEach(files::add); item.setFiles(files); return item; } /** - * 保存全新导入草稿,并将预查后的并发同名冲突归一为稳定结果。 + * 统计指定语义类型的通用资源数量。 * - * @param skill 待保存 Skill - * @return 已保存草稿 - * @throws BusinessException 名称不可用或保存失败时抛出 + * @param skill 标准 Skill 聚合 + * @param kind 资源类型 + * @return 匹配资源数量 */ + private static int countResources(com.easyagents.skill.model.Skill skill, SkillResourceKind kind) { + return (int) skill.getResources().stream() + .filter(resource -> resource.getKind() == kind) + .count(); + } + + private SkillImportPreviewFile previewFile(String path, String kind, String mediaType, boolean text, long size) { + SkillImportPreviewFile file = new SkillImportPreviewFile(); + file.setPath(path); + file.setKind(kind); + file.setMediaType(mediaType); + file.setText(text); + file.setSize(size); + return file; + } + + private String calculatePackageHash(com.easyagents.skill.model.Skill skill) { + StringBuilder canonical = new StringBuilder("SKILL.md\n") + .append(SkillHashes.sha256Hex((skill.getSkillContent() == null ? "" : skill.getSkillContent()) + .getBytes(StandardCharsets.UTF_8))).append('\n'); + skill.getResources().stream().sorted(java.util.Comparator.comparing( + com.easyagents.skill.model.SkillResource::getPath)) + .forEach(resource -> canonical.append(resource.getPath()).append('\n') + .append(resource.getContentHash()).append('\n')); + return SkillHashes.sha256Hex(canonical.toString().getBytes(StandardCharsets.UTF_8)); + } + private Skill saveNewDraft(Skill skill) { try { return skillService.saveDraft(skill); @@ -532,13 +333,6 @@ public class SkillImportServiceImpl implements SkillImportService { } } - /** - * 覆盖有权管理的草稿,权限或资源竞态统一按名称不可用拒绝。 - * - * @param skill 待覆盖 Skill - * @return 已更新草稿 - * @throws BusinessException 名称不可用或覆盖失败时抛出 - */ private Skill overwriteExistingDraft(Skill skill) { try { return skillService.overwriteImportedDraft(skill); @@ -550,114 +344,24 @@ public class SkillImportServiceImpl implements SkillImportService { } } - /** - * 构建不暴露私有资源存在性、权限或发布状态的名称冲突异常。 - * - * @param name 导入包声明的 Skill 名称 - * @return 稳定的 HTTP 409 业务异常 - */ private BusinessException nameUnavailable(String name) { return new BusinessException(409, NAME_CONFLICT_ERROR_CODE, "Skill 名称不可用:" + name); } - private List buildCapabilityMappings(List skills) { - List mappings = new ArrayList<>(); - Map resolvedTargets = new HashMap<>(); - for (ManifestSkill skill : skills) { - for (ManifestBinding binding : skill.bindings) { - BigInteger targetId = resolveTargetCached(resolvedTargets, binding.type, binding.targetLogicalRef); - SkillImportCapabilityMapping mapping = new SkillImportCapabilityMapping(); - mapping.setBindingKey(binding.bindingKey); - mapping.setPackageRoot(skill.packageRoot); - mapping.setCapabilityType(binding.type.name()); - mapping.setTargetLogicalRef(binding.targetLogicalRef); - mapping.setTargetName(binding.targetName); - mapping.setTargetId(targetId); - mapping.setStatus(targetId == null ? "UNRESOLVED" : "RESOLVED"); - mappings.add(mapping); - } - } - return mappings; - } - - @SuppressWarnings("unchecked") - private List parseManifest(Map manifest) { - if (manifest == null || !(manifest.get("skills") instanceof List rawSkills)) { - throw new BusinessException("EasyFlow manifest 缺少 skills 列表"); - } - if (rawSkills.size() > MAX_SKILLS) { - throw new BusinessException("EasyFlow manifest Skill 数量超过限制"); - } - List result = new ArrayList<>(); - int totalBindings = 0; - for (Object rawSkill : rawSkills) { - if (!(rawSkill instanceof Map map)) { - throw new BusinessException("EasyFlow manifest Skill 项格式不正确"); - } - String packageRoot = requiredString(map.get("packageRoot"), "packageRoot", 128); - String packageHash = requiredString(map.get("packageHash"), "packageHash", 128); - List rawBindings = map.get("capabilities") instanceof List list ? list : List.of(); - if (rawBindings.size() > MAX_BINDINGS_PER_SKILL) { - throw new BusinessException("单个 Skill 的能力绑定超过 " + MAX_BINDINGS_PER_SKILL + " 项限制"); - } - totalBindings += rawBindings.size(); - if (totalBindings > MAX_TOTAL_BINDINGS) { - throw new BusinessException("EasyFlow manifest 能力绑定总数超过 " + MAX_TOTAL_BINDINGS + " 项限制"); - } - List bindings = new ArrayList<>(); - for (Object rawBinding : rawBindings) { - if (!(rawBinding instanceof Map bindingMap)) { - throw new BusinessException("EasyFlow manifest 能力绑定格式不正确"); - } - bindings.add(ManifestBinding.from(bindingMap)); - } - result.add(new ManifestSkill(packageRoot, packageHash, bindings)); - } - return result; - } - private Map findByNames(java.util.Collection names) { List safeNames = names == null ? List.of() : names.stream() - .filter(name -> name != null && !name.isBlank()).distinct().limit(MAX_SKILLS * 2L).toList(); + .filter(name -> name != null && !name.isBlank()).distinct().limit(MAX_RENAMES * 2L).toList(); if (safeNames.isEmpty()) { return new LinkedHashMap<>(); } Map result = new LinkedHashMap<>(); - for (Skill skill : skillService.list(QueryWrapper.create() - .eq(Skill::getTenantId, requireAccount().getTenantId()) - .in(Skill::getName, safeNames))) { - result.put(skill.getName(), skill); - } + skillService.list(QueryWrapper.create() + .eq(Skill::getTenantId, requireAccount().getTenantId()) + .in(Skill::getName, safeNames)) + .forEach(skill -> result.put(skill.getName(), skill)); return result; } - private BigInteger resolveTargetCached(Map cache, - SkillCapabilityType type, - String logicalRef) { - String key = type.name() + '\u0000' + logicalRef; - if (!cache.containsKey(key)) { - cache.put(key, targetAccessService.resolveLogicalRef(type, logicalRef)); - } - return cache.get(key); - } - - private SkillImportFormat detectFormat(String path, String originalName) { - boolean extensionSuggestsBundle = originalName != null && originalName.toLowerCase(java.util.Locale.ROOT).endsWith(".efskill"); - try (InputStream input = fileStorageService.readStream(path)) { - boolean hasManifest = bundleReader.containsManifest(input); - if (extensionSuggestsBundle && !hasManifest) { - throw new BusinessException(".efskill 文件缺少 EasyFlow manifest"); - } - if (!extensionSuggestsBundle && hasManifest) { - throw new BusinessException("标准 .zip 不能包含 EasyFlow manifest,请使用 .efskill 扩展名"); - } - return extensionSuggestsBundle ? SkillImportFormat.EASYFLOW : SkillImportFormat.STANDARD; - } catch (IOException exception) { - LOG.error("检测 Skill 导入包格式失败,path={}", path, exception); - throw new BusinessException(500, 500, "检测 Skill 导入包格式失败", exception); - } - } - private void validateUpload(MultipartFile file) { if (file == null || file.isEmpty()) { throw new BusinessException("Skill 导入文件不能为空"); @@ -667,12 +371,59 @@ public class SkillImportServiceImpl implements SkillImportService { throw new BusinessException(413, 4131, "Skill 导入文件超过 " + limit + " 字节限制"); } String name = file.getOriginalFilename(); - if (name == null || !(name.toLowerCase(java.util.Locale.ROOT).endsWith(".zip") - || name.toLowerCase(java.util.Locale.ROOT).endsWith(".efskill"))) { - throw new BusinessException("Skill 导入仅支持 .zip 或 .efskill 文件"); + if (name == null || !name.toLowerCase(java.util.Locale.ROOT).endsWith(".zip")) { + throw new BusinessException("Skill 导入仅支持标准 .zip 文件,.efskill 已停止支持"); } } + private void validateConfirmRequest(SkillImportConfirmRequest request) { + if (request == null || request.getImportToken() == null || request.getImportToken().isBlank()) { + throw new BusinessException("Skill 导入确认参数不能为空"); + } + if (request.getVisibilityScope() == null || request.getVisibilityScope().isBlank()) { + throw new BusinessException("Skill 使用范围不能为空"); + } + try { + request.setVisibilityScope(VisibilityScope.from(request.getVisibilityScope()).name()); + } catch (IllegalArgumentException exception) { + throw new BusinessException("Skill 使用范围无效"); + } + if (request.getRenames().size() > MAX_RENAMES) { + throw new BusinessException("Skill 重命名映射数量超过限制"); + } + } + + private boolean hasExactlyOneSkill(SkillPackageReadResult decoded) { + return decoded != null && decoded.getSkillPackage() != null + && decoded.getSkillPackage().getSkills().size() == 1; + } + + private SkillImportPreview invalidSkillCountPreview() { + SkillImportPreview preview = new SkillImportPreview(); + preview.setIssues(List.of(SkillValidationIssue.of( + "ERROR", "STANDARD_PACKAGE_SKILL_COUNT", "每个标准 Skill ZIP 必须且只能包含一个 Skill", null))); + return preview; + } + + private SkillImportPreview failedPreview(SkillPackageException exception) { + SkillImportPreview preview = new SkillImportPreview(); + preview.setSkills(List.of()); + if (exception.getReport() == null) { + preview.setIssues(List.of(SkillValidationIssue.of( + "ERROR", "STANDARD_PACKAGE_INVALID", exception.getMessage(), null))); + return preview; + } + preview.setIssues(exception.getReport().getIssues().stream().map(source -> { + SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(), + source.getMessage(), source.getPath()); + issue.setLine(source.getLine()); + issue.setColumn(source.getColumn()); + issue.setSuggestion(source.getSuggestion()); + return issue; + }).toList()); + return preview; + } + private void cleanupUnregisteredPath(String path) { if (path == null || path.isBlank()) { return; @@ -709,281 +460,14 @@ public class SkillImportServiceImpl implements SkillImportService { return account; } - private static String requiredString(Object value, String field, int maxLength) { - if (!(value instanceof String text) || text.isBlank() || text.length() > maxLength) { - throw new BusinessException("EasyFlow manifest 字段不正确:" + field); - } - return text; - } - - private static boolean booleanValue(Object value, boolean defaultValue) { - return value instanceof Boolean bool ? bool : defaultValue; - } - - private static int intValue(Object value, int defaultValue) { - return value instanceof Number number ? number.intValue() : defaultValue; - } - - private SkillImportPreview failedPreview(SkillImportFormat format, SkillPackageException exception) { - SkillImportPreview preview = new SkillImportPreview(); - preview.setFormat((format == null ? SkillImportFormat.STANDARD : format).name()); - preview.setSkills(List.of()); - if (exception.getReport() == null) { - preview.setIssues(List.of(SkillValidationIssue.of("ERROR", "PACKAGE_INVALID", - exception.getMessage(), null))); - return preview; - } - preview.setIssues(exception.getReport().getIssues().stream().map(source -> { - SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(), - source.getMessage(), source.getPath()); - issue.setLine(source.getLine()); - issue.setColumn(source.getColumn()); - issue.setSuggestion(source.getSuggestion()); - return issue; - }).toList()); - return preview; - } - /** - * 将增强包的安全解析失败转换为统一的结构化预览问题。 - * - * @param format 导入格式 - * @param code 问题码 - * @param message 安全错误消息 - * @param path 问题路径 - * @return 不含导入令牌的失败预览 - */ - private SkillImportPreview failedPreview(SkillImportFormat format, - String code, - String message, - String path) { - SkillImportPreview preview = new SkillImportPreview(); - preview.setFormat((format == null ? SkillImportFormat.STANDARD : format).name()); - preview.setSkills(List.of()); - SkillValidationIssue issue = SkillValidationIssue.of("ERROR", code, message, path); - issue.setSuggestion(format == SkillImportFormat.EASYFLOW - ? "请修复增强包结构或从可信 EasyFlow 环境重新导出" - : "请修复标准 Skill 包结构后重新导入"); - preview.setIssues(List.of(issue)); - return preview; - } - - private void assertImportable(SkillPackageReadResult decoded) { - boolean hasError = decoded.getValidationReport().getIssues().stream() - .anyMatch(issue -> "ERROR".equals(issue.getSeverity().name())); - if (hasError) { - throw new BusinessException("Skill 包存在校验错误,不能导入"); - } - } - - private void validateConfirmRequest(SkillImportConfirmRequest request) { - if (request.getRenames().size() > MAX_SKILLS) { - throw new BusinessException("Skill 重命名映射数量超过限制"); - } - if (request.getCapabilityMappings().size() > MAX_TOTAL_BINDINGS) { - throw new BusinessException("Skill 能力映射数量超过限制"); - } - for (Map.Entry rename : request.getRenames().entrySet()) { - if (rename.getKey() == null || rename.getKey().length() > 128 - || rename.getValue() == null || rename.getValue().length() > 128) { - throw new BusinessException("Skill 重命名映射字段超过限制"); - } - } - request.getCapabilityMappings().forEach(this::validateCapabilityMapping); - } - - private void validateCapabilityMapping(SkillImportCapabilityOverride mapping) { - if (mapping == null || mapping.bindingKey() == null || mapping.bindingKey().isBlank() - || mapping.bindingKey().length() > 256) { - throw new BusinessException("能力映射 bindingKey 不能为空且不能超过 256 个字符"); - } - if (mapping.disabled() && mapping.targetId() != null) { - throw new BusinessException("禁用能力映射时不能同时指定 targetId:" + mapping.bindingKey()); - } - if (mapping.targetId() != null && mapping.targetId().signum() <= 0) { - throw new BusinessException("能力映射 targetId 必须为正数:" + mapping.bindingKey()); - } - } - - private void validateManifestAgainstPackage(List manifestSkills, - List packageSkills) { - Map packageByRoot = new LinkedHashMap<>(); - for (com.easyagents.skill.model.Skill skill : packageSkills) { - if (packageByRoot.put(skill.getPackageRoot(), skill) != null) { - throw new BusinessException("Skill 包存在重复 packageRoot:" + skill.getPackageRoot()); - } - } - Set manifestRoots = new LinkedHashSet<>(); - for (ManifestSkill manifestSkill : manifestSkills) { - if (!manifestRoots.add(manifestSkill.packageRoot)) { - throw new BusinessException("EasyFlow manifest 存在重复 packageRoot:" + manifestSkill.packageRoot); - } - com.easyagents.skill.model.Skill skill = packageByRoot.get(manifestSkill.packageRoot); - if (skill == null) { - throw new BusinessException("EasyFlow manifest 引用了包内不存在的 Skill:" + manifestSkill.packageRoot); - } - if (!manifestSkill.packageHash.equals(calculateSkillPackageHash(skill))) { - throw new BusinessException("EasyFlow manifest 的 packageHash 与包内容不一致:" + manifestSkill.packageRoot); - } - } - if (!manifestRoots.equals(packageByRoot.keySet())) { - throw new BusinessException("EasyFlow manifest 与包内 Skill 列表不一致"); - } - } - - private String calculateSkillPackageHash(com.easyagents.skill.model.Skill skill) { - StringBuilder canonical = new StringBuilder("SKILL.md\n") - .append(SkillHashes.sha256Hex((skill.getSkillContent() == null ? "" : skill.getSkillContent()) - .getBytes(java.nio.charset.StandardCharsets.UTF_8))).append('\n'); - skill.getResources().stream().sorted(java.util.Comparator.comparing(com.easyagents.skill.model.SkillResource::getPath)) - .forEach(resource -> canonical.append(resource.getPath()).append('\n') - .append(resource.getContentHash()).append('\n')); - return SkillHashes.sha256Hex(canonical.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } - - private record PreviewDecode(SkillPackageReadResult readResult, Map manifest) { - } - - private static final class DecodedImport implements AutoCloseable { - private final SkillPackageReadResult readResult; - private final Map manifest; - private final EasyFlowBundleReader.PreparedBundle preparedBundle; - - private DecodedImport(SkillPackageReadResult readResult, - Map manifest, - EasyFlowBundleReader.PreparedBundle preparedBundle) { - this.readResult = readResult; - this.manifest = manifest; - this.preparedBundle = preparedBundle; - } - - @Override - public void close() { - if (preparedBundle != null) { - preparedBundle.close(); - } - } - } - - private record ManifestSkill(String packageRoot, String packageHash, List bindings) { - } - - private static final class ManifestBinding { - private final String bindingKey; - private final SkillCapabilityType type; - private final String targetLogicalRef; - private final String targetName; - private final String runtimeName; - private final boolean enabled; - private final String selectionMode; - private final List selectedTools; - private final String executionMode; - private final boolean hitlEnabled; - private final Map hitlConfig; - private final Map options; - private final int sortNo; - - private ManifestBinding(String bindingKey, - SkillCapabilityType type, - String targetLogicalRef, - String targetName, - String runtimeName, - boolean enabled, - String selectionMode, - List selectedTools, - String executionMode, - boolean hitlEnabled, - Map hitlConfig, - Map options, - int sortNo) { - this.bindingKey = bindingKey; - this.type = type; - this.targetLogicalRef = targetLogicalRef; - this.targetName = targetName; - this.runtimeName = runtimeName; - this.enabled = enabled; - this.selectionMode = selectionMode; - this.selectedTools = selectedTools; - this.executionMode = executionMode; - this.hitlEnabled = hitlEnabled; - this.hitlConfig = hitlConfig; - this.options = options; - this.sortNo = sortNo; - } - - private static ManifestBinding from(Map map) { - String bindingKey = requiredString(map.get("bindingKey"), "bindingKey", 256); - SkillCapabilityType type = SkillCapabilityType.from(requiredString(map.get("capabilityType"), "capabilityType", 32)); - String logicalRef = requiredString(map.get("targetLogicalRef"), "targetLogicalRef", 512); - String targetName = map.get("targetName") instanceof String text && text.length() <= 256 ? text : null; - String runtimeName = requiredString(map.get("runtimeName"), "runtimeName", 64); - List tools = parseTools(map.get("selectedToolNames")); - Map hitl = map.get("hitlConfig") instanceof Map value - ? toStringMap(value) : Map.of(); - Map options = map.get("options") instanceof Map value - ? toStringMap(value) : Map.of(); - return new ManifestBinding(bindingKey, type, logicalRef, targetName, runtimeName, - booleanValue(map.get("enabled"), true), - map.get("selectionMode") instanceof String text ? text : null, - tools, - map.get("executionMode") instanceof String text ? text : null, - booleanValue(map.get("hitlEnabled"), false), - SkillSensitiveConfigSanitizer.sanitizeHitl(hitl), - SkillSensitiveConfigSanitizer.sanitizeOptions(options), - intValue(map.get("sortNo"), 0)); - } - - private SkillCapabilityBinding toBinding() { - SkillCapabilityBinding binding = new SkillCapabilityBinding(); - binding.setCapabilityType(type.name()); - binding.setRuntimeName(runtimeName); - binding.setEnabled(enabled); - binding.setSelectionMode(selectionMode); - binding.setSelectedToolNamesJson(selectedTools); - binding.setExecutionMode(executionMode); - binding.setHitlEnabled(hitlEnabled); - binding.setHitlConfigJson(hitlConfig); - binding.setOptionsJson(options); - binding.setSortNo(sortNo); - return binding; - } - - private static List parseTools(Object value) { - if (!(value instanceof List list)) { - return List.of(); - } - if (list.size() > 200) { - throw new BusinessException("EasyFlow manifest MCP 工具数量超过限制"); - } - Set result = new LinkedHashSet<>(); - for (Object item : list) { - if (!(item instanceof String name) || name.isBlank() || name.length() > 128) { - throw new BusinessException("EasyFlow manifest MCP 工具名不正确"); - } - result.add(name); - } - return new ArrayList<>(result); - } - - private static Map toStringMap(Map value) { - Map result = new LinkedHashMap<>(); - for (Map.Entry entry : value.entrySet()) { - if (entry.getKey() instanceof String key) { - result.put(key, entry.getValue()); - } - } - return result; - } - } - - /** - * 预览阶段只计算 hash 和大小,不持有完整二进制内容。 + * 预览阶段仅计算二进制摘要,不持久化正文。 */ private static final class PreviewContentStore implements SkillContentStore { @Override public String put(byte[] bytes) { - return "sha256:" + com.easyagents.skill.util.SkillHashes.sha256Hex(bytes); + return "sha256:" + SkillHashes.sha256Hex(bytes); } @Override @@ -1001,7 +485,7 @@ public class SkillImportServiceImpl implements SkillImportService { digest.update(buffer, 0, length); } String hash = java.util.HexFormat.of().formatHex(digest.digest()); - return new SkillContentStage("preview:" + hash, "sha256:" + hash, hash, size, false); + return new SkillContentStage("preview:" + hash, "sha256:" + hash, hash, size); } catch (BusinessException exception) { throw exception; } catch (Exception exception) { @@ -1009,13 +493,11 @@ public class SkillImportServiceImpl implements SkillImportService { } } - @Override - public String commit(SkillContentStage stage) { - return stage.getContentRef(); - } - + @Override public String commit(SkillContentStage stage) { return stage.getContentRef(); } + @Override public void rollback(SkillContentStage stage) { } + @Override public void retain(String contentRef) { } + @Override public void release(String contentRef) { } @Override public InputStream open(String contentRef) { throw new UnsupportedOperationException(); } - @Override public byte[] readAllBytes(String contentRef) { throw new UnsupportedOperationException(); } @Override public boolean exists(String contentRef) { return true; } } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java index f857a8c3..7f8afcf3 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportStageStore.java @@ -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())); diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillManifestValidationException.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillManifestValidationException.java deleted file mode 100644 index c27ba464..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillManifestValidationException.java +++ /dev/null @@ -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; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCapabilityBindingMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCapabilityBindingMapper.java deleted file mode 100644 index c468e5a4..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCapabilityBindingMapper.java +++ /dev/null @@ -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 { -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java index 76181389..5d870d21 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java @@ -54,6 +54,31 @@ public interface SkillMapper extends BaseMapper { @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 snapshot, + @Param("publishedAt") Date publishedAt, + @Param("publishedBy") BigInteger publishedBy, + @Param("snapshotHash") String snapshotHash); + /** * 在租户边界内将 Skill 标记为下线,并清空审批实例 ID。 * @@ -65,42 +90,48 @@ public interface SkillMapper extends BaseMapper { + "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); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java index a4d86f90..f3459c0f 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java @@ -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 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 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 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) { diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java index f800c25d..f57b0513 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java @@ -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 ); } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java deleted file mode 100644 index 214d705b..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java +++ /dev/null @@ -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,并转移新增二进制内容引用的所有权。 - * - *

调用方在新增 Skill 或为已有 Skill 增加二进制资源前,必须通过内容仓库的 - * {@code put}/{@code commit} 为每个新增资源取得一份引用。保存成功后,这些新增引用转由 - * Skill 聚合持有;保存失败时不发生所有权转移,本方法产生的引用计数变更随事务回滚, - * 调用方仍负责释放在外部事务中预先取得的新引用。更新时,旧、新资源多重集的交集复用旧 - * 聚合已有所有权,本适配器会在替换前 retain 相同次数,以抵消资源替换对旧聚合的 release; - * 仅出现在新聚合中的引用直接接管调用方已取得的引用。

- * - * @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 existingResources, - List incomingResources) { - Map 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 contentRefCounts(List resources) { - Map 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 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 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 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; - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillCredentialValueGuard.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillCredentialValueGuard.java deleted file mode 100644 index d7b53f29..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillCredentialValueGuard.java +++ /dev/null @@ -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 平台附加配置中的高置信凭据值检测器。 - * - *

该检测器只用于能力配置和增强包元数据,不应用于标准 Skill 文档或资源正文。

- */ -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)(?|\\[(?: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 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'; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillPortableTargetSanitizer.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillPortableTargetSanitizer.java deleted file mode 100644 index bd6de5b2..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillPortableTargetSanitizer.java +++ /dev/null @@ -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; - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizer.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizer.java deleted file mode 100644 index 3fd054cc..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizer.java +++ /dev/null @@ -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 HITL_ALLOWED_KEYS = Set.of( - "prompt", "title", "description", "confirmLabel", "cancelLabel" - ); - private static final Set OPTIONS_ALLOWED_KEYS = Set.of( - "timeoutMs", "retryCount", "async", "readOnly" - ); - - private SkillSensitiveConfigSanitizer() { - } - - /** - * 仅保留已定义的非敏感 HITL 展示配置。 - * - * @param source 原始 HITL 配置 - * @return 白名单配置 - */ - public static Map sanitizeHitl(Map source) { - return sanitizeAllowed(source, HITL_ALLOWED_KEYS); - } - - /** - * 仅保留已定义的非敏感执行选项。 - * - * @param source 原始执行选项 - * @return 白名单配置 - */ - public static Map sanitizeOptions(Map source) { - return sanitizeAllowed(source, OPTIONS_ALLOWED_KEYS); - } - - private static Map sanitizeAllowed(Map source, Set allowedKeys) { - if (source == null || source.isEmpty()) { - return new LinkedHashMap<>(); - } - Map sanitized = new LinkedHashMap<>(); - for (Map.Entry 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; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java index 3b85a62c..5b8d8c45 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/security/SkillVisibilityQueryHelper.java @@ -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)); } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java index 21f9c47d..ccb66ab3 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java @@ -29,7 +29,7 @@ public interface SkillService extends IService { Skill getManagementDetail(BigInteger id); /** - * 获取仅包含标准 Skill 包内容的授权详情,不解析平台能力目标。 + * 获取仅包含标准 Skill 包内容的授权详情。 * * @param id Skill ID * @return Skill 包内容详情 @@ -81,16 +81,16 @@ public interface SkillService extends IService { 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 { */ Map buildPublishSnapshot(Skill skill); + /** + * 校验发布快照中的哈希与实际内容一致。 + * + * @param snapshot 发布快照 + */ + void assertSnapshotHash(Map snapshot); + /** * 构建删除审批使用的最小治理快照。 * * @param skill Skill - * @return 不含提示词、资源内容和能力配置的治理快照 + * @return 不含 SKILL.md 正文和资源内容的治理快照 */ Map buildGovernanceSnapshot(Skill skill); diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java index df760fb6..67a8fb98 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillResourceServiceImpl.java @@ -34,10 +34,10 @@ public class SkillResourceServiceImpl extends ServiceImpl 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 implements * * @param skillCategoryService Skill 分类服务 * @param skillResourceService 通用资源服务 - * @param capabilityBindingService 能力绑定服务 * @param contentStore 二进制内容仓库 * @param resourceAccessService 资源访问服务 * @param categoryPermissionService 分类权限服务 @@ -81,14 +76,12 @@ public class SkillServiceImpl extends ServiceImpl 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 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 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 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 implements skillCategoryService.lockAndValidateUsableCategory(skill.getCategoryId()); validateDraft(skill); assertUniqueName(skill.getName(), null); - List resources = SkillResourceModelAdapter.toResources(skill); + List 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 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 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 implements throw new BusinessException(409, 4091, "文件已被其他操作更新,请重新加载后合并内容"); } } - // 来源由服务端继承,使旧版下划线导入草稿可继续编辑,同时仍在发布校验中阻断。 - skill.setSourceType(existing.getSourceType()); validateDraft(skill); assertUniqueName(skill.getName(), skill.getId()); - List resources = hasResourcePayload(skill) - ? SkillResourceModelAdapter.toResources(skill) + List 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 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 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 implements resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理该 Skill"); List 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 implements throw new BusinessException("Skill 发布校验失败:" + issue.getMessage()); }); Map 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> 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 implements throw new BusinessException("Skill 治理快照缺少资源标识"); } Map 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 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 snapshot) { + if (snapshot == null || snapshot.isEmpty()) { + throw new BusinessException("Skill 发布快照为空"); + } + Object declared = snapshot.get("snapshotHash"); + Map 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 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 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 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 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 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 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 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 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 implements private void refreshPackageSummary(Skill skill, List 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 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 resources) { @@ -719,41 +637,12 @@ public class SkillServiceImpl extends ServiceImpl 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 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 implements } } - private boolean hasResourcePayload(Skill skill) { - return skill.getResources() != null || skill.getReferences() != null - || skill.getScripts() != null || skill.getAssets() != null; - } - private void releaseContents(List resources) { for (SkillResource resource : resources) { if (resource.getContentRef() != null) { @@ -828,16 +711,14 @@ public class SkillServiceImpl extends ServiceImpl 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 implements for (SkillResource resource : resources) { Map 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 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 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)); - } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java index 9ed9af19..772d76bc 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java @@ -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 内容暂存描述不一致"); } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java index a60ff4bb..a324519f 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java @@ -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 toAgentResources(List 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; - } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillResourceModelAdapter.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillResourceModelAdapter.java deleted file mode 100644 index 9a9f5014..00000000 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillResourceModelAdapter.java +++ /dev/null @@ -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 toResources(Skill skill) { - if (skill.getResources() != null) { - return new ArrayList<>(skill.getResources()); - } - List 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 resources) { - List references = new ArrayList<>(); - List scripts = new ArrayList<>(); - List assets = new ArrayList<>(); - for (SkillResource resource : resources == null ? List.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 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); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java index 47755985..4f55db91 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/validation/SkillValidationIssue.java @@ -1,7 +1,7 @@ package tech.easyflow.skill.validation; /** - * Skill 包或能力配置的结构化校验问题。 + * 标准 Skill 包的结构化校验问题。 */ public class SkillValidationIssue { diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImplTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImplTest.java deleted file mode 100644 index 7a6cac3d..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityBindingServiceImplTest.java +++ /dev/null @@ -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 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 result = clearingService.replaceBindings(SKILL_ID, List.of()); - - ArgumentCaptor 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 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> 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 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 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 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 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 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 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 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 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 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(); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityMalformedInputTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityMalformedInputTest.java deleted file mode 100644 index a9c52bfc..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityMalformedInputTest.java +++ /dev/null @@ -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 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 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; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityTenantAndValidationTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityTenantAndValidationTest.java deleted file mode 100644 index 4bf6b94f..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/capability/SkillCapabilityTenantAndValidationTest.java +++ /dev/null @@ -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 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 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 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 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; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java index 49d9e62e..e4b1ee16 100644 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/file/SkillFileServiceImplTransactionTest.java @@ -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 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 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 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()); } /** diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowBundleReaderEntryLimitTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowBundleReaderEntryLimitTest.java deleted file mode 100644 index 00263316..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowBundleReaderEntryLimitTest.java +++ /dev/null @@ -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 中央目录"); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowManifestStrictInputTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowManifestStrictInputTest.java deleted file mode 100644 index 611d8546..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowManifestStrictInputTest.java +++ /dev/null @@ -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)); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodecTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodecTest.java deleted file mode 100644 index ba4532f0..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/EasyFlowSkillManifestCodecTest.java +++ /dev/null @@ -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 hitl = new LinkedHashMap<>(); - hitl.put("prompt", "确认执行"); - hitl.put("token", "hitl-secret"); - hitl.put("headers", Map.of("Authorization", "Bearer nested-secret")); - binding.setHitlConfigJson(hitl); - Map 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 manifest = objectMapper.readValue(encoded, new TypeReference<>() { }); - Map 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 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 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 runtimeBinding = validManifestBinding(); - runtimeBinding.put("runtimeName", "sk-proj-abcdefghijklmnopqrstuvwxyz123456"); - assertSensitivePath(runtimeBinding, "skills[0].capabilities[0].runtimeName"); - - Map toolBinding = validManifestBinding(); - toolBinding.put("selectedToolNames", List.of("sk-proj-abcdefghijklmnopqrstuvwxyz123456")); - assertSensitivePath(toolBinding, "skills[0].capabilities[0].selectedToolNames[0]"); - - Map 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 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 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> 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 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 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 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 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 validManifestBinding() { - Map 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 binding) throws Exception { - Map 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 firstBinding(Map manifest) { - List> skills = (List>) manifest.get("skills"); - List> bindings = (List>) skills.get(0).get("capabilities"); - assertTrue(!bindings.isEmpty()); - return bindings.get(0); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportFormatIsolationTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportFormatIsolationTest.java deleted file mode 100644 index c3deb146..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportFormatIsolationTest.java +++ /dev/null @@ -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 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 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 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 unzip(byte[] bytes) { - try { - Map 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 entries) { - StringBuilder result = new StringBuilder(); - entries.values().forEach(bytes -> result.append(new String(bytes, StandardCharsets.UTF_8))); - return result.toString(); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportRoundTripTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportRoundTripTest.java deleted file mode 100644 index 022a8131..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillExportRoundTripTest.java +++ /dev/null @@ -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 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 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 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 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 paths(byte[] bytes) { - try { - Set 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); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportConflictPrivacyTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportConflictPrivacyTest.java deleted file mode 100644 index 977aa115..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportConflictPrivacyTest.java +++ /dev/null @@ -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 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 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 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 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 login() { - LoginAccount account = new LoginAccount(); - account.setId(ACCOUNT_ID); - account.setTenantId(TENANT_ID); - MockedStatic 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()); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplPreviewTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplPreviewTest.java deleted file mode 100644 index 4279b5fa..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplPreviewTest.java +++ /dev/null @@ -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 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 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 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 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 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 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 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 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 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 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 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(); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplQueryEfficiencyTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplQueryEfficiencyTest.java deleted file mode 100644 index a94a99e7..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportServiceImplQueryEfficiencyTest.java +++ /dev/null @@ -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 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 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 imported; - try (MockedStatic 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 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 imported; - try (MockedStatic 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 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 firstBinding = binding("alpha-workflow-one", "alphaFlowOne"); - Map secondBinding = binding("alpha-workflow-two", "alphaFlowTwo"); - Map 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 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 login() { - LoginAccount account = new LoginAccount(); - account.setId(ACCOUNT_ID); - account.setTenantId(TENANT_ID); - MockedStatic 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(); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java index effb4fc8..b9c7cb01 100644 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/SkillImportStageStoreTest.java @@ -188,7 +188,7 @@ public class SkillImportStageStoreTest { BusinessException exception; try (MockedStatic 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; diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/StandardSkillPackageContractTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/StandardSkillPackageContractTest.java new file mode 100644 index 00000000..ed261826 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/imports/StandardSkillPackageContractTest.java @@ -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 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 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 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 login() { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.valueOf(7)); + account.setTenantId(BigInteger.ONE); + MockedStatic 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 zipPaths(byte[] bytes) { + try (InputStream input = new ByteArrayInputStream(bytes); + ZipInputStream zip = new ZipInputStream(input, StandardCharsets.UTF_8)) { + java.util.LinkedHashSet 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); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillStandardCleanupMigrationContractTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillStandardCleanupMigrationContractTest.java new file mode 100644 index 00000000..621a85ff --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillStandardCleanupMigrationContractTest.java @@ -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 标准化迁移脚本"); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillSummaryBackfillSqlTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillSummaryBackfillSqlTest.java deleted file mode 100644 index 1158c89a..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/mapper/SkillSummaryBackfillSqlTest.java +++ /dev/null @@ -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()); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java index fba4683a..afded2d2 100644 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java @@ -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 candidate = snapshot("sha256:candidate"); when(skillMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(draft); @@ -100,7 +105,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest { ArgumentCaptor 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 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 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 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 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; + } } diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillPublishAppServiceTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillPublishAppServiceTest.java new file mode 100644 index 00000000..08b700c2 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillPublishAppServiceTest.java @@ -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 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), + "首次发布"); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/repository/DBSkillRepositoryContentOwnershipTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/repository/DBSkillRepositoryContentOwnershipTest.java deleted file mode 100644 index 51e18e6d..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/repository/DBSkillRepositoryContentOwnershipTest.java +++ /dev/null @@ -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 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 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 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; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillCredentialValueGuardTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillCredentialValueGuardTest.java deleted file mode 100644 index cb8deef6..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillCredentialValueGuardTest.java +++ /dev/null @@ -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 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", - "password=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 safeValues = List.of( - "是否继续执行当前工作流?", - "请确认操作,运行时会从安全配置读取认证信息", - "token=${TOKEN}", - "Authorization: Bearer {{ token }}", - "apiKey=", - "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)); - } - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizerTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizerTest.java deleted file mode 100644 index 899f8c1c..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillSensitiveConfigSanitizerTest.java +++ /dev/null @@ -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 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 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 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 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 sanitized = SkillSensitiveConfigSanitizer.sanitizeOptions(null); - - sanitized.put("timeoutMs", 1_000); - - assertEquals(1_000, sanitized.get("timeoutMs")); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java index b030217f..aa5b3dbd 100644 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/security/SkillVisibilityQueryHelperTenantTest.java @@ -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")); } diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillLegacySummaryBackfillTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillLegacySummaryBackfillTest.java deleted file mode 100644 index ecf7aa0c..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillLegacySummaryBackfillTest.java +++ /dev/null @@ -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 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 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()); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java index 0f056cda..3b1588cd 100644 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillResourceServiceImplProjectionTest.java @@ -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")); } diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplContentReferenceTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplContentReferenceTest.java deleted file mode 100644 index a3219539..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplContentReferenceTest.java +++ /dev/null @@ -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 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 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 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 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 snapshot(String... refs) { - List> resources = new ArrayList<>(); - for (String ref : refs) { - Map resource = new LinkedHashMap<>(); - resource.put("path", "assets/" + resources.size()); - resource.put("contentRef", ref); - resources.add(resource); - } - return Map.of("resources", resources); - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplDeletionGuardTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplDeletionGuardTest.java deleted file mode 100644 index c9f1133e..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplDeletionGuardTest.java +++ /dev/null @@ -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 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 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; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplManagementTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplManagementTest.java deleted file mode 100644 index 739c9fc1..00000000 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplManagementTest.java +++ /dev/null @@ -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 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> 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 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 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 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; - } -} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java index 88b9c6fd..10674f3c 100644 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/store/DBSkillContentStoreTest.java @@ -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)); diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java index 200ed6bf..13585c9f 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java @@ -71,10 +71,6 @@ public class ResourceAccessServiceImpl implements ResourceAccessService { if (ResourceAction.MANAGE == action) { return false; } - if (CategoryResourceType.SKILL == resourceType && resource.getCategoryId() == null - && categoryPermissionService.getAccess(resourceType.getCode(), loginAccount).isAllAccess()) { - return true; - } // Agent 的未分类语义为“全部分类可访问”,只跳过分类白名单,不能跳过可见范围校验。 boolean agentWithoutCategoryRestriction = CategoryResourceType.AGENT == resourceType && resource.getCategoryId() == null; diff --git a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java index da271888..54266d26 100644 --- a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java +++ b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/ResourceAccessServiceImplTest.java @@ -7,14 +7,12 @@ import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.enums.VisibilityScope; -import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.permission.resource.VisibilityResource; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysDeptService; import java.lang.reflect.Field; import java.math.BigInteger; -import java.util.Set; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -113,19 +111,16 @@ public class ResourceAccessServiceImplTest { } /** - * 验证 Skill 分类 ALL 范围可以读取其他创建者的未分类私有草稿。 + * 验证 Skill 分类 ALL 范围也不能读取其他创建者的未分类私有草稿。 */ @Test - public void allCategoryScopeShouldReadUnclassifiedPrivateSkill() { + public void allCategoryScopeShouldRejectUnclassifiedPrivateSkill() { LoginAccount account = account(8, 80); VisibilityResource resource = new TestVisibilityResource( BigInteger.ONE, BigInteger.valueOf(7), BigInteger.valueOf(90), null, VisibilityScope.PRIVATE.name()); - Mockito.when(categoryPermissionService.getAccess(CategoryResourceType.SKILL.getCode(), account)) - .thenReturn(new RoleCategoryAccessSnapshot( - CategoryResourceType.SKILL.getCode(), account.getId(), false, true, Set.of())); - assertTrue(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.READ)); + assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.READ)); assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.MANAGE)); } diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V55__mysql_skill_standard_package_cleanup.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V55__mysql_skill_standard_package_cleanup.sql new file mode 100644 index 00000000..73e52736 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V55__mysql_skill_standard_package_cleanup.sql @@ -0,0 +1,163 @@ +SET NAMES utf8mb4; + +-- V27 已完成旧表到标准资源表的受保护复制,此后应用只写标准表,旧表允许因正常编辑而陈旧。 +-- 所有不可逆删除前校验当前权威数据的完整性;任一校验命中都会通过固定主键冲突终止迁移。 +CREATE TEMPORARY TABLE `tmp_skill_standard_cleanup_guard` ( + `guard_key` TINYINT NOT NULL, + PRIMARY KEY (`guard_key`) +) ENGINE=InnoDB; +INSERT INTO `tmp_skill_standard_cleanup_guard` (`guard_key`) VALUES (1); + +-- Skill 目录名必须符合 Anthropic Skill 的小写连字符命名规则。 +INSERT INTO `tmp_skill_standard_cleanup_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill` +WHERE `name` IS NULL OR `name` NOT REGEXP '^[a-z0-9]+(-[a-z0-9]+)*$' +LIMIT 1; + +-- 通用资源仍须严格位于所属 Skill 的租户边界内,二进制引用必须可解析。 +INSERT INTO `tmp_skill_standard_cleanup_guard` (`guard_key`) +SELECT 1 +FROM `tb_skill_resource` resource +LEFT JOIN `tb_skill` skill ON skill.`id` = resource.`skill_id` +LEFT JOIN `tb_skill_content` content ON content.`content_ref` = resource.`content_ref` +WHERE skill.`id` IS NULL + OR resource.`tenant_id` <> skill.`tenant_id` + OR (resource.`is_text` = 0 AND (resource.`content_ref` IS NULL OR content.`content_ref` IS NULL)) +LIMIT 1; + +-- 旧 EasyFlow 私有包暂存不能在删除格式字段后被误当作标准包。 +SET @guard_skill_import_format = ( + SELECT IF(COUNT(1) > 0, + 'INSERT INTO `tmp_skill_standard_cleanup_guard` (`guard_key`) SELECT 1 FROM `tb_skill_import_stage` WHERE `format` <> ''STANDARD'' LIMIT 1', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_import_stage' + AND column_name = 'format'); +PREPARE guard_skill_import_format_stmt FROM @guard_skill_import_format; +EXECUTE guard_skill_import_format_stmt; +DEALLOCATE PREPARE guard_skill_import_format_stmt; + +DROP TEMPORARY TABLE `tmp_skill_standard_cleanup_guard`; + +-- 历史 enabled=false 的已发布 Skill 映射为正式下线状态,避免删除字段后扩大可见性。 +SET @normalize_disabled_skill = ( + SELECT IF(COUNT(1) > 0, + 'UPDATE `tb_skill` SET `publish_status` = ''OFFLINE'', `current_approval_instance_id` = NULL, `modified` = `modified`, `modified_by` = `modified_by` WHERE `enabled` = 0 AND `publish_status` = ''PUBLISHED''', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill' + AND column_name = 'enabled'); +PREPARE normalize_disabled_skill_stmt FROM @normalize_disabled_skill; +EXECUTE normalize_disabled_skill_stmt; +DEALLOCATE PREPARE normalize_disabled_skill_stmt; + +-- 删除已下线的能力绑定权限及其角色关系。 +DELETE FROM `tb_sys_role_menu` WHERE `menu_id` = 367400000000000024; +DELETE FROM `tb_sys_menu` +WHERE `id` = 367400000000000024 + AND `permission_tag` = '/api/v1/skill/capability'; + +UPDATE `tb_sys_menu` +SET `menu_title` = '技能库', + `remark` = '标准 Skill 管理', + `modified` = `modified`, + `modified_by` = `modified_by` +WHERE `id` = 367400000000000001 + AND `menu_url` = '/ai/skill'; + +-- 索引不再依赖 enabled,改为围绕列表真实过滤条件组织。 +SET @drop_skill_category_index = ( + SELECT IF(COUNT(1) > 0, + 'ALTER TABLE `tb_skill` DROP INDEX `idx_skill_tenant_category`', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill' + AND index_name = 'idx_skill_tenant_category'); +PREPARE drop_skill_category_index_stmt FROM @drop_skill_category_index; +EXECUTE drop_skill_category_index_stmt; +DEALLOCATE PREPARE drop_skill_category_index_stmt; + +SET @add_skill_list_index = ( + SELECT IF(COUNT(1) = 0, + 'ALTER TABLE `tb_skill` ADD KEY `idx_skill_tenant_list` (`tenant_id`, `category_id`, `visibility_scope`, `publish_status`, `modified`)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill' + AND index_name = 'idx_skill_tenant_list'); +PREPARE add_skill_list_index_stmt FROM @add_skill_list_index; +EXECUTE add_skill_list_index_stmt; +DEALLOCATE PREPARE add_skill_list_index_stmt; + +SET @drop_skill_resource_sort_index = ( + SELECT IF(COUNT(1) > 0, + 'ALTER TABLE `tb_skill_resource` DROP INDEX `idx_skill_resource_tenant_skill`', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_resource' + AND index_name = 'idx_skill_resource_tenant_skill'); +PREPARE drop_skill_resource_sort_index_stmt FROM @drop_skill_resource_sort_index; +EXECUTE drop_skill_resource_sort_index_stmt; +DEALLOCATE PREPARE drop_skill_resource_sort_index_stmt; + +SET @add_skill_resource_index = ( + SELECT IF(COUNT(1) = 0, + 'ALTER TABLE `tb_skill_resource` ADD KEY `idx_skill_resource_tenant_skill` (`tenant_id`, `skill_id`, `normalized_path`)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_resource' + AND index_name = 'idx_skill_resource_tenant_skill'); +PREPARE add_skill_resource_index_stmt FROM @add_skill_resource_index; +EXECUTE add_skill_resource_index_stmt; +DEALLOCATE PREPARE add_skill_resource_index_stmt; + +-- 物理删除已由 V27 完整替代的五张旧包模型表和能力绑定表。 +DROP TABLE IF EXISTS `tb_skill_capability_binding`; +DROP TABLE IF EXISTS `tb_skill_reference`; +DROP TABLE IF EXISTS `tb_skill_script`; +DROP TABLE IF EXISTS `tb_skill_asset`; +DROP TABLE IF EXISTS `tb_skill_asset_content`; + +-- 仅保留标准包持久化所需字段;动态 DDL 允许 MySQL 非事务 DDL 失败后安全重跑。 +SET @drop_skill_legacy_columns = ( + SELECT IF(COUNT(1) = 0, + 'SELECT 1', + CONCAT('ALTER TABLE `tb_skill` ', GROUP_CONCAT(CONCAT('DROP COLUMN `', column_name, '`') SEPARATOR ', '))) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill' + AND column_name IN ('metadata_json', 'enabled', 'source_type', 'capability_hash', + 'resource_count', 'capability_count', 'reference_count', 'script_count', 'asset_count')); +PREPARE drop_skill_legacy_columns_stmt FROM @drop_skill_legacy_columns; +EXECUTE drop_skill_legacy_columns_stmt; +DEALLOCATE PREPARE drop_skill_legacy_columns_stmt; + +SET @drop_skill_resource_derived_columns = ( + SELECT IF(COUNT(1) = 0, + 'SELECT 1', + CONCAT('ALTER TABLE `tb_skill_resource` ', GROUP_CONCAT(CONCAT('DROP COLUMN `', column_name, '`') SEPARATOR ', '))) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_resource' + AND column_name IN ('kind', 'language', 'metadata_json', 'sort_no')); +PREPARE drop_skill_resource_derived_columns_stmt FROM @drop_skill_resource_derived_columns; +EXECUTE drop_skill_resource_derived_columns_stmt; +DEALLOCATE PREPARE drop_skill_resource_derived_columns_stmt; + +SET @drop_skill_import_format = ( + SELECT IF(COUNT(1) > 0, + 'ALTER TABLE `tb_skill_import_stage` DROP COLUMN `format`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tb_skill_import_stage' + AND column_name = 'format'); +PREPARE drop_skill_import_format_stmt FROM @drop_skill_import_format; +EXECUTE drop_skill_import_format_stmt; +DEALLOCATE PREPARE drop_skill_import_format_stmt;