diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/HiddenSkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/HiddenSkillQueryRepository.java new file mode 100644 index 00000000..d8ac3056 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/HiddenSkillQueryRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.skill.Skill; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** Query-side access for the platform hidden-skill governance list. */ +public interface HiddenSkillQueryRepository { + Page search(String keyword, Long namespaceId, Pageable pageable); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaHiddenSkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaHiddenSkillQueryRepository.java new file mode 100644 index 00000000..f59c7211 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaHiddenSkillQueryRepository.java @@ -0,0 +1,72 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.skill.Skill; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +/** + * Executes the pageable, presentation-specific hidden-skill search without widening the domain + * repository with platform-governance query concerns. + */ +@Repository +public class JpaHiddenSkillQueryRepository implements HiddenSkillQueryRepository { + + private final EntityManager entityManager; + + public JpaHiddenSkillQueryRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Page search(String keyword, Long namespaceId, Pageable pageable) { + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + + CriteriaQuery query = builder.createQuery(Skill.class); + Root root = query.from(Skill.class); + query.select(root) + .where(buildPredicates(keyword, namespaceId, builder, root).toArray(Predicate[]::new)) + .orderBy(builder.desc(root.get("updatedAt"))); + TypedQuery typedQuery = entityManager.createQuery(query); + typedQuery.setFirstResult((int) pageable.getOffset()); + typedQuery.setMaxResults(pageable.getPageSize()); + + CriteriaQuery countQuery = builder.createQuery(Long.class); + Root countRoot = countQuery.from(Skill.class); + countQuery.select(builder.count(countRoot)) + .where(buildPredicates(keyword, namespaceId, builder, countRoot).toArray(Predicate[]::new)); + + return new PageImpl<>(typedQuery.getResultList(), pageable, entityManager.createQuery(countQuery).getSingleResult()); + } + + private List buildPredicates(String keyword, + Long namespaceId, + CriteriaBuilder builder, + Root root) { + List predicates = new ArrayList<>(); + predicates.add(builder.isTrue(root.get("hidden"))); + if (namespaceId != null) { + predicates.add(builder.equal(root.get("namespaceId"), namespaceId)); + } + if (StringUtils.hasText(keyword)) { + String pattern = "%" + keyword.trim().toLowerCase(Locale.ROOT) + "%"; + predicates.add(builder.or( + builder.like(builder.lower(root.get("displayName")), pattern), + builder.like(builder.lower(root.get("slug")), pattern), + builder.like(builder.lower(root.get("summary")), pattern) + )); + } + return predicates; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index 2ed0a5c5..b60f01fd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -63,6 +63,33 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { .toList(); } + @Override + public List getHiddenSkillSummaries(List skills) { + if (skills.isEmpty()) { + return List.of(); + } + Map namespacesById = namespaceRepository.findByIdIn( + skills.stream().map(Skill::getNamespaceId).distinct().toList()) + .stream() + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + Map ownersById = userAccountRepository == null + ? Map.of() + : userAccountRepository.findByIdIn(skills.stream().map(Skill::getOwnerId).distinct().toList()) + .stream().collect(Collectors.toMap(UserAccount::getId, Function.identity())); + Map projections = + skillLifecycleProjectionService.projectPublishedSummaries(skills); + + return skills.stream() + .map(skill -> toSummaryResponse( + skill, + namespacesById, + ownersById, + projections.get(skill.getId()), + false + )) + .toList(); + } + private SkillSummaryResponse toSummaryResponse(Skill skill, String currentUserId, Map namespacesById, @@ -76,6 +103,21 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { if (skill.getOwnerId().equals(currentUserId)) { projection = skillLifecycleProjectionService.projectForOwnerSummary(skill); } + return toSummaryResponse( + skill, + namespacesById, + ownersById, + projection, + canSubmitPromotion(skill, projection.publishedVersion(), namespace) + ); + } + + private SkillSummaryResponse toSummaryResponse(Skill skill, + Map namespacesById, + Map ownersById, + SkillLifecycleProjectionService.Projection projection, + boolean canSubmitPromotion) { + Namespace namespace = namespacesById.get(skill.getNamespaceId()); SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion(); SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion(); SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion(); @@ -97,7 +139,7 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { ownersById.get(skill.getOwnerId()) != null ? ownersById.get(skill.getOwnerId()).getDisplayName() : null, - canSubmitPromotion(skill, publishedVersion, namespace), + canSubmitPromotion, toLifecycleVersion(headlineVersion), toLifecycleVersion(publishedVersion), toLifecycleVersion(ownerPreviewVersion), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java index bdafe81c..a45b6821 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillQueryRepository.java @@ -10,4 +10,6 @@ import java.util.List; */ public interface MySkillQueryRepository { List getSkillSummaries(List skills, String currentUserId); + + List getHiddenSkillSummaries(List skills); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index e36dc201..7e96572e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.repository.HiddenSkillQueryRepository; import com.iflytek.skillhub.repository.MySkillQueryRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -37,6 +38,7 @@ public class MySkillAppService { private final SkillStarRepository skillStarRepository; private final SkillSubscriptionRepository skillSubscriptionRepository; private final MySkillQueryRepository mySkillQueryRepository; + private final HiddenSkillQueryRepository hiddenSkillQueryRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; private final NamespaceRepository namespaceRepository; @@ -46,6 +48,7 @@ public class MySkillAppService { SkillStarRepository skillStarRepository, SkillSubscriptionRepository skillSubscriptionRepository, MySkillQueryRepository mySkillQueryRepository, + HiddenSkillQueryRepository hiddenSkillQueryRepository, SkillLifecycleProjectionService skillLifecycleProjectionService, NamespaceRepository namespaceRepository) { this.skillRepository = skillRepository; @@ -53,6 +56,7 @@ public class MySkillAppService { this.skillStarRepository = skillStarRepository; this.skillSubscriptionRepository = skillSubscriptionRepository; this.mySkillQueryRepository = mySkillQueryRepository; + this.hiddenSkillQueryRepository = hiddenSkillQueryRepository; this.skillLifecycleProjectionService = skillLifecycleProjectionService; this.namespaceRepository = namespaceRepository; } @@ -79,19 +83,36 @@ public class MySkillAppService { MySkillFilter normalizedFilter = parseFilter(filter); Page skillPage; - if (normalizedFilter == MySkillFilter.ALL + if (normalizedFilter == MySkillFilter.HIDDEN) { + skillPage = listHiddenSkills(page, size, keyword, namespace, platformRoles); + } else if (normalizedFilter == MySkillFilter.ALL && (keyword == null || keyword.isBlank()) && (namespace == null || namespace.isBlank())) { - skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size)); + skillPage = skillRepository.findVisibleByOwnerId(userId, PageRequest.of(page, size)); } else { skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles); } - List items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId); + List items = normalizedFilter == MySkillFilter.HIDDEN + ? mySkillQueryRepository.getHiddenSkillSummaries(skillPage.getContent()) + : mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId); return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); } + private Page listHiddenSkills(int page, + int size, + String keyword, + String namespace, + java.util.Set platformRoles) { + PageRequest pageable = PageRequest.of(page, size); + if (!platformRoles.contains("SUPER_ADMIN")) { + return Page.empty(pageable); + } + Long namespaceId = resolveNamespaceId(namespace); + return hiddenSkillQueryRepository.search(normalizeKeyword(keyword), namespaceId, pageable); + } + public PageResponse listMyStars(String userId, int page, int size) { Page starPage = skillStarRepository.findByUserId( userId, @@ -150,17 +171,10 @@ public class MySkillAppService { List skills = skillRepository.findByOwnerId(userId); // Namespace filter - Long namespaceId = null; - if (namespace != null && !namespace.isBlank()) { - namespaceId = namespaceRepository.findBySlug(namespace.trim()) - .map(Namespace::getId) - .orElse(-1L); - } + Long namespaceId = resolveNamespaceId(namespace); final Long finalNamespaceId = namespaceId; - String normalizedKeyword = keyword != null && !keyword.isBlank() - ? keyword.trim().toLowerCase(java.util.Locale.ROOT) - : null; + String normalizedKeyword = normalizeKeyword(keyword); List filtered = skills.stream() .filter(skill -> matchesNamespace(skill, finalNamespaceId)) @@ -168,13 +182,28 @@ public class MySkillAppService { .filter(skill -> matchesFilter(skill, filter, platformRoles)) .toList(); - int fromIndex = Math.min(page * size, filtered.size()); - int toIndex = Math.min(fromIndex + size, filtered.size()); - return new PageImpl<>( - filtered.subList(fromIndex, toIndex), - PageRequest.of(page, size), - filtered.size() - ); + return page(filtered, page, size); + } + + private Long resolveNamespaceId(String namespace) { + if (namespace == null || namespace.isBlank()) { + return null; + } + return namespaceRepository.findBySlug(namespace.trim()) + .map(Namespace::getId) + .orElse(-1L); + } + + private String normalizeKeyword(String keyword) { + return keyword != null && !keyword.isBlank() + ? keyword.trim().toLowerCase(java.util.Locale.ROOT) + : null; + } + + private Page page(List skills, int page, int size) { + int fromIndex = Math.min(page * size, skills.size()); + int toIndex = Math.min(fromIndex + size, skills.size()); + return new PageImpl<>(skills.subList(fromIndex, toIndex), PageRequest.of(page, size), skills.size()); } private boolean matchesNamespace(Skill skill, Long namespaceId) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java index 48e05639..c739f5b4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepositoryTest.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.repository; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; @@ -132,4 +135,35 @@ class JpaMySkillQueryRepositoryTest { assertThat(responses.get(0).publishedVersion()).isNotNull(); assertThat(responses.get(0).canSubmitPromotion()).isFalse(); } + + @Test + void getHiddenSkillSummaries_batchesPublishedVersionProjection() { + Skill first = new Skill(101L, "first-hidden", "owner-1", SkillVisibility.PUBLIC); + Skill second = new Skill(101L, "second-hidden", "owner-2", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(first, "id", 10L); + ReflectionTestUtils.setField(second, "id", 20L); + first.setLatestVersionId(110L); + second.setLatestVersionId(120L); + + SkillVersion firstVersion = new SkillVersion(10L, "1.0.0", "owner-1"); + SkillVersion secondVersion = new SkillVersion(20L, "2.0.0", "owner-2"); + firstVersion.setStatus(SkillVersionStatus.PUBLISHED); + secondVersion.setStatus(SkillVersionStatus.PUBLISHED); + ReflectionTestUtils.setField(firstVersion, "id", 110L); + ReflectionTestUtils.setField(secondVersion, "id", 120L); + + Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 101L); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); + given(skillVersionRepository.findByIdIn(List.of(110L, 120L))) + .willReturn(List.of(firstVersion, secondVersion)); + + var responses = repository.getHiddenSkillSummaries(List.of(first, second)); + + assertThat(responses).extracting("slug").containsExactly("first-hidden", "second-hidden"); + assertThat(responses).allMatch(response -> !response.canSubmitPromotion()); + verify(skillVersionRepository).findByIdIn(List.of(110L, 120L)); + verifyNoMoreInteractions(skillVersionRepository); + verifyNoInteractions(promotionRequestRepository); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index 7c8bbb3e..44acf402 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.social.SkillStar; import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository; import com.iflytek.skillhub.repository.JpaMySkillQueryRepository; +import com.iflytek.skillhub.repository.HiddenSkillQueryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -57,6 +58,9 @@ class MySkillAppServiceTest { @Mock private PromotionRequestRepository promotionRequestRepository; + @Mock + private HiddenSkillQueryRepository hiddenSkillQueryRepository; + private MySkillAppService service; private SkillLifecycleProjectionService skillLifecycleProjectionService; private JpaMySkillQueryRepository mySkillQueryRepository; @@ -75,6 +79,7 @@ class MySkillAppServiceTest { skillStarRepository, skillSubscriptionRepository, mySkillQueryRepository, + hiddenSkillQueryRepository, skillLifecycleProjectionService, namespaceRepository ); @@ -132,7 +137,7 @@ class MySkillAppServiceTest { ReflectionTestUtils.setField(pendingVersion, "id", 11L); ReflectionTestUtils.setField(pendingVersion, "createdAt", Instant.parse("2026-03-15T09:30:00Z")); - given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10))) .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); given(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).willReturn(List.of()); given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(pendingVersion)); @@ -165,7 +170,7 @@ class MySkillAppServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); ReflectionTestUtils.setField(namespace, "id", 101L); - given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10))) .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion)); @@ -197,7 +202,7 @@ class MySkillAppServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); ReflectionTestUtils.setField(namespace, "id", 101L); - given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10))) .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(publishedVersion)); @@ -230,14 +235,12 @@ class MySkillAppServiceTest { } @Test - void listMySkills_filtersHiddenOnlyForSuperAdmins() { - Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "user-1"); + void listMySkills_listsHiddenSkillsAcrossOwnersOnlyForSuperAdmins() { + Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "publisher"); hiddenSkill.setHidden(true); - Skill publishedSkill = createSkill(4L, 101L, "published-skill", "user-1"); - SkillVersion hiddenVersion = createVersion(3L, 33L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); - given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(hiddenSkill, publishedSkill)); - given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of(hiddenVersion)); + given(hiddenSkillQueryRepository.search(null, null, PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(hiddenSkill), PageRequest.of(0, 10), 1)); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); var regularUserResult = service.listMySkills("user-1", 0, 10, "HIDDEN", Set.of("USER")); @@ -248,12 +251,41 @@ class MySkillAppServiceTest { assertThat(superAdminResult.items()).extracting("slug").containsExactly("hidden-skill"); } + @Test + void listMySkills_doesNotExposeHiddenSkillsToSkillAdmins() { + var result = service.listMySkills("skill-admin", 0, 10, "HIDDEN", Set.of("SKILL_ADMIN")); + + assertThat(result.total()).isZero(); + assertThat(result.items()).isEmpty(); + } + + @Test + void listMySkills_delegatesHiddenFilteringAndPaginationToQueryRepository() { + Skill hiddenSkill = createSkill(7L, 101L, "hidden-agent", "publisher"); + hiddenSkill.setHidden(true); + Namespace namespace = namespace(101L, "team-ai"); + PageRequest pageRequest = PageRequest.of(1, 5); + + given(namespaceRepository.findBySlug("team-ai")).willReturn(Optional.of(namespace)); + given(hiddenSkillQueryRepository.search("agent", 101L, pageRequest)) + .willReturn(new PageImpl<>(List.of(hiddenSkill), pageRequest, 6)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); + + var result = service.listMySkills( + "super-admin", 1, 5, "HIDDEN", " Agent ", "team-ai", Set.of("SUPER_ADMIN")); + + assertThat(result.total()).isEqualTo(6); + assertThat(result.page()).isEqualTo(1); + assertThat(result.size()).isEqualTo(5); + assertThat(result.items()).extracting("slug").containsExactly("hidden-agent"); + } + @Test void listMySkills_exposesRejectedOwnerPreviewInSummary() { Skill skill = createSkill(5L, 101L, "rejected-skill", "user-1"); SkillVersion rejectedVersion = createVersion(5L, 55L, "1.1.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); - given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10))) .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); given(skillVersionRepository.findBySkillId(5L)).willReturn(List.of(rejectedVersion)); given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); @@ -272,7 +304,7 @@ class MySkillAppServiceTest { SkillVersion rejectedVersion = createVersion(6L, 60L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); SkillVersion publishedVersion = createVersion(6L, 61L, "2.0.0", SkillVersionStatus.PUBLISHED, "2026-03-16T09:30:00Z"); - given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + given(skillRepository.findVisibleByOwnerId("user-1", PageRequest.of(0, 10))) .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); given(skillVersionRepository.findBySkillIdAndStatus(6L, SkillVersionStatus.PUBLISHED)).willReturn(List.of(publishedVersion)); given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index 520ebed1..c8cf6f49 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -21,7 +21,7 @@ public interface SkillRepository { void flush(); void delete(Skill skill); List findByOwnerId(String ownerId); - Page findByOwnerId(String ownerId, Pageable pageable); + Page findVisibleByOwnerId(String ownerId, Pageable pageable); void incrementDownloadCount(Long skillId); void incrementSubscriptionCount(Long skillId); void decrementSubscriptionCount(Long skillId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index ed209a50..6a59e305 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -83,8 +83,8 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { } @Override - public Page findByOwnerId(String ownerId, Pageable pageable) { - return delegate.findByOwnerId(ownerId, pageable); + public Page findVisibleByOwnerId(String ownerId, Pageable pageable) { + return delegate.findVisibleByOwnerId(ownerId, pageable); } @Override diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index 1b2e0246..2821c3ff 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -33,11 +33,11 @@ public interface SkillJpaRepository extends JpaRepository, SkillRep List findByNamespaceIdAndStatusOrderByCreatedAtDesc(Long namespaceId, SkillStatus status); Page findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable); List findByOwnerId(String ownerId); - Page findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable); + Page findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(String ownerId, Pageable pageable); @Override - default Page findByOwnerId(String ownerId, Pageable pageable) { - return findByOwnerIdOrderByUpdatedAtDesc(ownerId, pageable); + default Page findVisibleByOwnerId(String ownerId, Pageable pageable) { + return findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(ownerId, pageable); } @Modifying diff --git a/web/src/features/admin/use-admin-skills.ts b/web/src/features/admin/use-admin-skills.ts new file mode 100644 index 00000000..4ca3053e --- /dev/null +++ b/web/src/features/admin/use-admin-skills.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { adminApi } from '@/api/client' + +export function useRestoreHiddenSkill() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (skillId: number) => adminApi.unhideSkill(skillId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5cae8447..ae77f032 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -386,6 +386,12 @@ "unarchiveSuccessTitle": "Skill restored", "unarchiveSuccessDescription": "\"{{skill}}\" has been restored and can publish new versions again.", "unarchiveErrorTitle": "Failed to restore skill", + "restoreHidden": "Restore visibility", + "restoreHiddenConfirmTitle": "Restore hidden skill", + "restoreHiddenConfirmDescription": "\"{{skill}}\" will return to ordinary product surfaces.", + "restoreHiddenSuccessTitle": "Skill visibility restored", + "restoreHiddenSuccessDescription": "\"{{skill}}\" was removed from the hidden skill list.", + "restoreHiddenErrorTitle": "Failed to restore hidden skill", "withdrawReview": "Withdraw Review", "withdrawConfirmTitle": "Withdraw upload", "withdrawConfirmDescription": "After withdrawal, \"{{skill}}\" will no longer be reviewed and the pending version will be deleted.", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index fc2299c6..cb32b413 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -386,6 +386,12 @@ "unarchiveSuccessTitle": "Скилл восстановлен", "unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.", "unarchiveErrorTitle": "Не удалось восстановить скилл", + "restoreHidden": "Вернуть видимость", + "restoreHiddenConfirmTitle": "Восстановить скрытый скилл", + "restoreHiddenConfirmDescription": "«{{skill}}» снова появится в обычных разделах продукта.", + "restoreHiddenSuccessTitle": "Видимость скилла восстановлена", + "restoreHiddenSuccessDescription": "«{{skill}}» удалён из списка скрытых скиллов.", + "restoreHiddenErrorTitle": "Не удалось восстановить скрытый скилл", "withdrawReview": "Отозвать рецензию", "withdrawConfirmTitle": "Отозвать загрузку", "withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a49969b8..5e67b612 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -386,6 +386,12 @@ "unarchiveSuccessTitle": "技能已恢复", "unarchiveSuccessDescription": "“{{skill}}”已恢复,可继续发布新版本。", "unarchiveErrorTitle": "恢复技能失败", + "restoreHidden": "恢复显示", + "restoreHiddenConfirmTitle": "确认恢复隐藏技能", + "restoreHiddenConfirmDescription": "恢复后“{{skill}}”会重新进入普通用户可见的产品入口。", + "restoreHiddenSuccessTitle": "技能已恢复显示", + "restoreHiddenSuccessDescription": "“{{skill}}”已从隐藏技能列表移除。", + "restoreHiddenErrorTitle": "恢复隐藏技能失败", "withdrawReview": "撤销审核", "withdrawConfirmTitle": "确认撤销上传", "withdrawConfirmDescription": "撤销后“{{skill}}”将不再进入审核流程,当前待审核版本会被删除。", diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index c82faf93..80b38107 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -5,11 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const navigateMock = vi.fn() const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => void }) => void) | undefined }> = [] const useMySkillsMock = vi.fn() +const searchMock: { filter?: string } = {} +let isSuperAdmin = false vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, useLocation: () => ({ pathname: '/dashboard/skills' }), - useSearch: () => ({}), + useSearch: () => searchMock, })) vi.mock('react-i18next', async () => { @@ -23,7 +25,7 @@ vi.mock('react-i18next', async () => { }) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: () => false }), + useAuth: () => ({ hasRole: (role: string) => role === 'SUPER_ADMIN' && isSuperAdmin }), })) vi.mock('@/shared/ui/button', () => ({ @@ -79,6 +81,10 @@ vi.mock('@/shared/hooks/use-debounce', () => ({ useDebounce: (value: string) => value, })) +vi.mock('@/features/admin/use-admin-skills', () => ({ + useRestoreHiddenSkill: () => ({ mutateAsync: vi.fn() }), +})) + vi.mock('@/shared/lib/skill-lifecycle', () => ({ getHeadlineVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), getPublishedVersion: () => ({ id: 11, version: '1.0.0', status: 'PUBLISHED' }), @@ -114,6 +120,8 @@ describe('MySkillsPage', () => { beforeEach(() => { navigateMock.mockReset() buttonRecords.length = 0 + delete searchMock.filter + isSuperAdmin = false useMySkillsMock.mockReturnValue({ data: { items: [ @@ -216,6 +224,25 @@ describe('MySkillsPage', () => { }) }) + it('shows a direct restore action instead of owner actions in the hidden governance view', () => { + searchMock.filter = 'HIDDEN' + isSuperAdmin = true + + renderToStaticMarkup(createElement(MySkillsPage)) + + expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(true) + expect(buttonRecords.some((button) => button.label === 'mySkills.update')).toBe(false) + expect(buttonRecords.some((button) => button.label === 'mySkills.archive')).toBe(false) + }) + + it('does not render the restore action for a non-super-admin even when the hidden filter is in the URL', () => { + searchMock.filter = 'HIDDEN' + + renderToStaticMarkup(createElement(MySkillsPage)) + + expect(buttonRecords.some((button) => button.label === 'mySkills.restoreHidden')).toBe(false) + }) + it('exports a named component function', () => { expect(typeof MySkillsPage).toBe('function') }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 4dbe4765..ec0901b0 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -14,6 +14,7 @@ import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/sh import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries' import { useDebounce } from '@/shared/hooks/use-debounce' +import { useRestoreHiddenSkill } from '@/features/admin/use-admin-skills' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' @@ -63,6 +64,7 @@ export function MySkillsPage() { const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) + const [restoreHiddenTarget, setRestoreHiddenTarget] = useState<{ skillId: number; name: string } | null>(null) const updateSearch = useCallback((next: Partial, options?: { replace?: boolean }) => { navigate({ @@ -96,12 +98,14 @@ export function MySkillsPage() { const skills = skillPage?.items ?? [] const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1 const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN')) + const canRestoreHidden = filter === 'HIDDEN' && hasRole('SUPER_ADMIN') const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== '' const emptyStateKey = getMySkillEmptyStateKey(filter) const archiveMutation = useArchiveSkill() const unarchiveMutation = useUnarchiveSkill() const withdrawMutation = useWithdrawSkillReview() const submitPromotionMutation = useSubmitPromotion() + const restoreHiddenMutation = useRestoreHiddenSkill() const handleSkillClick = (namespace: string, slug: string) => { navigate({ @@ -270,6 +274,23 @@ export function MySkillsPage() { } } + const handleRestoreHiddenSkill = async () => { + if (!restoreHiddenTarget) { + return + } + try { + await restoreHiddenMutation.mutateAsync(restoreHiddenTarget.skillId) + toast.success( + t('mySkills.restoreHiddenSuccessTitle'), + t('mySkills.restoreHiddenSuccessDescription', { skill: restoreHiddenTarget.name }), + ) + setRestoreHiddenTarget(null) + } catch (error) { + toast.error(t('mySkills.restoreHiddenErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + if (isLoading) { return (
@@ -302,24 +323,26 @@ export function MySkillsPage() { aria-label={t('mySkills.searchPlaceholder')} className="sm:max-w-md" /> - + {filter === 'HIDDEN' ? null : ( + + )} {hasActiveSearch ? ( + ) : filter === 'HIDDEN' ? null : skill.status !== 'ARCHIVED' && (
@@ -515,6 +551,19 @@ export function MySkillsPage() { /> )} + { + if (!open) { + setRestoreHiddenTarget(null) + } + }} + title={t('mySkills.restoreHiddenConfirmTitle')} + description={restoreHiddenTarget ? t('mySkills.restoreHiddenConfirmDescription', { skill: restoreHiddenTarget.name }) : ''} + confirmText={t('mySkills.restoreHidden')} + onConfirm={handleRestoreHiddenSkill} + /> + {