From eb7123c9f722343f26f7710c727512d3fd4aae9e Mon Sep 17 00:00:00 2001
From: dongmucat <1127093059@qq.com>
Date: Fri, 24 Apr 2026 13:46:34 +0800
Subject: [PATCH] feat(publish): support bulk skill package upload
---
.../portal/SkillPublishController.java | 51 +----
.../support/ZipPackageExtractor.java | 1 +
.../iflytek/skillhub/dto/PublishResponse.java | 18 +-
.../dto/PublishResultDetailResponse.java | 12 +
.../service/SkillPublishAppService.java | 150 +++++++++++++
.../support/SkillPackageArchiveExtractor.java | 4 +-
.../portal/SkillPublishControllerTest.java | 72 +++---
.../service/SkillPublishAppServiceTest.java | 206 ++++++++++++++++++
.../SkillPackageArchiveExtractorTest.java | 2 +-
web/e2e/helpers/test-data-builder.ts | 38 ++++
web/e2e/publish-flow-ui.spec.ts | 105 +++++++--
web/src/api/generated/schema.d.ts | 14 ++
web/src/api/types.ts | 6 +-
web/src/features/publish/upload-zone.tsx | 8 +-
web/src/i18n/locales/en.json | 7 +-
web/src/i18n/locales/zh.json | 7 +-
web/src/pages/dashboard/publish.tsx | 52 +++--
.../hooks/use-skill-queries.publish.test.ts | 95 ++++++++
web/src/shared/hooks/use-skill-queries.ts | 46 +++-
web/src/shared/lib/publish-result.test.ts | 39 ++++
web/src/shared/lib/publish-result.ts | 17 ++
21 files changed, 826 insertions(+), 124 deletions(-)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java
rename server/skillhub-app/src/main/java/com/iflytek/skillhub/{controller => service}/support/SkillPackageArchiveExtractor.java (97%)
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java
rename server/skillhub-app/src/test/java/com/iflytek/skillhub/{controller => service}/support/SkillPackageArchiveExtractorTest.java (99%)
create mode 100644 web/src/shared/hooks/use-skill-queries.publish.test.ts
create mode 100644 web/src/shared/lib/publish-result.test.ts
create mode 100644 web/src/shared/lib/publish-result.ts
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java
index 3abdf5e87..712237d79 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java
@@ -2,45 +2,34 @@
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
-import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
-import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
-import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
-import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PublishResponse;
-import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
+import com.iflytek.skillhub.service.SkillPublishAppService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
-import java.util.List;
/**
* Upload endpoints for skill packages.
*
- *
The controller is responsible for archive extraction and request shaping,
- * while the domain service owns all publication validation and state changes.
+ *
The controller is responsible for request binding while the app service
+ * orchestrates archive extraction and publication.
*/
@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillPublishController extends BaseApiController {
- private final SkillPublishService skillPublishService;
- private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
- private final SkillHubMetrics skillHubMetrics;
+ private final SkillPublishAppService skillPublishAppService;
- public SkillPublishController(SkillPublishService skillPublishService,
- SkillPackageArchiveExtractor skillPackageArchiveExtractor,
- ApiResponseFactory responseFactory,
- SkillHubMetrics skillHubMetrics) {
+ public SkillPublishController(SkillPublishAppService skillPublishAppService,
+ ApiResponseFactory responseFactory) {
super(responseFactory);
- this.skillPublishService = skillPublishService;
- this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
- this.skillHubMetrics = skillHubMetrics;
+ this.skillPublishAppService = skillPublishAppService;
}
/**
@@ -57,34 +46,14 @@ public ApiResponse publish(
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
-
- List entries;
- try {
- entries = skillPackageArchiveExtractor.extract(file);
- } catch (IllegalArgumentException e) {
- throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
- }
-
- SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries(
+ PublishResponse response = skillPublishAppService.publish(
namespace,
- entries,
- principal.userId(),
+ file,
skillVisibility,
+ principal.userId(),
principal.platformRoles(),
confirmWarnings
);
-
- PublishResponse response = new PublishResponse(
- publishResult.skillId(),
- namespace,
- publishResult.slug(),
- publishResult.version().getVersion(),
- publishResult.version().getStatus().name(),
- publishResult.version().getFileCount(),
- publishResult.version().getTotalSize()
- );
- skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name());
-
return ok("response.success.published", response);
}
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java
index 2beaec705..629a8db86 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java
@@ -3,6 +3,7 @@
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
+import com.iflytek.skillhub.service.support.SkillPackageArchiveExtractor;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java
index 6b62ccebe..025cc0121 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java
@@ -1,5 +1,7 @@
package com.iflytek.skillhub.dto;
+import java.util.List;
+
public record PublishResponse(
Long skillId,
String namespace,
@@ -7,5 +9,17 @@ public record PublishResponse(
String version,
String status,
int fileCount,
- long totalSize
-) {}
+ long totalSize,
+ List results
+) {
+ public PublishResponse(
+ Long skillId,
+ String namespace,
+ String slug,
+ String version,
+ String status,
+ int fileCount,
+ long totalSize) {
+ this(skillId, namespace, slug, version, status, fileCount, totalSize, List.of());
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java
new file mode 100644
index 000000000..437d7d43e
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResultDetailResponse.java
@@ -0,0 +1,12 @@
+package com.iflytek.skillhub.dto;
+
+public record PublishResultDetailResponse(
+ String packagePath,
+ Long skillId,
+ String namespace,
+ String slug,
+ String version,
+ String status,
+ int fileCount,
+ long totalSize
+) {}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java
new file mode 100644
index 000000000..4f5c2f39d
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillPublishAppService.java
@@ -0,0 +1,150 @@
+package com.iflytek.skillhub.service;
+
+import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
+import com.iflytek.skillhub.domain.skill.SkillVisibility;
+import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
+import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
+import com.iflytek.skillhub.dto.PublishResponse;
+import com.iflytek.skillhub.dto.PublishResultDetailResponse;
+import com.iflytek.skillhub.metrics.SkillHubMetrics;
+import com.iflytek.skillhub.service.support.SkillPackageArchiveExtractor;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+@Service
+public class SkillPublishAppService {
+
+ private static final String SKILL_MD = "SKILL.md";
+ private static final String SKILL_MD_SUFFIX = "/" + SKILL_MD;
+
+ private final SkillPublishService skillPublishService;
+ private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
+ private final SkillHubMetrics skillHubMetrics;
+
+ public SkillPublishAppService(SkillPublishService skillPublishService,
+ SkillPackageArchiveExtractor skillPackageArchiveExtractor,
+ SkillHubMetrics skillHubMetrics) {
+ this.skillPublishService = skillPublishService;
+ this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
+ this.skillHubMetrics = skillHubMetrics;
+ }
+
+ @Transactional
+ public PublishResponse publish(String namespace,
+ MultipartFile file,
+ SkillVisibility visibility,
+ String publisherId,
+ Set platformRoles,
+ boolean confirmWarnings) throws IOException {
+ List entries;
+ try {
+ entries = skillPackageArchiveExtractor.extract(file);
+ } catch (IllegalArgumentException e) {
+ throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
+ }
+
+ List packages = discoverPackages(entries);
+ List details = new ArrayList<>();
+ for (DiscoveredPackage skillPackage : packages) {
+ SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
+ namespace,
+ skillPackage.entries(),
+ publisherId,
+ visibility,
+ platformRoles,
+ confirmWarnings
+ );
+ details.add(toDetail(namespace, skillPackage.packagePath(), result));
+ }
+
+ for (PublishResultDetailResponse detail : details) {
+ skillHubMetrics.incrementSkillPublish(namespace, detail.status());
+ }
+
+ PublishResultDetailResponse first = details.getFirst();
+ return new PublishResponse(
+ first.skillId(),
+ first.namespace(),
+ first.slug(),
+ first.version(),
+ first.status(),
+ first.fileCount(),
+ first.totalSize(),
+ List.copyOf(details)
+ );
+ }
+
+ private List discoverPackages(List entries) {
+ if (entries.stream().anyMatch(entry -> SKILL_MD.equals(entry.path()))) {
+ return List.of(new DiscoveredPackage("", entries));
+ }
+
+ List packageRoots = entries.stream()
+ .map(PackageEntry::path)
+ .filter(path -> path.endsWith(SKILL_MD_SUFFIX))
+ .map(path -> path.substring(0, path.length() - SKILL_MD_SUFFIX.length()))
+ .distinct()
+ .sorted(Comparator.naturalOrder())
+ .toList();
+
+ if (packageRoots.isEmpty()) {
+ throw new DomainBadRequestException("error.skill.publish.skillMd.notFound");
+ }
+
+ rejectNestedPackageRoots(packageRoots);
+
+ List packages = new ArrayList<>();
+ for (String root : packageRoots) {
+ String prefix = root + "/";
+ List packageEntries = entries.stream()
+ .filter(entry -> entry.path().startsWith(prefix))
+ .map(entry -> new PackageEntry(
+ entry.path().substring(prefix.length()),
+ entry.content(),
+ entry.size(),
+ entry.contentType()
+ ))
+ .toList();
+ packages.add(new DiscoveredPackage(root, packageEntries));
+ }
+
+ return packages;
+ }
+
+ private void rejectNestedPackageRoots(List packageRoots) {
+ for (int i = 0; i < packageRoots.size(); i++) {
+ String parent = packageRoots.get(i) + "/";
+ for (int j = i + 1; j < packageRoots.size(); j++) {
+ if (packageRoots.get(j).startsWith(parent)) {
+ throw new DomainBadRequestException(
+ "error.skill.publish.package.invalid",
+ "Nested skill packages are not supported: " + packageRoots.get(j)
+ );
+ }
+ }
+ }
+ }
+
+ private PublishResultDetailResponse toDetail(String namespace,
+ String packagePath,
+ SkillPublishService.PublishResult result) {
+ return new PublishResultDetailResponse(
+ packagePath,
+ result.skillId(),
+ namespace,
+ result.slug(),
+ result.version().getVersion(),
+ result.version().getStatus().name(),
+ result.version().getFileCount(),
+ result.version().getTotalSize()
+ );
+ }
+
+ private record DiscoveredPackage(String packagePath, List entries) {}
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractor.java
similarity index 97%
rename from server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java
rename to server/skillhub-app/src/main/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractor.java
index 5d98e93a2..ccd079561 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractor.java
@@ -1,4 +1,4 @@
-package com.iflytek.skillhub.controller.support;
+package com.iflytek.skillhub.service.support;
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
@@ -80,7 +80,7 @@ public List extract(MultipartFile file) throws IOException {
* If all file paths share a single root directory prefix (e.g., "my-skill/xxx"),
* strip that prefix. Otherwise return entries unchanged.
*/
- static List stripSingleRootDirectory(List entries) {
+ public static List stripSingleRootDirectory(List entries) {
if (entries.isEmpty()) return entries;
Set rootSegments = new HashSet<>();
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java
index ae5fd27bb..d47c3e723 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java
@@ -1,12 +1,9 @@
package com.iflytek.skillhub.controller.portal;
-import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
-import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
-import org.mockito.ArgumentMatchers;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
@@ -17,11 +14,10 @@
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
-import com.iflytek.skillhub.domain.skill.SkillVersion;
-import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
-import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
-import com.iflytek.skillhub.metrics.SkillHubMetrics;
+import com.iflytek.skillhub.dto.PublishResponse;
+import com.iflytek.skillhub.dto.PublishResultDetailResponse;
+import com.iflytek.skillhub.service.SkillPublishAppService;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -38,8 +34,8 @@
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
-import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.multipart.MultipartFile;
@SpringBootTest
@AutoConfigureMockMvc
@@ -51,7 +47,7 @@ class SkillPublishControllerTest {
private MockMvc mockMvc;
@MockBean
- private SkillPublishService skillPublishService;
+ private SkillPublishAppService skillPublishAppService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@@ -59,25 +55,16 @@ class SkillPublishControllerTest {
@MockBean
private DeviceAuthService deviceAuthService;
- @MockBean
- private SkillHubMetrics skillHubMetrics;
-
@Test
void publish_recordsMetricsAfterSuccess() throws Exception {
- SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1");
- version.setStatus(SkillVersionStatus.PENDING_REVIEW);
- version.setFileCount(1);
- version.setTotalSize(128L);
- ReflectionTestUtils.setField(version, "id", 34L);
-
- given(skillPublishService.publishFromEntries(
+ given(skillPublishAppService.publish(
eq("global"),
- ArgumentMatchers.>any(),
- eq("usr_1"),
+ org.mockito.ArgumentMatchers.any(MultipartFile.class),
eq(SkillVisibility.PUBLIC),
+ eq("usr_1"),
eq(Set.of("SUPER_ADMIN")),
eq(false)))
- .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
+ .willReturn(publishResponse("demo-skill", "PENDING_REVIEW"));
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
@@ -108,27 +95,20 @@ void publish_recordsMetricsAfterSuccess() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.skillId").value(12))
- .andExpect(jsonPath("$.data.slug").value("demo-skill"));
-
- verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW");
+ .andExpect(jsonPath("$.data.slug").value("demo-skill"))
+ .andExpect(jsonPath("$.data.results[0].slug").value("demo-skill"));
}
@Test
void publish_passesWarningConfirmationFlag() throws Exception {
- SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1");
- version.setStatus(SkillVersionStatus.PENDING_REVIEW);
- version.setFileCount(1);
- version.setTotalSize(128L);
- ReflectionTestUtils.setField(version, "id", 34L);
-
- given(skillPublishService.publishFromEntries(
+ given(skillPublishAppService.publish(
eq("global"),
- ArgumentMatchers.>any(),
- eq("usr_1"),
+ org.mockito.ArgumentMatchers.any(MultipartFile.class),
eq(SkillVisibility.PUBLIC),
+ eq("usr_1"),
eq(Set.of("SUPER_ADMIN")),
eq(true)))
- .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
+ .willReturn(publishResponse("demo-skill", "PENDING_REVIEW"));
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
@@ -159,6 +139,28 @@ void publish_passesWarningConfirmationFlag() throws Exception {
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
+
+ verify(skillPublishAppService).publish(
+ eq("global"),
+ org.mockito.ArgumentMatchers.any(MultipartFile.class),
+ eq(SkillVisibility.PUBLIC),
+ eq("usr_1"),
+ eq(Set.of("SUPER_ADMIN")),
+ eq(true));
+ }
+
+ private PublishResponse publishResponse(String slug, String status) {
+ PublishResultDetailResponse detail = new PublishResultDetailResponse(
+ "",
+ 12L,
+ "global",
+ slug,
+ "1.0.0",
+ status,
+ 1,
+ 128L
+ );
+ return new PublishResponse(12L, "global", slug, "1.0.0", status, 1, 128L, List.of(detail));
}
private byte[] buildZipBytes() throws Exception {
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java
new file mode 100644
index 000000000..31669836c
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillPublishAppServiceTest.java
@@ -0,0 +1,206 @@
+package com.iflytek.skillhub.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
+import com.iflytek.skillhub.domain.skill.SkillVersion;
+import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
+import com.iflytek.skillhub.domain.skill.SkillVisibility;
+import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
+import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
+import com.iflytek.skillhub.dto.PublishResponse;
+import com.iflytek.skillhub.metrics.SkillHubMetrics;
+import com.iflytek.skillhub.service.support.SkillPackageArchiveExtractor;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.transaction.annotation.Transactional;
+
+@ExtendWith(MockitoExtension.class)
+class SkillPublishAppServiceTest {
+
+ @Mock
+ private SkillPublishService skillPublishService;
+ @Mock
+ private SkillPackageArchiveExtractor skillPackageArchiveExtractor;
+ @Mock
+ private SkillHubMetrics skillHubMetrics;
+
+ @Test
+ void publish_discoversMultipleSkillDirectoriesAndPublishesInOneBatch() throws Exception {
+ SkillPublishAppService service = newService();
+ MockMultipartFile file = upload();
+ given(skillPackageArchiveExtractor.extract(file)).willReturn(List.of(
+ entry("alpha/SKILL.md", skillMd("Alpha Skill", "1.0.0")),
+ entry("alpha/README.md", "alpha readme"),
+ entry("beta/SKILL.md", skillMd("Beta Skill", "2.0.0")),
+ entry("beta/src/main.py", "print('beta')\n")
+ ));
+ given(skillPublishService.publishFromEntries(
+ eq("global"),
+ anyList(),
+ eq("usr_1"),
+ eq(SkillVisibility.PUBLIC),
+ eq(Set.of("SUPER_ADMIN")),
+ eq(true)))
+ .willReturn(
+ new SkillPublishService.PublishResult(101L, "alpha-skill",
+ version("1.0.0", SkillVersionStatus.PENDING_REVIEW, 2, 64L)),
+ new SkillPublishService.PublishResult(102L, "beta-skill",
+ version("2.0.0", SkillVersionStatus.PENDING_REVIEW, 2, 96L))
+ );
+
+ PublishResponse response = service.publish(
+ "global",
+ file,
+ SkillVisibility.PUBLIC,
+ "usr_1",
+ Set.of("SUPER_ADMIN"),
+ true
+ );
+
+ assertEquals(101L, response.skillId());
+ assertEquals("alpha-skill", response.slug());
+ assertEquals(2, response.results().size());
+ assertEquals("alpha", response.results().get(0).packagePath());
+ assertEquals("beta", response.results().get(1).packagePath());
+ assertEquals("beta-skill", response.results().get(1).slug());
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> entriesCaptor = ArgumentCaptor.forClass(List.class);
+ verify(skillPublishService, org.mockito.Mockito.times(2)).publishFromEntries(
+ eq("global"),
+ entriesCaptor.capture(),
+ eq("usr_1"),
+ eq(SkillVisibility.PUBLIC),
+ eq(Set.of("SUPER_ADMIN")),
+ eq(true)
+ );
+ assertEquals(List.of("SKILL.md", "README.md"), paths(entriesCaptor.getAllValues().get(0)));
+ assertEquals(List.of("SKILL.md", "src/main.py"), paths(entriesCaptor.getAllValues().get(1)));
+ verify(skillHubMetrics, org.mockito.Mockito.times(2)).incrementSkillPublish("global", "PENDING_REVIEW");
+ }
+
+ @Test
+ void publish_usesSingleRootPackageUnchangedForCompatibility() throws Exception {
+ SkillPublishAppService service = newService();
+ MockMultipartFile file = upload();
+ given(skillPackageArchiveExtractor.extract(file)).willReturn(List.of(
+ entry("SKILL.md", skillMd("Solo Skill", "1.0.0")),
+ entry("README.md", "solo readme")
+ ));
+ given(skillPublishService.publishFromEntries(
+ eq("global"),
+ anyList(),
+ eq("usr_1"),
+ eq(SkillVisibility.PRIVATE),
+ eq(Set.of()),
+ eq(false)))
+ .willReturn(new SkillPublishService.PublishResult(201L, "solo-skill",
+ version("1.0.0", SkillVersionStatus.UPLOADED, 2, 32L)));
+
+ PublishResponse response = service.publish(
+ "global",
+ file,
+ SkillVisibility.PRIVATE,
+ "usr_1",
+ Set.of(),
+ false
+ );
+
+ assertEquals(201L, response.skillId());
+ assertEquals("solo-skill", response.slug());
+ assertEquals(1, response.results().size());
+ assertEquals("", response.results().get(0).packagePath());
+ }
+
+ @Test
+ void publish_ignoresCollectionFilesOutsideDiscoveredSkillDirectories() throws Exception {
+ SkillPublishAppService service = newService();
+ MockMultipartFile file = upload();
+ given(skillPackageArchiveExtractor.extract(file)).willReturn(List.of(
+ entry("alpha/SKILL.md", skillMd("Alpha Skill", "1.0.0")),
+ entry("README.md", "collection readme")
+ ));
+ given(skillPublishService.publishFromEntries(
+ eq("global"),
+ anyList(),
+ eq("usr_1"),
+ eq(SkillVisibility.PUBLIC),
+ eq(Set.of()),
+ eq(false)))
+ .willReturn(new SkillPublishService.PublishResult(301L, "alpha-skill",
+ version("1.0.0", SkillVersionStatus.PENDING_REVIEW, 1, 24L)));
+
+ PublishResponse response = service.publish(
+ "global",
+ file,
+ SkillVisibility.PUBLIC,
+ "usr_1",
+ Set.of(),
+ false
+ );
+
+ assertEquals(1, response.results().size());
+ assertEquals("alpha-skill", response.results().get(0).slug());
+ }
+
+ @Test
+ void publishMethod_isTransactionalSoBatchPublishesShareOneTransaction() throws Exception {
+ Method method = SkillPublishAppService.class.getMethod(
+ "publish",
+ String.class,
+ org.springframework.web.multipart.MultipartFile.class,
+ SkillVisibility.class,
+ String.class,
+ Set.class,
+ boolean.class
+ );
+ assertNotNull(method.getAnnotation(Transactional.class));
+ }
+
+ private SkillPublishAppService newService() {
+ return new SkillPublishAppService(skillPublishService, skillPackageArchiveExtractor, skillHubMetrics);
+ }
+
+ private MockMultipartFile upload() {
+ return new MockMultipartFile("file", "skills.zip", "application/zip", new byte[] {1, 2, 3});
+ }
+
+ private PackageEntry entry(String path, String content) {
+ return new PackageEntry(path, content.getBytes(StandardCharsets.UTF_8), content.length(), "text/markdown");
+ }
+
+ private String skillMd(String name, String version) {
+ return """
+ ---
+ name: %s
+ version: %s
+ ---
+ """.formatted(name, version);
+ }
+
+ private SkillVersion version(String value, SkillVersionStatus status, int fileCount, long totalSize) {
+ SkillVersion version = new SkillVersion(1L, value, "usr_1");
+ version.setStatus(status);
+ version.setFileCount(fileCount);
+ version.setTotalSize(totalSize);
+ return version;
+ }
+
+ private List paths(List entries) {
+ return entries.stream().map(PackageEntry::path).toList();
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractorTest.java
similarity index 99%
rename from server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java
rename to server/skillhub-app/src/test/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractorTest.java
index 403be311b..c7681db73 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/support/SkillPackageArchiveExtractorTest.java
@@ -1,4 +1,4 @@
-package com.iflytek.skillhub.controller.support;
+package com.iflytek.skillhub.service.support;
import com.iflytek.skillhub.config.SkillPublishProperties;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts
index 0123468ba..7dfd10729 100644
--- a/web/e2e/helpers/test-data-builder.ts
+++ b/web/e2e/helpers/test-data-builder.ts
@@ -156,6 +156,35 @@ function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions):
}
}
+function createSkillCollectionPackageZipFile(
+ suffix: string,
+ packages: SeedSkillOptions[],
+): { filePath: string; cleanup: () => void } {
+ const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-collection-'))
+ const collectionDir = path.join(tempRoot, `collection-${suffix}`)
+ const zipPath = path.join(tempRoot, `collection-${suffix}.zip`)
+
+ execFileSync('mkdir', ['-p', collectionDir])
+ writeFileSync(path.join(collectionDir, 'README.md'), '# Skill collection\n', 'utf8')
+
+ packages.forEach((options, index) => {
+ const packageDir = path.join(collectionDir, `skill-${index + 1}`)
+ const { readmeHeading, skillMd } = buildSkillPackageContent(`${suffix}_${index}`, options)
+ execFileSync('mkdir', ['-p', packageDir])
+ writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8')
+ writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8')
+ })
+
+ execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: collectionDir })
+
+ return {
+ filePath: zipPath,
+ cleanup: () => {
+ rmSync(tempRoot, { recursive: true, force: true })
+ },
+ }
+}
+
async function parseEnvelope(response: Awaited>): Promise {
const text = await response.text()
let parsed: ApiEnvelope | null = null
@@ -521,6 +550,15 @@ export class E2eTestDataBuilder {
return filePath
}
+ createSkillCollectionPackageFile(packages: SeedSkillOptions[]): string {
+ const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
+ const { filePath, cleanup } = createSkillCollectionPackageZipFile(unique, packages)
+ this.cleanupTasks.push(async () => {
+ cleanup()
+ })
+ return filePath
+ }
+
async createReviewData(): Promise {
const namespace = await this.ensureReviewableNamespace()
const skill = await this.publishSkill(namespace.slug)
diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts
index 867c8248b..e240afb89 100644
--- a/web/e2e/publish-flow-ui.spec.ts
+++ b/web/e2e/publish-flow-ui.spec.ts
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'
+import type { Page } from '@playwright/test'
import path from 'node:path'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
@@ -8,12 +9,32 @@ interface PublishEnvelope {
code: number
msg?: string
data: {
- namespace: string
- slug: string
- version: string
+ namespace?: string
+ slug?: string
+ version?: string
+ results?: Array<{
+ namespace: string
+ slug: string
+ version: string
+ }>
}
}
+async function chooseNamespace(page: Page, namespaceSlug: string) {
+ const namespaceTrigger = page.locator('#namespace')
+ await expect(namespaceTrigger).toBeVisible()
+ await namespaceTrigger.click()
+ const namespaceOption = page.getByRole('option', {
+ name: new RegExp(`\\(@${namespaceSlug}\\)`),
+ }).first()
+ await expect(namespaceOption).toBeVisible()
+ await namespaceOption.evaluate((element: HTMLElement) => {
+ element.scrollIntoView({ block: 'center' })
+ element.click()
+ })
+ await expect(namespaceTrigger).toContainText(`@${namespaceSlug}`)
+}
+
test.describe('Publish Flow UI (Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
@@ -31,19 +52,7 @@ test.describe('Publish Flow UI (Real API)', () => {
await page.goto('/dashboard/publish')
await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible()
-
- const namespaceTrigger = page.locator('#namespace')
- await expect(namespaceTrigger).toBeVisible()
- await namespaceTrigger.click()
- const namespaceOption = page.getByRole('option', {
- name: new RegExp(`\\(@${namespace.slug}\\)`),
- }).first()
- await expect(namespaceOption).toBeVisible()
- await namespaceOption.evaluate((element: HTMLElement) => {
- element.scrollIntoView({ block: 'center' })
- element.click()
- })
- await expect(namespaceTrigger).toContainText(`@${namespace.slug}`)
+ await chooseNamespace(page, namespace.slug)
await page.locator('input[type="file"]').setInputFiles(packagePath)
await expect(page.getByText(path.basename(packagePath))).toBeVisible()
@@ -72,4 +81,68 @@ test.describe('Publish Flow UI (Real API)', () => {
await builder.cleanup()
}
})
+
+ test('publishes a folder-packed skill collection and shows batch success toast', async ({ page }, testInfo) => {
+ const builder = new E2eTestDataBuilder(page, testInfo)
+ await builder.init()
+
+ try {
+ const namespace = await builder.ensureWritableNamespace()
+ const suffix = Date.now().toString(36)
+ const firstSkillName = `publish-bulk-a-${suffix}`
+ const secondSkillName = `publish-bulk-b-${suffix}`
+ const packagePath = builder.createSkillCollectionPackageFile([
+ { name: firstSkillName },
+ { name: secondSkillName },
+ ])
+
+ await page.goto('/dashboard/publish')
+ await chooseNamespace(page, namespace.slug)
+
+ await page.locator('input[type="file"]').setInputFiles(packagePath)
+ await expect(page.getByText(path.basename(packagePath))).toBeVisible()
+
+ const confirmButton = page.getByRole('button', { name: 'Confirm Publish' })
+ await expect(confirmButton).toBeEnabled()
+ await confirmButton.click()
+
+ await expect(page.getByText('Batch publish completed')).toBeVisible({ timeout: 90_000 })
+ await expect(page.getByText('2 skill packages were published successfully.')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 30_000 })
+ await expect(page.getByRole('heading', { name: firstSkillName, exact: true })).toBeVisible({ timeout: 30_000 })
+ await expect(page.getByRole('heading', { name: secondSkillName, exact: true })).toBeVisible({ timeout: 30_000 })
+ } finally {
+ await builder.cleanup()
+ }
+ })
+
+ test('keeps the publish page open when uploading an invalid package', async ({ page }, testInfo) => {
+ const builder = new E2eTestDataBuilder(page, testInfo)
+ await builder.init()
+
+ try {
+ const namespace = await builder.ensureWritableNamespace()
+ const invalidPackagePath = path.join(process.cwd(), 'e2e', 'fixtures', 'sample-skill.zip')
+
+ await page.goto('/dashboard/publish')
+ await chooseNamespace(page, namespace.slug)
+ await page.locator('input[type="file"]').setInputFiles(invalidPackagePath)
+ await expect(page.getByText(path.basename(invalidPackagePath))).toBeVisible()
+
+ const publishResponsePromise = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST'
+ && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`),
+ { timeout: 90_000 },
+ )
+ await page.getByRole('button', { name: 'Confirm Publish' }).click()
+ const publishResponse = await publishResponsePromise
+ expect(publishResponse.ok()).toBe(false)
+
+ await expect(page.getByText('Publish Failed')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible()
+ } finally {
+ await builder.cleanup()
+ }
+ })
})
diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts
index a734922a4..c8c34c55e 100644
--- a/web/src/api/generated/schema.d.ts
+++ b/web/src/api/generated/schema.d.ts
@@ -3351,6 +3351,20 @@ export interface components {
fileCount?: number;
/** Format: int64 */
totalSize?: number;
+ results?: components["schemas"]["PublishResultDetailResponse"][];
+ };
+ PublishResultDetailResponse: {
+ packagePath?: string;
+ /** Format: int64 */
+ skillId?: number;
+ namespace?: string;
+ slug?: string;
+ version?: string;
+ status?: string;
+ /** Format: int32 */
+ fileCount?: number;
+ /** Format: int64 */
+ totalSize?: number;
};
ReviewActionRequest: {
comment?: string;
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index 2b2f93823..14aaf7985 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -285,7 +285,7 @@ export interface PagedResponse {
}
// Publish
-export interface PublishResult {
+export interface PublishResultItem {
skillId: number
namespace: string
slug: string
@@ -295,6 +295,10 @@ export interface PublishResult {
totalSize: number
}
+export type PublishResult = PublishResultItem & {
+ results?: PublishResultItem[]
+}
+
export interface SkillDeleteResult {
skillId?: number
namespace?: string
diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx
index b28fa92be..b72a0ec5b 100644
--- a/web/src/features/publish/upload-zone.tsx
+++ b/web/src/features/publish/upload-zone.tsx
@@ -4,12 +4,12 @@ import { useDropzone } from 'react-dropzone'
import { cn } from '@/shared/lib/utils'
interface UploadZoneProps {
- onFileSelect: (file: File) => void
+ onFileSelect: (files: File[]) => void
disabled?: boolean
}
/**
- * Provides the publish page dropzone for uploading one zip package at a time.
+ * Provides the publish page dropzone for uploading zip packages.
* The component is intentionally stateless so packaging validation can remain in
* the publish flow that knows the surrounding form and backend constraints.
*/
@@ -18,7 +18,7 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
- onFileSelect(acceptedFiles[0])
+ onFileSelect(acceptedFiles)
}
},
[onFileSelect]
@@ -29,7 +29,7 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
accept: {
'application/zip': ['.zip'],
},
- maxFiles: 1,
+ multiple: true,
disabled,
})
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index c96495f9d..0aadf2720 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -1063,7 +1063,7 @@
},
"upload": {
"dropHint": "Drop to upload...",
- "dragHint": "Drag a ZIP file here, or click to select",
+ "dragHint": "Drag ZIP files here, or click to select",
"formatHint": "Only .zip format supported"
},
"layout": {
@@ -1212,13 +1212,18 @@
"private": "Private"
},
"file": "Skill Package File",
+ "selectedFileCount": "1 file selected",
+ "selectedFilesCount": "{{count}} files selected",
"removeSelectedFile": "Remove selected file",
+ "removeSelectedFiles": "Remove selected files",
"publishing": "Publishing...",
"confirm": "Confirm Publish",
"success": "Published Successfully",
"successDescription": "{{skill}} has been submitted for review and will be available after admin approval",
"publishedTitle": "Published Successfully",
"publishedDescription": "{{skill}} is now live and available for download",
+ "bulkSuccessTitle": "Batch publish completed",
+ "bulkSuccessDescription": "{{count}} skill packages were published successfully.",
"pendingReviewTitle": "Submitted for Review",
"pendingReviewDescription": "{{skill}} has been submitted for review and will be available after admin approval",
"error": "Publish Failed",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 122192852..d57f9841b 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -1064,7 +1064,7 @@
},
"upload": {
"dropHint": "放开以上传文件...",
- "dragHint": "拖拽 ZIP 文件到此处,或点击选择",
+ "dragHint": "拖拽 ZIP 文件到此处,或点击选择多个文件",
"formatHint": "仅支持 .zip 格式"
},
"layout": {
@@ -1213,13 +1213,18 @@
"private": "私有"
},
"file": "技能包文件",
+ "selectedFileCount": "已选择 1 个文件",
+ "selectedFilesCount": "已选择 {{count}} 个文件",
"removeSelectedFile": "删除已选文件",
+ "removeSelectedFiles": "删除已选文件",
"publishing": "发布中...",
"confirm": "确认发布",
"success": "发布成功",
"successDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用",
"publishedTitle": "发布成功",
"publishedDescription": "{{skill}} 已发布,现在可以下载使用",
+ "bulkSuccessTitle": "批量发布完成",
+ "bulkSuccessDescription": "{{count}} 个技能包已发布成功。",
"pendingReviewTitle": "已提交审核",
"pendingReviewDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用",
"error": "发布失败",
diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx
index 3b2ec57e3..dcdf60eb9 100644
--- a/web/src/pages/dashboard/publish.tsx
+++ b/web/src/pages/dashboard/publish.tsx
@@ -25,6 +25,7 @@ import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { toast } from '@/shared/lib/toast'
+import { formatPublishResultLabel, getPublishResultItems } from '@/shared/lib/publish-result'
import { ApiError } from '@/api/client'
const EMPTY_NAMESPACE_VALUE = '__select_namespace__'
@@ -32,7 +33,7 @@ const EMPTY_NAMESPACE_VALUE = '__select_namespace__'
export function PublishPage() {
const { t } = useTranslation()
const navigate = useNavigate()
- const [selectedFile, setSelectedFile] = useState(null)
+ const [selectedFiles, setSelectedFiles] = useState([])
const [namespaceSlug, setNamespaceSlug] = useState('')
const [visibility, setVisibility] = useState('PUBLIC')
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
@@ -46,19 +47,19 @@ export function PublishPage() {
: t('publish.visibilityOptions.namespaceOnly')
const handleRemoveSelectedFile = () => {
- setSelectedFile(null)
+ setSelectedFiles([])
setPrecheckWarnings([])
setWarningDialogOpen(false)
}
- const handleFileSelect = (file: File | null) => {
- setSelectedFile(file)
+ const handleFileSelect = (files: File[]) => {
+ setSelectedFiles(files)
setPrecheckWarnings([])
setWarningDialogOpen(false)
}
const publishSkill = async (confirmWarnings = false) => {
- if (!selectedFile || !namespaceSlug) {
+ if (selectedFiles.length === 0 || !namespaceSlug) {
toast.error(t('publish.selectRequired'))
return
}
@@ -66,22 +67,28 @@ export function PublishPage() {
try {
const result = await publishMutation.mutateAsync({
namespace: namespaceSlug,
- file: selectedFile,
+ files: selectedFiles,
visibility,
confirmWarnings,
})
setPrecheckWarnings([])
setWarningDialogOpen(false)
- const skillLabel = `${result.namespace}/${result.slug}@${result.version}`
- if (result.status === 'PUBLISHED') {
+ const publishResults = getPublishResultItems(result)
+
+ if (publishResults.length > 1) {
+ toast.success(
+ t('publish.bulkSuccessTitle'),
+ t('publish.bulkSuccessDescription', { count: publishResults.length })
+ )
+ } else if (publishResults[0]?.status === 'PUBLISHED') {
toast.success(
t('publish.publishedTitle'),
- t('publish.publishedDescription', { skill: skillLabel })
+ t('publish.publishedDescription', { skill: formatPublishResultLabel(publishResults[0]) })
)
} else {
toast.success(
t('publish.pendingReviewTitle'),
- t('publish.pendingReviewDescription', { skill: skillLabel })
+ t('publish.pendingReviewDescription', { skill: publishResults[0] ? formatPublishResultLabel(publishResults[0]) : '' })
)
}
navigate({ to: '/dashboard/skills' })
@@ -189,19 +196,28 @@ export function PublishPage() {
{t('publish.file')}
`${file.name}-${file.lastModified}`).join('|') || 'empty'}
onFileSelect={handleFileSelect}
disabled={publishMutation.isPending}
/>
- {selectedFile && (
+ {selectedFiles.length > 0 && (
-
+
-
- {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
-
+
+
+ {selectedFiles.length === 1
+ ? t('publish.selectedFileCount')
+ : t('publish.selectedFilesCount', { count: selectedFiles.length })}
+
+ {selectedFiles.map((file) => (
+
+ {file.name} ({(file.size / 1024).toFixed(1)} KB)
+
+ ))}
+
- {t('publish.removeSelectedFile')}
+ {selectedFiles.length === 1 ? t('publish.removeSelectedFile') : t('publish.removeSelectedFiles')}
)}
@@ -220,7 +236,7 @@ export function PublishPage() {
className="w-full text-primary-foreground disabled:text-primary-foreground"
size="lg"
onClick={handlePublish}
- disabled={!selectedFile || !namespaceSlug || publishMutation.isPending}
+ disabled={selectedFiles.length === 0 || !namespaceSlug || publishMutation.isPending}
>
{publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')}
diff --git a/web/src/shared/hooks/use-skill-queries.publish.test.ts b/web/src/shared/hooks/use-skill-queries.publish.test.ts
new file mode 100644
index 000000000..eb9823757
--- /dev/null
+++ b/web/src/shared/hooks/use-skill-queries.publish.test.ts
@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PublishResult } from '@/api/types'
+
+const mocks = vi.hoisted(() => ({
+ fetchJson: vi.fn<() => Promise
>(),
+}))
+
+interface MutationOptions {
+ mutationFn: (variables: TVariables) => Promise
+}
+
+interface PublishVariables {
+ namespace: string
+ files: File[]
+ visibility: string
+}
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: (options: MutationOptions) => options,
+ useQuery: vi.fn(),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}))
+
+vi.mock('@/api/client', () => ({
+ WEB_API_PREFIX: '/api/web',
+ fetchJson: mocks.fetchJson,
+ fetchText: vi.fn(),
+ getCsrfHeaders: () => ({ 'X-XSRF-TOKEN': 'token' }),
+ skillLifecycleApi: {},
+}))
+
+vi.mock('@/features/skill/skill-delete-flow', () => ({
+ clearDeletedSkillQueries: vi.fn(),
+}))
+
+import { usePublishSkill } from './use-skill-queries'
+
+const alphaResult = {
+ skillId: 1,
+ namespace: 'team',
+ slug: 'alpha',
+ version: '1.0.0',
+ status: 'PUBLISHED',
+ fileCount: 2,
+ totalSize: 128,
+}
+
+const betaResult = {
+ ...alphaResult,
+ skillId: 2,
+ slug: 'beta',
+}
+
+function createZip(name: string): File {
+ return new File(['zip'], name, { type: 'application/zip' })
+}
+
+describe('usePublishSkill mutation', () => {
+ beforeEach(() => {
+ mocks.fetchJson.mockReset()
+ })
+
+ it('returns backend batch results from a single publish request', async () => {
+ mocks.fetchJson.mockResolvedValueOnce({
+ ...alphaResult,
+ results: [alphaResult, betaResult],
+ })
+
+ const mutation = usePublishSkill() as unknown as MutationOptions
+ const result = await mutation.mutationFn({
+ namespace: 'team',
+ files: [createZip('alpha.zip'), createZip('beta.zip')],
+ visibility: 'PUBLIC',
+ })
+
+ expect(mocks.fetchJson).toHaveBeenCalledTimes(1)
+ expect(result.results?.map((item) => item.slug)).toEqual(['alpha', 'beta'])
+ })
+
+ it('falls back to single-file requests when the backend only returns one result', async () => {
+ mocks.fetchJson
+ .mockResolvedValueOnce(alphaResult)
+ .mockResolvedValueOnce(betaResult)
+
+ const mutation = usePublishSkill() as unknown as MutationOptions
+ const result = await mutation.mutationFn({
+ namespace: 'team',
+ files: [createZip('alpha.zip'), createZip('beta.zip')],
+ visibility: 'PUBLIC',
+ })
+
+ expect(mocks.fetchJson).toHaveBeenCalledTimes(2)
+ expect(result.results?.map((item) => item.slug)).toEqual(['alpha', 'beta'])
+ })
+})
diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts
index cf19d18cc..ad02778ba 100644
--- a/web/src/shared/hooks/use-skill-queries.ts
+++ b/web/src/shared/hooks/use-skill-queries.ts
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
import { clearDeletedSkillQueries } from '@/features/skill/skill-delete-flow'
+import { getPublishResultItems } from '@/shared/lib/publish-result'
import { getSkillDetailQueryKey } from './query-keys'
import { buildSkillSearchUrl } from './skill-query-helpers'
@@ -37,10 +38,28 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`)
}
-async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean }): Promise {
+interface PublishSkillParams {
+ namespace: string
+ file?: File
+ files?: File[]
+ visibility: string
+ confirmWarnings?: boolean
+}
+
+function getPublishFiles(params: PublishSkillParams): File[] {
+ if (params.files && params.files.length > 0) {
+ return params.files
+ }
+
+ return params.file ? [params.file] : []
+}
+
+async function publishFiles(params: PublishSkillParams, files: File[]): Promise {
const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace
const formData = new FormData()
- formData.append('file', params.file)
+ for (const file of files) {
+ formData.append('file', file)
+ }
formData.append('visibility', params.visibility)
formData.append('confirmWarnings', String(params.confirmWarnings === true))
@@ -52,6 +71,29 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
})
}
+async function publishSkill(params: PublishSkillParams): Promise {
+ const files = getPublishFiles(params)
+ const result = await publishFiles(params, files)
+ const resultItems = getPublishResultItems(result)
+
+ if (files.length <= 1 || resultItems.length >= files.length) {
+ return result
+ }
+
+ const remainingResults: PublishResult[] = []
+ for (const file of files.slice(resultItems.length)) {
+ remainingResults.push(await publishFiles(params, [file]))
+ }
+
+ return {
+ ...result,
+ results: [
+ ...resultItems,
+ ...remainingResults.flatMap(getPublishResultItems),
+ ],
+ }
+}
+
export function useSearchSkills(params: SearchParams) {
return useQuery({
queryKey: ['skills', 'search', params],
diff --git a/web/src/shared/lib/publish-result.test.ts b/web/src/shared/lib/publish-result.test.ts
new file mode 100644
index 000000000..d9da6cffa
--- /dev/null
+++ b/web/src/shared/lib/publish-result.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import type { PublishResult } from '@/api/types'
+import { formatPublishResultLabel, getPublishResultItems, isPublishResultItem } from './publish-result'
+
+const singleResult = {
+ skillId: 1,
+ namespace: 'team',
+ slug: 'alpha',
+ version: '1.0.0',
+ status: 'PUBLISHED',
+ fileCount: 2,
+ totalSize: 128,
+}
+
+describe('publish-result helpers', () => {
+ it('keeps the legacy single publish response as one result item', () => {
+ expect(isPublishResultItem(singleResult)).toBe(true)
+ expect(getPublishResultItems(singleResult)).toEqual([singleResult])
+ expect(formatPublishResultLabel(singleResult)).toBe('team/alpha@1.0.0')
+ })
+
+ it('prefers backend batch results when present', () => {
+ const batchResult: PublishResult = {
+ ...singleResult,
+ results: [
+ singleResult,
+ { ...singleResult, skillId: 2, slug: 'beta', status: 'PENDING_REVIEW' },
+ ],
+ }
+
+ expect(getPublishResultItems(batchResult).map((item) => item.slug)).toEqual(['alpha', 'beta'])
+ })
+
+ it('falls back to the top-level publish result when batch results are empty', () => {
+ const result: PublishResult = { ...singleResult, results: [] }
+
+ expect(getPublishResultItems(result)).toEqual([result])
+ })
+})
diff --git a/web/src/shared/lib/publish-result.ts b/web/src/shared/lib/publish-result.ts
new file mode 100644
index 000000000..0bbf8d189
--- /dev/null
+++ b/web/src/shared/lib/publish-result.ts
@@ -0,0 +1,17 @@
+import type { PublishResult, PublishResultItem } from '@/api/types'
+
+export function isPublishResultItem(value: PublishResult): value is PublishResultItem {
+ return typeof value.skillId === 'number'
+}
+
+export function getPublishResultItems(result: PublishResult): PublishResultItem[] {
+ if (Array.isArray(result.results) && result.results.length > 0) {
+ return result.results
+ }
+
+ return [result]
+}
+
+export function formatPublishResultLabel(result: PublishResultItem): string {
+ return `${result.namespace}/${result.slug}@${result.version}`
+}