mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-12 23:01:05 +00:00
feat(skill): support rereleasing published versions
This commit is contained in:
parent
29a08d3c54
commit
3f82a88c4e
2 changed files with 214 additions and 2 deletions
|
|
@ -5,10 +5,12 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
|||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.SlugValidator;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
|
||||
|
|
@ -17,18 +19,24 @@ import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator;
|
|||
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
|
||||
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
|
|
@ -88,6 +96,50 @@ public class SkillPublishService {
|
|||
String publisherId,
|
||||
SkillVisibility visibility,
|
||||
java.util.Set<String> platformRoles) {
|
||||
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PublishResult rereleasePublishedVersion(
|
||||
Long skillId,
|
||||
String sourceVersion,
|
||||
String targetVersion,
|
||||
String publisherId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Skill skill = skillRepository.findById(skillId)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
|
||||
assertCanManageLifecycle(skill, publisherId, userNamespaceRoles);
|
||||
|
||||
SkillVersion publishedVersion = skillVersionRepository.findBySkillIdAndVersion(skillId, sourceVersion)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", sourceVersion));
|
||||
if (publishedVersion.getStatus() != SkillVersionStatus.PUBLISHED) {
|
||||
throw new DomainBadRequestException("error.skill.version.notPublished", sourceVersion);
|
||||
}
|
||||
if (skillVersionRepository.findBySkillIdAndVersion(skillId, targetVersion).isPresent()) {
|
||||
throw new DomainBadRequestException("error.skill.version.exists", targetVersion);
|
||||
}
|
||||
|
||||
List<PackageEntry> entries = rebuildEntriesForRerelease(skillId, publishedVersion.getId(), targetVersion);
|
||||
|
||||
return publishFromEntriesInternal(
|
||||
resolveNamespaceSlug(skill.getNamespaceId()),
|
||||
entries,
|
||||
publisherId,
|
||||
skill.getVisibility(),
|
||||
Set.of(),
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private PublishResult publishFromEntriesInternal(
|
||||
String namespaceSlug,
|
||||
List<PackageEntry> entries,
|
||||
String publisherId,
|
||||
SkillVisibility visibility,
|
||||
Set<String> platformRoles,
|
||||
boolean forceAutoPublish,
|
||||
boolean bypassMembershipCheck) {
|
||||
|
||||
// 1. Find namespace by slug
|
||||
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
|
||||
|
|
@ -96,7 +148,7 @@ public class SkillPublishService {
|
|||
boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN");
|
||||
|
||||
// 2. Check publisher is member unless SUPER_ADMIN short-circuits permission checks
|
||||
if (!isSuperAdmin) {
|
||||
if (!isSuperAdmin && !bypassMembershipCheck) {
|
||||
namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug));
|
||||
}
|
||||
|
|
@ -153,7 +205,7 @@ public class SkillPublishService {
|
|||
|
||||
// 8. Create SkillVersion
|
||||
SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId);
|
||||
boolean autoPublish = isSuperAdmin;
|
||||
boolean autoPublish = forceAutoPublish || isSuperAdmin;
|
||||
if (autoPublish) {
|
||||
version.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
version.setPublishedAt(LocalDateTime.now());
|
||||
|
|
@ -253,6 +305,64 @@ public class SkillPublishService {
|
|||
return new PublishResult(skill.getId(), skill.getSlug(), version);
|
||||
}
|
||||
|
||||
private String resolveNamespaceSlug(Long namespaceId) {
|
||||
return namespaceRepository.findById(namespaceId)
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.notFound", namespaceId))
|
||||
.getSlug();
|
||||
}
|
||||
|
||||
private void assertCanManageLifecycle(Skill skill,
|
||||
String actorUserId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId());
|
||||
boolean canManage = skill.getOwnerId().equals(actorUserId)
|
||||
|| namespaceRole == NamespaceRole.ADMIN
|
||||
|| namespaceRole == NamespaceRole.OWNER;
|
||||
if (!canManage) {
|
||||
throw new DomainForbiddenException("error.skill.lifecycle.noPermission");
|
||||
}
|
||||
}
|
||||
|
||||
private List<PackageEntry> rebuildEntriesForRerelease(Long skillId, Long versionId, String targetVersion) {
|
||||
List<SkillFile> files = skillFileRepository.findByVersionId(versionId).stream()
|
||||
.sorted(Comparator.comparing(SkillFile::getFilePath))
|
||||
.toList();
|
||||
List<PackageEntry> entries = new ArrayList<>(files.size());
|
||||
for (SkillFile file : files) {
|
||||
byte[] content = readAllBytes(objectStorageService.getObject(file.getStorageKey()));
|
||||
if ("SKILL.md".equals(file.getFilePath())) {
|
||||
content = rewriteSkillMdVersion(content, targetVersion);
|
||||
}
|
||||
entries.add(new PackageEntry(
|
||||
file.getFilePath(),
|
||||
content,
|
||||
content.length,
|
||||
file.getContentType() != null ? file.getContentType() : "application/octet-stream"
|
||||
));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private byte[] readAllBytes(InputStream inputStream) {
|
||||
try (InputStream in = inputStream) {
|
||||
return in.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to read stored skill file", e);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] rewriteSkillMdVersion(byte[] content, String targetVersion) {
|
||||
String skillMdContent = new String(content);
|
||||
SkillMetadata metadata = skillMetadataParser.parse(skillMdContent);
|
||||
Map<String, Object> frontmatter = new LinkedHashMap<>(metadata.frontmatter());
|
||||
frontmatter.put("version", targetVersion);
|
||||
String rewritten = "---\n"
|
||||
+ new Yaml().dump(frontmatter).trim()
|
||||
+ "\n---\n"
|
||||
+ metadata.body();
|
||||
return rewritten.getBytes();
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildManifest(List<PackageEntry> entries) {
|
||||
return entries.stream()
|
||||
.map(entry -> Map.<String, Object>of(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import org.mockito.Mock;
|
|||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
|
@ -415,6 +417,106 @@ class SkillPublishServiceTest {
|
|||
verify(skillRepository).save(skill);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception {
|
||||
String publisherId = "user-100";
|
||||
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 11L);
|
||||
skill.setDisplayName("Demo Skill");
|
||||
skill.setSummary("Original summary");
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setId(namespace, 1L);
|
||||
|
||||
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
|
||||
setId(sourceVersion, 21L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
sourceVersion.setPublishedAt(LocalDateTime.of(2026, 3, 15, 10, 0));
|
||||
|
||||
String sourceSkillMd = """
|
||||
---
|
||||
name: Demo Skill
|
||||
description: Original summary
|
||||
version: 1.2.3
|
||||
---
|
||||
Hello world
|
||||
""";
|
||||
byte[] readmeBytes = "# Demo".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
|
||||
SkillFile readmeFile = new SkillFile(sourceVersion.getId(), "README.md", (long) readmeBytes.length, "text/markdown", "hash2", "skills/11/21/README.md");
|
||||
|
||||
SkillMetadata rereleaseMetadata = new SkillMetadata(
|
||||
"Demo Skill",
|
||||
"Original summary",
|
||||
"1.2.4",
|
||||
"Hello world",
|
||||
Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
|
||||
|
||||
when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
|
||||
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
|
||||
when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile, readmeFile));
|
||||
when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
|
||||
when(objectStorageService.getObject(readmeFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(readmeBytes));
|
||||
when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
|
||||
when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
|
||||
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
|
||||
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
|
||||
SkillVersion saved = invocation.getArgument(0);
|
||||
if (saved.getId() == null) {
|
||||
setId(saved, 30L);
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
when(skillRepository.save(any())).thenReturn(skill);
|
||||
|
||||
SkillPublishService.PublishResult result = service.rereleasePublishedVersion(
|
||||
skill.getId(),
|
||||
"1.2.3",
|
||||
"1.2.4",
|
||||
publisherId,
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
|
||||
);
|
||||
|
||||
assertEquals("1.2.4", result.version().getVersion());
|
||||
assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus());
|
||||
assertEquals(30L, skill.getLatestVersionId());
|
||||
verify(reviewTaskRepository, never()).save(any());
|
||||
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
|
||||
verify(skillPackageValidator).validate(argThat(entries ->
|
||||
entries.size() == 2
|
||||
&& entries.stream().anyMatch(entry ->
|
||||
entry.path().equals("SKILL.md")
|
||||
&& new String(entry.content(), StandardCharsets.UTF_8).contains("version: 1.2.4"))));
|
||||
verify(prePublishValidator).validate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRereleasePublishedVersion_ShouldRejectDuplicateTargetVersion() throws Exception {
|
||||
String publisherId = "user-100";
|
||||
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
|
||||
setId(skill, 11L);
|
||||
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
|
||||
setId(sourceVersion, 21L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
SkillVersion existingTarget = new SkillVersion(skill.getId(), "1.2.4", publisherId);
|
||||
setId(existingTarget, 22L);
|
||||
|
||||
when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.of(existingTarget));
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion(
|
||||
skill.getId(),
|
||||
"1.2.3",
|
||||
"1.2.4",
|
||||
publisherId,
|
||||
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
|
||||
));
|
||||
}
|
||||
|
||||
private void setId(Object entity, Long id) throws Exception {
|
||||
Field idField = entity.getClass().getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue