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 3e480060..6d582cae 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 @@ -70,10 +70,12 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) java.util.Set platformRoles) { SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(), + platformRoles != null ? platformRoles : java.util.Set.of()); SkillDetailResponse response = new SkillDetailResponse( detail.id(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 366038ee..d4df24c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -73,6 +73,7 @@ public class AuthContextFilter extends OncePerRequestFilter { return; } request.setAttribute("userId", principal.userId()); + request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of()); Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( NamespaceMember::getNamespaceId, 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 677336c7..800c6a7a 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 @@ -22,6 +22,7 @@ import java.util.Map; import java.util.TimeZone; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -148,7 +149,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + org.mockito.ArgumentMatchers.>any())) .thenReturn(new SkillQueryService.SkillDetailDTO( 1L, "demo", @@ -195,7 +197,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + org.mockito.ArgumentMatchers.>any())) .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); mockMvc.perform(get("/api/web/skills/team/demo")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index 53c8367a..ae5fd27b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -4,6 +4,9 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.mockito.ArgumentMatchers; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; @@ -69,7 +72,7 @@ class SkillPublishControllerTest { given(skillPublishService.publishFromEntries( eq("global"), - anyList(), + ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), @@ -120,7 +123,7 @@ class SkillPublishControllerTest { given(skillPublishService.publishFromEntries( eq("global"), - anyList(), + ArgumentMatchers.>any(), eq("usr_1"), eq(SkillVisibility.PUBLIC), eq(Set.of("SUPER_ADMIN")), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index f4632159..65c8e753 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import java.util.Map; +import java.util.Set; /** * Evaluates whether a caller may read a skill based on publication state, visibility, ownership, @@ -11,6 +12,13 @@ import java.util.Map; public class VisibilityChecker { public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles) { + return canAccess(skill, currentUserId, userNamespaceRoles, Set.of()); + } + + public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles, Set platformRoles) { + if (isSuperAdmin(platformRoles)) { + return true; + } if (skill.isHidden()) { return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); } @@ -31,4 +39,8 @@ public class VisibilityChecker { private boolean isAdminOrAbove(NamespaceRole role) { return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; } + + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } } 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 0a64260c..2f01d5fd 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 @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -149,12 +150,28 @@ public class SkillQueryService { String skillSlug, String currentUserId, Map userNsRoles) { + return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public SkillDetailDTO getSkillDetail( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); + // Archived namespace: only members (or super admins) may view + if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespaceSlug); + } + // Visibility check - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles, platformRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } @@ -186,7 +203,7 @@ public class SkillQueryService { skill.getNamespaceId(), skill.getCreatedAt(), skill.getUpdatedAt(), - canManageRestrictedSkill(skill, currentUserId, userNsRoles), + canManageRestrictedSkill(skill, currentUserId, userNsRoles, platformRoles), canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles), headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()), currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId), @@ -630,6 +647,13 @@ public class SkillQueryService { } private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles) { + return canManageRestrictedSkill(skill, currentUserId, userNsRoles, Set.of()); + } + + private boolean canManageRestrictedSkill(Skill skill, String currentUserId, Map userNsRoles, Set platformRoles) { + if (platformRoles != null && platformRoles.contains("SUPER_ADMIN")) { + return true; + } if (currentUserId == null) { return false; } @@ -671,6 +695,10 @@ public class SkillQueryService { return currentUserId != null && userNsRoles.containsKey(namespaceId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private String resolveOwnerPreviewReviewComment(SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion) { if (ownerPreviewVersion == null || !"REJECTED".equals(ownerPreviewVersion.status())) { return null; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 1ffe5c8f..af26901a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -158,4 +159,34 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles); assertTrue(canAccess); } + + @Test + void testSuperAdminCanAccessPrivateSkill() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testSuperAdminCanAccessHiddenSkill() { + boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testSuperAdminCanAccessUnpublishedSkill() { + boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN")); + assertTrue(canAccess); + } + + @Test + void testNonSuperAdminPlatformRolesDoNotGrantAccess() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("REVIEWER")); + assertFalse(canAccess); + } + + @Test + void testEmptyPlatformRolesDoNotGrantAccess() { + boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of()); + assertFalse(canAccess); + } } 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 a8c8042f..ab0b4aba 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 @@ -30,6 +30,7 @@ import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -50,7 +51,6 @@ class SkillQueryServiceTest { private SkillTagRepository skillTagRepository; @Mock private ObjectStorageService objectStorageService; - @Mock private VisibilityChecker visibilityChecker; @Mock private PromotionRequestRepository promotionRequestRepository; @@ -65,6 +65,7 @@ class SkillQueryServiceTest { @BeforeEach void setUp() { + visibilityChecker = new VisibilityChecker(); skillSlugResolutionService = new SkillSlugResolutionService(skillRepository); skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository); service = new SkillQueryService( @@ -105,7 +106,6 @@ class SkillQueryServiceTest { 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.findById(10L)).thenReturn(Optional.of(version)); when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null))); @@ -148,7 +148,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill)); - when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -176,7 +175,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false); // Act & Assert assertThrows(DomainForbiddenException.class, () -> @@ -215,13 +213,11 @@ class SkillQueryServiceTest { setId(namespace, 1L); Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC); setId(skill1, 1L); - Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE); + Skill skill2 = new Skill(1L, "skill2", "user-200", SkillVisibility.PRIVATE); setId(skill2, 2L); when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2)); - when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false); // Act Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -267,8 +263,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) .thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill)); - when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false); Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -296,8 +290,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) .thenReturn(List.of(visibleSkill, hiddenSkill)); - when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true); - when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false); Page result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable); @@ -325,7 +317,6 @@ class SkillQueryServiceTest { 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(file1)); when(objectStorageService.exists("key1")).thenReturn(true); @@ -358,7 +349,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, callerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); assertThrows(DomainBadRequestException.class, () -> @@ -385,7 +375,6 @@ class SkillQueryServiceTest { 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(file)); when(objectStorageService.exists(file.getStorageKey())).thenReturn(true); @@ -419,7 +408,6 @@ class SkillQueryServiceTest { 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); @@ -482,7 +470,6 @@ class SkillQueryServiceTest { 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)); SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( @@ -516,7 +503,6 @@ class SkillQueryServiceTest { 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.findById(11L)).thenReturn(Optional.of(latestVersion)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); when(objectStorageService.exists("storage-key")).thenReturn(true); @@ -555,7 +541,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected)); Page result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20)); @@ -589,7 +574,6 @@ class SkillQueryServiceTest { 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.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of(version100, version110)); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110)); @@ -631,7 +615,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version)); when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); @@ -664,7 +647,6 @@ class SkillQueryServiceTest { 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); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -691,7 +673,6 @@ class SkillQueryServiceTest { 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.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty()); @@ -723,7 +704,6 @@ class SkillQueryServiceTest { 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.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); @@ -753,7 +733,6 @@ class SkillQueryServiceTest { 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.findById(11L)).thenReturn(Optional.of(published)); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)) @@ -784,7 +763,6 @@ class SkillQueryServiceTest { 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.findById(11L)).thenReturn(Optional.of(published)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -813,7 +791,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(pending)); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) .thenReturn(List.of()); @@ -853,7 +830,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); @@ -889,7 +865,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(rejected)); when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of()); when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected)); @@ -925,7 +900,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( @@ -960,7 +934,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - 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); @@ -992,7 +965,6 @@ class SkillQueryServiceTest { when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); - when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); assertThrows(DomainBadRequestException.class, () -> @@ -1024,7 +996,6 @@ class SkillQueryServiceTest { 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.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published)); Page result = service.listVersions( @@ -1059,7 +1030,6 @@ class SkillQueryServiceTest { 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.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published)); Page result = service.listVersions( diff --git a/web/src/features/skill/version-status-badge.tsx b/web/src/features/skill/version-status-badge.tsx new file mode 100644 index 00000000..0a518b04 --- /dev/null +++ b/web/src/features/skill/version-status-badge.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' + +type VersionStatus = + | 'DRAFT' + | 'SCANNING' + | 'SCAN_FAILED' + | 'UPLOADED' + | 'PENDING_REVIEW' + | 'PUBLISHED' + | 'REJECTED' + | 'YANKED' + +const statusStyles: Record = { + PUBLISHED: + 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400', + UPLOADED: + 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400', + PENDING_REVIEW: + 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400', + REJECTED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + SCANNING: + 'border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-400', + SCAN_FAILED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + YANKED: + 'border-border/60 bg-secondary/40 text-muted-foreground', + DRAFT: + 'border-border/60 bg-secondary/40 text-muted-foreground', +} + +const i18nKeys: Record = { + DRAFT: 'skillDetail.versionStatusDraft', + SCANNING: 'skillDetail.versionStatusScanning', + SCAN_FAILED: 'skillDetail.versionStatusScanFailed', + UPLOADED: 'skillDetail.versionStatusUploaded', + PENDING_REVIEW: 'skillDetail.versionStatusPendingReview', + PUBLISHED: 'skillDetail.versionStatusPublished', + REJECTED: 'skillDetail.versionStatusRejected', + YANKED: 'skillDetail.versionStatusYanked', +} + +/** Color-coded row styles (left-border + subtle background) for version cards. */ +export const versionRowStyles: Record = { + UPLOADED: + 'border-l-[3px] !border-l-blue-500 bg-blue-500/[0.03]', + PENDING_REVIEW: + 'border-l-[3px] !border-l-amber-500 bg-amber-500/[0.03]', + REJECTED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + SCANNING: + 'border-l-[3px] !border-l-purple-500 bg-purple-500/[0.03]', + SCAN_FAILED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + PUBLISHED: '', + YANKED: '', + DRAFT: '', +} + +export function getVersionRowStyle(status?: string): string { + if (!status) return '' + return versionRowStyles[status as VersionStatus] ?? '' +} + +export function VersionStatusBadge({ + status, + className, +}: { + status?: string + className?: string +}) { + const { t } = useTranslation() + if (!status) return null + + const style = statusStyles[status as VersionStatus] ?? statusStyles.DRAFT + const label = i18nKeys[status as VersionStatus] + ? t(i18nKeys[status as VersionStatus]) + : status + + return ( + + {label} + + ) +}