发布 v1.10 #5
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
/**
|
||||
* Git 仓库 Skill 扫描请求。
|
||||
*
|
||||
* @param repositoryUrl HTTPS Git 仓库地址,可省略 .git 后缀
|
||||
*/
|
||||
public record SkillGitRepositoryScanRequest(String repositoryUrl) {
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -61,6 +61,16 @@
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jgit</groupId>
|
||||
<artifactId>org.eclipse.jgit</artifactId>
|
||||
<version>7.7.0.202606012155-r</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jgit</groupId>
|
||||
<artifactId>org.eclipse.jgit.http.apache</artifactId>
|
||||
<version>7.7.0.202606012155-r</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.URL;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Git 仓库远程地址访问策略,负责协议、凭证、端口与 SSRF 边界校验。
|
||||
*/
|
||||
@Component
|
||||
public class GitRepositoryAccessPolicy {
|
||||
|
||||
private final SkillGitImportProperties properties;
|
||||
private final HostResolver hostResolver;
|
||||
|
||||
/**
|
||||
* 创建 Git 地址访问策略。
|
||||
*
|
||||
* @param properties Git 导入配置
|
||||
*/
|
||||
public GitRepositoryAccessPolicy(SkillGitImportProperties properties) {
|
||||
this(properties, InetAddress::getAllByName);
|
||||
}
|
||||
|
||||
GitRepositoryAccessPolicy(SkillGitImportProperties properties, HostResolver hostResolver) {
|
||||
this.properties = properties;
|
||||
this.hostResolver = hostResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化用户输入的 Git 仓库地址。
|
||||
*
|
||||
* @param repositoryUrl 用户输入地址
|
||||
* @return 规范化 HTTPS 地址
|
||||
* @throws BusinessException 地址不符合安全边界
|
||||
*/
|
||||
public String normalizeRepositoryUrl(String repositoryUrl) {
|
||||
if (repositoryUrl == null || repositoryUrl.isBlank()) {
|
||||
throw new BusinessException("请输入 Git 仓库地址");
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(repositoryUrl.trim()).normalize();
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new BusinessException("Git 仓库地址格式不正确");
|
||||
}
|
||||
validateUri(uri, true);
|
||||
String path = uri.getRawPath();
|
||||
if (path == null || path.isBlank() || "/".equals(path)) {
|
||||
throw new BusinessException("Git 仓库地址缺少仓库路径");
|
||||
}
|
||||
String normalizedPath = path.length() > 1 && path.endsWith("/")
|
||||
? path.substring(0, path.length() - 1) : path;
|
||||
try {
|
||||
return new URI("https", null, uri.getHost().toLowerCase(Locale.ROOT),
|
||||
uri.getPort(), normalizedPath, null, null).toASCIIString();
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new BusinessException("Git 仓库地址格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 JGit 实际发起的连接地址,包括重定向后的服务地址。
|
||||
*
|
||||
* @param url 连接地址
|
||||
* @throws java.io.IOException 地址被访问策略拒绝
|
||||
*/
|
||||
ResolvedConnection resolveConnection(URL url) throws IOException {
|
||||
try {
|
||||
URI uri = url.toURI();
|
||||
InetAddress[] addresses = validateUri(uri, false);
|
||||
return new ResolvedConnection(uri.getHost().toLowerCase(Locale.ROOT), addresses);
|
||||
} catch (URISyntaxException | BusinessException exception) {
|
||||
throw new IOException(exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private InetAddress[] validateUri(URI uri, boolean repositoryInput) {
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme())) {
|
||||
throw new BusinessException("Git 仓库仅支持 HTTPS 地址");
|
||||
}
|
||||
if (uri.getUserInfo() != null) {
|
||||
throw new BusinessException("Git 仓库地址不能包含用户名或密码");
|
||||
}
|
||||
if (repositoryInput && (uri.getQuery() != null || uri.getFragment() != null)) {
|
||||
throw new BusinessException("Git 仓库地址不能包含查询参数或片段");
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new BusinessException("Git 仓库地址缺少有效主机");
|
||||
}
|
||||
int port = uri.getPort() < 0 ? 443 : uri.getPort();
|
||||
if (!properties.getAllowedPorts().contains(port)) {
|
||||
throw new BusinessException("Git 仓库端口不在允许范围内");
|
||||
}
|
||||
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = hostResolver.resolve(normalizedHost);
|
||||
} catch (UnknownHostException exception) {
|
||||
throw new BusinessException("无法解析 Git 仓库主机");
|
||||
}
|
||||
if (addresses.length == 0) {
|
||||
throw new BusinessException("无法解析 Git 仓库主机");
|
||||
}
|
||||
if (!properties.getTrustedPrivateHosts().contains(normalizedHost)
|
||||
&& Arrays.stream(addresses).anyMatch(this::isBlockedAddress)) {
|
||||
throw new BusinessException("Git 仓库地址指向受限网络");
|
||||
}
|
||||
return addresses.clone();
|
||||
}
|
||||
|
||||
private boolean isBlockedAddress(InetAddress address) {
|
||||
if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress() || address.isMulticastAddress()) {
|
||||
return true;
|
||||
}
|
||||
byte[] bytes = address.getAddress();
|
||||
if (address instanceof Inet4Address && bytes.length == 4) {
|
||||
int first = Byte.toUnsignedInt(bytes[0]);
|
||||
int second = Byte.toUnsignedInt(bytes[1]);
|
||||
int third = Byte.toUnsignedInt(bytes[2]);
|
||||
return first == 0 || first == 10 || first == 127 || first >= 224
|
||||
|| (first == 100 && second >= 64 && second <= 127)
|
||||
|| (first == 169 && second == 254)
|
||||
|| (first == 172 && second >= 16 && second <= 31)
|
||||
|| (first == 192 && second == 0 && (third == 0 || third == 2))
|
||||
|| (first == 192 && second == 88 && third == 99)
|
||||
|| (first == 192 && second == 168)
|
||||
|| (first == 198 && (second == 18 || second == 19))
|
||||
|| (first == 198 && second == 51 && third == 100)
|
||||
|| (first == 203 && second == 0 && third == 113);
|
||||
}
|
||||
if (address instanceof Inet6Address && bytes.length == 16) {
|
||||
int first = Byte.toUnsignedInt(bytes[0]);
|
||||
int second = Byte.toUnsignedInt(bytes[1]);
|
||||
return (first & 0xFE) == 0xFC || (first == 0xFE && (second & 0xC0) == 0x80)
|
||||
|| (first == 0x20 && second == 0x01 && Byte.toUnsignedInt(bytes[2]) == 0x0D
|
||||
&& Byte.toUnsignedInt(bytes[3]) == 0xB8);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface HostResolver {
|
||||
|
||||
/**
|
||||
* 解析目标主机的全部地址。
|
||||
*
|
||||
* @param host 规范化主机名
|
||||
* @return 解析地址
|
||||
* @throws UnknownHostException 主机无法解析
|
||||
*/
|
||||
InetAddress[] resolve(String host) throws UnknownHostException;
|
||||
}
|
||||
|
||||
record ResolvedConnection(String host, InetAddress[] addresses) {
|
||||
|
||||
ResolvedConnection {
|
||||
addresses = addresses.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用于实际连接的固定地址。
|
||||
*
|
||||
* @return 地址副本
|
||||
*/
|
||||
@Override
|
||||
public InetAddress[] addresses() {
|
||||
return addresses.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.factory.SkillFactory;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import com.easyagents.skill.util.SkillPaths;
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
import com.easyagents.skill.validation.SkillValidationIssue;
|
||||
import com.easyagents.skill.validation.SkillValidationSeverity;
|
||||
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
|
||||
import org.eclipse.jgit.lib.Constants;
|
||||
import org.eclipse.jgit.lib.FileMode;
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
import org.eclipse.jgit.lib.ObjectLoader;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevWalk;
|
||||
import org.eclipse.jgit.treewalk.TreeWalk;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLConnection;
|
||||
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.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* 固定 Git Tree 中的标准 Skill 候选发现、校验与标准包组装器。
|
||||
*/
|
||||
@Component
|
||||
public class SkillGitCandidateScanner {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillGitCandidateScanner.class);
|
||||
private static final int LFS_POINTER_MAX_BYTES = 1_024;
|
||||
private static final byte[] LFS_HEADER = "version https://git-lfs.github.com/spec/v1"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final SkillGitImportProperties properties;
|
||||
private final SkillPackageLimits packageLimits = SkillPackageLimits.defaults();
|
||||
private final DefaultSkillValidator validator = new DefaultSkillValidator();
|
||||
|
||||
/**
|
||||
* 创建候选扫描器。
|
||||
*
|
||||
* @param properties Git 导入配置
|
||||
*/
|
||||
public SkillGitCandidateScanner(SkillGitImportProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描快照固定提交中的全部标准 Skill 候选。
|
||||
*
|
||||
* @param snapshot 固定提交快照
|
||||
* @return 候选及其资源归属
|
||||
* @throws BusinessException 仓库结构不可读取或超过安全限额
|
||||
*/
|
||||
public SkillGitRepositoryScan scan(SkillGitRepositoryReader.Snapshot snapshot) {
|
||||
Repository repository = snapshot.repository();
|
||||
try (RevWalk revWalk = new RevWalk(repository)) {
|
||||
RevCommit commit = revWalk.parseCommit(ObjectId.fromString(snapshot.commitSha()));
|
||||
List<RawEntry> entries = readEntries(repository, commit);
|
||||
Set<String> roots = discoverCandidateRoots(entries);
|
||||
if (roots.size() > properties.getMaxCandidates()) {
|
||||
throw new BusinessException(413, 4133, "Git 仓库中的 Skill 候选数量超过限制");
|
||||
}
|
||||
Map<String, List<RawEntry>> ownedEntries = assignEntries(entries, roots);
|
||||
List<SkillGitScanResult.Candidate> candidates = new ArrayList<>(roots.size());
|
||||
Map<String, List<SkillGitRepositoryScan.RepositoryFile>> filesByCandidate = new LinkedHashMap<>();
|
||||
roots.stream().sorted().forEach(root -> {
|
||||
CandidateBuild build = buildCandidate(repository, snapshot.commitSha(), root,
|
||||
ownedEntries.getOrDefault(root, List.of()));
|
||||
candidates.add(build.candidate());
|
||||
filesByCandidate.put(build.candidate().candidateId(), build.files());
|
||||
});
|
||||
return new SkillGitRepositoryScan(candidates, filesByCandidate);
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(500, 500, "读取 Git 仓库文件树失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个可导入候选组装为包含单个标准 Skill 的 ZIP。
|
||||
*
|
||||
* @param snapshot 固定提交快照
|
||||
* @param scan 同一快照的扫描数据
|
||||
* @param candidateId 候选 ID
|
||||
* @return 待交给标准包预检的临时 ZIP
|
||||
* @throws BusinessException 候选不存在、不可导入或文件读取失败
|
||||
*/
|
||||
public Path createStandardZip(SkillGitRepositoryReader.Snapshot snapshot,
|
||||
SkillGitRepositoryScan scan,
|
||||
String candidateId) {
|
||||
SkillGitScanResult.Candidate candidate = scan.candidates().stream()
|
||||
.filter(item -> item.candidateId().equals(candidateId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BusinessException("Git Skill 候选不存在,请重新扫描"));
|
||||
if (!candidate.importable()) {
|
||||
throw new BusinessException("Git Skill 候选存在校验问题,不能导入:" + candidate.path());
|
||||
}
|
||||
List<SkillGitRepositoryScan.RepositoryFile> files = scan.filesByCandidate().get(candidateId);
|
||||
if (files == null || files.stream().noneMatch(file -> SkillPaths.SKILL_FILE.equals(file.relativePath()))) {
|
||||
throw new BusinessException("Git Skill 候选缺少 SKILL.md,请重新扫描");
|
||||
}
|
||||
Path archive;
|
||||
try {
|
||||
archive = Files.createTempFile("easyflow-git-skill-", ".zip");
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(500, 500, "创建 Git Skill 临时包失败", exception);
|
||||
}
|
||||
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(archive))) {
|
||||
for (SkillGitRepositoryScan.RepositoryFile file : files.stream()
|
||||
.sorted(Comparator.comparing(SkillGitRepositoryScan.RepositoryFile::relativePath)).toList()) {
|
||||
FileMode mode = FileMode.fromBits(file.fileModeBits());
|
||||
if (!isRegular(mode)) {
|
||||
throw new BusinessException("Git Skill 包含不支持的文件对象:" + file.relativePath());
|
||||
}
|
||||
ZipEntry entry = new ZipEntry(candidate.name() + "/" + file.relativePath());
|
||||
entry.setTime(0L);
|
||||
output.putNextEntry(entry);
|
||||
snapshot.repository().open(file.objectId(), Constants.OBJ_BLOB).copyTo(output);
|
||||
output.closeEntry();
|
||||
}
|
||||
return archive;
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
deleteArchive(archive);
|
||||
if (exception instanceof BusinessException businessException) {
|
||||
throw businessException;
|
||||
}
|
||||
throw new BusinessException(500, 500, "组装 Git Skill 标准包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除扫描器创建的临时标准包。
|
||||
*
|
||||
* @param archive 临时 ZIP
|
||||
*/
|
||||
public void deleteArchive(Path archive) {
|
||||
if (archive == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(archive);
|
||||
} catch (IOException exception) {
|
||||
// 临时包删除失败不能覆盖已生成的导入预览;记录完整路径供运维清理。
|
||||
LOG.warn("清理 Git Skill 临时包失败,path={}", archive, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private List<RawEntry> readEntries(Repository repository, RevCommit commit) throws IOException {
|
||||
List<RawEntry> entries = new ArrayList<>();
|
||||
try (TreeWalk treeWalk = new TreeWalk(repository)) {
|
||||
treeWalk.addTree(commit.getTree());
|
||||
treeWalk.setRecursive(true);
|
||||
while (treeWalk.next()) {
|
||||
if (entries.size() >= properties.getMaxRepositoryFiles()) {
|
||||
throw new BusinessException(413, 4134, "Git 仓库文件数量超过限制");
|
||||
}
|
||||
FileMode mode = treeWalk.getFileMode(0);
|
||||
ObjectId objectId = treeWalk.getObjectId(0).copy();
|
||||
long size = isRegular(mode) || FileMode.SYMLINK.equals(mode)
|
||||
? repository.open(objectId, Constants.OBJ_BLOB).getSize() : 0L;
|
||||
entries.add(new RawEntry(treeWalk.getPathString(), objectId, mode, size));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private Set<String> discoverCandidateRoots(List<RawEntry> entries) {
|
||||
Set<String> roots = new LinkedHashSet<>();
|
||||
for (RawEntry entry : entries) {
|
||||
if (isRegular(entry.mode()) && SkillPaths.SKILL_FILE.equals(fileName(entry.path()))) {
|
||||
roots.add(parentPath(entry.path()));
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private Map<String, List<RawEntry>> assignEntries(List<RawEntry> entries, Set<String> roots) {
|
||||
Map<String, List<RawEntry>> result = new HashMap<>();
|
||||
roots.forEach(root -> result.put(root, new ArrayList<>()));
|
||||
for (RawEntry entry : entries) {
|
||||
String owner = nearestCandidateRoot(entry.path(), roots);
|
||||
if (owner != null) {
|
||||
result.get(owner).add(entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String nearestCandidateRoot(String filePath, Set<String> roots) {
|
||||
String parent = parentPath(filePath);
|
||||
while (parent != null) {
|
||||
if (roots.contains(parent)) {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.isEmpty() ? null : parentPath(parent);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private CandidateBuild buildCandidate(Repository repository, String commitSha, String root,
|
||||
List<RawEntry> ownedEntries) {
|
||||
RawEntry skillFile = ownedEntries.stream()
|
||||
.filter(entry -> isRegular(entry.mode()))
|
||||
.filter(entry -> SkillPaths.SKILL_FILE.equals(relativePath(root, entry.path())))
|
||||
.findFirst().orElseThrow(() -> new BusinessException("Git Skill 候选缺少 SKILL.md"));
|
||||
List<SkillGitScanResult.Issue> issues = new ArrayList<>();
|
||||
String fallbackName = root.isEmpty() ? "root-skill" : fileName(root);
|
||||
String name = fallbackName;
|
||||
String description = "";
|
||||
String skillContent = null;
|
||||
if (skillFile.size() > packageLimits.getMaxTextFileBytes()) {
|
||||
issues.add(issue("TEXT_FILE_SIZE_LIMIT", "SKILL.md 超过允许大小", SkillPaths.SKILL_FILE));
|
||||
} else {
|
||||
try {
|
||||
skillContent = decodeUtf8(loadBytes(repository, skillFile));
|
||||
Skill skill = SkillFactory.create(skillContent);
|
||||
skill.setPackageRoot(root.isEmpty() ? skill.getName() : fileName(root));
|
||||
name = skill.getName();
|
||||
description = skill.getDescription();
|
||||
validator.validateReport(skill, packageLimits).getIssues().stream()
|
||||
.filter(item -> item.getSeverity() == SkillValidationSeverity.ERROR)
|
||||
.map(this::toIssue)
|
||||
.forEach(issues::add);
|
||||
} catch (SkillValidationException exception) {
|
||||
issues.add(issue(exception.getCode() == null ? "INVALID_SKILL" : exception.getCode(),
|
||||
exception.getMessage(), SkillPaths.SKILL_FILE));
|
||||
} catch (CharacterCodingException exception) {
|
||||
issues.add(issue("INVALID_UTF8", "SKILL.md 不是有效的 UTF-8 文本", SkillPaths.SKILL_FILE));
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(500, 500, "读取 Git SKILL.md 失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
List<SkillGitRepositoryScan.RepositoryFile> files = new ArrayList<>(ownedEntries.size());
|
||||
Set<String> collisionKeys = new HashSet<>();
|
||||
collisionKeys.add(SkillPaths.collisionKey(SkillPaths.SKILL_FILE));
|
||||
long totalBytes = 0L;
|
||||
int resourceCount = 0;
|
||||
for (RawEntry entry : ownedEntries) {
|
||||
String relative = relativePath(root, entry.path());
|
||||
totalBytes = addSize(totalBytes, entry.size());
|
||||
files.add(new SkillGitRepositoryScan.RepositoryFile(entry.path(), relative,
|
||||
entry.objectId(), entry.size(), entry.mode().getBits()));
|
||||
if (SkillPaths.SKILL_FILE.equals(relative)) {
|
||||
continue;
|
||||
}
|
||||
resourceCount++;
|
||||
validateResource(repository, entry, relative, collisionKeys, issues);
|
||||
}
|
||||
if (resourceCount + 1 > packageLimits.getMaxEntryCount()) {
|
||||
issues.add(issue("ENTRY_COUNT_LIMIT", "Skill 文件数量超过允许上限", null));
|
||||
}
|
||||
if (totalBytes > packageLimits.getMaxTotalUncompressedBytes()) {
|
||||
issues.add(issue("TOTAL_SIZE_LIMIT", "Skill 资源总大小超过允许上限", null));
|
||||
}
|
||||
String candidateId = SkillHashes.sha256Hex((commitSha + "\n" + root + "\n"
|
||||
+ skillFile.objectId().name()).getBytes(StandardCharsets.UTF_8));
|
||||
SkillGitScanResult.CandidateStatus status = issues.isEmpty()
|
||||
? SkillGitScanResult.CandidateStatus.IMPORTABLE
|
||||
: SkillGitScanResult.CandidateStatus.NEEDS_ATTENTION;
|
||||
SkillGitScanResult.Candidate candidate = new SkillGitScanResult.Candidate(
|
||||
candidateId, root.isEmpty() ? "/" : root, name, description, status,
|
||||
resourceCount, totalBytes, issues);
|
||||
return new CandidateBuild(candidate, files);
|
||||
}
|
||||
|
||||
private void validateResource(Repository repository, RawEntry entry, String relative,
|
||||
Set<String> collisionKeys, List<SkillGitScanResult.Issue> issues) {
|
||||
if (FileMode.SYMLINK.equals(entry.mode())) {
|
||||
issues.add(issue("GIT_SYMLINK_UNSUPPORTED", "暂不支持符号链接", relative));
|
||||
return;
|
||||
}
|
||||
if (FileMode.GITLINK.equals(entry.mode())) {
|
||||
issues.add(issue("GIT_SUBMODULE_UNSUPPORTED", "暂不支持 Git 子模块", relative));
|
||||
return;
|
||||
}
|
||||
if (!isRegular(entry.mode())) {
|
||||
issues.add(issue("GIT_OBJECT_UNSUPPORTED", "不支持该 Git 文件对象类型", relative));
|
||||
return;
|
||||
}
|
||||
String normalized;
|
||||
try {
|
||||
normalized = SkillPaths.normalize(relative);
|
||||
} catch (SkillValidationException exception) {
|
||||
issues.add(issue("UNSAFE_RESOURCE_PATH", "资源路径不符合 Skill 规范", relative));
|
||||
return;
|
||||
}
|
||||
String collisionKey = SkillPaths.collisionKey(normalized);
|
||||
if (!collisionKeys.add(collisionKey)) {
|
||||
issues.add(issue("DUPLICATE_RESOURCE_PATH", "资源路径存在大小写或 Unicode 冲突", relative));
|
||||
}
|
||||
if (normalized.length() > packageLimits.getMaxPathLength()
|
||||
|| SkillPaths.depth(normalized) + 1 > packageLimits.getMaxPathDepth()) {
|
||||
issues.add(issue("RESOURCE_PATH_LIMIT", "资源路径长度或深度超过限制", relative));
|
||||
}
|
||||
String mediaType = mediaType(relative);
|
||||
boolean text = SkillResources.isText(relative, mediaType);
|
||||
long fileLimit = text ? packageLimits.getMaxTextFileBytes() : packageLimits.getMaxBinaryFileBytes();
|
||||
if (entry.size() > fileLimit) {
|
||||
issues.add(issue(text ? "TEXT_FILE_SIZE_LIMIT" : "BINARY_FILE_SIZE_LIMIT",
|
||||
"资源文件超过允许大小", relative));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] bytes = null;
|
||||
if (text) {
|
||||
bytes = loadBytes(repository, entry);
|
||||
decodeUtf8(bytes);
|
||||
}
|
||||
if (entry.size() <= LFS_POINTER_MAX_BYTES
|
||||
&& isLfsPointer(bytes == null ? loadBytes(repository, entry) : bytes)) {
|
||||
issues.add(issue("GIT_LFS_UNRESOLVED", "Git LFS 文件尚未包含真实内容", relative));
|
||||
}
|
||||
} catch (CharacterCodingException exception) {
|
||||
issues.add(issue("INVALID_UTF8", "文本资源不是有效的 UTF-8 内容", relative));
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(500, 500, "读取 Git Skill 资源失败:" + relative, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] loadBytes(Repository repository, RawEntry entry) throws IOException {
|
||||
ObjectLoader loader = repository.open(entry.objectId(), Constants.OBJ_BLOB);
|
||||
long maximum = Math.max(packageLimits.getMaxBinaryFileBytes(), packageLimits.getMaxTextFileBytes());
|
||||
if (loader.getSize() > maximum) {
|
||||
throw new IOException("Git Blob exceeds the configured single-file limit");
|
||||
}
|
||||
return loader.getBytes((int) Math.min(Integer.MAX_VALUE, maximum));
|
||||
}
|
||||
|
||||
private String decodeUtf8(byte[] bytes) throws CharacterCodingException {
|
||||
return StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
}
|
||||
|
||||
private boolean isLfsPointer(byte[] bytes) {
|
||||
if (bytes.length < LFS_HEADER.length) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < LFS_HEADER.length; index++) {
|
||||
if (bytes[index] != LFS_HEADER[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private SkillGitScanResult.Issue toIssue(SkillValidationIssue source) {
|
||||
return issue(source.getCode(), source.getMessage(), source.getPath());
|
||||
}
|
||||
|
||||
private SkillGitScanResult.Issue issue(String code, String message, String path) {
|
||||
return new SkillGitScanResult.Issue(code, message, path);
|
||||
}
|
||||
|
||||
private String mediaType(String path) {
|
||||
String guessed = URLConnection.guessContentTypeFromName(path);
|
||||
return guessed == null ? "application/octet-stream" : guessed;
|
||||
}
|
||||
|
||||
private long addSize(long total, long size) {
|
||||
try {
|
||||
return Math.addExact(total, Math.max(0L, size));
|
||||
} catch (ArithmeticException exception) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRegular(FileMode mode) {
|
||||
return FileMode.REGULAR_FILE.equals(mode) || FileMode.EXECUTABLE_FILE.equals(mode);
|
||||
}
|
||||
|
||||
private String relativePath(String root, String path) {
|
||||
return root.isEmpty() ? path : path.substring(root.length() + 1);
|
||||
}
|
||||
|
||||
private String parentPath(String path) {
|
||||
int index = path.lastIndexOf('/');
|
||||
return index < 0 ? "" : path.substring(0, index);
|
||||
}
|
||||
|
||||
private String fileName(String path) {
|
||||
int index = path.lastIndexOf('/');
|
||||
return index < 0 ? path : path.substring(index + 1);
|
||||
}
|
||||
|
||||
private record RawEntry(String path, ObjectId objectId, FileMode mode, long size) {
|
||||
}
|
||||
|
||||
private record CandidateBuild(SkillGitScanResult.Candidate candidate,
|
||||
List<SkillGitRepositoryScan.RepositoryFile> files) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Skill Git 仓库导入的网络与资源边界配置。
|
||||
*/
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "easyflow.skill.git-import")
|
||||
public class SkillGitImportProperties {
|
||||
|
||||
/** Git 网络操作超时时间。 */
|
||||
private Duration timeout = Duration.ofSeconds(45);
|
||||
|
||||
/** 单次 Git 远程操作总超时时间。 */
|
||||
private Duration operationTimeout = Duration.ofSeconds(90);
|
||||
|
||||
/** 单实例允许同时执行的 Git 远程操作数量。 */
|
||||
private int maxConcurrentOperations = 4;
|
||||
|
||||
/** 同一账号两次仓库扫描之间的最小间隔。 */
|
||||
private Duration scanMinimumInterval = Duration.ofSeconds(2);
|
||||
|
||||
/** 扫描令牌有效期。 */
|
||||
private Duration scanTtl = Duration.ofMinutes(30);
|
||||
|
||||
/** 临时裸仓库最大磁盘占用。 */
|
||||
private long maxRepositoryBytes = 256L * 1024 * 1024;
|
||||
|
||||
/** 单仓库最大文件对象数量。 */
|
||||
private int maxRepositoryFiles = 50_000;
|
||||
|
||||
/** 单仓库最大 Skill 候选数量。 */
|
||||
private int maxCandidates = 2_000;
|
||||
|
||||
/** 允许访问的 HTTPS 端口。 */
|
||||
private Set<Integer> allowedPorts = new LinkedHashSet<>(Set.of(443));
|
||||
|
||||
/** 允许解析到私网地址的可信 Git 主机。 */
|
||||
private Set<String> trustedPrivateHosts = new LinkedHashSet<>();
|
||||
|
||||
/**
|
||||
* 获取 Git 网络操作超时时间。
|
||||
*
|
||||
* @return 超时时间
|
||||
*/
|
||||
public Duration getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Git 网络操作超时时间。
|
||||
*
|
||||
* @param timeout 超时时间
|
||||
*/
|
||||
public void setTimeout(Duration timeout) {
|
||||
this.timeout = timeout == null ? Duration.ofSeconds(45) : timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单次 Git 远程操作总超时时间。
|
||||
*
|
||||
* @return 总超时时间
|
||||
*/
|
||||
public Duration getOperationTimeout() {
|
||||
return operationTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单次 Git 远程操作总超时时间。
|
||||
*
|
||||
* @param operationTimeout 总超时时间
|
||||
*/
|
||||
public void setOperationTimeout(Duration operationTimeout) {
|
||||
this.operationTimeout = positive(operationTimeout, "operationTimeout");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单实例 Git 远程操作并发上限。
|
||||
*
|
||||
* @return 并发上限
|
||||
*/
|
||||
public int getMaxConcurrentOperations() {
|
||||
return maxConcurrentOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单实例 Git 远程操作并发上限。
|
||||
*
|
||||
* @param maxConcurrentOperations 并发上限
|
||||
*/
|
||||
public void setMaxConcurrentOperations(int maxConcurrentOperations) {
|
||||
this.maxConcurrentOperations = positive(maxConcurrentOperations, "maxConcurrentOperations");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同一账号两次仓库扫描的最小间隔。
|
||||
*
|
||||
* @return 最小间隔
|
||||
*/
|
||||
public Duration getScanMinimumInterval() {
|
||||
return scanMinimumInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置同一账号两次仓库扫描的最小间隔。
|
||||
*
|
||||
* @param scanMinimumInterval 最小间隔
|
||||
*/
|
||||
public void setScanMinimumInterval(Duration scanMinimumInterval) {
|
||||
this.scanMinimumInterval = positive(scanMinimumInterval, "scanMinimumInterval");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扫描令牌有效期。
|
||||
*
|
||||
* @return 有效期
|
||||
*/
|
||||
public Duration getScanTtl() {
|
||||
return scanTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置扫描令牌有效期。
|
||||
*
|
||||
* @param scanTtl 有效期
|
||||
*/
|
||||
public void setScanTtl(Duration scanTtl) {
|
||||
this.scanTtl = scanTtl == null ? Duration.ofMinutes(30) : scanTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时仓库大小上限。
|
||||
*
|
||||
* @return 字节数
|
||||
*/
|
||||
public long getMaxRepositoryBytes() {
|
||||
return maxRepositoryBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置临时仓库大小上限。
|
||||
*
|
||||
* @param maxRepositoryBytes 字节数
|
||||
*/
|
||||
public void setMaxRepositoryBytes(long maxRepositoryBytes) {
|
||||
this.maxRepositoryBytes = positive(maxRepositoryBytes, "maxRepositoryBytes");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仓库文件对象数量上限。
|
||||
*
|
||||
* @return 文件对象数量
|
||||
*/
|
||||
public int getMaxRepositoryFiles() {
|
||||
return maxRepositoryFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置仓库文件对象数量上限。
|
||||
*
|
||||
* @param maxRepositoryFiles 文件对象数量
|
||||
*/
|
||||
public void setMaxRepositoryFiles(int maxRepositoryFiles) {
|
||||
this.maxRepositoryFiles = positive(maxRepositoryFiles, "maxRepositoryFiles");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 候选数量上限。
|
||||
*
|
||||
* @return 候选数量
|
||||
*/
|
||||
public int getMaxCandidates() {
|
||||
return maxCandidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Skill 候选数量上限。
|
||||
*
|
||||
* @param maxCandidates 候选数量
|
||||
*/
|
||||
public void setMaxCandidates(int maxCandidates) {
|
||||
this.maxCandidates = positive(maxCandidates, "maxCandidates");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取允许访问的 HTTPS 端口。
|
||||
*
|
||||
* @return 端口集合
|
||||
*/
|
||||
public Set<Integer> getAllowedPorts() {
|
||||
return allowedPorts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置允许访问的 HTTPS 端口。
|
||||
*
|
||||
* @param allowedPorts 端口集合
|
||||
*/
|
||||
public void setAllowedPorts(Set<Integer> allowedPorts) {
|
||||
LinkedHashSet<Integer> normalized = new LinkedHashSet<>();
|
||||
if (allowedPorts != null) {
|
||||
allowedPorts.stream().filter(port -> port != null && port > 0 && port <= 65_535)
|
||||
.forEach(normalized::add);
|
||||
}
|
||||
this.allowedPorts = normalized.isEmpty() ? new LinkedHashSet<>(Set.of(443)) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可信私网 Git 主机。
|
||||
*
|
||||
* @return 小写主机集合
|
||||
*/
|
||||
public Set<String> getTrustedPrivateHosts() {
|
||||
return trustedPrivateHosts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置可信私网 Git 主机。
|
||||
*
|
||||
* @param trustedPrivateHosts 主机集合
|
||||
*/
|
||||
public void setTrustedPrivateHosts(Set<String> trustedPrivateHosts) {
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
if (trustedPrivateHosts != null) {
|
||||
trustedPrivateHosts.stream().filter(host -> host != null && !host.isBlank())
|
||||
.map(host -> host.trim().toLowerCase(java.util.Locale.ROOT))
|
||||
.forEach(normalized::add);
|
||||
}
|
||||
this.trustedPrivateHosts = normalized;
|
||||
}
|
||||
|
||||
private static int positive(int value, String name) {
|
||||
if (value <= 0) {
|
||||
throw new IllegalArgumentException(name + " must be positive");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static long positive(long value, String name) {
|
||||
if (value <= 0) {
|
||||
throw new IllegalArgumentException(name + " must be positive");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Duration positive(Duration value, String name) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalArgumentException(name + " must be positive");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.imports.SkillImportPreview;
|
||||
import tech.easyflow.skill.imports.SkillImportService;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 编排 Git 仓库扫描、固定提交复核与标准 Skill 导入预览。
|
||||
*/
|
||||
@Service
|
||||
public class SkillGitImportService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillGitImportService.class);
|
||||
private static final int MAX_PREPARE_COUNT = SkillImportService.MAX_BATCH_SKILL_COUNT;
|
||||
|
||||
private final GitRepositoryAccessPolicy accessPolicy;
|
||||
private final SkillGitRepositoryReader repositoryReader;
|
||||
private final SkillGitCandidateScanner candidateScanner;
|
||||
private final SkillGitScanStore scanStore;
|
||||
private final SkillGitScanRateLimiter scanRateLimiter;
|
||||
private final SkillImportService skillImportService;
|
||||
|
||||
/**
|
||||
* 创建 Git Skill 导入编排服务。
|
||||
*
|
||||
* @param accessPolicy 仓库地址访问策略
|
||||
* @param repositoryReader Git 仓库读取器
|
||||
* @param candidateScanner Skill 候选扫描器
|
||||
* @param scanStore 短期扫描会话仓库
|
||||
* @param scanRateLimiter 仓库扫描频控器
|
||||
* @param skillImportService 标准 Skill ZIP 导入服务
|
||||
*/
|
||||
public SkillGitImportService(GitRepositoryAccessPolicy accessPolicy,
|
||||
SkillGitRepositoryReader repositoryReader,
|
||||
SkillGitCandidateScanner candidateScanner,
|
||||
SkillGitScanStore scanStore,
|
||||
SkillGitScanRateLimiter scanRateLimiter,
|
||||
SkillImportService skillImportService) {
|
||||
this.accessPolicy = accessPolicy;
|
||||
this.repositoryReader = repositoryReader;
|
||||
this.candidateScanner = candidateScanner;
|
||||
this.scanStore = scanStore;
|
||||
this.scanRateLimiter = scanRateLimiter;
|
||||
this.skillImportService = skillImportService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描任意受支持的 HTTPS Git 仓库并登记短期固定提交结果。
|
||||
*
|
||||
* @param repositoryUrl Git 仓库地址,可省略 .git 后缀
|
||||
* @return 候选扫描结果
|
||||
*/
|
||||
public SkillGitScanResult scan(String repositoryUrl) {
|
||||
scanRateLimiter.check();
|
||||
String normalizedUrl = accessPolicy.normalizeRepositoryUrl(repositoryUrl);
|
||||
try (SkillGitRepositoryReader.Snapshot snapshot = repositoryReader.openDefault(normalizedUrl)) {
|
||||
SkillGitRepositoryScan repositoryScan = candidateScanner.scan(snapshot);
|
||||
SkillGitScanResult.RepositoryInfo repository = new SkillGitScanResult.RepositoryInfo(
|
||||
repositoryName(normalizedUrl), normalizedUrl,
|
||||
Repository.shortenRefName(snapshot.branchRef()), snapshot.commitSha());
|
||||
return scanStore.create(repository, snapshot.branchRef(), repositoryScan.candidates());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复核扫描时的固定提交,并把选中候选转换为既有标准包导入预览。
|
||||
*
|
||||
* @param scanToken 扫描令牌
|
||||
* @param candidateIds 选中的候选 ID
|
||||
* @return 与现有 ZIP 导入一致的预览列表
|
||||
*/
|
||||
public List<SkillImportPreview> prepare(String scanToken, List<String> candidateIds) {
|
||||
validateCandidateIds(candidateIds);
|
||||
return scanStore.withLockedSession(scanToken,
|
||||
session -> prepareLocked(scanToken, session, List.copyOf(candidateIds)));
|
||||
}
|
||||
|
||||
private List<SkillImportPreview> prepareLocked(String scanToken,
|
||||
SkillGitScanStore.Session session,
|
||||
List<String> candidateIds) {
|
||||
SkillGitScanResult original = session.result();
|
||||
Set<String> allowedIds = new HashSet<>();
|
||||
original.candidates().stream().filter(SkillGitScanResult.Candidate::importable)
|
||||
.map(SkillGitScanResult.Candidate::candidateId).forEach(allowedIds::add);
|
||||
if (!allowedIds.containsAll(candidateIds)) {
|
||||
throw new BusinessException("所选 Git Skill 不存在或当前不可导入,请重新扫描");
|
||||
}
|
||||
|
||||
List<SkillImportPreview> previews = new ArrayList<>(candidateIds.size());
|
||||
try (SkillGitRepositoryReader.Snapshot snapshot = repositoryReader.openCommit(
|
||||
original.repository().url(), session.branchRef(), original.repository().commitSha())) {
|
||||
SkillGitRepositoryScan current = candidateScanner.scan(snapshot);
|
||||
Set<String> currentImportableIds = new HashSet<>();
|
||||
current.candidates().stream().filter(SkillGitScanResult.Candidate::importable)
|
||||
.map(SkillGitScanResult.Candidate::candidateId).forEach(currentImportableIds::add);
|
||||
if (!currentImportableIds.containsAll(candidateIds)) {
|
||||
throw new BusinessException("Git 仓库扫描结果已变化,请重新扫描");
|
||||
}
|
||||
for (String candidateId : candidateIds) {
|
||||
SkillGitScanResult.Candidate candidate = current.candidates().stream()
|
||||
.filter(item -> item.candidateId().equals(candidateId))
|
||||
.findFirst().orElseThrow(() -> new BusinessException("Git Skill 候选不存在,请重新扫描"));
|
||||
Path archive = candidateScanner.createStandardZip(snapshot, current, candidateId);
|
||||
try {
|
||||
previews.add(skillImportService.preview(candidate.name() + ".zip", archive.toFile()));
|
||||
} finally {
|
||||
candidateScanner.deleteArchive(archive);
|
||||
}
|
||||
}
|
||||
scanStore.remove(scanToken);
|
||||
return List.copyOf(previews);
|
||||
} catch (RuntimeException exception) {
|
||||
cancelPreparedPreviews(previews);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelPreparedPreviews(List<SkillImportPreview> previews) {
|
||||
for (SkillImportPreview preview : previews) {
|
||||
if (preview == null || preview.getImportToken() == null || preview.getImportToken().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
skillImportService.cancel(preview.getImportToken());
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
LOG.error("回滚 Git Skill 导入预览失败,token={}", preview.getImportToken(), cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCandidateIds(List<String> candidateIds) {
|
||||
if (candidateIds == null || candidateIds.isEmpty()) {
|
||||
throw new BusinessException("请选择要导入的 Git Skill");
|
||||
}
|
||||
if (candidateIds.size() > MAX_PREPARE_COUNT) {
|
||||
throw new BusinessException("单次最多导入 " + MAX_PREPARE_COUNT + " 个 Git Skill");
|
||||
}
|
||||
if (candidateIds.stream().anyMatch(id -> id == null || !id.matches("^[a-fA-F0-9]{64}$"))) {
|
||||
throw new BusinessException("Git Skill 候选参数格式不正确");
|
||||
}
|
||||
if (new HashSet<>(candidateIds).size() != candidateIds.size()) {
|
||||
throw new BusinessException("不能重复选择同一个 Git Skill");
|
||||
}
|
||||
}
|
||||
|
||||
private String repositoryName(String repositoryUrl) {
|
||||
try {
|
||||
String path = new URI(repositoryUrl).getPath();
|
||||
String name = path.substring(path.lastIndexOf('/') + 1);
|
||||
return name.toLowerCase(java.util.Locale.ROOT).endsWith(".git")
|
||||
? name.substring(0, name.length() - 4) : name;
|
||||
} catch (URISyntaxException | RuntimeException exception) {
|
||||
throw new BusinessException("Git 仓库地址格式不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.SynchronousQueue;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 以有界线程池和总超时执行 Git 远程操作。
|
||||
*/
|
||||
@Component
|
||||
public class SkillGitOperationExecutor {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillGitOperationExecutor.class);
|
||||
|
||||
private final SkillGitImportProperties properties;
|
||||
private final ThreadPoolExecutor executor;
|
||||
|
||||
/**
|
||||
* 创建 Git 远程操作执行器。
|
||||
*
|
||||
* @param properties Git 导入配置
|
||||
*/
|
||||
public SkillGitOperationExecutor(SkillGitImportProperties properties) {
|
||||
this.properties = properties;
|
||||
int concurrency = properties.getMaxConcurrentOperations();
|
||||
this.executor = new ThreadPoolExecutor(concurrency, concurrency,
|
||||
30L, TimeUnit.SECONDS, new SynchronousQueue<>(), threadFactory(),
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
this.executor.allowCoreThreadTimeOut(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在并发与总超时边界内执行远程操作。
|
||||
*
|
||||
* @param operation 远程操作
|
||||
* @param <T> 返回值类型
|
||||
* @return 操作结果
|
||||
* @throws BusinessException 操作繁忙、超时或执行失败
|
||||
*/
|
||||
public <T> T execute(Callable<T> operation) {
|
||||
return execute(operation, ignored -> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在并发与总超时边界内执行远程操作,并清理超时后才产出的结果。
|
||||
*
|
||||
* @param operation 远程操作
|
||||
* @param abandonedResultCleanup 超时或中断后结果清理器
|
||||
* @param <T> 返回值类型
|
||||
* @return 操作结果
|
||||
* @throws BusinessException 操作繁忙、超时或执行失败
|
||||
*/
|
||||
public <T> T execute(Callable<T> operation, Consumer<T> abandonedResultCleanup) {
|
||||
AtomicBoolean abandoned = new AtomicBoolean();
|
||||
AtomicReference<T> produced = new AtomicReference<>();
|
||||
Future<T> future;
|
||||
try {
|
||||
future = executor.submit(() -> {
|
||||
T result = operation.call();
|
||||
produced.set(result);
|
||||
cleanupIfAbandoned(abandoned, produced, abandonedResultCleanup);
|
||||
return result;
|
||||
});
|
||||
} catch (java.util.concurrent.RejectedExecutionException exception) {
|
||||
throw new BusinessException(429, 42921, "Git 导入任务繁忙,请稍后重试", exception);
|
||||
}
|
||||
try {
|
||||
T result = future.get(properties.getOperationTimeout().toMillis(), TimeUnit.MILLISECONDS);
|
||||
produced.compareAndSet(result, null);
|
||||
return result;
|
||||
} catch (TimeoutException exception) {
|
||||
abandoned.set(true);
|
||||
future.cancel(true);
|
||||
cleanupIfAbandoned(abandoned, produced, abandonedResultCleanup);
|
||||
throw new BusinessException(504, 50421, "Git 仓库读取超时,请稍后重试", exception);
|
||||
} catch (InterruptedException exception) {
|
||||
abandoned.set(true);
|
||||
future.cancel(true);
|
||||
cleanupIfAbandoned(abandoned, produced, abandonedResultCleanup);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException(503, 50321, "Git 仓库读取被中断,请稍后重试", exception);
|
||||
} catch (ExecutionException exception) {
|
||||
Throwable cause = exception.getCause();
|
||||
if (cause instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (cause instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new BusinessException(502, 5021, "Git 仓库读取失败,请稍后重试", cause);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> void cleanupIfAbandoned(AtomicBoolean abandoned,
|
||||
AtomicReference<T> produced,
|
||||
Consumer<T> abandonedResultCleanup) {
|
||||
if (!abandoned.get()) {
|
||||
return;
|
||||
}
|
||||
T result = produced.getAndSet(null);
|
||||
if (result != null) {
|
||||
try {
|
||||
abandonedResultCleanup.accept(result);
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.error("清理已放弃的 Git 远程操作结果失败", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止执行器并中断尚未结束的远程操作。
|
||||
*/
|
||||
@PreDestroy
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
private ThreadFactory threadFactory() {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
return task -> {
|
||||
Thread thread = new Thread(task, "easyflow-skill-git-" + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.config.Registry;
|
||||
import org.apache.http.config.RegistryBuilder;
|
||||
import org.apache.http.conn.socket.ConnectionSocketFactory;
|
||||
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
|
||||
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
|
||||
import org.apache.http.ssl.SSLContexts;
|
||||
import org.eclipse.jgit.api.Git;
|
||||
import org.eclipse.jgit.api.LsRemoteCommand;
|
||||
import org.eclipse.jgit.api.TransportConfigCallback;
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.eclipse.jgit.lib.Constants;
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
import org.eclipse.jgit.lib.Ref;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.transport.RefSpec;
|
||||
import org.eclipse.jgit.transport.TagOpt;
|
||||
import org.eclipse.jgit.transport.Transport;
|
||||
import org.eclipse.jgit.transport.TransportHttp;
|
||||
import org.eclipse.jgit.transport.http.HttpConnection;
|
||||
import org.eclipse.jgit.transport.http.HttpConnectionFactory;
|
||||
import org.eclipse.jgit.transport.http.HttpConnectionFactory2;
|
||||
import org.eclipse.jgit.transport.http.apache.HttpClientConnection;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.Proxy;
|
||||
import java.net.URL;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.KeyManager;
|
||||
import javax.net.ssl.TrustManager;
|
||||
|
||||
/**
|
||||
* 基于 Eclipse JGit 的受控只读仓库读取器。
|
||||
*/
|
||||
@Component
|
||||
public class SkillGitRepositoryReader {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillGitRepositoryReader.class);
|
||||
private static final String PINNED_REF = "refs/easyflow/skill-import";
|
||||
|
||||
private final GitRepositoryAccessPolicy accessPolicy;
|
||||
private final SkillGitImportProperties properties;
|
||||
private final SkillGitOperationExecutor operationExecutor;
|
||||
|
||||
/**
|
||||
* 创建 Git 仓库读取器。
|
||||
*
|
||||
* @param accessPolicy 地址访问策略
|
||||
* @param properties Git 导入配置
|
||||
* @param operationExecutor Git 远程操作执行器
|
||||
*/
|
||||
public SkillGitRepositoryReader(GitRepositoryAccessPolicy accessPolicy,
|
||||
SkillGitImportProperties properties,
|
||||
SkillGitOperationExecutor operationExecutor) {
|
||||
this.accessPolicy = accessPolicy;
|
||||
this.properties = properties;
|
||||
this.operationExecutor = operationExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取远程默认分支的当前固定提交。
|
||||
*
|
||||
* @param repositoryUrl 规范化仓库地址
|
||||
* @return 临时只读仓库快照
|
||||
* @throws BusinessException 远程仓库不可读取或超过资源限制
|
||||
*/
|
||||
public Snapshot openDefault(String repositoryUrl) {
|
||||
return operationExecutor.execute(() -> openDefaultInternal(repositoryUrl), Snapshot::close);
|
||||
}
|
||||
|
||||
private Snapshot openDefaultInternal(String repositoryUrl) {
|
||||
RemoteHead remoteHead = resolveRemoteHead(repositoryUrl);
|
||||
return cloneBranch(repositoryUrl, remoteHead.branchRef(), remoteHead.commitSha());
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新读取扫描时固定的提交;默认分支已变化时尝试按提交 SHA 获取。
|
||||
*
|
||||
* @param repositoryUrl 规范化仓库地址
|
||||
* @param branchRef 扫描时默认分支完整引用
|
||||
* @param commitSha 扫描时完整提交 SHA
|
||||
* @return 固定提交快照
|
||||
* @throws BusinessException 提交不可读取或仓库超过资源限制
|
||||
*/
|
||||
public Snapshot openCommit(String repositoryUrl, String branchRef, String commitSha) {
|
||||
return operationExecutor.execute(
|
||||
() -> openCommitInternal(repositoryUrl, branchRef, commitSha), Snapshot::close);
|
||||
}
|
||||
|
||||
private Snapshot openCommitInternal(String repositoryUrl, String branchRef, String commitSha) {
|
||||
Snapshot branchSnapshot = cloneBranch(repositoryUrl, branchRef, null);
|
||||
if (commitSha.equals(branchSnapshot.commitSha())) {
|
||||
return branchSnapshot;
|
||||
}
|
||||
try {
|
||||
RefSpec pinned = new RefSpec().setForceUpdate(true)
|
||||
.setSourceDestination(commitSha, PINNED_REF);
|
||||
// Git.wrap 与快照共享 Repository;此处不能关闭包装器,否则会提前关闭后续扫描所需的仓库。
|
||||
Git.wrap(branchSnapshot.repository()).fetch()
|
||||
.setRemote(Constants.DEFAULT_REMOTE_NAME)
|
||||
.setRefSpecs(pinned)
|
||||
.setDepth(1)
|
||||
.setTagOpt(TagOpt.NO_TAGS)
|
||||
.setTimeout(timeoutSeconds())
|
||||
.setTransportConfigCallback(transportCallback())
|
||||
.call();
|
||||
ObjectId pinnedId = branchSnapshot.repository().resolve(PINNED_REF);
|
||||
if (pinnedId == null || !commitSha.equals(pinnedId.name())) {
|
||||
throw new BusinessException("扫描时的 Git 提交已不可读取,请重新扫描");
|
||||
}
|
||||
enforceRepositorySize(branchSnapshot.directory());
|
||||
return branchSnapshot.withCommit(commitSha);
|
||||
} catch (GitAPIException | IOException exception) {
|
||||
branchSnapshot.close();
|
||||
throw remoteFailure("扫描时的 Git 提交已不可读取,请重新扫描", exception);
|
||||
} catch (RuntimeException exception) {
|
||||
branchSnapshot.close();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private RemoteHead resolveRemoteHead(String repositoryUrl) {
|
||||
try {
|
||||
LsRemoteCommand command = Git.lsRemoteRepository()
|
||||
.setRemote(repositoryUrl)
|
||||
.setTimeout(timeoutSeconds())
|
||||
.setTransportConfigCallback(transportCallback());
|
||||
Map<String, Ref> refs = command.callAsMap();
|
||||
Ref head = refs.get(Constants.HEAD);
|
||||
if (head == null || head.getObjectId() == null) {
|
||||
throw new BusinessException("Git 仓库没有可读取的默认分支");
|
||||
}
|
||||
Ref branch = resolveHeadBranch(head, refs.values());
|
||||
if (branch == null) {
|
||||
throw new BusinessException("无法识别 Git 仓库默认分支");
|
||||
}
|
||||
return new RemoteHead(branch.getName(), head.getObjectId().name());
|
||||
} catch (GitAPIException exception) {
|
||||
throw remoteFailure("无法读取 Git 仓库,请检查地址和访问权限", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Ref resolveHeadBranch(Ref head, Collection<Ref> refs) {
|
||||
if (head.isSymbolic() && head.getTarget() != null
|
||||
&& head.getTarget().getName().startsWith(Constants.R_HEADS)) {
|
||||
return head.getTarget();
|
||||
}
|
||||
return refs.stream()
|
||||
.filter(ref -> ref.getName().startsWith(Constants.R_HEADS))
|
||||
.filter(ref -> head.getObjectId().equals(ref.getObjectId()))
|
||||
.sorted(Comparator.comparingInt(this::branchPriority).thenComparing(Ref::getName))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private int branchPriority(Ref ref) {
|
||||
return switch (ref.getName()) {
|
||||
case Constants.R_HEADS + "main" -> 0;
|
||||
case Constants.R_HEADS + Constants.MASTER -> 1;
|
||||
default -> 2;
|
||||
};
|
||||
}
|
||||
|
||||
private Snapshot cloneBranch(String repositoryUrl, String branchRef, String expectedCommit) {
|
||||
Path directory = createTemporaryDirectory();
|
||||
Path gitDirectory = directory.resolve("repository.git");
|
||||
Repository repository = null;
|
||||
try {
|
||||
Git git = Git.cloneRepository()
|
||||
.setURI(repositoryUrl)
|
||||
.setDirectory(gitDirectory.toFile())
|
||||
.setBare(true)
|
||||
.setNoCheckout(true)
|
||||
.setBranchesToClone(java.util.List.of(branchRef))
|
||||
.setBranch(branchRef)
|
||||
.setDepth(1)
|
||||
.setTagOption(TagOpt.NO_TAGS)
|
||||
.setTimeout(timeoutSeconds())
|
||||
.setTransportConfigCallback(transportCallback())
|
||||
.call();
|
||||
repository = git.getRepository();
|
||||
ObjectId head = repository.resolve(Constants.HEAD);
|
||||
if (head == null) {
|
||||
repository.close();
|
||||
deleteDirectory(directory);
|
||||
throw new BusinessException("Git 仓库默认分支没有可读取的提交");
|
||||
}
|
||||
enforceRepositorySize(directory);
|
||||
if (expectedCommit != null && !expectedCommit.equals(head.name())) {
|
||||
repository.close();
|
||||
deleteDirectory(directory);
|
||||
throw new BusinessException("Git 仓库默认分支在扫描期间发生变化,请重新扫描");
|
||||
}
|
||||
return new Snapshot(repository, directory, branchRef, head.name());
|
||||
} catch (GitAPIException | IOException exception) {
|
||||
if (repository != null) {
|
||||
repository.close();
|
||||
}
|
||||
deleteDirectory(directory);
|
||||
throw remoteFailure("无法读取 Git 仓库,请检查地址和访问权限", exception);
|
||||
} catch (RuntimeException exception) {
|
||||
if (repository != null) {
|
||||
repository.close();
|
||||
}
|
||||
deleteDirectory(directory);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private TransportConfigCallback transportCallback() {
|
||||
HttpConnectionFactory factory = new PolicyHttpConnectionFactory(
|
||||
accessPolicy, properties.getMaxRepositoryBytes(), timeoutMillis());
|
||||
int timeout = timeoutSeconds();
|
||||
return transport -> configureTransport(transport, factory, timeout);
|
||||
}
|
||||
|
||||
private void configureTransport(Transport transport, HttpConnectionFactory factory, int timeout) {
|
||||
if (!(transport instanceof TransportHttp httpTransport)) {
|
||||
throw new IllegalArgumentException("Git 仓库仅支持 HTTPS 传输");
|
||||
}
|
||||
transport.setTimeout(timeout);
|
||||
httpTransport.setHttpConnectionFactory(factory);
|
||||
}
|
||||
|
||||
private int timeoutSeconds() {
|
||||
long seconds = Math.max(1L, properties.getTimeout().toSeconds());
|
||||
return (int) Math.min(Integer.MAX_VALUE, seconds);
|
||||
}
|
||||
|
||||
private int timeoutMillis() {
|
||||
long millis = Math.max(1L, properties.getTimeout().toMillis());
|
||||
return (int) Math.min(Integer.MAX_VALUE, millis);
|
||||
}
|
||||
|
||||
private Path createTemporaryDirectory() {
|
||||
try {
|
||||
return Files.createTempDirectory("easyflow-skill-git-");
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(500, 500, "创建 Git 仓库临时目录失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceRepositorySize(Path directory) {
|
||||
try (java.util.stream.Stream<Path> paths = Files.walk(directory)) {
|
||||
long total = 0L;
|
||||
java.util.Iterator<Path> iterator = paths.filter(Files::isRegularFile).iterator();
|
||||
while (iterator.hasNext()) {
|
||||
total = Math.addExact(total, Files.size(iterator.next()));
|
||||
if (total > properties.getMaxRepositoryBytes()) {
|
||||
throw new BusinessException(413, 4132, "Git 仓库超过允许的临时存储大小");
|
||||
}
|
||||
}
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new BusinessException(413, 4132, "Git 仓库超过允许的临时存储大小");
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(500, 500, "检查 Git 仓库临时文件失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private BusinessException remoteFailure(String message, Exception exception) {
|
||||
return new BusinessException(502, 5021, message, exception);
|
||||
}
|
||||
|
||||
private static void deleteDirectory(Path directory) {
|
||||
if (directory == null || !Files.exists(directory)) {
|
||||
return;
|
||||
}
|
||||
try (java.util.stream.Stream<Path> paths = Files.walk(directory)) {
|
||||
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException exception) {
|
||||
LOG.warn("清理 Skill Git 临时文件失败,path={}", path, exception);
|
||||
}
|
||||
});
|
||||
} catch (IOException exception) {
|
||||
LOG.warn("遍历 Skill Git 临时目录失败,path={}", directory, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次固定提交的临时仓库快照。
|
||||
*/
|
||||
public static final class Snapshot implements AutoCloseable {
|
||||
|
||||
private final Repository repository;
|
||||
private final Path directory;
|
||||
private final String branchRef;
|
||||
private final String commitSha;
|
||||
|
||||
Snapshot(Repository repository, Path directory, String branchRef, String commitSha) {
|
||||
this.repository = repository;
|
||||
this.directory = directory;
|
||||
this.branchRef = branchRef;
|
||||
this.commitSha = commitSha;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 JGit 仓库。
|
||||
*
|
||||
* @return 只读仓库
|
||||
*/
|
||||
public Repository repository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时目录。
|
||||
*
|
||||
* @return 临时目录
|
||||
*/
|
||||
public Path directory() {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认分支完整引用。
|
||||
*
|
||||
* @return 分支引用
|
||||
*/
|
||||
public String branchRef() {
|
||||
return branchRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取固定提交 SHA。
|
||||
*
|
||||
* @return 完整提交 SHA
|
||||
*/
|
||||
public String commitSha() {
|
||||
return commitSha;
|
||||
}
|
||||
|
||||
private Snapshot withCommit(String pinnedCommit) {
|
||||
return new Snapshot(repository, directory, branchRef, pinnedCommit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭仓库并清理临时目录。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
repository.close();
|
||||
deleteDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private record RemoteHead(String branchRef, String commitSha) {
|
||||
}
|
||||
|
||||
private static final class PolicyHttpConnectionFactory implements HttpConnectionFactory2 {
|
||||
|
||||
private final GitRepositoryAccessPolicy accessPolicy;
|
||||
private final long maxResponseBytes;
|
||||
private final AtomicLong responseBytes = new AtomicLong();
|
||||
private final Map<String, InetAddress[]> pinnedAddresses = new ConcurrentHashMap<>();
|
||||
private final CloseableHttpClient httpClient;
|
||||
|
||||
private PolicyHttpConnectionFactory(GitRepositoryAccessPolicy accessPolicy,
|
||||
long maxResponseBytes,
|
||||
int timeoutMillis) {
|
||||
this.accessPolicy = accessPolicy;
|
||||
this.maxResponseBytes = maxResponseBytes;
|
||||
Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
|
||||
.register("https", new SSLConnectionSocketFactory(
|
||||
SSLContexts.createSystemDefault(),
|
||||
SSLConnectionSocketFactory.getDefaultHostnameVerifier()))
|
||||
.register("http", PlainConnectionSocketFactory.INSTANCE)
|
||||
.build();
|
||||
PoolingHttpClientConnectionManager connectionManager =
|
||||
new PoolingHttpClientConnectionManager(registry, this::resolvePinnedAddress);
|
||||
connectionManager.setMaxTotal(4);
|
||||
connectionManager.setDefaultMaxPerRoute(2);
|
||||
RequestConfig requestConfig = RequestConfig.custom()
|
||||
.setConnectTimeout(timeoutMillis)
|
||||
.setConnectionRequestTimeout(timeoutMillis)
|
||||
.setSocketTimeout(timeoutMillis)
|
||||
.setRedirectsEnabled(false)
|
||||
.build();
|
||||
this.httpClient = HttpClients.custom()
|
||||
.setConnectionManager(connectionManager)
|
||||
.setDefaultRequestConfig(requestConfig)
|
||||
.disableAutomaticRetries()
|
||||
.disableRedirectHandling()
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpConnection create(URL url) throws IOException {
|
||||
return create(url, Proxy.NO_PROXY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpConnection create(URL url, Proxy proxy) throws IOException {
|
||||
if (proxy != null && !Proxy.NO_PROXY.equals(proxy)) {
|
||||
throw new IOException("Git repository proxy access is disabled");
|
||||
}
|
||||
GitRepositoryAccessPolicy.ResolvedConnection resolved = accessPolicy.resolveConnection(url);
|
||||
pinnedAddresses.putIfAbsent(resolved.host(), resolved.addresses());
|
||||
return limited(new HttpClientConnection(url.toString(), Proxy.NO_PROXY, httpClient));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一次 JGit HTTP 会话并在结束时释放专用客户端。
|
||||
*
|
||||
* @return Git HTTP 会话
|
||||
*/
|
||||
@Override
|
||||
public GitSession newSession() {
|
||||
return new GitSession() {
|
||||
@Override
|
||||
public HttpConnection configure(HttpConnection connection, boolean sslVerify) throws IOException {
|
||||
if (!sslVerify) {
|
||||
throw new IOException("Git repository TLS verification must remain enabled");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
httpClient.close();
|
||||
} catch (IOException exception) {
|
||||
LOG.warn("关闭 Skill Git HTTP 客户端失败", exception);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private InetAddress[] resolvePinnedAddress(String host) throws UnknownHostException {
|
||||
InetAddress[] addresses = pinnedAddresses.get(host.toLowerCase(Locale.ROOT));
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
throw new UnknownHostException("Git repository host was not approved: " + host);
|
||||
}
|
||||
return addresses.clone();
|
||||
}
|
||||
|
||||
private HttpConnection limited(HttpConnection connection) {
|
||||
return new LimitedHttpConnection(connection, responseBytes, maxResponseBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 JGit HTTP 响应流实施单次远程操作的累计字节上限。
|
||||
*/
|
||||
private static final class LimitedHttpConnection implements HttpConnection {
|
||||
|
||||
private final HttpConnection delegate;
|
||||
private final AtomicLong totalBytes;
|
||||
private final long maximumBytes;
|
||||
private InputStream inputStream;
|
||||
|
||||
private LimitedHttpConnection(HttpConnection delegate, AtomicLong totalBytes, long maximumBytes) {
|
||||
this.delegate = delegate;
|
||||
this.totalBytes = totalBytes;
|
||||
this.maximumBytes = maximumBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() throws IOException {
|
||||
return delegate.getResponseCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getURL() {
|
||||
return delegate.getURL();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResponseMessage() throws IOException {
|
||||
return delegate.getResponseMessage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return delegate.getHeaderFields();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
delegate.setRequestProperty(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
delegate.setRequestMethod(method);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUseCaches(boolean useCaches) {
|
||||
delegate.setUseCaches(useCaches);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConnectTimeout(int timeout) {
|
||||
delegate.setConnectTimeout(timeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadTimeout(int timeout) {
|
||||
delegate.setReadTimeout(timeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return delegate.getContentType();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
if (inputStream == null) {
|
||||
int contentLength = delegate.getContentLength();
|
||||
if (contentLength > 0 && totalBytes.get() + contentLength > maximumBytes) {
|
||||
throw new IOException("Git repository response exceeds the configured size limit");
|
||||
}
|
||||
inputStream = new FilterInputStream(delegate.getInputStream()) {
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
int value = super.read();
|
||||
if (value >= 0) {
|
||||
addBytes(1L);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
int count = super.read(buffer, offset, length);
|
||||
if (count > 0) {
|
||||
addBytes(count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
};
|
||||
}
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
private void addBytes(long count) throws IOException {
|
||||
if (totalBytes.addAndGet(count) > maximumBytes) {
|
||||
throw new IOException("Git repository response exceeds the configured size limit");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHeaderField(String name) {
|
||||
return delegate.getHeaderField(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getHeaderFields(String name) {
|
||||
return delegate.getHeaderFields(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getContentLength() {
|
||||
return delegate.getContentLength();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInstanceFollowRedirects(boolean followRedirects) {
|
||||
delegate.setInstanceFollowRedirects(followRedirects);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDoOutput(boolean doOutput) {
|
||||
delegate.setDoOutput(doOutput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFixedLengthStreamingMode(int contentLength) {
|
||||
delegate.setFixedLengthStreamingMode(contentLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() throws IOException {
|
||||
return delegate.getOutputStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChunkedStreamingMode(int chunkLength) {
|
||||
delegate.setChunkedStreamingMode(chunkLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestMethod() {
|
||||
return delegate.getRequestMethod();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return delegate.usingProxy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() throws IOException {
|
||||
delegate.connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(KeyManager[] keyManagers, TrustManager[] trustManagers,
|
||||
SecureRandom random)
|
||||
throws NoSuchAlgorithmException, KeyManagementException {
|
||||
delegate.configure(keyManagers, trustManagers, random);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setHostnameVerifier(HostnameVerifier verifier)
|
||||
throws NoSuchAlgorithmException, KeyManagementException {
|
||||
delegate.setHostnameVerifier(verifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 单次仓库读取期间的候选扫描数据。
|
||||
*
|
||||
* @param candidates 对外候选列表
|
||||
* @param filesByCandidate 候选对应的普通文件对象
|
||||
*/
|
||||
record SkillGitRepositoryScan(
|
||||
List<SkillGitScanResult.Candidate> candidates,
|
||||
Map<String, List<RepositoryFile>> filesByCandidate) {
|
||||
|
||||
/**
|
||||
* 创建不可变扫描数据。
|
||||
*/
|
||||
SkillGitRepositoryScan {
|
||||
candidates = List.copyOf(candidates);
|
||||
filesByCandidate = Map.copyOf(filesByCandidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定提交中的普通文件对象。
|
||||
*
|
||||
* @param path 仓库相对路径
|
||||
* @param relativePath Skill 根目录相对路径
|
||||
* @param objectId Git Blob ID
|
||||
* @param size 原始字节数
|
||||
* @param fileModeBits Git 文件模式
|
||||
*/
|
||||
record RepositoryFile(String path, String relativePath, ObjectId objectId,
|
||||
long size, int fileModeBits) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import com.alicp.jetcache.Cache;
|
||||
import com.alicp.jetcache.CacheInvokeException;
|
||||
import com.alicp.jetcache.CacheResult;
|
||||
import com.alicp.jetcache.CacheResultCode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 对同一租户账号的 Git 仓库扫描实施短周期频控。
|
||||
*/
|
||||
@Component
|
||||
public class SkillGitScanRateLimiter {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillGitScanRateLimiter.class);
|
||||
private static final String CACHE_PREFIX = "skill:git-scan:rate:";
|
||||
|
||||
private final Cache<String, Object> defaultCache;
|
||||
private final SkillGitImportProperties properties;
|
||||
|
||||
/**
|
||||
* 创建仓库扫描频控器。
|
||||
*
|
||||
* @param defaultCache 平台默认缓存
|
||||
* @param properties Git 导入配置
|
||||
*/
|
||||
public SkillGitScanRateLimiter(@Qualifier("defaultCache") Cache<String, Object> defaultCache,
|
||||
SkillGitImportProperties properties) {
|
||||
this.defaultCache = defaultCache;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前账号是否允许发起新的仓库扫描。
|
||||
*
|
||||
* @throws BusinessException 未登录、请求过于频繁或缓存不可用
|
||||
*/
|
||||
public void check() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getTenantId() == null || account.getId() == null) {
|
||||
throw new BusinessException(401, 401, "未登录或登录态无效");
|
||||
}
|
||||
check(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用已认证账号执行频控检查,供契约测试复用。
|
||||
*
|
||||
* @param account 已认证账号
|
||||
* @throws BusinessException 请求过于频繁或缓存不可用
|
||||
*/
|
||||
void check(LoginAccount account) {
|
||||
long intervalMillis = properties.getScanMinimumInterval().toMillis();
|
||||
String key = CACHE_PREFIX + account.getTenantId() + ":" + account.getId();
|
||||
try {
|
||||
CacheResult result = defaultCache.PUT_IF_ABSENT(
|
||||
key, Boolean.TRUE, intervalMillis, TimeUnit.MILLISECONDS);
|
||||
if (result.getResultCode() == CacheResultCode.SUCCESS) {
|
||||
return;
|
||||
}
|
||||
if (result.getResultCode() == CacheResultCode.EXISTS) {
|
||||
throw new BusinessException(429, 42922, "Git 仓库扫描过于频繁,请稍后重试");
|
||||
}
|
||||
LOG.error("Git 仓库扫描频控缓存写入失败,code={}, message={}",
|
||||
result.getResultCode(), result.getMessage());
|
||||
throw new BusinessException(503, 50322, "Git 仓库扫描暂不可用,请稍后重试");
|
||||
} catch (CacheInvokeException exception) {
|
||||
LOG.error("Git 仓库扫描频控缓存访问失败", exception);
|
||||
throw new BusinessException(503, 50322, "Git 仓库扫描暂不可用,请稍后重试", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Git 仓库 Skill 候选扫描结果。
|
||||
*
|
||||
* @param scanToken 短期扫描令牌
|
||||
* @param repository 仓库固定提交信息
|
||||
* @param summary 候选数量摘要
|
||||
* @param candidates 候选列表
|
||||
* @param expiresAt 令牌过期时间
|
||||
*/
|
||||
public record SkillGitScanResult(
|
||||
String scanToken,
|
||||
RepositoryInfo repository,
|
||||
Summary summary,
|
||||
List<Candidate> candidates,
|
||||
Date expiresAt) implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 创建不可变扫描结果。
|
||||
*/
|
||||
public SkillGitScanResult {
|
||||
candidates = candidates == null ? List.of() : List.copyOf(candidates);
|
||||
expiresAt = expiresAt == null ? null : new Date(expiresAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回防御性复制的过期时间。
|
||||
*
|
||||
* @return 过期时间
|
||||
*/
|
||||
@Override
|
||||
public Date expiresAt() {
|
||||
return expiresAt == null ? null : new Date(expiresAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定提交的仓库信息。
|
||||
*
|
||||
* @param name 仓库名称
|
||||
* @param url 规范化仓库地址
|
||||
* @param defaultBranch 默认分支
|
||||
* @param commitSha 完整提交 SHA
|
||||
*/
|
||||
public record RepositoryInfo(String name, String url, String defaultBranch,
|
||||
String commitSha) implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选数量摘要。
|
||||
*
|
||||
* @param discovered 发现数量
|
||||
* @param importable 可导入数量
|
||||
* @param needsAttention 需处理数量
|
||||
*/
|
||||
public record Summary(int discovered, int importable, int needsAttention) implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 Skill 候选。
|
||||
*
|
||||
* @param candidateId 固定提交内稳定候选 ID
|
||||
* @param path 仓库相对路径
|
||||
* @param name 标准 Skill 名称
|
||||
* @param description 用途描述
|
||||
* @param status 候选状态
|
||||
* @param resourceCount 资源文件数量
|
||||
* @param totalBytes 候选总字节数
|
||||
* @param issues 校验问题
|
||||
*/
|
||||
public record Candidate(String candidateId, String path, String name, String description,
|
||||
CandidateStatus status, int resourceCount, long totalBytes,
|
||||
List<Issue> issues) implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 创建不可变候选。
|
||||
*/
|
||||
public Candidate {
|
||||
issues = issues == null ? List.of() : List.copyOf(issues);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断候选是否可以进入标准包预检。
|
||||
*
|
||||
* @return 可导入时为 true
|
||||
*/
|
||||
public boolean importable() {
|
||||
return status == CandidateStatus.IMPORTABLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选状态。
|
||||
*/
|
||||
public enum CandidateStatus {
|
||||
/** 可进入标准包预检。 */
|
||||
IMPORTABLE,
|
||||
/** 需修正仓库内容后重新扫描。 */
|
||||
NEEDS_ATTENTION
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选校验问题。
|
||||
*
|
||||
* @param code 稳定问题码
|
||||
* @param message 用户可读说明
|
||||
* @param path 问题文件路径
|
||||
*/
|
||||
public record Issue(String code, String message, String path) implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import com.alicp.jetcache.AutoReleaseLock;
|
||||
import com.alicp.jetcache.Cache;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* 保存短期 Git 扫描会话并约束租户、账号与并发使用。
|
||||
*/
|
||||
@Service
|
||||
public class SkillGitScanStore {
|
||||
|
||||
private static final String CACHE_PREFIX = "skill:git-scan:";
|
||||
|
||||
private final Cache<String, Object> defaultCache;
|
||||
private final SkillGitImportProperties properties;
|
||||
|
||||
/**
|
||||
* 创建 Git 扫描会话仓库。
|
||||
*
|
||||
* @param defaultCache 平台默认缓存
|
||||
* @param properties Git 导入配置
|
||||
*/
|
||||
public SkillGitScanStore(@Qualifier("defaultCache") Cache<String, Object> defaultCache,
|
||||
SkillGitImportProperties properties) {
|
||||
this.defaultCache = defaultCache;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建绑定当前账号的扫描会话。
|
||||
*
|
||||
* @param repository 仓库固定提交信息
|
||||
* @param branchRef 默认分支完整引用
|
||||
* @param candidates 候选列表
|
||||
* @return 可返回给前端的扫描结果
|
||||
*/
|
||||
public SkillGitScanResult create(SkillGitScanResult.RepositoryInfo repository,
|
||||
String branchRef,
|
||||
List<SkillGitScanResult.Candidate> candidates) {
|
||||
LoginAccount account = requireAccount();
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
Date expiresAt = new Date(System.currentTimeMillis() + properties.getScanTtl().toMillis());
|
||||
int importable = (int) candidates.stream().filter(SkillGitScanResult.Candidate::importable).count();
|
||||
SkillGitScanResult result = new SkillGitScanResult(token, repository,
|
||||
new SkillGitScanResult.Summary(candidates.size(), importable, candidates.size() - importable),
|
||||
candidates, expiresAt);
|
||||
Session session = new Session(account.getTenantId(), account.getId(), branchRef, result);
|
||||
long ttlSeconds = Math.max(1L, properties.getScanTtl().toSeconds());
|
||||
defaultCache.put(cacheKey(token), session, ttlSeconds, TimeUnit.SECONDS);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在扫描会话单次锁内执行业务动作。
|
||||
*
|
||||
* @param token 扫描令牌
|
||||
* @param action 受锁保护的动作
|
||||
* @param <T> 返回值类型
|
||||
* @return 动作结果
|
||||
*/
|
||||
public <T> T withLockedSession(String token, Function<Session, T> action) {
|
||||
validateToken(token);
|
||||
LoginAccount account = requireAccount();
|
||||
try (AutoReleaseLock lock = defaultCache.tryLock(lockKey(token), 5, TimeUnit.MINUTES)) {
|
||||
if (lock == null) {
|
||||
throw new BusinessException("Git 仓库导入正在处理中,请勿重复提交");
|
||||
}
|
||||
return action.apply(findOwned(token, account));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除已成功转换为标准导入预览的扫描会话。
|
||||
*
|
||||
* @param token 扫描令牌
|
||||
*/
|
||||
public void remove(String token) {
|
||||
validateToken(token);
|
||||
defaultCache.remove(cacheKey(token));
|
||||
}
|
||||
|
||||
private Session findOwned(String token, LoginAccount account) {
|
||||
Object cached = defaultCache.get(cacheKey(token));
|
||||
if (!(cached instanceof Session session)) {
|
||||
throw new BusinessException(404, 404, "Git 仓库扫描结果不存在或已过期,请重新扫描");
|
||||
}
|
||||
if (!account.getTenantId().equals(session.tenantId())
|
||||
|| !account.getId().equals(session.accountId())) {
|
||||
throw new BusinessException(403, 403, "无权限使用该 Git 仓库扫描结果");
|
||||
}
|
||||
Date expiresAt = session.result().expiresAt();
|
||||
if (expiresAt == null || !expiresAt.after(new Date())) {
|
||||
defaultCache.remove(cacheKey(token));
|
||||
throw new BusinessException("Git 仓库扫描结果已过期,请重新扫描");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
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 validateToken(String token) {
|
||||
if (token == null || !token.matches("^[a-fA-F0-9]{32}$")) {
|
||||
throw new BusinessException("Git 仓库扫描令牌格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private String cacheKey(String token) {
|
||||
return CACHE_PREFIX + token;
|
||||
}
|
||||
|
||||
private String lockKey(String token) {
|
||||
return CACHE_PREFIX + "lock:" + token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可序列化的短期扫描会话。
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param accountId 账号 ID
|
||||
* @param branchRef 默认分支完整引用
|
||||
* @param result 对外扫描结果
|
||||
*/
|
||||
public record Session(java.math.BigInteger tenantId,
|
||||
java.math.BigInteger accountId,
|
||||
String branchRef,
|
||||
SkillGitScanResult result) implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import tech.easyflow.skill.validation.SkillValidationIssue;
|
||||
public class SkillImportPreview {
|
||||
|
||||
private List<SkillImportPreviewItem> skills = new ArrayList<>();
|
||||
private String sourceName;
|
||||
private String importToken;
|
||||
private Date expiresAt;
|
||||
private List<SkillValidationIssue> issues = new ArrayList<>();
|
||||
@@ -33,6 +34,24 @@ public class SkillImportPreview {
|
||||
this.skills = skills == null ? new ArrayList<>() : skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本预览对应的原始导入包名称。
|
||||
*
|
||||
* @return 原始导入包名称
|
||||
*/
|
||||
public String getSourceName() {
|
||||
return sourceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置本预览对应的原始导入包名称。
|
||||
*
|
||||
* @param sourceName 原始导入包名称
|
||||
*/
|
||||
public void setSourceName(String sourceName) {
|
||||
this.sourceName = sourceName;
|
||||
}
|
||||
|
||||
public String getImportToken() { return importToken; }
|
||||
public void setImportToken(String importToken) { this.importToken = importToken; }
|
||||
public Date getExpiresAt() { return expiresAt; }
|
||||
|
||||
@@ -2,6 +2,7 @@ package tech.easyflow.skill.imports;
|
||||
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -10,6 +11,17 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
*/
|
||||
public interface SkillImportService {
|
||||
|
||||
/** 单次批量预检或确认允许处理的最大 Skill 数量。 */
|
||||
int MAX_BATCH_SKILL_COUNT = 50;
|
||||
|
||||
/**
|
||||
* 批量预检上传的标准 Skill ZIP,并将多 Skill 包拆分为独立预览。
|
||||
*
|
||||
* @param files 标准 Skill ZIP 列表
|
||||
* @return 每个 Skill 对应一个 importToken 的预览列表
|
||||
*/
|
||||
List<SkillImportPreview> previewBatch(List<MultipartFile> files);
|
||||
|
||||
/**
|
||||
* 上传并创建可单次确认的导入预览。
|
||||
*
|
||||
@@ -18,6 +30,15 @@ public interface SkillImportService {
|
||||
*/
|
||||
SkillImportPreview preview(MultipartFile file);
|
||||
|
||||
/**
|
||||
* 暂存并预检后端生成的标准 Skill ZIP。
|
||||
*
|
||||
* @param originalName 用于结果展示的原始文件名
|
||||
* @param file 后端受控临时 ZIP
|
||||
* @return 导入预览与 importToken
|
||||
*/
|
||||
SkillImportPreview preview(String originalName, File file);
|
||||
|
||||
/**
|
||||
* 使用单次 importToken 确认导入。
|
||||
*
|
||||
|
||||
@@ -2,9 +2,12 @@ package tech.easyflow.skill.imports;
|
||||
|
||||
import com.easyagents.skill.codec.SkillPackageReadOptions;
|
||||
import com.easyagents.skill.codec.SkillPackageReadResult;
|
||||
import com.easyagents.skill.codec.SkillPackageWriteOptions;
|
||||
import com.easyagents.skill.codec.ZipSkillPackageCodec;
|
||||
import com.easyagents.skill.exception.SkillPackageException;
|
||||
import com.easyagents.skill.model.SkillDocument;
|
||||
import com.easyagents.skill.model.SkillPackage;
|
||||
import com.easyagents.skill.model.SkillPackageLayout;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
@@ -38,10 +41,15 @@ import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.enums.VisibilityScope;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.io.File;
|
||||
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.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -56,7 +64,7 @@ import java.util.Set;
|
||||
public class SkillImportServiceImpl implements SkillImportService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillImportServiceImpl.class);
|
||||
private static final int MAX_RENAMES = 20;
|
||||
private static final int MAX_RENAMES = MAX_BATCH_SKILL_COUNT;
|
||||
private static final int NAME_CONFLICT_ERROR_CODE = 4092;
|
||||
private static final String NAME_UNAVAILABLE_REASON = "NAME_UNAVAILABLE";
|
||||
|
||||
@@ -99,6 +107,117 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
return hasExactlyOneSkill(decoded) ? buildPreview(decoded, null) : invalidSkillCountPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<SkillImportPreview> previewBatch(List<MultipartFile> files) {
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new BusinessException("请选择至少一个 Skill ZIP 文件");
|
||||
}
|
||||
if (files.size() > MAX_BATCH_SKILL_COUNT) {
|
||||
throw batchLimitExceeded();
|
||||
}
|
||||
|
||||
List<SkillImportPreview> prepared = new ArrayList<>();
|
||||
int totalSkills = 0;
|
||||
try {
|
||||
for (MultipartFile file : files) {
|
||||
PreparedUpload upload = previewUploadedPackage(
|
||||
file, MAX_BATCH_SKILL_COUNT - totalSkills);
|
||||
prepared.addAll(upload.previews());
|
||||
totalSkills += upload.skillCount();
|
||||
}
|
||||
return List.copyOf(prepared);
|
||||
} catch (RuntimeException exception) {
|
||||
cancelPreparedPreviews(prepared);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预检一个上传包,并在需要时拆分为独立 Skill 预览。
|
||||
*
|
||||
* @param file 上传的标准 ZIP
|
||||
* @param remainingSkills 当前批次剩余可接收 Skill 数
|
||||
* @return 本包产生的独立预览及 Skill 数量
|
||||
*/
|
||||
private PreparedUpload previewUploadedPackage(MultipartFile file, int remainingSkills) {
|
||||
validateUpload(file);
|
||||
String sourceName = file.getOriginalFilename();
|
||||
SkillPackageReadResult reportOnly;
|
||||
try (InputStream input = file.getInputStream()) {
|
||||
reportOnly = new ZipSkillPackageCodec(new PreviewContentStore())
|
||||
.decode(input, SkillPackageReadOptions.reportOnly());
|
||||
} catch (SkillPackageException exception) {
|
||||
return new PreparedUpload(List.of(failedPreview(exception, sourceName)), 0);
|
||||
} catch (IOException exception) {
|
||||
LOG.error("读取标准 Skill ZIP 失败,file={}", sourceName, exception);
|
||||
throw new BusinessException(500, 500, "读取 Skill 导入包失败", exception);
|
||||
}
|
||||
|
||||
int skillCount = reportOnly.getSkillPackage().getSkills().size();
|
||||
if (skillCount > remainingSkills) {
|
||||
throw batchLimitExceeded();
|
||||
}
|
||||
if (skillCount == 1) {
|
||||
SkillImportPreview preview = preview(file);
|
||||
preview.setSourceName(sourceName);
|
||||
return new PreparedUpload(List.of(preview), 1);
|
||||
}
|
||||
if (reportOnly.getValidationReport().hasErrors()) {
|
||||
return new PreparedUpload(List.of(failedPreview(reportOnly, sourceName)), skillCount);
|
||||
}
|
||||
return splitAndPreview(file, sourceName, skillCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将已通过整包校验的多 Skill ZIP 拆成独立标准包并逐一暂存。
|
||||
*
|
||||
* @param file 原始上传 ZIP
|
||||
* @param sourceName 原始文件名
|
||||
* @param skillCount 已解析的 Skill 数量
|
||||
* @return 每个 Skill 对应一个 token 的预览结果
|
||||
*/
|
||||
private PreparedUpload splitAndPreview(MultipartFile file, String sourceName, int skillCount) {
|
||||
List<SkillImportPreview> prepared = new ArrayList<>(skillCount);
|
||||
try (ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
|
||||
InputStream input = file.getInputStream()) {
|
||||
SkillPackageReadResult decoded = codec.decode(input, SkillPackageReadOptions.defaults());
|
||||
for (com.easyagents.skill.model.Skill skill : decoded.getSkillPackage().getSkills()) {
|
||||
Path archive = null;
|
||||
try {
|
||||
archive = Files.createTempFile("easyflow-skill-import-", ".zip");
|
||||
try (OutputStream output = Files.newOutputStream(archive,
|
||||
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)),
|
||||
output, SkillPackageWriteOptions.defaults());
|
||||
}
|
||||
SkillImportPreview preview = preview(sourceName, archive.toFile());
|
||||
if (!hasUsableSingleSkillPreview(preview)) {
|
||||
cancelPreview(preview);
|
||||
throw new BusinessException(500, 500, "拆分 Skill 导入包后预检失败,请重新打包后重试");
|
||||
}
|
||||
preview.setSourceName(sourceName);
|
||||
prepared.add(preview);
|
||||
} finally {
|
||||
deleteLocalArchive(archive);
|
||||
}
|
||||
}
|
||||
return new PreparedUpload(List.copyOf(prepared), skillCount);
|
||||
} catch (SkillPackageException exception) {
|
||||
cancelPreparedPreviews(prepared);
|
||||
return new PreparedUpload(List.of(failedPreview(exception, sourceName)), skillCount);
|
||||
} catch (IOException exception) {
|
||||
cancelPreparedPreviews(prepared);
|
||||
LOG.error("拆分多 Skill ZIP 失败,file={}", sourceName, exception);
|
||||
throw new BusinessException(500, 500, "拆分 Skill 导入包失败", exception);
|
||||
} catch (RuntimeException exception) {
|
||||
cancelPreparedPreviews(prepared);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@@ -109,24 +228,10 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
String storedPath = null;
|
||||
try {
|
||||
storedPath = fileStorageService.save(file, "skill-imports/" + account.getTenantId());
|
||||
if (storedPath == null || storedPath.isBlank()) {
|
||||
throw new BusinessException(500, 500, "Skill 导入临时包存储失败,请稍后重试");
|
||||
}
|
||||
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;
|
||||
return previewStoredPath(storedPath, file.getOriginalFilename());
|
||||
} catch (SkillPackageException exception) {
|
||||
cleanupUnregisteredPath(storedPath);
|
||||
return failedPreview(exception);
|
||||
return failedPreview(exception, file.getOriginalFilename());
|
||||
} catch (BusinessException exception) {
|
||||
cleanupUnregisteredPath(storedPath);
|
||||
throw exception;
|
||||
@@ -140,6 +245,50 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public SkillImportPreview preview(String originalName, File file) {
|
||||
validateGeneratedFile(originalName, file);
|
||||
LoginAccount account = requireAccount();
|
||||
String storedPath = null;
|
||||
try {
|
||||
storedPath = fileStorageService.save(file, "skill-imports/" + account.getTenantId());
|
||||
return previewStoredPath(storedPath, originalName);
|
||||
} catch (SkillPackageException exception) {
|
||||
cleanupUnregisteredPath(storedPath);
|
||||
return failedPreview(exception, originalName);
|
||||
} catch (BusinessException exception) {
|
||||
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(storedPath);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private SkillImportPreview previewStoredPath(String storedPath, String originalName) throws IOException {
|
||||
if (storedPath == null || storedPath.isBlank()) {
|
||||
throw new BusinessException(500, 500, "Skill 导入临时包存储失败,请稍后重试");
|
||||
}
|
||||
SkillPackageReadResult decoded;
|
||||
try (InputStream input = fileStorageService.readStream(storedPath)) {
|
||||
decoded = new ZipSkillPackageCodec(new PreviewContentStore())
|
||||
.decode(input, SkillPackageReadOptions.reportOnly());
|
||||
}
|
||||
if (!hasExactlyOneSkill(decoded)) {
|
||||
cleanupUnregisteredPath(storedPath);
|
||||
return invalidSkillCountPreview(originalName);
|
||||
}
|
||||
SkillImportStage stage = stageStore.create(storedPath, originalName);
|
||||
return buildPreview(decoded, stage);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@@ -236,6 +385,7 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
private SkillImportPreview buildPreview(SkillPackageReadResult decoded, SkillImportStage stage) {
|
||||
SkillImportPreview preview = new SkillImportPreview();
|
||||
if (stage != null) {
|
||||
preview.setSourceName(stage.getOriginalName());
|
||||
preview.setImportToken(stage.getImportToken());
|
||||
preview.setExpiresAt(stage.getExpiresAt());
|
||||
}
|
||||
@@ -376,6 +526,19 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGeneratedFile(String originalName, File file) {
|
||||
if (file == null || !file.isFile() || file.length() == 0L) {
|
||||
throw new BusinessException("Skill 导入文件不能为空");
|
||||
}
|
||||
long limit = SkillPackageLimits.defaults().getMaxCompressedPackageBytes();
|
||||
if (file.length() > limit) {
|
||||
throw new BusinessException(413, 4131, "Skill 导入文件超过 " + limit + " 字节限制");
|
||||
}
|
||||
if (originalName == null || !originalName.toLowerCase(java.util.Locale.ROOT).endsWith(".zip")) {
|
||||
throw new BusinessException("Skill 导入仅支持标准 .zip 文件");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateConfirmRequest(SkillImportConfirmRequest request) {
|
||||
if (request == null || request.getImportToken() == null || request.getImportToken().isBlank()) {
|
||||
throw new BusinessException("Skill 导入确认参数不能为空");
|
||||
@@ -399,14 +562,33 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
}
|
||||
|
||||
private SkillImportPreview invalidSkillCountPreview() {
|
||||
return invalidSkillCountPreview(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单 token 路径收到非单 Skill 包时的失败预览。
|
||||
*
|
||||
* @param sourceName 原始文件名
|
||||
* @return 失败预览
|
||||
*/
|
||||
private SkillImportPreview invalidSkillCountPreview(String sourceName) {
|
||||
SkillImportPreview preview = new SkillImportPreview();
|
||||
preview.setSourceName(sourceName);
|
||||
preview.setIssues(List.of(SkillValidationIssue.of(
|
||||
"ERROR", "STANDARD_PACKAGE_SKILL_COUNT", "每个标准 Skill ZIP 必须且只能包含一个 Skill", null)));
|
||||
return preview;
|
||||
}
|
||||
|
||||
private SkillImportPreview failedPreview(SkillPackageException exception) {
|
||||
/**
|
||||
* 将包解析异常转换为可展示的失败预览。
|
||||
*
|
||||
* @param exception 包解析异常
|
||||
* @param sourceName 原始文件名
|
||||
* @return 失败预览
|
||||
*/
|
||||
private SkillImportPreview failedPreview(SkillPackageException exception, String sourceName) {
|
||||
SkillImportPreview preview = new SkillImportPreview();
|
||||
preview.setSourceName(sourceName);
|
||||
preview.setSkills(List.of());
|
||||
if (exception.getReport() == null) {
|
||||
preview.setIssues(List.of(SkillValidationIssue.of(
|
||||
@@ -424,6 +606,92 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将报告模式的整包校验错误转换为失败预览。
|
||||
*
|
||||
* @param decoded 报告模式解码结果
|
||||
* @param sourceName 原始文件名
|
||||
* @return 失败预览
|
||||
*/
|
||||
private SkillImportPreview failedPreview(SkillPackageReadResult decoded, String sourceName) {
|
||||
SkillImportPreview preview = new SkillImportPreview();
|
||||
preview.setSourceName(sourceName);
|
||||
preview.setSkills(List.of());
|
||||
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;
|
||||
}).toList());
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断拆分后的预览是否满足单 Skill token 不变量。
|
||||
*
|
||||
* @param preview 拆分包预览
|
||||
* @return token、Skill 与校验结果均可用时返回 true
|
||||
*/
|
||||
private boolean hasUsableSingleSkillPreview(SkillImportPreview preview) {
|
||||
return preview != null
|
||||
&& preview.getImportToken() != null
|
||||
&& !preview.getImportToken().isBlank()
|
||||
&& preview.getSkills().size() == 1
|
||||
&& preview.getIssues().stream().noneMatch(issue -> "ERROR".equals(issue.getSeverity()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建统一的批量 Skill 数量超限异常。
|
||||
*
|
||||
* @return 业务异常
|
||||
*/
|
||||
private BusinessException batchLimitExceeded() {
|
||||
return new BusinessException("单次最多导入 " + MAX_BATCH_SKILL_COUNT + " 个 Skill");
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽力回滚一组已创建的预览 token。
|
||||
*
|
||||
* @param previews 已创建的预览
|
||||
*/
|
||||
private void cancelPreparedPreviews(List<SkillImportPreview> previews) {
|
||||
previews.forEach(this::cancelPreview);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽力回滚一个已创建的预览 token。
|
||||
*
|
||||
* @param preview 已创建的预览
|
||||
*/
|
||||
private void cancelPreview(SkillImportPreview preview) {
|
||||
if (preview == null || preview.getImportToken() == null || preview.getImportToken().isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
cancel(preview.getImportToken());
|
||||
} catch (RuntimeException cleanupException) {
|
||||
LOG.error("回滚 Skill 导入预览失败,token={}", preview.getImportToken(), cleanupException);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽力删除拆包过程中创建的本地临时 ZIP。
|
||||
*
|
||||
* @param archive 本地临时 ZIP 路径
|
||||
*/
|
||||
private void deleteLocalArchive(Path archive) {
|
||||
if (archive == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(archive);
|
||||
} catch (IOException cleanupException) {
|
||||
LOG.warn("清理本地 Skill 拆分临时包失败,path={}", archive, cleanupException);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupUnregisteredPath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return;
|
||||
@@ -500,4 +768,13 @@ public class SkillImportServiceImpl implements SkillImportService {
|
||||
@Override public InputStream open(String contentRef) { throw new UnsupportedOperationException(); }
|
||||
@Override public boolean exists(String contentRef) { return true; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个上传包完成预检后的独立预览及实际 Skill 数量。
|
||||
*
|
||||
* @param previews 独立预览列表
|
||||
* @param skillCount 实际解析出的 Skill 数量
|
||||
*/
|
||||
private record PreparedUpload(List<SkillImportPreview> previews, int skillCount) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
/**
|
||||
* {@link GitRepositoryAccessPolicy} 地址安全契约测试。
|
||||
*/
|
||||
public class GitRepositoryAccessPolicyTest {
|
||||
|
||||
private GitRepositoryAccessPolicy policy;
|
||||
|
||||
/**
|
||||
* 初始化不依赖公网 DNS 的可信测试主机。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
SkillGitImportProperties properties = new SkillGitImportProperties();
|
||||
properties.setTrustedPrivateHosts(Set.of("git.example.test"));
|
||||
policy = new GitRepositoryAccessPolicy(properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓库地址允许省略 .git 后缀并移除结尾斜线。
|
||||
*/
|
||||
@Test
|
||||
public void acceptsHttpsRepositoryWithOptionalGitSuffix() {
|
||||
assertEquals(policy.normalizeRepositoryUrl(
|
||||
" https://git.example.test/team/skills/ "),
|
||||
"https://git.example.test/team/skills");
|
||||
assertEquals(policy.normalizeRepositoryUrl(
|
||||
"https://git.example.test/team/skills.git"),
|
||||
"https://git.example.test/team/skills.git");
|
||||
assertEquals(policy.normalizeRepositoryUrl("https://192.0.1.1/team/skills.git"),
|
||||
"https://192.0.1.1/team/skills.git");
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝非 HTTPS、内嵌凭证和 URL 参数。
|
||||
*/
|
||||
@Test
|
||||
public void rejectsUnsafeRepositoryInputs() {
|
||||
assertRejected("http://git.example.test/team/skills.git");
|
||||
assertRejected("https://user:secret@git.example.test/team/skills.git");
|
||||
assertRejected("https://git.example.test/team/skills.git?token=secret");
|
||||
assertRejected("https://git.example.test:8443/team/skills.git");
|
||||
assertRejected("https://127.0.0.1/team/skills.git");
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际连接使用策略校验后返回的固定 DNS 地址。
|
||||
*
|
||||
* @throws Exception 构造测试地址失败
|
||||
*/
|
||||
@Test
|
||||
public void resolvesAddressesForPinnedConnection() throws Exception {
|
||||
SkillGitImportProperties properties = new SkillGitImportProperties();
|
||||
InetAddress publicAddress = InetAddress.getByAddress(new byte[]{93, (byte) 184, (byte) 216, 34});
|
||||
GitRepositoryAccessPolicy pinnedPolicy = new GitRepositoryAccessPolicy(
|
||||
properties, host -> new InetAddress[]{publicAddress});
|
||||
|
||||
GitRepositoryAccessPolicy.ResolvedConnection resolved = pinnedPolicy.resolveConnection(
|
||||
new URL("https://git.example.test/team/skills.git/info/refs"));
|
||||
|
||||
assertEquals("git.example.test", resolved.host());
|
||||
assertEquals(publicAddress, resolved.addresses()[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入校验后主机重新绑定到内网时,实际连接阶段必须拒绝。
|
||||
*
|
||||
* @throws Exception 构造测试地址失败
|
||||
*/
|
||||
@Test
|
||||
public void rejectsDnsRebindingBeforeConnection() throws Exception {
|
||||
SkillGitImportProperties properties = new SkillGitImportProperties();
|
||||
InetAddress publicAddress = InetAddress.getByAddress(new byte[]{93, (byte) 184, (byte) 216, 34});
|
||||
InetAddress loopback = InetAddress.getByAddress(new byte[]{127, 0, 0, 1});
|
||||
AtomicInteger resolutions = new AtomicInteger();
|
||||
GitRepositoryAccessPolicy rebindingPolicy = new GitRepositoryAccessPolicy(properties,
|
||||
host -> resolutions.getAndIncrement() == 0
|
||||
? new InetAddress[]{publicAddress} : new InetAddress[]{loopback});
|
||||
|
||||
assertEquals("https://git.example.test/team/skills.git",
|
||||
rebindingPolicy.normalizeRepositoryUrl("https://git.example.test/team/skills.git"));
|
||||
assertThrows(IOException.class, () -> rebindingPolicy.resolveConnection(
|
||||
new URL("https://git.example.test/team/skills.git/info/refs")));
|
||||
}
|
||||
|
||||
private void assertRejected(String value) {
|
||||
assertThrows(BusinessException.class, () -> policy.normalizeRepositoryUrl(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.eclipse.jgit.api.Git;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link SkillGitCandidateScanner} 固定提交扫描与标准包组装测试。
|
||||
*/
|
||||
public class SkillGitCandidateScannerTest {
|
||||
|
||||
/**
|
||||
* 发现根目录和嵌套 Skill,并把不可解析的 LFS 资源标为需处理。
|
||||
*
|
||||
* @throws Exception 仓库或 ZIP 读写失败
|
||||
*/
|
||||
@Test
|
||||
public void discoversCandidatesAndCreatesIndependentStandardZip() throws Exception {
|
||||
SkillGitImportProperties properties = new SkillGitImportProperties();
|
||||
SkillGitCandidateScanner scanner = new SkillGitCandidateScanner(properties);
|
||||
Path directory = Files.createTempDirectory("skill-git-scanner-test-");
|
||||
try (Git git = Git.init().setDirectory(directory.toFile()).call()) {
|
||||
write(directory.resolve("SKILL.md"), skill("root-skill", "根目录技能"));
|
||||
write(directory.resolve("references/guide.md"), "# Guide\n");
|
||||
write(directory.resolve("nested-skill/SKILL.md"), skill("nested-skill", "嵌套技能"));
|
||||
write(directory.resolve("nested-skill/assets/model.bin"),
|
||||
"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 10\n");
|
||||
git.add().addFilepattern(".").call();
|
||||
String commitSha = git.commit().setMessage("test")
|
||||
.setAuthor("EasyFlow", "test@easyflow.local")
|
||||
.setCommitter("EasyFlow", "test@easyflow.local").call().getName();
|
||||
SkillGitRepositoryReader.Snapshot snapshot = new SkillGitRepositoryReader.Snapshot(
|
||||
git.getRepository(), directory, "refs/heads/master", commitSha);
|
||||
|
||||
SkillGitRepositoryScan scan = scanner.scan(snapshot);
|
||||
|
||||
assertEquals(scan.candidates().size(), 2);
|
||||
SkillGitScanResult.Candidate root = candidate(scan, "root-skill");
|
||||
SkillGitScanResult.Candidate nested = candidate(scan, "nested-skill");
|
||||
assertTrue(root.importable());
|
||||
assertFalse(nested.importable());
|
||||
assertTrue(nested.issues().stream()
|
||||
.anyMatch(issue -> "GIT_LFS_UNRESOLVED".equals(issue.code())));
|
||||
|
||||
Path archive = scanner.createStandardZip(snapshot, scan, root.candidateId());
|
||||
try {
|
||||
assertEquals(zipEntries(archive),
|
||||
Set.of("root-skill/SKILL.md", "root-skill/references/guide.md"));
|
||||
} finally {
|
||||
scanner.deleteArchive(archive);
|
||||
}
|
||||
// Snapshot 与 Git 共享 Repository,交给 Git 的 try-with-resources 统一关闭。
|
||||
} finally {
|
||||
deleteDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private SkillGitScanResult.Candidate candidate(SkillGitRepositoryScan scan, String name) {
|
||||
return scan.candidates().stream().filter(item -> name.equals(item.name())).findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
private Set<String> zipEntries(Path archive) throws IOException {
|
||||
Set<String> entries = new HashSet<>();
|
||||
try (ZipInputStream input = new ZipInputStream(Files.newInputStream(archive))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = input.getNextEntry()) != null) {
|
||||
entries.add(entry.getName());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private String skill(String name, String description) {
|
||||
return "---\nname: " + name + "\ndescription: " + description + "\n---\n# " + name + "\n";
|
||||
}
|
||||
|
||||
private void write(Path path, String content) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void deleteDirectory(Path directory) throws IOException {
|
||||
if (!Files.exists(directory)) {
|
||||
return;
|
||||
}
|
||||
try (java.util.stream.Stream<Path> paths = Files.walk(directory)) {
|
||||
for (Path path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link SkillGitOperationExecutor} 并发与总超时契约测试。
|
||||
*/
|
||||
public class SkillGitOperationExecutorTest {
|
||||
|
||||
private SkillGitOperationExecutor operationExecutor;
|
||||
|
||||
/**
|
||||
* 关闭测试中创建的远程操作执行器。
|
||||
*/
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (operationExecutor != null) {
|
||||
operationExecutor.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单实例并发达到上限时快速拒绝额外任务。
|
||||
*
|
||||
* @throws Exception 线程协作失败
|
||||
*/
|
||||
@Test
|
||||
public void rejectsOperationWhenConcurrencyIsExhausted() throws Exception {
|
||||
SkillGitImportProperties properties = new SkillGitImportProperties();
|
||||
properties.setMaxConcurrentOperations(1);
|
||||
operationExecutor = new SkillGitOperationExecutor(properties);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<String> first = caller.submit(() -> operationExecutor.execute(() -> {
|
||||
started.countDown();
|
||||
release.await();
|
||||
return "done";
|
||||
}));
|
||||
assertTrue(started.await(2, TimeUnit.SECONDS));
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> operationExecutor.execute(() -> "second"));
|
||||
|
||||
assertEquals(429, exception.getHttpStatus());
|
||||
release.countDown();
|
||||
assertEquals("done", first.get(2, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 超过远程操作总时限时返回网关超时并取消任务。
|
||||
*/
|
||||
@Test
|
||||
public void cancelsOperationAfterTotalTimeout() {
|
||||
SkillGitImportProperties properties = new SkillGitImportProperties();
|
||||
properties.setOperationTimeout(Duration.ofMillis(30));
|
||||
operationExecutor = new SkillGitOperationExecutor(properties);
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> operationExecutor.execute(() -> {
|
||||
Thread.sleep(5_000L);
|
||||
return "late";
|
||||
}));
|
||||
|
||||
assertEquals(504, exception.getHttpStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务忽略中断并在超时后返回资源时,执行器负责回收该结果。
|
||||
*
|
||||
* @throws Exception 等待清理回调失败
|
||||
*/
|
||||
@Test
|
||||
public void cleansResultProducedAfterTimeout() throws Exception {
|
||||
SkillGitImportProperties properties = new SkillGitImportProperties();
|
||||
properties.setOperationTimeout(Duration.ofMillis(30));
|
||||
operationExecutor = new SkillGitOperationExecutor(properties);
|
||||
CountDownLatch cleaned = new CountDownLatch(1);
|
||||
|
||||
assertThrows(BusinessException.class, () -> operationExecutor.execute(() -> {
|
||||
try {
|
||||
Thread.sleep(5_000L);
|
||||
} catch (InterruptedException ignored) {
|
||||
// 模拟底层驱动在取消后仍返回已经创建的资源。
|
||||
}
|
||||
return "late-resource";
|
||||
}, result -> cleaned.countDown()));
|
||||
|
||||
assertTrue(cleaned.await(2, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tech.easyflow.skill.gitimport;
|
||||
|
||||
import com.alicp.jetcache.Cache;
|
||||
import com.alicp.jetcache.CacheResult;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillGitScanRateLimiter} 分布式频控契约测试。
|
||||
*/
|
||||
public class SkillGitScanRateLimiterTest {
|
||||
|
||||
/**
|
||||
* 首次扫描写入带过期时间的账号频控键。
|
||||
*/
|
||||
@Test
|
||||
public void acceptsFirstScanAndStoresAccountScopedKey() {
|
||||
Cache<String, Object> cache = mock(Cache.class);
|
||||
when(cache.PUT_IF_ABSENT(eq("skill:git-scan:rate:10:20"), eq(Boolean.TRUE),
|
||||
anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(CacheResult.SUCCESS_WITHOUT_MSG);
|
||||
SkillGitScanRateLimiter limiter = new SkillGitScanRateLimiter(cache, new SkillGitImportProperties());
|
||||
|
||||
limiter.check(account());
|
||||
|
||||
verify(cache).PUT_IF_ABSENT(eq("skill:git-scan:rate:10:20"), eq(Boolean.TRUE),
|
||||
eq(2_000L), eq(TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 频控键已存在时返回 HTTP 429。
|
||||
*/
|
||||
@Test
|
||||
public void rejectsRepeatedScanWithinMinimumInterval() {
|
||||
Cache<String, Object> cache = mock(Cache.class);
|
||||
when(cache.PUT_IF_ABSENT(eq("skill:git-scan:rate:10:20"), eq(Boolean.TRUE),
|
||||
anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(CacheResult.EXISTS_WITHOUT_MSG);
|
||||
SkillGitScanRateLimiter limiter = new SkillGitScanRateLimiter(cache, new SkillGitImportProperties());
|
||||
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> limiter.check(account()));
|
||||
|
||||
assertEquals(429, exception.getHttpStatus());
|
||||
}
|
||||
|
||||
private LoginAccount account() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setTenantId(BigInteger.TEN);
|
||||
account.setId(BigInteger.valueOf(20));
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,24 @@ 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.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.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
@@ -29,8 +36,11 @@ 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.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;
|
||||
|
||||
/**
|
||||
@@ -60,22 +70,66 @@ public class StandardSkillPackageContractTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个上传 ZIP 只能承载一个 Skill,批量导入由多个 token 独立完成。
|
||||
* 一个父目录包装的多 Skill ZIP 会被拆成多个独立 token 预览。
|
||||
*/
|
||||
@Test
|
||||
public void previewRejectsMultipleSkillsInOneZip() {
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
SkillImportServiceImpl service = importService(skillService);
|
||||
public void previewSplitsParentWrappedSkillsIntoIndependentTokens() {
|
||||
BatchFixture fixture = batchImportService();
|
||||
MultipartFile file = multipartFile("skill-bundle.zip", multiSkillZip("skill-bundle/", 2));
|
||||
|
||||
SkillImportPreview preview;
|
||||
List<SkillImportPreview> previews;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
preview = service.previewStandardForTest(new ByteArrayInputStream(multiSkillZip()));
|
||||
previews = fixture.service().previewBatch(List.of(file));
|
||||
}
|
||||
|
||||
assertTrue(preview.getSkills().isEmpty());
|
||||
assertTrue(preview.getIssues().stream()
|
||||
.anyMatch(issue -> "STANDARD_PACKAGE_SKILL_COUNT".equals(issue.getCode())));
|
||||
assertEquals(2, previews.size());
|
||||
assertEquals(List.of("skill-1", "skill-2"), previews.stream()
|
||||
.map(preview -> preview.getSkills().get(0).getName()).toList());
|
||||
assertTrue(previews.stream().allMatch(preview -> preview.getSkills().size() == 1));
|
||||
assertTrue(previews.stream().allMatch(preview -> preview.getImportToken() != null));
|
||||
assertTrue(previews.stream().allMatch(preview -> "skill-bundle.zip".equals(preview.getSourceName())));
|
||||
verify(fixture.fileStorageService(), times(2)).save(any(java.io.File.class), anyString());
|
||||
verify(fixture.stageStore(), times(2)).create(anyString(), anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 ZIP 解析出的 Skill 超过统一上限时,在创建暂存 token 前拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void previewRejectsMoreThanFiftySkills() {
|
||||
BatchFixture fixture = batchImportService();
|
||||
MultipartFile file = multipartFile("too-many.zip", multiSkillZip("", 51));
|
||||
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
exception = assertThrows(BusinessException.class,
|
||||
() -> fixture.service().previewBatch(List.of(file)));
|
||||
}
|
||||
|
||||
assertTrue(exception.getMessage().contains("50"));
|
||||
verify(fixture.fileStorageService(), times(0)).save(any(java.io.File.class), anyString());
|
||||
verify(fixture.stageStore(), times(0)).create(anyString(), anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 多个上传包合计超过五十个 Skill 时,已创建的独立 token 会全部回滚。
|
||||
*/
|
||||
@Test
|
||||
public void previewEnforcesFiftySkillLimitAcrossUploadedPackages() {
|
||||
BatchFixture fixture = batchImportService();
|
||||
MultipartFile first = multipartFile("first.zip", multiSkillZip("", 25));
|
||||
MultipartFile second = multipartFile("second.zip", multiSkillZip("bundle/", 26));
|
||||
|
||||
BusinessException exception;
|
||||
try (MockedStatic<SaTokenUtil> ignored = login()) {
|
||||
exception = assertThrows(BusinessException.class,
|
||||
() -> fixture.service().previewBatch(List.of(first, second)));
|
||||
}
|
||||
|
||||
assertTrue(exception.getMessage().contains("50"));
|
||||
verify(fixture.fileStorageService(), times(25)).save(any(java.io.File.class), anyString());
|
||||
verify(fixture.stageStore(), times(25)).create(anyString(), anyString());
|
||||
verify(fixture.stageStore(), times(25)).cancel(anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,12 +205,21 @@ public class StandardSkillPackageContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] multiSkillZip() {
|
||||
/**
|
||||
* 创建包含指定数量 Skill 的测试 ZIP。
|
||||
*
|
||||
* @param parent 可选父目录前缀
|
||||
* @param count Skill 数量
|
||||
* @return ZIP 字节
|
||||
*/
|
||||
private byte[] multiSkillZip(String parent, int count) {
|
||||
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"));
|
||||
for (int index = 1; index <= count; index++) {
|
||||
String name = "skill-" + index;
|
||||
write(zip, parent + name + "/SKILL.md", skillContent(name));
|
||||
}
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
} catch (Exception exception) {
|
||||
@@ -164,6 +227,64 @@ public class StandardSkillPackageContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带内存文件存储行为的批量导入测试夹具。
|
||||
*
|
||||
* @return 批量导入测试夹具
|
||||
*/
|
||||
private BatchFixture batchImportService() {
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
when(skillService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
FileStorageService fileStorageService = mock(FileStorageService.class);
|
||||
SkillImportStageStore stageStore = mock(SkillImportStageStore.class);
|
||||
Map<String, byte[]> storedFiles = new HashMap<>();
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
try {
|
||||
when(fileStorageService.save(any(java.io.File.class), anyString())).thenAnswer(invocation -> {
|
||||
java.io.File file = invocation.getArgument(0);
|
||||
String path = "stored-" + sequence.incrementAndGet() + ".zip";
|
||||
storedFiles.put(path, Files.readAllBytes(file.toPath()));
|
||||
return path;
|
||||
});
|
||||
when(fileStorageService.readStream(anyString())).thenAnswer(invocation ->
|
||||
new ByteArrayInputStream(storedFiles.get(invocation.getArgument(0))));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("创建测试文件存储失败", exception);
|
||||
}
|
||||
when(stageStore.create(anyString(), anyString())).thenAnswer(invocation -> {
|
||||
SkillImportStage stage = new SkillImportStage();
|
||||
stage.setFilePath(invocation.getArgument(0));
|
||||
stage.setOriginalName(invocation.getArgument(1));
|
||||
stage.setImportToken("token-" + sequence.get());
|
||||
stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
|
||||
return stage;
|
||||
});
|
||||
SkillImportServiceImpl service = new SkillImportServiceImpl(skillService,
|
||||
mock(DBSkillContentStore.class), fileStorageService, stageStore,
|
||||
mock(ResourceAccessService.class));
|
||||
return new BatchFixture(service, fileStorageService, stageStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可重复打开输入流的上传文件替身。
|
||||
*
|
||||
* @param name 原始文件名
|
||||
* @param bytes 文件字节
|
||||
* @return 上传文件替身
|
||||
*/
|
||||
private MultipartFile multipartFile(String name, byte[] bytes) {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn((long) bytes.length);
|
||||
when(file.getOriginalFilename()).thenReturn(name);
|
||||
try {
|
||||
when(file.getInputStream()).thenAnswer(ignored -> new ByteArrayInputStream(bytes));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("创建测试上传文件失败", exception);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private void write(ZipOutputStream zip, String path, String content) throws Exception {
|
||||
zip.putNextEntry(new ZipEntry(path));
|
||||
zip.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
@@ -189,4 +310,16 @@ public class StandardSkillPackageContractTest {
|
||||
throw new IllegalStateException("读取标准 Skill 测试包失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量预检测试依赖集合。
|
||||
*
|
||||
* @param service 导入服务
|
||||
* @param fileStorageService 文件存储
|
||||
* @param stageStore 导入暂存仓库
|
||||
*/
|
||||
private record BatchFixture(SkillImportServiceImpl service,
|
||||
FileStorageService fileStorageService,
|
||||
SkillImportStageStore stageStore) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillCreateDialog from './SkillCreateDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
cancelSkillImport: vi.fn(),
|
||||
importSkillConfirmBatch: vi.fn(),
|
||||
importSkillPreviews: vi.fn(),
|
||||
prepareSkillGitCandidates: vi.fn(),
|
||||
saveSkill: vi.fn(),
|
||||
scanSkillGitRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
|
||||
describe('skill Git repository import dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.scanSkillGitRepository.mockResolvedValue({
|
||||
data: {
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'a'.repeat(64),
|
||||
description: '聚合热门 AI 内容',
|
||||
issues: [],
|
||||
name: 'aihot',
|
||||
path: 'aihot',
|
||||
resourceCount: 2,
|
||||
status: 'IMPORTABLE',
|
||||
totalBytes: 2048,
|
||||
},
|
||||
],
|
||||
expiresAt: '2026-08-15T14:00:00Z',
|
||||
repository: {
|
||||
commitSha: 'b'.repeat(40),
|
||||
defaultBranch: 'main',
|
||||
name: 'khazix-skills',
|
||||
url: 'https://github.com/KKKKhazix/khazix-skills',
|
||||
},
|
||||
scanToken: 'c'.repeat(32),
|
||||
summary: { discovered: 1, importable: 1, needsAttention: 0 },
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.prepareSkillGitCandidates.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
importToken: 'd'.repeat(32),
|
||||
issues: [],
|
||||
skills: [
|
||||
{
|
||||
conflict: false,
|
||||
name: 'aihot',
|
||||
packageId: 'aihot',
|
||||
packageRoot: 'aihot',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.importSkillConfirmBatch.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
importToken: 'd'.repeat(32),
|
||||
skills: [{ id: 101, name: 'aihot' }],
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('scans, selects and imports one repository candidate through the standard pipeline', async () => {
|
||||
const wrapper = mount(SkillCreateDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElDialog: {
|
||||
props: ['modelValue', 'title'],
|
||||
template:
|
||||
'<section><h2>{{ title }}</h2><slot/><footer><slot name="footer"/></footer></section>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
categories: [],
|
||||
defaultMode: 'import',
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const sourceButtons = wrapper.findAllComponents({ name: 'ElRadioButton' });
|
||||
sourceButtons
|
||||
.find((item) => item.props('value') === 'git')
|
||||
?.vm.$emit('click');
|
||||
sourceButtons
|
||||
.find((item) => item.props('value') === 'git')
|
||||
?.vm.$emit('update:modelValue', 'git');
|
||||
const sourceGroup = wrapper.findAllComponents({ name: 'ElRadioGroup' })[1];
|
||||
sourceGroup?.vm.$emit('update:modelValue', 'git');
|
||||
await flushPromises();
|
||||
|
||||
const repositoryInput = wrapper.find(
|
||||
'input[placeholder="输入 HTTPS Git 仓库地址"]',
|
||||
);
|
||||
await repositoryInput.setValue(
|
||||
'https://github.com/KKKKhazix/khazix-skills',
|
||||
);
|
||||
const scanButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '扫描仓库');
|
||||
await scanButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.scanSkillGitRepository).toHaveBeenCalledWith(
|
||||
'https://github.com/KKKKhazix/khazix-skills',
|
||||
);
|
||||
expect(wrapper.text()).toContain('khazix-skills');
|
||||
expect(wrapper.text()).toContain('aihot');
|
||||
|
||||
await wrapper
|
||||
.get('.skill-create-dialog__candidate-toggle')
|
||||
.trigger('click');
|
||||
const importButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('导入所选'));
|
||||
await importButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.prepareSkillGitCandidates).toHaveBeenCalledWith(
|
||||
'c'.repeat(32),
|
||||
['a'.repeat(64)],
|
||||
);
|
||||
expect(apiMocks.importSkillConfirmBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
conflictStrategy: 'REJECT',
|
||||
importToken: 'd'.repeat(32),
|
||||
visibilityScope: 'PRIVATE',
|
||||
}),
|
||||
]);
|
||||
expect(wrapper.emitted('imported')).toEqual([[1]]);
|
||||
});
|
||||
|
||||
it('renders and confirms every Skill returned from one multi-Skill ZIP', async () => {
|
||||
const archive = new File(['fixture'], 'skill-bundle.zip', {
|
||||
type: 'application/zip',
|
||||
});
|
||||
apiMocks.importSkillPreviews.mockResolvedValue({
|
||||
data: ['skill-1', 'skill-2'].map((name, index) => ({
|
||||
importToken: `${index + 1}`.repeat(32),
|
||||
issues: [],
|
||||
skills: [
|
||||
{
|
||||
conflict: false,
|
||||
name,
|
||||
packageId: name,
|
||||
packageRoot: name,
|
||||
},
|
||||
],
|
||||
sourceName: archive.name,
|
||||
})),
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.importSkillConfirmBatch.mockResolvedValue({
|
||||
data: ['skill-1', 'skill-2'].map((name, index) => ({
|
||||
importToken: `${index + 1}`.repeat(32),
|
||||
skills: [{ id: index + 1, name }],
|
||||
success: true,
|
||||
})),
|
||||
errorCode: 0,
|
||||
});
|
||||
const wrapper = mount(SkillCreateDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElDialog: {
|
||||
props: ['modelValue', 'title'],
|
||||
template:
|
||||
'<section><h2>{{ title }}</h2><slot/><footer><slot name="footer"/></footer></section>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
categories: [],
|
||||
defaultMode: 'import',
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const fileInput = wrapper.get('input[type="file"]');
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
configurable: true,
|
||||
value: [archive],
|
||||
});
|
||||
await fileInput.trigger('change');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.importSkillPreviews).toHaveBeenCalledWith([archive]);
|
||||
expect(wrapper.text()).toContain('skill-1');
|
||||
expect(wrapper.text()).toContain('skill-2');
|
||||
expect(wrapper.text()).toContain('skill-bundle.zip');
|
||||
|
||||
const importButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '导入');
|
||||
await importButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.importSkillConfirmBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ importToken: '1'.repeat(32) }),
|
||||
expect.objectContaining({ importToken: '2'.repeat(32) }),
|
||||
]);
|
||||
expect(wrapper.emitted('imported')).toEqual([[2]]);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ describe('skill create and import contract', () => {
|
||||
expect(dialogSource).toContain('value="import">批量导入');
|
||||
expect(dialogSource).toContain('accept=".zip,application/zip"');
|
||||
expect(dialogSource).toContain('multiple');
|
||||
expect(dialogSource).toContain('单次最多导入 20 个技能');
|
||||
expect(dialogSource).toContain('const MAX_IMPORT_SKILLS = 50');
|
||||
expect(dialogSource).toContain('支持单技能或多技能 ZIP');
|
||||
expect(dialogSource).toContain('response.data.map((preview, index)');
|
||||
expect(dialogSource).toContain('importSkillConfirmBatch');
|
||||
expect(dialogSource).not.toContain('.efskill');
|
||||
});
|
||||
@@ -18,4 +20,14 @@ describe('skill create and import contract', () => {
|
||||
expect(dialogSource).toContain('v-model="form.visibilityScope"');
|
||||
expect(dialogSource).toContain('visibilityScope: form.visibilityScope');
|
||||
});
|
||||
|
||||
it('supports scanning arbitrary HTTPS Git repositories and selecting candidates', () => {
|
||||
expect(dialogSource).toContain('value="git">Git 仓库');
|
||||
expect(dialogSource).toContain('scanSkillGitRepository');
|
||||
expect(dialogSource).toContain('prepareSkillGitCandidates');
|
||||
expect(dialogSource).toContain('搜索技能名称、用途或路径');
|
||||
expect(dialogSource).toContain('toggleGitCandidate(candidate)');
|
||||
expect(dialogSource).toContain('next.size >= MAX_IMPORT_SKILLS');
|
||||
expect(dialogSource).not.toContain('github.com/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type {
|
||||
SkillCategory,
|
||||
SkillGitCandidate,
|
||||
SkillGitScanResult,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
SkillVisibilityScope,
|
||||
@@ -10,7 +12,14 @@ import type {
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Document, UploadFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
Check,
|
||||
Document,
|
||||
Link,
|
||||
Plus,
|
||||
Search,
|
||||
UploadFilled,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
@@ -30,7 +39,9 @@ import {
|
||||
cancelSkillImport,
|
||||
importSkillConfirmBatch,
|
||||
importSkillPreviews,
|
||||
prepareSkillGitCandidates,
|
||||
saveSkill,
|
||||
scanSkillGitRepository,
|
||||
} from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { flattenSkillCategories } from './skill-category';
|
||||
@@ -38,9 +49,11 @@ import { buildInitialSkillDraft } from './skill-create';
|
||||
|
||||
type CreateMode = 'import' | 'manual';
|
||||
type ConflictStrategy = 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
type ImportSource = 'git' | 'zip';
|
||||
|
||||
interface ImportRow {
|
||||
file: File;
|
||||
key: string;
|
||||
label: string;
|
||||
message?: string;
|
||||
preview?: SkillImportPreview;
|
||||
rename: string;
|
||||
@@ -69,14 +82,21 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const MAX_IMPORT_SKILLS = 50;
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const mode = ref<CreateMode>('manual');
|
||||
const importSource = ref<ImportSource>('zip');
|
||||
const busy = ref(false);
|
||||
const actionError = ref('');
|
||||
const importRows = ref<ImportRow[]>([]);
|
||||
const importFinished = ref(false);
|
||||
const gitRepositoryUrl = ref('');
|
||||
const gitSearch = ref('');
|
||||
const gitScan = ref<SkillGitScanResult>();
|
||||
const selectedGitCandidateIds = ref<Set<string>>(new Set());
|
||||
const form = reactive({
|
||||
categoryId: '' as number | string,
|
||||
displayName: '',
|
||||
@@ -102,28 +122,44 @@ const rules: FormRules = {
|
||||
{ max: 128, message: '技能名称不能超过 128 个字符', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
const confirmText = computed(() =>
|
||||
mode.value === 'manual' ? '创建并进入编辑' : '导入',
|
||||
const confirmText = computed(() => {
|
||||
if (mode.value === 'manual') return '创建并进入编辑';
|
||||
if (importSource.value !== 'git' || importRows.value.length > 0)
|
||||
return '导入';
|
||||
const count = selectedGitCandidateIds.value.size;
|
||||
return `导入所选${count > 0 ? `(${count})` : ''}`;
|
||||
});
|
||||
const dialogWidth = computed(() =>
|
||||
mode.value === 'import' && importSource.value === 'git'
|
||||
? 'min(920px, calc(100vw - 32px))'
|
||||
: 'min(720px, calc(100vw - 32px))',
|
||||
);
|
||||
const importReady = computed(
|
||||
() =>
|
||||
importRows.value.length > 0 &&
|
||||
importRows.value.every((row) => {
|
||||
const item = row.preview?.skills[0];
|
||||
if (!row.preview?.importToken || !item || importErrorCount(row.preview))
|
||||
return false;
|
||||
if (!item.conflict) return true;
|
||||
if (row.strategy === 'OVERWRITE') return item.overwriteAllowed === true;
|
||||
if (row.strategy === 'RENAME') return canonicalName(row.rename);
|
||||
return false;
|
||||
}),
|
||||
() => importRows.value.length > 0 && rowsReady(importRows.value),
|
||||
);
|
||||
const filteredGitCandidates = computed(() => {
|
||||
const keyword = normalizeSearch(gitSearch.value);
|
||||
if (!keyword) return gitScan.value?.candidates || [];
|
||||
return (gitScan.value?.candidates || []).filter((candidate) =>
|
||||
normalizeSearch(
|
||||
`${candidate.name} ${candidate.description || ''} ${candidate.path}`,
|
||||
).includes(keyword),
|
||||
);
|
||||
});
|
||||
const submitDisabled = computed(() => {
|
||||
if (mode.value === 'manual') return false;
|
||||
if (importRows.value.length > 0) return !importReady.value;
|
||||
return (
|
||||
importSource.value === 'zip' || selectedGitCandidateIds.value.size === 0
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
mode.value = props.defaultMode;
|
||||
importSource.value = 'zip';
|
||||
Object.assign(form, {
|
||||
categoryId: props.defaultCategoryId || '',
|
||||
displayName: '',
|
||||
@@ -131,6 +167,10 @@ watch(
|
||||
});
|
||||
importRows.value = [];
|
||||
importFinished.value = false;
|
||||
gitRepositoryUrl.value = '';
|
||||
gitSearch.value = '';
|
||||
gitScan.value = undefined;
|
||||
selectedGitCandidateIds.value = new Set();
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
if (mode.value === 'manual')
|
||||
@@ -141,7 +181,13 @@ watch(
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return;
|
||||
await (mode.value === 'manual' ? createManualSkill() : confirmImports());
|
||||
if (mode.value === 'manual') {
|
||||
await createManualSkill();
|
||||
} else if (importSource.value === 'git' && importRows.value.length === 0) {
|
||||
await prepareGitImports();
|
||||
} else {
|
||||
await confirmImports();
|
||||
}
|
||||
}
|
||||
|
||||
async function createManualSkill() {
|
||||
@@ -185,8 +231,8 @@ async function handleDrop(event: DragEvent) {
|
||||
|
||||
async function previewFiles(files: File[]) {
|
||||
if (busy.value || files.length === 0) return;
|
||||
if (files.length > 20) {
|
||||
ElMessage.warning('单次最多导入 20 个技能');
|
||||
if (files.length > MAX_IMPORT_SKILLS) {
|
||||
ElMessage.warning(`单次最多选择 ${MAX_IMPORT_SKILLS} 个 ZIP`);
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => !file.name.toLowerCase().endsWith('.zip'))) {
|
||||
@@ -202,11 +248,14 @@ async function previewFiles(files: File[]) {
|
||||
actionError.value = response.message || '导入预检失败';
|
||||
return;
|
||||
}
|
||||
importRows.value = files.map((file, index) => {
|
||||
const preview = response.data[index];
|
||||
importRows.value = response.data.map((preview, index) => {
|
||||
const item = preview?.skills[0];
|
||||
const sourceName = preview?.sourceName || `导入包 ${index + 1}`;
|
||||
return {
|
||||
file,
|
||||
key:
|
||||
preview?.importToken ||
|
||||
`${sourceName}-${item?.packageRoot || item?.packageId || 'invalid'}-${index}`,
|
||||
label: sourceName,
|
||||
preview,
|
||||
rename: item ? `${item.name}-copy` : '',
|
||||
strategy: item?.conflict ? 'RENAME' : 'REJECT',
|
||||
@@ -227,11 +276,15 @@ async function confirmImports() {
|
||||
ElMessage.warning('请先处理导入错误和名称冲突');
|
||||
return;
|
||||
}
|
||||
await confirmImportRows(importRows.value);
|
||||
}
|
||||
|
||||
async function confirmImportRows(rows: ImportRow[]) {
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await importSkillConfirmBatch(
|
||||
importRows.value.map((row) => {
|
||||
rows.map((row) => {
|
||||
const item = row.preview!.skills[0]!;
|
||||
return {
|
||||
categoryId: form.categoryId || undefined,
|
||||
@@ -251,7 +304,7 @@ async function confirmImports() {
|
||||
}
|
||||
let succeeded = 0;
|
||||
response.data.forEach((result, index) => {
|
||||
const row = importRows.value[index];
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
row.result = result.success ? 'success' : 'failed';
|
||||
row.message = result.message;
|
||||
@@ -259,7 +312,7 @@ async function confirmImports() {
|
||||
});
|
||||
importFinished.value = true;
|
||||
emit('imported', succeeded);
|
||||
if (succeeded === importRows.value.length) {
|
||||
if (succeeded === rows.length) {
|
||||
ElMessage.success(`已导入 ${succeeded} 个技能`);
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
@@ -270,6 +323,101 @@ async function confirmImports() {
|
||||
}
|
||||
}
|
||||
|
||||
async function scanGitRepo() {
|
||||
if (busy.value) return;
|
||||
const repositoryUrl = gitRepositoryUrl.value.trim();
|
||||
if (!repositoryUrl) {
|
||||
ElMessage.warning('请输入 Git 仓库地址');
|
||||
return;
|
||||
}
|
||||
await cancelPendingImports();
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
importRows.value = [];
|
||||
importFinished.value = false;
|
||||
selectedGitCandidateIds.value = new Set();
|
||||
try {
|
||||
const response = await scanSkillGitRepository(repositoryUrl);
|
||||
if (response.errorCode !== 0 || !response.data?.scanToken) {
|
||||
actionError.value = response.message || '仓库扫描失败';
|
||||
gitScan.value = undefined;
|
||||
return;
|
||||
}
|
||||
gitScan.value = response.data;
|
||||
if (response.data.summary.discovered === 0) {
|
||||
ElMessage.info('仓库中未发现 SKILL.md');
|
||||
}
|
||||
} catch (error) {
|
||||
gitScan.value = undefined;
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'仓库扫描失败,请检查地址后重试',
|
||||
);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGitCandidate(candidate: SkillGitCandidate) {
|
||||
if (candidate.status !== 'IMPORTABLE' || busy.value) return;
|
||||
const next = new Set(selectedGitCandidateIds.value);
|
||||
if (next.has(candidate.candidateId)) {
|
||||
next.delete(candidate.candidateId);
|
||||
} else if (next.size >= MAX_IMPORT_SKILLS) {
|
||||
ElMessage.warning(`单次最多导入 ${MAX_IMPORT_SKILLS} 个技能`);
|
||||
return;
|
||||
} else {
|
||||
next.add(candidate.candidateId);
|
||||
}
|
||||
selectedGitCandidateIds.value = next;
|
||||
}
|
||||
|
||||
async function prepareGitImports() {
|
||||
const scan = gitScan.value;
|
||||
const selected = scan?.candidates.filter((candidate) =>
|
||||
selectedGitCandidateIds.value.has(candidate.candidateId),
|
||||
);
|
||||
if (!scan || !selected?.length) {
|
||||
ElMessage.warning('请先扫描并选择要导入的技能');
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await prepareSkillGitCandidates(
|
||||
scan.scanToken,
|
||||
selected.map((candidate) => candidate.candidateId),
|
||||
);
|
||||
if (response.errorCode !== 0) {
|
||||
actionError.value = response.message || '准备导入失败';
|
||||
return;
|
||||
}
|
||||
const rows = selected.map((candidate, index): ImportRow => {
|
||||
const preview = response.data[index];
|
||||
const item = preview?.skills[0];
|
||||
return {
|
||||
key: candidate.candidateId,
|
||||
label: candidate.path,
|
||||
preview,
|
||||
rename: item ? `${item.name}-copy` : '',
|
||||
strategy: item?.conflict ? 'RENAME' : 'REJECT',
|
||||
};
|
||||
});
|
||||
importRows.value = rows;
|
||||
if (rowsReady(rows)) {
|
||||
busy.value = false;
|
||||
await confirmImportRows(rows);
|
||||
}
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'准备导入失败,请重新扫描',
|
||||
);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestClose() {
|
||||
if (busy.value) return;
|
||||
await cancelPendingImports();
|
||||
@@ -291,6 +439,28 @@ function importErrorCount(preview?: SkillImportPreview) {
|
||||
.length;
|
||||
}
|
||||
|
||||
function rowsReady(rows: ImportRow[]) {
|
||||
return rows.every((row) => {
|
||||
const item = row.preview?.skills[0];
|
||||
if (!row.preview?.importToken || !item || importErrorCount(row.preview))
|
||||
return false;
|
||||
if (!item.conflict) return true;
|
||||
if (row.strategy === 'OVERWRITE') return item.overwriteAllowed === true;
|
||||
if (row.strategy === 'RENAME') return canonicalName(row.rename);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string) {
|
||||
return value.trim().toLocaleLowerCase().replaceAll(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function canonicalName(value: string) {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim());
|
||||
}
|
||||
@@ -304,7 +474,7 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
class="skill-create-dialog"
|
||||
width="min(720px, calc(100vw - 32px))"
|
||||
:width="dialogWidth"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!busy"
|
||||
:show-close="!busy"
|
||||
@@ -371,6 +541,16 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
</ElForm>
|
||||
|
||||
<template v-if="mode === 'import'">
|
||||
<ElRadioGroup
|
||||
v-model="importSource"
|
||||
class="skill-create-dialog__source"
|
||||
:disabled="busy || importRows.length > 0"
|
||||
>
|
||||
<ElRadioButton value="zip">本地 ZIP</ElRadioButton>
|
||||
<ElRadioButton value="git">Git 仓库</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
|
||||
<template v-if="importSource === 'zip'">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="skill-create-dialog__file-input"
|
||||
@@ -389,18 +569,129 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
>
|
||||
<ElIcon><UploadFilled /></ElIcon>
|
||||
<span>选择或拖入标准 Skill ZIP</span>
|
||||
<small>最多 20 个,每个 ZIP 包含一个技能</small>
|
||||
<small>最多导入 50 个技能,支持单技能或多技能 ZIP</small>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<section v-else class="skill-create-dialog__git">
|
||||
<div class="skill-create-dialog__git-url">
|
||||
<ElInput
|
||||
v-model="gitRepositoryUrl"
|
||||
clearable
|
||||
:disabled="busy"
|
||||
placeholder="输入 HTTPS Git 仓库地址"
|
||||
@keyup.enter="scanGitRepo"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Link /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElButton :loading="busy" :disabled="busy" @click="scanGitRepo">
|
||||
扫描仓库
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<template v-if="gitScan">
|
||||
<div class="skill-create-dialog__git-summary">
|
||||
<div>
|
||||
<strong>{{ gitScan.repository.name }}</strong>
|
||||
<small>
|
||||
{{ gitScan.repository.defaultBranch }} ·
|
||||
{{ gitScan.repository.commitSha.slice(0, 8) }}
|
||||
</small>
|
||||
</div>
|
||||
<span>发现 {{ gitScan.summary.discovered }} 个</span>
|
||||
<span>可导入 {{ gitScan.summary.importable }} 个</span>
|
||||
<span v-if="gitScan.summary.needsAttention">
|
||||
需处理 {{ gitScan.summary.needsAttention }} 个
|
||||
</span>
|
||||
</div>
|
||||
<ElInput
|
||||
v-model="gitSearch"
|
||||
class="skill-create-dialog__git-search"
|
||||
clearable
|
||||
placeholder="搜索技能名称、用途或路径"
|
||||
>
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="skill-create-dialog__candidates">
|
||||
<article
|
||||
v-for="candidate in filteredGitCandidates"
|
||||
:key="candidate.candidateId"
|
||||
:class="{
|
||||
'is-selected': selectedGitCandidateIds.has(
|
||||
candidate.candidateId,
|
||||
),
|
||||
}"
|
||||
>
|
||||
<div class="skill-create-dialog__candidate-main">
|
||||
<strong>{{ candidate.name }}</strong>
|
||||
<p>{{ candidate.description || '暂无用途说明' }}</p>
|
||||
<small>
|
||||
{{ candidate.path }} · {{ candidate.resourceCount }} 个资源 ·
|
||||
{{ formatBytes(candidate.totalBytes) }}
|
||||
</small>
|
||||
<small
|
||||
v-if="candidate.status === 'NEEDS_ATTENTION'"
|
||||
class="skill-create-dialog__candidate-issue"
|
||||
>
|
||||
{{ candidate.issues[0]?.message || '内容需要处理' }}
|
||||
</small>
|
||||
</div>
|
||||
<ElTag
|
||||
:type="
|
||||
candidate.status === 'IMPORTABLE' ? 'success' : 'warning'
|
||||
"
|
||||
effect="plain"
|
||||
>
|
||||
{{ candidate.status === 'IMPORTABLE' ? '可导入' : '需处理' }}
|
||||
</ElTag>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-create-dialog__candidate-toggle"
|
||||
:class="{
|
||||
'is-selected': selectedGitCandidateIds.has(
|
||||
candidate.candidateId,
|
||||
),
|
||||
}"
|
||||
:disabled="candidate.status !== 'IMPORTABLE' || busy"
|
||||
:aria-label="
|
||||
selectedGitCandidateIds.has(candidate.candidateId)
|
||||
? `取消选择 ${candidate.name}`
|
||||
: `选择 ${candidate.name}`
|
||||
"
|
||||
@click="toggleGitCandidate(candidate)"
|
||||
>
|
||||
<ElIcon>
|
||||
<Check
|
||||
v-if="selectedGitCandidateIds.has(candidate.candidateId)"
|
||||
/>
|
||||
<Plus v-else />
|
||||
</ElIcon>
|
||||
</button>
|
||||
</article>
|
||||
<div
|
||||
v-if="filteredGitCandidates.length === 0"
|
||||
class="skill-create-dialog__candidate-empty"
|
||||
>
|
||||
没有匹配的技能
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div v-if="importRows.length > 0" class="skill-create-dialog__imports">
|
||||
<article v-for="row in importRows" :key="row.file.name">
|
||||
<article v-for="row in importRows" :key="row.key">
|
||||
<ElIcon><Document /></ElIcon>
|
||||
<div>
|
||||
<strong>{{ row.preview?.skills[0]?.name || row.file.name }}</strong>
|
||||
<strong>{{ row.preview?.skills[0]?.name || row.label }}</strong>
|
||||
<small v-if="importErrorCount(row.preview)">
|
||||
{{ row.preview?.issues?.[0]?.message || '包内容不符合规范' }}
|
||||
</small>
|
||||
<small v-else-if="row.result === 'failed'">{{ row.message }}</small>
|
||||
<small v-else>{{ row.file.name }}</small>
|
||||
<small v-else>{{ row.label }}</small>
|
||||
</div>
|
||||
<ElTag v-if="row.result === 'success'" type="success" effect="plain">
|
||||
成功
|
||||
@@ -448,7 +739,7 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="mode === 'import' && !importReady"
|
||||
:disabled="submitDisabled"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmText }}
|
||||
@@ -493,6 +784,10 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skill-create-dialog__source {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-create-dialog__dropzone {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -523,6 +818,141 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-url {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary > div {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary small,
|
||||
.skill-create-dialog__git-summary span {
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates {
|
||||
display: grid;
|
||||
max-height: 336px;
|
||||
overflow: auto;
|
||||
border-block: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto 36px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-height: 88px;
|
||||
padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
transition:
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article:hover,
|
||||
.skill-create-dialog__candidates article.is-selected {
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: hsl(var(--text-secondary));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main small {
|
||||
overflow: hidden;
|
||||
color: hsl(var(--text-muted));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-main .skill-create-dialog__candidate-issue {
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: hsl(var(--text-secondary));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-default));
|
||||
border: 1px solid hsl(var(--line-strong));
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle:hover,
|
||||
.skill-create-dialog__candidate-toggle:focus-visible,
|
||||
.skill-create-dialog__candidate-toggle.is-selected {
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle:focus-visible {
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 20%);
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-toggle:disabled {
|
||||
color: hsl(var(--text-disabled));
|
||||
cursor: not-allowed;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-color: hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidate-empty {
|
||||
display: grid;
|
||||
min-height: 112px;
|
||||
color: hsl(var(--text-muted));
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -574,5 +1004,21 @@ function scopeLabel(scope: SkillVisibilityScope) {
|
||||
.skill-create-dialog__imports article {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-url {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skill-create-dialog__git-summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article {
|
||||
grid-template-columns: minmax(0, 1fr) 32px;
|
||||
}
|
||||
|
||||
.skill-create-dialog__candidates article > .el-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SkillCategoryDraft,
|
||||
SkillFileContent,
|
||||
SkillFileNode,
|
||||
SkillGitScanResult,
|
||||
SkillImportBatchResult,
|
||||
SkillImportConfirmPayload,
|
||||
SkillImportPreview,
|
||||
@@ -261,6 +262,23 @@ export function importSkillPreviews(files: File[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function scanSkillGitRepository(repositoryUrl: string) {
|
||||
return api.post<RequestResult<SkillGitScanResult>>(
|
||||
'/api/v1/skill/import/repository/scan',
|
||||
{ repositoryUrl },
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareSkillGitCandidates(
|
||||
scanToken: string,
|
||||
candidateIds: string[],
|
||||
) {
|
||||
return api.post<RequestResult<SkillImportPreview[]>>(
|
||||
'/api/v1/skill/import/repository/prepare',
|
||||
{ candidateIds, scanToken },
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillConfirmBatch(payload: SkillImportConfirmPayload[]) {
|
||||
return api.post<RequestResult<SkillImportBatchResult[]>>(
|
||||
'/api/v1/skill/import/confirmBatch',
|
||||
|
||||
@@ -157,6 +157,7 @@ export interface SkillImportPreview {
|
||||
expiresAt?: string;
|
||||
importToken?: string;
|
||||
issues?: SkillValidationIssue[];
|
||||
sourceName?: string;
|
||||
skills: SkillImportPreviewItem[];
|
||||
}
|
||||
|
||||
@@ -175,6 +176,42 @@ export interface SkillImportBatchResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export type SkillGitCandidateStatus = 'IMPORTABLE' | 'NEEDS_ATTENTION';
|
||||
|
||||
export interface SkillGitCandidateIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface SkillGitCandidate {
|
||||
candidateId: string;
|
||||
description?: string;
|
||||
issues: SkillGitCandidateIssue[];
|
||||
name: string;
|
||||
path: string;
|
||||
resourceCount: number;
|
||||
status: SkillGitCandidateStatus;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface SkillGitScanResult {
|
||||
candidates: SkillGitCandidate[];
|
||||
expiresAt: string;
|
||||
repository: {
|
||||
commitSha: string;
|
||||
defaultBranch: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
scanToken: string;
|
||||
summary: {
|
||||
discovered: number;
|
||||
importable: number;
|
||||
needsAttention: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillFileBuffer {
|
||||
content: string;
|
||||
conflict?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user