diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 49e6491c..efdeebb4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -104,7 +104,8 @@ public class SkillController extends BaseApiController { v.getChangelog(), v.getFileCount(), v.getTotalSize(), - v.getPublishedAt() + v.getPublishedAt(), + skillQueryService.isDownloadAvailable(v) ))); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java index 77bb8e06..1e970791 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java @@ -9,5 +9,6 @@ public record SkillVersionResponse( String changelog, int fileCount, long totalSize, - LocalDateTime publishedAt + LocalDateTime publishedAt, + boolean downloadAvailable ) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 81047387..6f3e9ac8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import org.junit.jupiter.api.Test; @@ -20,6 +21,7 @@ import java.util.Map; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -174,4 +176,21 @@ class SkillControllerTest { .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } + + @Test + void listVersionsShouldExposeDownloadAvailability() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + when(skillQueryService.listVersions( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()), + any())) + .thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(version))); + when(skillQueryService.isDownloadAvailable(version)).thenReturn(false); + + mockMvc.perform(get("/api/v1/skills/team/demo/versions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].downloadAvailable").value(false)); + } } 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 6ef970e8..35427e4c 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 @@ -215,7 +215,7 @@ public class SkillQueryService { SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); - return skillFileRepository.findByVersionId(skillVersion.getId()); + return availableFiles(skillVersion.getId()); } public List listFilesByTag( @@ -228,7 +228,7 @@ public class SkillQueryService { Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); - return skillFileRepository.findByVersionId(skillVersion.getId()); + return availableFiles(skillVersion.getId()); } public InputStream getFileContent( @@ -302,6 +302,16 @@ public class SkillQueryService { return new PageImpl<>(pageContent, pageable, visibleVersions.size()); } + public boolean isDownloadAvailable(SkillVersion version) { + if (version == null) { + return false; + } + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + return false; + } + return objectStorageService.exists(getBundleStorageKey(version.getSkillId(), version.getId())); + } + public ResolvedVersionDTO resolveVersion( String namespaceSlug, String skillSlug, @@ -361,12 +371,22 @@ public class SkillQueryService { } private SkillFile findFile(SkillVersion skillVersion, String filePath) { - return skillFileRepository.findByVersionId(skillVersion.getId()).stream() + return availableFiles(skillVersion.getId()).stream() .filter(f -> f.getFilePath().equals(filePath)) .findFirst() .orElseThrow(() -> new DomainBadRequestException("error.skill.file.notFound", filePath)); } + private List availableFiles(Long versionId) { + return skillFileRepository.findByVersionId(versionId).stream() + .filter(file -> objectStorageService.exists(file.getStorageKey())) + .toList(); + } + + private String getBundleStorageKey(Long skillId, Long versionId) { + return String.format("packages/%d/%d/bundle.zip", skillId, versionId); + } + private InputStream readFileContent(SkillFile file) { try { return objectStorageService.getObject(file.getStorageKey()); 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 59d3c432..7b25987d 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 @@ -278,6 +278,7 @@ class SkillQueryServiceTest { when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1)); + when(objectStorageService.exists("key1")).thenReturn(true); // Act List result = service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles); @@ -335,6 +336,7 @@ class SkillQueryServiceTest { when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file)); + when(objectStorageService.exists(file.getStorageKey())).thenReturn(true); when(objectStorageService.getObject(file.getStorageKey())) .thenThrow(new UncheckedIOException(new java.io.FileNotFoundException(file.getStorageKey()))); @@ -345,6 +347,48 @@ class SkillQueryServiceTest { assertArrayEquals(new Object[]{filePath}, ex.messageArgs()); } + @Test + void testListFiles_ShouldHideEntriesWhoseStorageObjectIsMissing() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + 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, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); + setId(skillVersion, 1L); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile availableFile = new SkillFile(1L, "SKILL.md", 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md"); + SkillFile missingFile = new SkillFile(1L, "_meta.json", 100L, "application/json", "hash2", "skills/1/1/_meta.json"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile)); + when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true); + when(objectStorageService.exists("skills/1/1/_meta.json")).thenReturn(false); + + List result = service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles); + + assertEquals(1, result.size()); + assertEquals("SKILL.md", result.get(0).getFilePath()); + } + + @Test + void testIsDownloadAvailable_ShouldReturnFalseWhenBundleIsMissing() throws Exception { + SkillVersion version = new SkillVersion(1L, "1.0.0", "user-100"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + assertFalse(service.isDownloadAvailable(version)); + verify(objectStorageService).exists("packages/1/10/bundle.zip"); + } + @Test void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception { String namespaceSlug = "test-ns"; @@ -402,6 +446,7 @@ class SkillQueryServiceTest { when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + when(objectStorageService.exists("storage-key")).thenReturn(true); List result = service.listFilesByTag(namespaceSlug, skillSlug, "latest", "user-100", userNsRoles); @@ -804,6 +849,7 @@ class SkillQueryServiceTest { when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + when(objectStorageService.exists("storage-key")).thenReturn(true); List result = service.listFiles(namespaceSlug, skillSlug, version, ownerId, userNsRoles); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 954bc531..42d85ddf 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -181,6 +181,7 @@ export interface SkillVersion { fileCount: number totalSize: number publishedAt: string + downloadAvailable: boolean } export interface SkillVersionDetail { diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 4187cb8d..8538f68c 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -109,7 +109,7 @@ export function SkillDetailPage() { const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW' const canInteract = skill?.canInteract ?? true - const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' + const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false) const refreshSkill = () => { queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] })