diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index ca67907c..2238b7e0 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -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 platformRoles) { + return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false); + } + + @Transactional + public PublishResult rereleasePublishedVersion( + Long skillId, + String sourceVersion, + String targetVersion, + String publisherId, + Map 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 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 entries, + String publisherId, + SkillVisibility visibility, + Set 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 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 rebuildEntriesForRerelease(Long skillId, Long versionId, String targetVersion) { + List files = skillFileRepository.findByVersionId(versionId).stream() + .sorted(Comparator.comparing(SkillFile::getFilePath)) + .toList(); + List 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 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> buildManifest(List entries) { return entries.stream() .map(entry -> Map.of( diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 02cc64e2..e5cfb885 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -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);