diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index e431eaf3..a4030ebf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -56,15 +56,16 @@ public class CliSkillAppService { ); List items = response.items().stream() + .filter(item -> item.publishedVersion() != null) .map(item -> new CliSearchItem( item.namespace(), item.slug(), - item.publishedVersion() != null ? item.publishedVersion().version() : null, + item.publishedVersion().version(), item.summary() )) .toList(); - return new CliSearchResult(items, response.total(), limit); + return new CliSearchResult(items, items.size(), limit); } public CliResolveResponse resolve(String namespace, String slug, String version, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index fdac46f8..984be1d0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -8,7 +8,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.search.SearchQuery; @@ -184,6 +186,36 @@ class SkillSearchAppServiceTest { .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } + @Test + void search_shouldNotExposeDownloadUnavailableVersionAsPublishedSummary() { + Skill skill = new Skill(1L, "not-ready", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion version = new SkillVersion(10L, "1.0.0", "owner-1"); + setField(version, "id", 101L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version)); + when(skillVersionRepository.findBySkillIdInAndStatus(List.of(10L), SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(version)); + + SkillSearchAppService.SearchResponse response = service.search(null, null, "newest", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("not-ready", response.items().getFirst().slug()); + assertEquals(null, response.items().getFirst().publishedVersion()); + } + @Test void search_shouldNormalizeAndPassLabelSlugs() { when(searchQueryService.search(any())) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index b7fbe1d7..b2c230fe 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -76,6 +76,37 @@ class CliSkillAppServiceTest { assertEquals(20, result.limit()); } + @Test + void search_filtersResultsWithoutInstallablePublishedVersion() { + var searchResponse = new SkillSearchAppService.SearchResponse( + List.of( + new SkillSummaryResponse( + 1L, "draft-only", "Draft Only", "No installable version", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + null, null, null, "NONE" + ), + new SkillSummaryResponse( + 2L, "ready", "Ready", "Installable", + "PUBLIC", "ACTIVE", 0L, 0, BigDecimal.ZERO, 0, + "global", Instant.now(), false, + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), + null, "PUBLISHED" + ) + ), + 2L, 0, 20 + ); + given(skillSearchAppService.search("demo", null, "newest", 0, 20, null, null)) + .willReturn(searchResponse); + + var result = service.search("demo", 20, null, null); + + assertEquals(1, result.items().size()); + assertEquals("ready", result.items().getFirst().slug()); + assertEquals(1L, result.total()); + } + @Test void resolve_delegatesToQueryService() { given(skillQueryService.resolveVersion("global", "demo", "2.0.0", null, null, "user-1", Map.of())) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java new file mode 100644 index 00000000..dfeeed1c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillInstallability.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.domain.skill; + +/** + * Defines whether a skill version can be installed through public download + * paths. Storage object presence is checked later by the download service so + * fallback bundle behavior stays separate from domain publication state. + */ +public final class SkillInstallability { + private SkillInstallability() { + } + + public static boolean isInstallableVersion(SkillVersion version) { + return version != null + && version.getStatus() == SkillVersionStatus.PUBLISHED + && version.isDownloadReady() + && version.getYankedAt() == null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 56b69ddd..db14fe2e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -306,7 +306,7 @@ public class SkillDownloadService { /** * Asserts that the version can be downloaded. - * - PUBLISHED: anyone with skill access can download + * - PUBLISHED: must be installable before public download * - UPLOADED/PENDING_REVIEW: only skill owner or namespace admin can download */ private void assertDownloadableVersion(Skill skill, @@ -315,7 +315,9 @@ public class SkillDownloadService { Map userNsRoles) { switch (version.getStatus()) { case PUBLISHED -> { - // Anyone with skill access can download published versions + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion()); + } } case UPLOADED, PENDING_REVIEW -> { if (!canManageSkillDraft(skill, currentUserId, userNsRoles)) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 368ae850..1ad3fd35 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillInstallability; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; @@ -91,7 +92,7 @@ public class SkillLifecycleProjectionService { List unresolvedSkillIds = new java.util.ArrayList<>(); for (Skill skill : skills) { SkillVersion latestVersion = latestVersionsById.get(skill.getLatestVersionId()); - if (latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED) { + if (SkillInstallability.isInstallableVersion(latestVersion)) { publishedBySkillId.put(skill.getId(), latestVersion); } else { unresolvedSkillIds.add(skill.getId()); @@ -100,7 +101,9 @@ public class SkillLifecycleProjectionService { if (!unresolvedSkillIds.isEmpty()) { for (SkillVersion version : skillVersionRepository.findBySkillIdInAndStatus(unresolvedSkillIds, SkillVersionStatus.PUBLISHED)) { - publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + if (SkillInstallability.isInstallableVersion(version)) { + publishedBySkillId.merge(version.getSkillId(), version, this::newerVersion); + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 7d966327..66c5e319 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -487,13 +487,7 @@ public class SkillQueryService { } public boolean isDownloadAvailable(SkillVersion version) { - if (version == null) { - return false; - } - if (version.getStatus() != SkillVersionStatus.PUBLISHED) { - return false; - } - return version.isDownloadReady(); + return SkillInstallability.isInstallableVersion(version); } public ReviewSkillSnapshotDTO getReviewSkillSnapshot(Long skillVersionId) { @@ -565,6 +559,7 @@ public class SkillQueryService { Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); + assertInstallableVersion(resolved, resolved.getVersion()); String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); @@ -916,6 +911,12 @@ public class SkillQueryService { } } + private void assertInstallableVersion(SkillVersion version, String versionStr) { + if (!SkillInstallability.isInstallableVersion(version)) { + throw new DomainBadRequestException("error.skill.version.notDownloadable", versionStr); + } + } + /** * Checks whether the caller may preview a specific version's files and metadata. * Published versions are visible to everyone; all other statuses are restricted diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index ce84509d..2b7ea78b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -92,6 +92,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -137,6 +138,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -180,6 +182,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); String storageKey = "packages/1/10/bundle.zip"; InputStream content = new ByteArrayInputStream("test".getBytes()); ObjectMetadata metadata = new ObjectMetadata(1000L, "application/zip", Instant.now()); @@ -232,6 +235,73 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadVersion_ShouldRejectDownloadUnavailablePublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + + @Test + void testDownloadVersion_ShouldRejectYankedPublishedVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String versionStr = "1.0.0"; + String userId = "user-100"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion version = new SkillVersion(1L, versionStr, userId); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, versionStr)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.downloadVersion(namespaceSlug, skillSlug, versionStr, userId, userNsRoles)); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{versionStr}, ex.messageArgs()); + verify(skillRepository, never()).incrementDownloadCount(anyLong()); + verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong()); + verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); + } + @Test void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throws Exception { String namespaceSlug = "test-ns"; @@ -249,6 +319,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, versionStr, userId); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(10L, "SKILL.md", 4L, "text/markdown", "hash", "skills/1/10/SKILL.md"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -297,6 +368,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).thenReturn(List.of(skill)); @@ -332,6 +404,7 @@ class SkillDownloadServiceTest { SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); setId(version, 10L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); when(namespaceRepository.findBySlug("team-ai")).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(2L, "demo-skill")).thenReturn(List.of(skill)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 7ee683ac..032bc683 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -27,6 +27,7 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.UncheckedIOException; import java.lang.reflect.Field; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -439,6 +440,17 @@ class SkillQueryServiceTest { assertTrue(service.isDownloadAvailable(version)); } + @Test + void testIsDownloadAvailable_ShouldReturnFalseWhenVersionIsYanked() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + version.setYankedAt(Instant.parse("2026-06-12T00:00:00Z")); + + assertFalse(service.isDownloadAvailable(version)); + } + @Test void testIsDownloadAvailable_ShouldNotHitObjectStorageForListSignals() throws Exception { SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); @@ -565,9 +577,11 @@ class SkillQueryServiceTest { SkillVersion version100 = new SkillVersion(1L, "1.0.0", "user-100"); setId(version100, 9L); version100.setStatus(SkillVersionStatus.PUBLISHED); + version100.setDownloadReady(true); SkillVersion version110 = new SkillVersion(1L, "1.1.0", "user-100"); setId(version110, 10L); version110.setStatus(SkillVersionStatus.PUBLISHED); + version110.setDownloadReady(true); SkillFile version100File = new SkillFile(9L, "SKILL.md", 10L, "text/markdown", "hash100", "key100"); SkillFile version110File = new SkillFile(10L, "SKILL.md", 10L, "text/markdown", "hash110", "key110"); @@ -611,6 +625,7 @@ class SkillQueryServiceTest { SkillVersion version = new SkillVersion(3L, "1.0.0 beta", "user-100"); setId(version, 11L); version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); SkillFile file = new SkillFile(11L, "SKILL.md", 10L, "text/markdown", "hash", "key"); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); @@ -632,6 +647,35 @@ class SkillQueryServiceTest { assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); } + @Test + void testResolveVersion_ShouldRejectDownloadUnavailableLatestVersion() throws Exception { + String namespaceSlug = "global"; + String skillSlug = "not-ready"; + + Namespace namespace = new Namespace(namespaceSlug, "Global", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 3L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(3L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(false); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.resolveVersion(namespaceSlug, skillSlug, null, null, null, null, Map.of())); + + assertEquals("error.skill.version.notDownloadable", ex.messageCode()); + assertArrayEquals(new Object[]{"1.0.0"}, ex.messageArgs()); + } + @Test void testGetSkillDetail_ShouldFlagLifecyclePermissionForOwner() throws Exception { String namespaceSlug = "test-ns";