feat: 支持 Skill 仓库扫描与批量导入

- 统一支持本地单包、多包和 Git 候选最多 50 项导入

- 固定提交并限制 SSRF、DNS、并发、频率、超时和仓库资源

- 增加导入对话框、接口契约和安全回归测试
This commit is contained in:
2026-08-19 22:39:55 +08:00
parent 4e8640dcaf
commit 9579619384
28 changed files with 3949 additions and 86 deletions

View File

@@ -16,6 +16,8 @@ import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryPrepareRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryScanRequest;
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
import tech.easyflow.admin.controller.skill.vo.SkillView;
import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest;
@@ -39,6 +41,8 @@ import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
import tech.easyflow.skill.imports.SkillImportPreview;
import tech.easyflow.skill.imports.SkillImportService;
import tech.easyflow.skill.gitimport.SkillGitImportService;
import tech.easyflow.skill.gitimport.SkillGitScanResult;
import tech.easyflow.skill.publish.SkillPublishAppService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.service.SkillApprovalStateService;
@@ -77,6 +81,7 @@ public class SkillController {
private final SkillApprovalStateService skillApprovalStateService;
private final SkillPublishAppService skillPublishAppService;
private final SkillImportService skillImportService;
private final SkillGitImportService skillGitImportService;
private final SkillExportService skillExportService;
private final SkillFileService skillFileService;
private final SkillToolBindingService skillToolBindingService;
@@ -93,6 +98,7 @@ public class SkillController {
* @param skillApprovalStateService 审批状态服务
* @param skillPublishAppService 发布服务
* @param skillImportService 导入服务
* @param skillGitImportService Git 仓库导入服务
* @param skillExportService 导出服务
* @param skillFileService 文件服务
* @param skillToolBindingService Skill Tool 绑定服务
@@ -106,6 +112,7 @@ public class SkillController {
SkillApprovalStateService skillApprovalStateService,
SkillPublishAppService skillPublishAppService,
SkillImportService skillImportService,
SkillGitImportService skillGitImportService,
SkillExportService skillExportService,
SkillFileService skillFileService,
SkillToolBindingService skillToolBindingService,
@@ -118,6 +125,7 @@ public class SkillController {
this.skillApprovalStateService = skillApprovalStateService;
this.skillPublishAppService = skillPublishAppService;
this.skillImportService = skillImportService;
this.skillGitImportService = skillGitImportService;
this.skillExportService = skillExportService;
this.skillFileService = skillFileService;
this.skillToolBindingService = skillToolBindingService;
@@ -447,10 +455,43 @@ public class SkillController {
if (uploads.isEmpty()) {
throw new BusinessException("请选择要导入的标准 Skill ZIP");
}
if (uploads.size() > 20) {
throw new BusinessException("单次最多预检 20 个 Skill ZIP");
if (uploads.size() > SkillImportService.MAX_BATCH_SKILL_COUNT) {
throw new BusinessException("单次最多选择 "
+ SkillImportService.MAX_BATCH_SKILL_COUNT + " 个 Skill ZIP");
}
return Result.ok(uploads.stream().map(skillImportService::preview).toList());
return Result.ok(skillImportService.previewBatch(uploads));
}
/**
* 扫描 HTTPS Git 仓库中的标准 Skill 候选。
*
* @param request 仓库地址请求
* @return 固定提交的候选列表
*/
@PostMapping("/import/repository/scan")
@SaCheckPermission("/api/v1/skill/import")
public Result<SkillGitScanResult> scanGitRepository(
@JsonBody(required = true, skipConvertError = false) SkillGitRepositoryScanRequest request) {
if (request == null) {
throw new BusinessException("请输入 Git 仓库地址");
}
return Result.ok(skillGitImportService.scan(request.repositoryUrl()));
}
/**
* 将选中的 Git Skill 候选转换为既有标准 ZIP 导入预览。
*
* @param request 扫描令牌与候选 ID
* @return 与本地 ZIP 导入一致的预览列表
*/
@PostMapping("/import/repository/prepare")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillImportPreview>> prepareGitRepositoryImport(
@JsonBody(required = true, skipConvertError = false) SkillGitRepositoryPrepareRequest request) {
if (request == null) {
throw new BusinessException("请选择要导入的 Git Skill");
}
return Result.ok(skillGitImportService.prepare(request.scanToken(), request.candidateIds()));
}
/**
@@ -479,8 +520,9 @@ public class SkillController {
if (requests == null || requests.isEmpty()) {
throw new BusinessException("请选择要确认导入的 Skill");
}
if (requests.size() > 20) {
throw new BusinessException("单次最多确认导入 20 个 Skill");
if (requests.size() > SkillImportService.MAX_BATCH_SKILL_COUNT) {
throw new BusinessException("单次最多确认导入 "
+ SkillImportService.MAX_BATCH_SKILL_COUNT + " 个 Skill");
}
List<SkillImportBatchResultView> results = new java.util.ArrayList<>(requests.size());
for (SkillImportConfirmRequest request : requests) {

View File

@@ -0,0 +1,12 @@
package tech.easyflow.admin.controller.skill.vo;
import java.util.List;
/**
* 选中 Git Skill 候选的标准导入预览准备请求。
*
* @param scanToken 短期扫描令牌
* @param candidateIds 选中的候选 ID
*/
public record SkillGitRepositoryPrepareRequest(String scanToken, List<String> candidateIds) {
}

View File

@@ -0,0 +1,9 @@
package tech.easyflow.admin.controller.skill.vo;
/**
* Git 仓库 Skill 扫描请求。
*
* @param repositoryUrl HTTPS Git 仓库地址,可省略 .git 后缀
*/
public record SkillGitRepositoryScanRequest(String repositoryUrl) {
}

View File

@@ -8,6 +8,8 @@ import org.testng.annotations.Test;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryPrepareRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryScanRequest;
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest;
import tech.easyflow.admin.controller.skill.vo.SkillView;
@@ -19,6 +21,7 @@ import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.file.SkillFileService;
import tech.easyflow.skill.gitimport.SkillGitImportService;
import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
import tech.easyflow.skill.imports.SkillImportService;
@@ -144,6 +147,26 @@ public class SkillControllerContractTest {
.anyMatch("/api/v1/skill/capability"::equals));
}
/**
* Git 仓库扫描与候选准备沿用 Skill 导入权限,并使用白名单请求 DTO。
*
* @throws Exception 反射失败
*/
@Test
public void gitRepositoryEndpointsReuseImportPermission() throws Exception {
Method scan = SkillController.class.getMethod(
"scanGitRepository", SkillGitRepositoryScanRequest.class);
Method prepare = SkillController.class.getMethod(
"prepareGitRepositoryImport", SkillGitRepositoryPrepareRequest.class);
Assert.assertEquals(scan.getAnnotation(SaCheckPermission.class).value(),
new String[]{"/api/v1/skill/import"});
Assert.assertEquals(prepare.getAnnotation(SaCheckPermission.class).value(),
new String[]{"/api/v1/skill/import"});
Assert.assertNotNull(scan.getParameters()[0].getAnnotation(JsonBody.class));
Assert.assertNotNull(prepare.getParameters()[0].getAnnotation(JsonBody.class));
}
/**
* 发布入口把必填发布说明原样交给应用服务。
*/
@@ -195,7 +218,8 @@ public class SkillControllerContractTest {
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),
publishService, importService, mock(SkillGitImportService.class),
mock(SkillExportService.class), mock(SkillFileService.class),
mock(SkillToolBindingService.class), mock(SkillToolOptionQueryService.class),
accessService, mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
mock(AiResourceCreatorNameSupport.class));