refactor(governance): page hidden skill queries

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-08 11:27:36 +08:00
parent 927780db46
commit 4efd6c6366
9 changed files with 145 additions and 55 deletions

View file

@ -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<Skill> search(String keyword, Long namespaceId, Pageable pageable);
}

View file

@ -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<Skill> search(String keyword, Long namespaceId, Pageable pageable) {
CriteriaBuilder builder = entityManager.getCriteriaBuilder();
CriteriaQuery<Skill> query = builder.createQuery(Skill.class);
Root<Skill> root = query.from(Skill.class);
query.select(root)
.where(buildPredicates(keyword, namespaceId, builder, root).toArray(Predicate[]::new))
.orderBy(builder.desc(root.get("updatedAt")));
TypedQuery<Skill> typedQuery = entityManager.createQuery(query);
typedQuery.setFirstResult((int) pageable.getOffset());
typedQuery.setMaxResults(pageable.getPageSize());
CriteriaQuery<Long> countQuery = builder.createQuery(Long.class);
Root<Skill> 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<Predicate> buildPredicates(String keyword,
Long namespaceId,
CriteriaBuilder builder,
Root<Skill> root) {
List<Predicate> 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;
}
}

View file

@ -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<Skill> 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<SkillSummaryResponse> listMyStars(String userId, int page, int size) {

View file

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

View file

@ -22,8 +22,6 @@ public interface SkillRepository {
void delete(Skill skill);
List<Skill> findByOwnerId(String ownerId);
Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable);
List<Skill> findByHiddenTrue();
Page<Skill> findByHiddenTrue(Pageable pageable);
void incrementDownloadCount(Long skillId);
void incrementSubscriptionCount(Long skillId);
void decrementSubscriptionCount(Long skillId);

View file

@ -87,16 +87,6 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
return delegate.findVisibleByOwnerId(ownerId, pageable);
}
@Override
public List<Skill> findByHiddenTrue() {
return delegate.findByHiddenTrue();
}
@Override
public Page<Skill> findByHiddenTrue(Pageable pageable) {
return delegate.findByHiddenTrue(pageable);
}
@Override
public void incrementDownloadCount(Long skillId) {
delegate.incrementDownloadCount(skillId);

View file

@ -34,24 +34,12 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
List<Skill> findByOwnerId(String ownerId);
Page<Skill> findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(String ownerId, Pageable pageable);
List<Skill> findByHiddenTrueOrderByUpdatedAtDesc();
Page<Skill> findByHiddenTrueOrderByUpdatedAtDesc(Pageable pageable);
@Override
default Page<Skill> findVisibleByOwnerId(String ownerId, Pageable pageable) {
return findByOwnerIdAndHiddenFalseOrderByUpdatedAtDesc(ownerId, pageable);
}
@Override
default List<Skill> findByHiddenTrue() {
return findByHiddenTrueOrderByUpdatedAtDesc();
}
@Override
default Page<Skill> findByHiddenTrue(Pageable pageable) {
return findByHiddenTrueOrderByUpdatedAtDesc(pageable);
}
@Modifying
@Transactional
@Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId")

View file

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

View file

@ -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"
/>
<Select
value={namespaceFilter || ALL_NAMESPACES_VALUE}
onValueChange={(value) => {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
<SelectItem key={ns.id} value={ns.slug}>
@{ns.slug}
</SelectItem>
))}
</SelectContent>
</Select>
{filter === 'HIDDEN' ? null : (
<Select
value={namespaceFilter || ALL_NAMESPACES_VALUE}
onValueChange={(value) => {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
<SelectItem key={ns.id} value={ns.slug}>
@{ns.slug}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{hasActiveSearch ? (
<Button
type="button"
@ -427,7 +430,7 @@ export function MySkillsPage() {
</div>
</div>
<div className="flex items-center gap-2 pl-4">
{filter === 'HIDDEN' ? (
{canRestoreHidden ? (
<Button
size="sm"
variant="outline"
@ -438,7 +441,7 @@ export function MySkillsPage() {
>
{t('mySkills.restoreHidden')}
</Button>
) : skill.status !== 'ARCHIVED' && (
) : filter === 'HIDDEN' ? null : skill.status !== 'ARCHIVED' && (
<Button
size="sm"
variant="outline"