Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>The controller is responsible for archive extraction and request shaping,
* while the domain service owns all publication validation and state changes.
* <p>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;
}

/**
Expand All @@ -57,34 +46,14 @@ public ApiResponse<PublishResponse> publish(
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {

SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());

List<PackageEntry> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
package com.iflytek.skillhub.dto;

import java.util.List;

public record PublishResponse(
Long skillId,
String namespace,
String slug,
String version,
String status,
int fileCount,
long totalSize
) {}
long totalSize,
List<PublishResultDetailResponse> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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<String> platformRoles,
boolean confirmWarnings) throws IOException {
List<PackageEntry> entries;
try {
entries = skillPackageArchiveExtractor.extract(file);
} catch (IllegalArgumentException e) {
throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
}

List<DiscoveredPackage> packages = discoverPackages(entries);
List<PublishResultDetailResponse> 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<DiscoveredPackage> discoverPackages(List<PackageEntry> entries) {
if (entries.stream().anyMatch(entry -> SKILL_MD.equals(entry.path()))) {
return List.of(new DiscoveredPackage("", entries));
}

List<String> 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<DiscoveredPackage> packages = new ArrayList<>();
for (String root : packageRoots) {
String prefix = root + "/";
List<PackageEntry> 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<String> 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<PackageEntry> entries) {}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -80,7 +80,7 @@ public List<PackageEntry> 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<PackageEntry> stripSingleRootDirectory(List<PackageEntry> entries) {
public static List<PackageEntry> stripSingleRootDirectory(List<PackageEntry> entries) {
if (entries.isEmpty()) return entries;

Set<String> rootSegments = new HashSet<>();
Expand Down
Loading
Loading