mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-09 22:31:14 +00:00
fix: degrade gracefully for missing skill storage assets
This commit is contained in:
parent
c8ba86e8a2
commit
83606ef930
7 changed files with 94 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ public record SkillVersionResponse(
|
|||
String changelog,
|
||||
int fileCount,
|
||||
long totalSize,
|
||||
LocalDateTime publishedAt
|
||||
LocalDateTime publishedAt,
|
||||
boolean downloadAvailable
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ export interface SkillVersion {
|
|||
fileCount: number
|
||||
totalSize: number
|
||||
publishedAt: string
|
||||
downloadAvailable: boolean
|
||||
}
|
||||
|
||||
export interface SkillVersionDetail {
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue