fix: degrade gracefully for missing skill storage assets

This commit is contained in:
yun-zhi-ztl 2026-03-17 13:56:39 +08:00
parent c8ba86e8a2
commit 83606ef930
7 changed files with 94 additions and 6 deletions

View file

@ -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);

View file

@ -9,5 +9,6 @@ public record SkillVersionResponse(
String changelog,
int fileCount,
long totalSize,
LocalDateTime publishedAt
LocalDateTime publishedAt,
boolean downloadAvailable
) {}

View file

@ -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.<Long, NamespaceRole>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));
}
}

View file

@ -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<SkillFile> 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<SkillFile> 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());

View file

@ -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<SkillFile> 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<Long, NamespaceRole> 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<SkillFile> 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<SkillFile> 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<SkillFile> result = service.listFiles(namespaceSlug, skillSlug, version, ownerId, userNsRoles);

View file

@ -181,6 +181,7 @@ export interface SkillVersion {
fileCount: number
totalSize: number
publishedAt: string
downloadAvailable: boolean
}
export interface SkillVersionDetail {

View file

@ -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] })