mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
refactor(governance): page hidden skill queries
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
927780db46
commit
4efd6c6366
9 changed files with 145 additions and 55 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue