From 5c5634dd22be860598edb5d789ea37682a7a7d64 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:15:04 +0800 Subject: [PATCH 1/4] fix(governance): restore hidden skill management Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skillhub/service/MySkillAppService.java | 66 ++++++++++++++----- .../service/MySkillAppServiceTest.java | 18 +++-- .../domain/skill/SkillRepository.java | 2 + .../infra/jpa/JpaSkillRepositoryAdapter.java | 10 +++ .../infra/jpa/SkillJpaRepository.java | 12 ++++ web/src/features/admin/use-admin-skills.ts | 13 ++++ web/src/i18n/locales/en.json | 6 ++ web/src/i18n/locales/ru.json | 6 ++ web/src/i18n/locales/zh.json | 6 ++ web/src/pages/dashboard/my-skills.test.ts | 23 ++++++- web/src/pages/dashboard/my-skills.tsx | 60 +++++++++++++++-- 11 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 web/src/features/admin/use-admin-skills.ts 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..5d69b9d9 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 @@ -79,7 +79,9 @@ 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)); @@ -92,6 +94,28 @@ public class MySkillAppService { 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); + } + 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); + } + public PageResponse listMyStars(String userId, int page, int size) { Page starPage = skillStarRepository.findByUserId( userId, @@ -150,17 +174,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 +185,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/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index 7c8bbb3e..acd58494 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 @@ -230,14 +230,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(skillRepository.findByHiddenTrue(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,6 +246,14 @@ 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_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 520ebed1..a6d4c760 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,6 +22,8 @@ public interface SkillRepository { void delete(Skill skill); List findByOwnerId(String ownerId); Page findByOwnerId(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 ed209a50..f1502007 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,6 +87,16 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { return delegate.findByOwnerId(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 1b2e0246..18e97fa0 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,12 +34,24 @@ public interface SkillJpaRepository extends JpaRepository, SkillRep Page findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable); List findByOwnerId(String ownerId); Page findByOwnerIdOrderByUpdatedAtDesc(String ownerId, Pageable pageable); + List findByHiddenTrueOrderByUpdatedAtDesc(); + Page findByHiddenTrueOrderByUpdatedAtDesc(Pageable pageable); @Override default Page findByOwnerId(String ownerId, Pageable pageable) { return findByOwnerIdOrderByUpdatedAtDesc(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/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..5833836d 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,17 @@ 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('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..a3a739f2 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({ @@ -102,6 +104,7 @@ export function MySkillsPage() { const unarchiveMutation = useUnarchiveSkill() const withdrawMutation = useWithdrawSkillReview() const submitPromotionMutation = useSubmitPromotion() + const restoreHiddenMutation = useRestoreHiddenSkill() const handleSkillClick = (namespace: string, slug: string) => { navigate({ @@ -270,6 +273,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 (
@@ -362,8 +382,8 @@ export function MySkillsPage() { return ( handleSkillClick(skill.namespace, skill.slug)} + className={`p-5 group animate-fade-up delay-${Math.min(idx + 1, 6)} ${filter === 'HIDDEN' ? '' : 'cursor-pointer'}`} + onClick={filter === 'HIDDEN' ? undefined : () => handleSkillClick(skill.namespace, skill.slug)} >
@@ -407,7 +427,18 @@ export function MySkillsPage() {
- {skill.status !== 'ARCHIVED' && ( + {filter === 'HIDDEN' ? ( + + ) : skill.status !== 'ARCHIVED' && (
@@ -515,6 +548,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} + /> + { From 927780db46c1b2b33eaa5f931b659fc5c48fb3bc Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:19:06 +0800 Subject: [PATCH 2/4] fix(governance): exclude hidden skills from owner list Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../iflytek/skillhub/service/MySkillAppService.java | 2 +- .../skillhub/service/MySkillAppServiceTest.java | 10 +++++----- .../iflytek/skillhub/domain/skill/SkillRepository.java | 2 +- .../skillhub/infra/jpa/JpaSkillRepositoryAdapter.java | 4 ++-- .../iflytek/skillhub/infra/jpa/SkillJpaRepository.java | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) 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 5d69b9d9..5541ace7 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 @@ -84,7 +84,7 @@ public class MySkillAppService { } 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); } 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 acd58494..8316af4b 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 @@ -132,7 +132,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 +165,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 +197,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)); @@ -259,7 +259,7 @@ class MySkillAppServiceTest { 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"))); @@ -278,7 +278,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 a6d4c760..a22a5371 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); List findByHiddenTrue(); Page findByHiddenTrue(Pageable pageable); void incrementDownloadCount(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 f1502007..1452d89b 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 18e97fa0..33e7aa49 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,13 +33,13 @@ 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); List findByHiddenTrueOrderByUpdatedAtDesc(); Page findByHiddenTrueOrderByUpdatedAtDesc(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); } @Override From 4efd6c6366462bbda18fc1d7957ec4f0cdc321c3 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:27:36 +0800 Subject: [PATCH 3/4] refactor(governance): page hidden skill queries Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../HiddenSkillQueryRepository.java | 10 +++ .../JpaHiddenSkillQueryRepository.java | 72 +++++++++++++++++++ .../skillhub/service/MySkillAppService.java | 15 ++-- .../service/MySkillAppServiceTest.java | 28 +++++++- .../domain/skill/SkillRepository.java | 2 - .../infra/jpa/JpaSkillRepositoryAdapter.java | 10 --- .../infra/jpa/SkillJpaRepository.java | 12 ---- web/src/pages/dashboard/my-skills.test.ts | 8 +++ web/src/pages/dashboard/my-skills.tsx | 43 +++++------ 9 files changed, 145 insertions(+), 55 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/HiddenSkillQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaHiddenSkillQueryRepository.java 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' && (