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/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 5541ace7..aff1d07f 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; } @@ -103,17 +107,8 @@ public class MySkillAppService { if (!platformRoles.contains("SUPER_ADMIN")) { return Page.empty(pageable); } - if ((keyword == null || keyword.isBlank()) && (namespace == null || namespace.isBlank())) { - return skillRepository.findByHiddenTrue(pageable); - } - Long namespaceId = resolveNamespaceId(namespace); - String normalizedKeyword = normalizeKeyword(keyword); - List filtered = skillRepository.findByHiddenTrue().stream() - .filter(skill -> matchesNamespace(skill, namespaceId)) - .filter(skill -> matchesKeyword(skill, normalizedKeyword)) - .toList(); - return page(filtered, page, size); + return hiddenSkillQueryRepository.search(normalizeKeyword(keyword), namespaceId, pageable); } public PageResponse listMyStars(String userId, int page, int size) { 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 8316af4b..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 ); @@ -234,7 +239,7 @@ class MySkillAppServiceTest { Skill hiddenSkill = createSkill(3L, 101L, "hidden-skill", "publisher"); hiddenSkill.setHidden(true); - given(skillRepository.findByHiddenTrue(PageRequest.of(0, 10))) + 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"))); @@ -254,6 +259,27 @@ class MySkillAppServiceTest { 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"); 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 a22a5371..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 @@ -22,8 +22,6 @@ public interface SkillRepository { void delete(Skill skill); List findByOwnerId(String ownerId); Page findVisibleByOwnerId(String ownerId, Pageable pageable); - List findByHiddenTrue(); - Page findByHiddenTrue(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 1452d89b..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 @@ -87,16 +87,6 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { return delegate.findVisibleByOwnerId(ownerId, pageable); } - @Override - public List findByHiddenTrue() { - return delegate.findByHiddenTrue(); - } - - @Override - public Page findByHiddenTrue(Pageable pageable) { - return delegate.findByHiddenTrue(pageable); - } - @Override public void incrementDownloadCount(Long skillId) { delegate.incrementDownloadCount(skillId); 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 33e7aa49..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 @@ -34,24 +34,12 @@ public interface SkillJpaRepository extends JpaRepository, SkillRep Page findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable); List findByOwnerId(String ownerId); Page findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(String ownerId, Pageable pageable); - List findByHiddenTrueOrderByUpdatedAtDesc(); - Page findByHiddenTrueOrderByUpdatedAtDesc(Pageable pageable); @Override default Page findVisibleByOwnerId(String ownerId, Pageable pageable) { return findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(ownerId, pageable); } - @Override - default List findByHiddenTrue() { - return findByHiddenTrueOrderByUpdatedAtDesc(); - } - - @Override - default Page findByHiddenTrue(Pageable pageable) { - return findByHiddenTrueOrderByUpdatedAtDesc(pageable); - } - @Modifying @Transactional @Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId") diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index 5833836d..80b38107 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -235,6 +235,14 @@ describe('MySkillsPage', () => { 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 a3a739f2..ec0901b0 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -98,6 +98,7 @@ 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() @@ -322,24 +323,26 @@ export function MySkillsPage() { aria-label={t('mySkills.searchPlaceholder')} className="sm:max-w-md" /> - + {filter === 'HIDDEN' ? null : ( + + )} {hasActiveSearch ? ( - ) : skill.status !== 'ARCHIVED' && ( + ) : filter === 'HIDDEN' ? null : skill.status !== 'ARCHIVED' && (