From 1577384ffbc223514b5356f34522ba6b4e00e9cc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 15 Jul 2026 10:01:24 +0800 Subject: [PATCH 01/23] fix(namespace): allow super admin namespace visibility Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/NamespaceController.java | 18 +++- .../NamespacePortalQueryAppService.java | 48 +++++++++- .../NamespacePortalControllerTest.java | 46 ++++++++++ .../NamespacePortalQueryAppServiceTest.java | 44 ++++++++++ .../my-namespaces-super-admin-actions.spec.ts | 88 +++++++++++++++++++ web/src/pages/dashboard/my-namespaces.test.ts | 25 +++++- web/src/pages/dashboard/my-namespaces.tsx | 18 ++-- 7 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 web/e2e/my-namespaces-super-admin-actions.spec.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index c0c34567..bed9d962 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -61,15 +61,19 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces") public ApiResponse> listNamespaces( Pageable pageable, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, normalizePlatformRoles(platformRoles))); } @GetMapping("/me/namespaces") public ApiResponse> listMyNamespaces( @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespaces(userNsRoles, normalizePlatformRoles(platformRoles))); } @GetMapping("/namespaces/{slug}") @@ -165,6 +169,12 @@ public class NamespaceController extends BaseApiController { namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles)); } + private Set normalizePlatformRoles(Set platformRoles) { + return platformRoles != null + ? platformRoles + : Set.of(); + } + @GetMapping("/namespaces/{slug}/member-candidates") public ApiResponse> searchMemberCandidates( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index e8df0ad9..e11742d1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -26,6 +27,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +38,9 @@ import org.springframework.transaction.annotation.Transactional; @Service public class NamespacePortalQueryAppService { + private static final String SUPER_ADMIN_ROLE = "SUPER_ADMIN"; + private static final String NAMESPACE_SLUG_SORT = "slug"; + private final NamespaceRepository namespaceRepository; private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; @@ -56,6 +61,25 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + return listNamespaces(pageable, userNamespaceRoles, Set.of()); + } + + @Transactional(readOnly = true) + public PageResponse listNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles) { + if (isSuperAdmin(platformRoles)) { + Page namespaces = namespaceRepository.findByStatus( + NamespaceStatus.ACTIVE, + PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(NAMESPACE_SLUG_SORT).ascending() + ) + ); + return PageResponse.from(namespaces.map(NamespaceResponse::from)); + } + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); if (namespaceRoles.isEmpty()) { Page empty = new PageImpl<>( @@ -84,12 +108,22 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public List listMyNamespaces(Map userNamespaceRoles) { + return listMyNamespaces(userNamespaceRoles, Set.of()); + } + + @Transactional(readOnly = true) + public List listMyNamespaces(Map userNamespaceRoles, + Set platformRoles) { Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); - if (namespaceRoles.isEmpty()) { + if (namespaceRoles.isEmpty() && !isSuperAdmin(platformRoles)) { return List.of(); } - return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + List visibleNamespaces = isSuperAdmin(platformRoles) + ? listAllNamespaces() + : namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()); + + return visibleNamespaces.stream() .sorted(Comparator.comparing(Namespace::getSlug)) .map(namespace -> MyNamespaceResponse.from( namespace, @@ -138,4 +172,14 @@ public class NamespacePortalQueryAppService { MemberResponse.from(member, userMap.get(member.getUserId())) )); } + + private List listAllNamespaces() { + return Arrays.stream(NamespaceStatus.values()) + .flatMap(status -> namespaceRepository.findByStatus(status, Pageable.unpaged()).getContent().stream()) + .toList(); + } + + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 566d3ef0..d94a1746 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -77,6 +77,52 @@ class NamespacePortalControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @Test + void listNamespaces_superAdminReturnsAllActiveNamespacesWithoutMembership() throws Exception { + Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace teamB = namespace(2L, "team-b", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(teamA, teamB), + org.springframework.data.domain.PageRequest.of(0, 20), + 2 + )); + + mockMvc.perform(get("/api/v1/namespaces") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) + .andExpect(jsonPath("$.data.items[1].slug").value("team-b")) + .andExpect(jsonPath("$.data.total").value(2)); + } + + @Test + void listMyNamespaces_superAdminReturnsAllNamespacesWithoutMembership() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace frozen = namespace(2L, "frozen", NamespaceStatus.FROZEN, NamespaceType.TEAM); + Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(active))); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.FROZEN), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(frozen))); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ARCHIVED), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(archived))); + + mockMvc.perform(get("/api/v1/me/namespaces") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("active")) + .andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist()) + .andExpect(jsonPath("$.data[1].slug").value("archived")) + .andExpect(jsonPath("$.data[2].slug").value("frozen")); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 05a9fd3c..39453e8f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -25,6 +25,7 @@ import com.iflytek.skillhub.dto.PageResponse; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -78,6 +79,25 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.get(1).canDelete()).isFalse(); } + @Test + void listNamespaces_superAdminReturnsAllActiveNamespaces() { + Namespace teamB = namespace(2L, "team-b"); + Namespace teamA = namespace(1L, "team-a"); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(teamA, teamB), PageRequest.of(0, 10), 2)); + + var response = service.listNamespaces( + PageRequest.of(0, 10), + Map.of(), + Set.of("SUPER_ADMIN") + ); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).slug()).isEqualTo("team-a"); + assertThat(response.items().get(1).slug()).isEqualTo("team-b"); + assertThat(response.total()).isEqualTo(2); + } + @Test void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() { Namespace teamA = namespace(1L, "team-a"); @@ -101,6 +121,30 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.items().get(1).slug()).isEqualTo("team-b"); } + @Test + void listMyNamespaces_superAdminReturnsAllNamespacesWithoutGrantingNamespaceRole() { + Namespace active = namespace(1L, "active"); + Namespace frozen = namespace(2L, "frozen"); + frozen.setStatus(NamespaceStatus.FROZEN); + Namespace archived = namespace(3L, "archived"); + archived.setStatus(NamespaceStatus.ARCHIVED); + + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(active), PageRequest.of(0, 1), 1)); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.FROZEN), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(frozen), PageRequest.of(0, 1), 1)); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ARCHIVED), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(archived), PageRequest.of(0, 1), 1)); + + var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response).hasSize(3); + assertThat(response).extracting("slug").containsExactly("active", "archived", "frozen"); + assertThat(response).extracting("currentUserRole").containsOnlyNulls(); + assertThat(response).extracting("canFreeze").containsOnly(false); + assertThat(response).extracting("canDelete").containsOnly(false); + } + @Test void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() { Namespace namespace = namespace(1L, "team-a"); diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts new file mode 100644 index 00000000..02b3ce56 --- /dev/null +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Route } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +function apiEnvelope(data: unknown) { + return { + code: 0, + msg: 'OK', + data, + timestamp: '2026-07-15T00:00:00Z', + requestId: 'e2e-super-admin-namespaces', + } +} + +async function fulfillJson(route: Route, data: unknown) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope(data)), + }) +} + +test.describe('My Namespaces super admin actions', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + + await page.route('**/api/v1/auth/me', (route) => fulfillJson(route, { + userId: 'super-admin', + username: 'super-admin', + displayName: 'Super Admin', + platformRoles: ['SUPER_ADMIN'], + })) + + await page.route('**/api/web/notifications/sse', (route) => route.fulfill({ + status: 204, + body: '', + })) + + await page.route('**/api/web/notifications/unread-count', (route) => fulfillJson(route, { count: 0 })) + + await page.route('**/api/web/me/namespaces', (route) => fulfillJson(route, [ + { + id: 101, + slug: 'visible-no-role', + displayName: 'Visible Without Membership', + description: 'Returned by SUPER_ADMIN namespace visibility', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-07-15T00:00:00Z', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + }, + { + id: 102, + slug: 'owned-team', + displayName: 'Owned Team', + description: 'Namespace where the user is a member', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-07-15T00:00:00Z', + currentUserRole: 'OWNER', + immutable: false, + canFreeze: true, + canUnfreeze: false, + canArchive: true, + canRestore: false, + canDelete: true, + }, + ])) + }) + + test('hides namespace-scoped actions for visible namespaces without membership', async ({ page }) => { + await page.goto('/dashboard/namespaces') + + const visibleCard = page.getByTestId('namespace-card-visible-no-role') + await expect(visibleCard.getByText('@visible-no-role')).toBeVisible() + await expect(visibleCard.getByText('Current role: Unknown')).toBeVisible() + await expect(visibleCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) + await expect(visibleCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + + const ownedCard = page.getByTestId('namespace-card-owned-team') + await expect(ownedCard.getByRole('button', { name: 'Manage Members' })).toBeVisible() + await expect(ownedCard.getByRole('button', { name: 'Review Tasks' })).toBeVisible() + }) +}) diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index f3044a6c..bd7689ac 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -12,6 +12,7 @@ const restoreMutateAsync = vi.fn() const deleteMutateAsync = vi.fn() let mockNamespaces: ManagedNamespace[] = [] +let mockPlatformRoles: string[] = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -28,7 +29,7 @@ vi.mock('react-i18next', async () => { }) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: () => false }), + useAuth: () => ({ hasRole: (role: string) => mockPlatformRoles.includes(role) }), })) vi.mock('@/shared/ui/button', () => ({ @@ -115,6 +116,7 @@ describe('MyNamespacesPage', () => { restoreMutateAsync.mockReset() deleteMutateAsync.mockReset() mockNamespaces = [] + mockPlatformRoles = [] }) it('exports a named component function', () => { @@ -137,6 +139,27 @@ describe('MyNamespacesPage', () => { expect(html).not.toContain('myNamespaces.delete') }) + it('hides namespace-scoped actions for super admins without namespace membership', () => { + mockPlatformRoles = ['SUPER_ADMIN'] + mockNamespaces = [buildNamespace({ currentUserRole: undefined })] + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('Team ML') + expect(html).toContain('myNamespaces.roleUnknown') + expect(html).not.toContain('myNamespaces.manageMembers') + expect(html).not.toContain('myNamespaces.reviewTasks') + }) + + it('shows namespace-scoped actions for namespace members', () => { + mockNamespaces = [buildNamespace({ currentUserRole: 'ADMIN' })] + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('myNamespaces.manageMembers') + expect(html).toContain('myNamespaces.reviewTasks') + }) + it('routes delete actions to the delete mutation and emits success feedback', async () => { const t = (key: string) => key const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML') diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 235f0b11..4e623b5c 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -286,7 +286,7 @@ export function MyNamespacesPage() {
- {namespace.type === 'TEAM' && ( + {namespace.type === 'TEAM' && Boolean(namespace.currentUserRole) && ( + {Boolean(namespace.currentUserRole) && ( + + )} {namespace.canFreeze && ( + )} + {Boolean(namespace.currentUserRole) && ( + + )} + {namespace.canFreeze && ( + + )} + {namespace.canUnfreeze && ( + + )} + {namespace.canArchive && ( + + )} + {namespace.canRestore && ( + + )} + {namespace.canDelete && ( + + )} +
-
- {namespace.type === 'TEAM' && Boolean(namespace.currentUserRole) && ( - - )} - {Boolean(namespace.currentUserRole) && ( - - )} - {namespace.canFreeze && ( - - )} - {namespace.canUnfreeze && ( - - )} - {namespace.canArchive && ( - - )} - {namespace.canRestore && ( - - )} - {namespace.canDelete && ( - - )} -
- - - ))} - + + ))} + + {namespacePage && namespacePage.total > namespacePage.size ? ( + + ) : null} + ) : ( ({ useNamespaceDetail: () => useNamespaceDetailMock(), })) +const useSearchSkillsMock = vi.fn() vi.mock('@/shared/hooks/use-skill-queries', () => ({ - useSearchSkills: () => ({ - data: { - items: [ - { - id: 1, - displayName: 'Demo Skill', - summary: 'summary', - namespace: 'global', - slug: 'demo', - downloadCount: 1, - starCount: 1, - ratingCount: 0, - updatedAt: '2026-03-20T00:00:00Z', - canSubmitPromotion: false, - publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, - }, - ], - total: 1, - page: 0, - size: 20, - }, - isLoading: false, - }), + useSearchSkills: () => useSearchSkillsMock(), })) import { renderToStaticMarkup } from 'react-dom/server' @@ -83,6 +62,30 @@ describe('NamespacePage', () => { data: { id: 1, slug: 'global', displayName: 'Global', type: 'GLOBAL', status: 'ACTIVE' }, isLoading: false, }) + useSearchSkillsMock.mockReturnValue({ + data: { + items: [ + { + id: 1, + displayName: 'Demo Skill', + summary: 'summary', + namespace: 'global', + slug: 'demo', + downloadCount: 1, + starCount: 1, + ratingCount: 0, + updatedAt: '2026-03-20T00:00:00Z', + canSubmitPromotion: false, + publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, + }, + ], + total: 1, + page: 0, + size: 20, + }, + isLoading: false, + error: null, + }) }) it('exports a named component function', () => { @@ -105,4 +108,17 @@ describe('NamespacePage', () => { expect(buttonRecords).toHaveLength(0) expect(html).not.toContain('type="checkbox"') }) + + it('renders a skill-list error state instead of the empty namespace state when skill loading fails', () => { + useSearchSkillsMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('namespace read failed'), + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('namespace.skillListErrorTitle') + expect(html).not.toContain('namespace.emptyTitle') + }) }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 275f7ba5..ebb5a65e 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -26,7 +26,7 @@ export function NamespacePage() { }, [namespace]) const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) - const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ + const { data: skillsData, isLoading: isLoadingSkills, error: skillsError } = useSearchSkills({ namespace, page, size: PAGE_SIZE, @@ -59,6 +59,11 @@ export function NamespacePage() {

{t('namespace.skillList')}

{isLoadingSkills ? ( + ) : skillsError ? ( + ) : skillsData && skillsData.items.length > 0 ? ( <>
diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index 4c5268ec..b031b9e8 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -31,6 +31,7 @@ describe('use-namespace-queries exports', () => { it('exports all expected hook functions', async () => { const mod = await import('./use-namespace-queries') expect(typeof mod.useMyNamespaces).toBe('function') + expect(typeof mod.useMyNamespacesPage).toBe('function') expect(typeof mod.useCreateNamespace).toBe('function') expect(typeof mod.useNamespaceDetail).toBe('function') expect(typeof mod.useNamespaceMembers).toBe('function') @@ -54,4 +55,14 @@ describe('use-namespace-queries exports', () => { enabled: false, })) }) + + it('passes page and size to the paged my namespaces query', async () => { + const mod = await import('./use-namespace-queries') + + mod.useMyNamespacesPage(3, 15) + + expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining({ + queryKey: ['namespaces', 'my', { page: 3, size: 15 }], + })) + }) }) diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index 7a31f66c..d48bc215 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -4,8 +4,16 @@ import { namespaceApi } from '@/api/client' import { replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache' import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers' +const MY_NAMESPACES_PAGE_SIZE = 20 +const MY_NAMESPACES_COMPAT_SIZE = 100 + async function getMyNamespaces(): Promise { - return namespaceApi.listMine() + const page = await namespaceApi.listMine({ page: 0, size: MY_NAMESPACES_COMPAT_SIZE }) + return page.items +} + +async function getMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE): Promise> { + return namespaceApi.listMine({ page, size }) } async function createNamespace(request: CreateNamespaceRequest): Promise { @@ -59,6 +67,14 @@ export function useMyNamespaces(enabled = true) { }) } +export function useMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE, enabled = true) { + return useQuery({ + queryKey: ['namespaces', 'my', { page, size }], + queryFn: () => getMyNamespacesPage(page, size), + enabled, + }) +} + export function useCreateNamespace() { const queryClient = useQueryClient() From daba21227171e99a9faf4f49f1cf86a9d6e3dea4 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 20 Jul 2026 15:54:21 +0800 Subject: [PATCH 05/23] fix(namespace): preserve my namespace compatibility Signed-off-by: dongmucat <1127093059@qq.com> --- scripts/namespace-smoke-test.sh | 12 ++- .../portal/NamespaceController.java | 11 ++- .../NamespacePortalQueryAppService.java | 39 +++++++-- .../NamespacePortalControllerTest.java | 38 +++++++-- .../NamespacePortalQueryAppServiceTest.java | 16 ++++ web/e2e/helpers/test-data-builder.ts | 12 +-- .../my-namespaces-super-admin-actions.spec.ts | 2 +- web/e2e/promotions-review.spec.ts | 2 +- web/src/api/client.test.ts | 44 +++++++++- web/src/api/client.ts | 8 +- web/src/api/generated/schema.d.ts | 85 ++++++++++++++++++- web/src/pages/dashboard/my-namespaces.test.ts | 8 +- web/src/pages/dashboard/my-namespaces.tsx | 18 +++- .../hooks/use-namespace-queries.test.ts | 20 +++++ web/src/shared/hooks/use-namespace-queries.ts | 19 ++++- 15 files changed, 295 insertions(+), 39 deletions(-) diff --git a/scripts/namespace-smoke-test.sh b/scripts/namespace-smoke-test.sh index ae2b7b59..838551cb 100755 --- a/scripts/namespace-smoke-test.sh +++ b/scripts/namespace-smoke-test.sh @@ -131,12 +131,18 @@ import sys slug = sys.argv[1] data = json.loads(os.environ["JSON_INPUT"]) items = data["data"] -raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +match = next((item for item in items if item["slug"] == slug), None) +if not match: + raise SystemExit(1) +if match.get("currentUserRole") is not None: + raise SystemExit(2) +if match["status"] != "ACTIVE": + raise SystemExit(3) PY then - pass "Namespace is not visible to unrelated users in my namespaces" + pass "SUPER_ADMIN can see namespace without namespace membership" else - fail "Unrelated user should not see team namespace in my namespaces" + fail "SUPER_ADMIN should see team namespace in my namespaces without namespace role" fi FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 981a4223..b8593149 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -68,7 +68,16 @@ public class NamespaceController extends BaseApiController { } @GetMapping("/me/namespaces") - public ApiResponse> listMyNamespaces( + public ApiResponse> listMyNamespaces( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespaces(userNsRoles, normalizePlatformRoles(platformRoles))); + } + + @GetMapping("/me/namespaces/page") + public ApiResponse> listMyNamespacesPage( Pageable pageable, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 0be311fe..f2597960 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -115,11 +116,20 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public List listMyNamespaces(Map userNamespaceRoles, Set platformRoles) { - return listMyNamespaces( - PageRequest.of(0, MAX_MY_NAMESPACE_PAGE_SIZE), - userNamespaceRoles, - platformRoles - ).items(); + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty() && !isSuperAdmin(platformRoles)) { + return List.of(); + } + + List visibleNamespaces = isSuperAdmin(platformRoles) + ? listAllNamespacesByPage() + : namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .toList(); + + return visibleNamespaces.stream() + .map(namespace -> myNamespaceResponse(namespace, namespaceRoles)) + .toList(); } @Transactional(readOnly = true) @@ -213,6 +223,25 @@ public class NamespacePortalQueryAppService { namespaceService.canDelete(namespace, currentUserRole)); } + private List listAllNamespacesByPage() { + List namespaces = new ArrayList<>(); + int pageNumber = 0; + Page page; + do { + page = namespaceRepository.findAll(PageRequest.of( + pageNumber, + MAX_MY_NAMESPACE_PAGE_SIZE, + Sort.by(NAMESPACE_SLUG_SORT).ascending() + )); + namespaces.addAll(page.getContent()); + pageNumber++; + if (page.getContent().isEmpty()) { + break; + } + } while (!page.isLast() && namespaces.size() < page.getTotalElements()); + return namespaces; + } + private Pageable normalizeMyNamespacesPageable(Pageable pageable) { int page = pageable != null && pageable.isPaged() ? Math.max(pageable.getPageNumber(), 0) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 21c2c752..aa61ef26 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -100,9 +100,33 @@ class NamespacePortalControllerTest { } @Test - void listMyNamespaces_superAdminReturnsPagedNamespacesWithoutMembership() throws Exception { + void listMyNamespaces_superAdminKeepsLegacyArrayContract() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findAll(any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active, archived), + org.springframework.data.domain.PageRequest.of(0, 2), + 2 + )); + + mockMvc.perform(get("/api/v1/me/namespaces") + .param("page", "0") + .param("size", "2") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("active")) + .andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist()) + .andExpect(jsonPath("$.data[1].slug").value("archived")) + .andExpect(jsonPath("$.data.items").doesNotExist()); + } + + @Test + void listMyNamespacesPage_superAdminReturnsPagedNamespacesWithoutMembership() throws Exception { Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); - Namespace frozen = namespace(2L, "frozen", NamespaceStatus.FROZEN, NamespaceType.TEAM); Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); given(namespaceRepository.findAll(any())) @@ -112,7 +136,7 @@ class NamespacePortalControllerTest { 3 )); - mockMvc.perform(get("/api/v1/me/namespaces") + mockMvc.perform(get("/api/web/me/namespaces/page") .param("page", "0") .param("size", "2") .with(auth("super-1", Set.of("SUPER_ADMIN"))) @@ -139,10 +163,10 @@ class NamespacePortalControllerTest { .requestAttr("userId", "owner-1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) - .andExpect(jsonPath("$.data.items[0].status").value("ARCHIVED")) - .andExpect(jsonPath("$.data.items[0].currentUserRole").value("OWNER")) - .andExpect(jsonPath("$.data.items[0].canDelete").value(false)); + .andExpect(jsonPath("$.data[0].slug").value("team-a")) + .andExpect(jsonPath("$.data[0].status").value("ARCHIVED")) + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")) + .andExpect(jsonPath("$.data[0].canDelete").value(false)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 7ef03e09..4567c015 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -145,6 +145,22 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.size()).isEqualTo(2); } + @Test + void listMyNamespaces_superAdminCompatibilityCollectsAllRepositoryPages() { + Namespace first = namespace(1L, "first"); + Namespace second = namespace(2L, "second"); + Namespace third = namespace(3L, "third"); + + when(namespaceRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(first, second), PageRequest.of(0, 2), 3)) + .thenReturn(new PageImpl<>(List.of(third), PageRequest.of(1, 2), 3)); + + var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response).extracting("slug").containsExactly("first", "second", "third"); + assertThat(response).extracting("currentUserRole").containsOnlyNulls(); + } + @Test void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() { Namespace namespace = namespace(1L, "team-a"); diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 9735a039..71cd9ebd 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -44,13 +44,6 @@ interface NamespaceCandidate { status: string } -interface PageResponse { - items: T[] - total: number - page: number - size: number -} - interface ApiEnvelope { code: number msg: string @@ -256,10 +249,9 @@ export class E2eTestDataBuilder { } async listMyNamespaces(): Promise { - const page = await parseEnvelope>( - await this.request.get('/api/web/me/namespaces?page=0&size=100'), + return parseEnvelope( + await this.request.get('/api/web/me/namespaces'), ) - return page.items } private isTeamNamespace(namespace: SeededNamespace): boolean { diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index abb1e1f8..1f3fdccf 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -39,7 +39,7 @@ test.describe('My Namespaces super admin actions', () => { await page.route('**/api/web/skills/*/star', (route) => fulfillJson(route, false)) - await page.route('**/api/web/me/namespaces?**', (route) => fulfillJson(route, { + await page.route('**/api/web/me/namespaces/page?**', (route) => fulfillJson(route, { items: [ { id: 101, diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts index 99e595b6..8030635b 100644 --- a/web/e2e/promotions-review.spec.ts +++ b/web/e2e/promotions-review.spec.ts @@ -74,7 +74,7 @@ test.describe('Promotion review dashboard', () => { }), }) }) - await page.route('**/api/web/me/namespaces?**', async (route) => { + await page.route('**/api/web/me/namespaces/page?**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 3bb7c2d3..7236f292 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -164,6 +164,46 @@ describe('namespaceApi.delete', () => { }) describe('namespaceApi.listMine', () => { + it('keeps the compatibility endpoint as a current user namespace array', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: [{ + id: 1, + slug: 'team-a', + displayName: 'Team A', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-05-07T00:00:00Z', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + }], + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-test', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + const namespaces = await namespaceApi.listMine() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/me/namespaces', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ) + expect(namespaces).toEqual([expect.objectContaining({ slug: 'team-a' })]) + }) +}) + +describe('namespaceApi.listMinePage', () => { it('requests a bounded page of current user namespaces', async () => { window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } const fetchMock = vi.fn().mockResolvedValue({ @@ -183,10 +223,10 @@ describe('namespaceApi.listMine', () => { }) vi.stubGlobal('fetch', fetchMock) - const page = await namespaceApi.listMine({ page: 2, size: 25 }) + const page = await namespaceApi.listMinePage({ page: 2, size: 25 }) expect(fetchMock).toHaveBeenCalledWith( - 'https://api.example.com/api/web/me/namespaces?page=2&size=25', + 'https://api.example.com/api/web/me/namespaces/page?page=2&size=25', expect.objectContaining({ headers: expect.any(Headers), }), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 78256d13..463eb36b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -639,10 +639,14 @@ export const namespaceApi = { return namespace }, - async listMine(params: { page?: number; size?: number } = {}): Promise> { + async listMine(): Promise { + return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) + }, + + async listMinePage(params: { page?: number; size?: number } = {}): Promise> { const page = params.page ?? 0 const size = params.size ?? 20 - return fetchJson>(`${WEB_API_PREFIX}/me/namespaces?page=${page}&size=${size}`) + return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?page=${page}&size=${size}`) }, async getDetail(slug: string): Promise { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 0a596131..4d9642a5 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2660,6 +2660,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/me/namespaces": { parameters: { query?: never; @@ -4557,6 +4589,15 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseListMyNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseGovernanceSummaryResponse: { /** Format: int32 */ code?: number; @@ -10008,7 +10049,7 @@ export interface operations { }; }; }; - listMyNamespaces: { + listMyNamespacesPage: { parameters: { query: { pageable: components["schemas"]["Pageable"]; @@ -10030,7 +10071,7 @@ export interface operations { }; }; }; - listMyNamespaces_1: { + listMyNamespacesPage_1: { parameters: { query: { pageable: components["schemas"]["Pageable"]; @@ -10052,6 +10093,46 @@ export interface operations { }; }; }; + listMyNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + }; + }; + }; + }; + listMyNamespaces_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + }; + }; + }; + }; listVisibleLabels: { parameters: { query?: never; diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index 257fd183..e57790a9 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -103,7 +103,7 @@ vi.mock('@/shared/lib/toast', () => ({ })) import { MyNamespacesPage } from './my-namespaces' -import { executeNamespaceAction, resolveNamespaceActionCopy } from './my-namespaces' +import { executeNamespaceAction, resolveNamespaceActionCopy, resolveValidNamespacePage } from './my-namespaces' function buildNamespace(overrides: Partial = {}): ManagedNamespace { return { @@ -205,6 +205,12 @@ describe('MyNamespacesPage', () => { expect(html).toContain('pagination.next') }) + it('backs up to the last valid page when a delete empties the current page', () => { + expect(resolveValidNamespacePage(2, 40, 20)).toBe(1) + expect(resolveValidNamespacePage(1, 40, 20)).toBe(1) + expect(resolveValidNamespacePage(1, 0, 20)).toBe(0) + }) + it('routes delete actions to the delete mutation and emits success feedback', async () => { const t = (key: string) => key const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML') diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index c4a8e93c..4b59e2b0 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -142,6 +142,12 @@ export async function executeNamespaceAction( } } +export function resolveValidNamespacePage(currentPage: number, total: number, size: number) { + const safeSize = Math.max(size, 1) + const lastPage = Math.max(Math.ceil(total / safeSize) - 1, 0) + return Math.min(Math.max(currentPage, 0), lastPage) +} + /** * Dashboard page for namespaces the current user can manage or review. It owns * namespace lifecycle actions because each action combines permissions, copy, @@ -163,6 +169,16 @@ export function MyNamespacesPage() { const namespaces = namespacePage?.items ?? [] const totalPages = namespacePage ? Math.max(Math.ceil(namespacePage.total / namespacePage.size), 1) : 1 + useEffect(() => { + if (!namespacePage) { + return + } + const validPage = resolveValidNamespacePage(page, namespacePage.total, namespacePage.size) + if (validPage !== page) { + setPage(validPage) + } + }, [namespacePage, page]) + const handleNamespaceClick = (slug: string) => { navigate({ to: `/space/${encodeURIComponent(slug)}` }) } diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index b031b9e8..42808b0b 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const useQueryMock = vi.hoisted(() => vi.fn()) +const listMinePageMock = vi.hoisted(() => vi.fn()) vi.mock('@tanstack/react-query', () => ({ useQuery: useQueryMock, @@ -11,6 +12,7 @@ vi.mock('@tanstack/react-query', () => ({ vi.mock('@/api/client', () => ({ namespaceApi: { listMine: vi.fn(), + listMinePage: listMinePageMock, }, })) @@ -26,6 +28,7 @@ vi.mock('@/api/client', () => ({ describe('use-namespace-queries exports', () => { beforeEach(() => { useQueryMock.mockClear() + listMinePageMock.mockReset() }) it('exports all expected hook functions', async () => { @@ -65,4 +68,21 @@ describe('use-namespace-queries exports', () => { queryKey: ['namespaces', 'my', { page: 3, size: 15 }], })) }) + + it('fetches every page for compatibility consumers instead of truncating after the first page', async () => { + const firstPageItems = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, slug: `team-${index + 1}` })) + listMinePageMock + .mockResolvedValueOnce({ items: firstPageItems, total: 101, page: 0, size: 100 }) + .mockResolvedValueOnce({ items: [{ id: 101, slug: 'team-101' }], total: 101, page: 1, size: 100 }) + const mod = await import('./use-namespace-queries') + + mod.useMyNamespaces() + const queryOptions = useQueryMock.mock.calls[useQueryMock.mock.calls.length - 1]?.[0] + const result = await queryOptions.queryFn() + + expect(listMinePageMock).toHaveBeenNthCalledWith(1, { page: 0, size: 100 }) + expect(listMinePageMock).toHaveBeenNthCalledWith(2, { page: 1, size: 100 }) + expect(result).toHaveLength(101) + expect(result[result.length - 1]).toEqual({ id: 101, slug: 'team-101' }) + }) }) diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index d48bc215..26ca5e8f 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -8,12 +8,25 @@ const MY_NAMESPACES_PAGE_SIZE = 20 const MY_NAMESPACES_COMPAT_SIZE = 100 async function getMyNamespaces(): Promise { - const page = await namespaceApi.listMine({ page: 0, size: MY_NAMESPACES_COMPAT_SIZE }) - return page.items + const namespaces: ManagedNamespace[] = [] + let page = 0 + let total = Number.POSITIVE_INFINITY + + while (namespaces.length < total) { + const response = await namespaceApi.listMinePage({ page, size: MY_NAMESPACES_COMPAT_SIZE }) + namespaces.push(...response.items) + total = response.total + page += 1 + if (response.items.length === 0) { + break + } + } + + return namespaces } async function getMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE): Promise> { - return namespaceApi.listMine({ page, size }) + return namespaceApi.listMinePage({ page, size }) } async function createNamespace(request: CreateNamespaceRequest): Promise { From 2fa7a53e2bdf7907bfdf08a202b0cc880865bc90 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 22 Jul 2026 17:34:14 +0800 Subject: [PATCH 06/23] fix(namespace): preserve super admin skill reads Signed-off-by: dongmucat <1127093059@qq.com> --- .../controller/portal/SkillController.java | 91 ++++-- .../controller/SkillControllerTest.java | 54 +++- .../DownloadRateLimitControllerTest.java | 7 +- .../portal/SkillControllerDownloadTest.java | 21 +- .../domain/skill/VisibilityChecker.java | 20 ++ .../skill/service/SkillDownloadService.java | 51 +++- .../skill/service/SkillQueryService.java | 163 +++++++++-- .../domain/skill/VisibilityCheckerTest.java | 20 ++ .../service/SkillDownloadServiceTest.java | 51 ++++ .../skill/service/SkillQueryServiceTest.java | 92 ++++++ web/e2e/helpers/test-data-builder.ts | 3 +- .../my-namespaces-super-admin-actions.spec.ts | 272 +++++++----------- 12 files changed, 607 insertions(+), 238 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 7c0df9c9..ac2a02d7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -37,6 +37,7 @@ import java.io.InputStream; import java.net.URI; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -74,10 +75,15 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); SkillDetailResponse response = new SkillDetailResponse( detail.id(), @@ -121,14 +127,16 @@ public class SkillController extends BaseApiController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { Page versions = skillQueryService.listVersions( namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(), - PageRequest.of(page, size)); + PageRequest.of(page, size), + normalizePlatformRoles(platformRoles)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), @@ -154,14 +162,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String version, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillVersionDetailDTO detail = skillQueryService.getVersionDetail( namespace, slug, version, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); SkillVersionDetailResponse response = new SkillVersionDetailResponse( @@ -185,7 +195,8 @@ public class SkillController extends BaseApiController { @RequestParam("from") String from, @RequestParam("to") String to, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillVersionCompareDTO compare = skillQueryService.compareVersions( namespace, @@ -193,7 +204,8 @@ public class SkillController extends BaseApiController { from, to, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ok("response.success.read", toCompareResponse(compare)); @@ -209,14 +221,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String version, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { List files = skillQueryService.listFiles( namespace, slug, version, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); List response = files.stream() @@ -238,14 +252,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String tagName, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { List files = skillQueryService.listFilesByTag( namespace, slug, tagName, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); List response = files.stream() @@ -272,7 +288,8 @@ public class SkillController extends BaseApiController { @PathVariable String version, @RequestParam("path") String path, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { InputStream content = skillQueryService.getFileContent( namespace, @@ -280,7 +297,8 @@ public class SkillController extends BaseApiController { version, path, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ResponseEntity.ok() @@ -295,7 +313,8 @@ public class SkillController extends BaseApiController { @PathVariable String tagName, @RequestParam("path") String path, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { InputStream content = skillQueryService.getFileContentByTag( namespace, @@ -303,7 +322,8 @@ public class SkillController extends BaseApiController { tagName, path, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ResponseEntity.ok() @@ -323,7 +343,8 @@ public class SkillController extends BaseApiController { @RequestParam(required = false) String tag, @RequestParam(required = false) String hash, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( namespace, @@ -332,7 +353,8 @@ public class SkillController extends BaseApiController { tag, hash, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); ResolveVersionResponse response = new ResolveVersionResponse( @@ -356,10 +378,15 @@ public class SkillController extends BaseApiController { @PathVariable String slug, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -372,10 +399,16 @@ public class SkillController extends BaseApiController { @PathVariable String version, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( - namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + version, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -388,10 +421,16 @@ public class SkillController extends BaseApiController { @PathVariable String tagName, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( - namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + tagName, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -418,6 +457,10 @@ public class SkillController extends BaseApiController { .body(new InputStreamResource(result.openContent())); } + private Set normalizePlatformRoles(Set platformRoles) { + return platformRoles != null ? platformRoles : Set.of(); + } + private boolean shouldRedirectToPresignedUrl(HttpServletRequest request, String presignedUrl) { if (presignedUrl == null || presignedUrl.isBlank()) { return false; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index ce622374..5599d475 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -20,11 +20,13 @@ import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TimeZone; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -57,7 +59,8 @@ class SkillControllerTest { eq("demo"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionDetailDTO( 10L, "1.0.0", @@ -87,7 +90,8 @@ class SkillControllerTest { eq("demo"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionDetailDTO( 10L, "1.0.0", @@ -122,7 +126,8 @@ class SkillControllerTest { eq("latest"), eq(null), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.ResolvedVersionDTO( 1L, "team", @@ -150,7 +155,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillDetailDTO( 1L, "demo", @@ -198,7 +204,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); mockMvc.perform(get("/api/web/skills/team/demo")) @@ -206,6 +213,31 @@ class SkillControllerTest { .andExpect(jsonPath("$.code").value(403)); } + @Test + void getSkillDetailShouldForwardPlatformRoles() throws Exception { + Set platformRoles = Set.of("SUPER_ADMIN"); + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq("super-1"), + eq(Map.of()), + eq(platformRoles))) + .thenThrow(new DomainForbiddenException("test.platformRoles.forwarded")); + + mockMvc.perform(get("/api/web/skills/team/demo") + .requestAttr("userId", "super-1") + .requestAttr("userNsRoles", Map.of()) + .requestAttr("platformRoles", platformRoles)) + .andExpect(status().isForbidden()); + + verify(skillQueryService).getSkillDetail( + "team", + "demo", + "super-1", + Map.of(), + platformRoles); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( @@ -213,7 +245,8 @@ class SkillControllerTest { eq("demo"), eq("latest"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(List.of(new SkillFile(20L, "README.md", 32L, "text/markdown", "hash", "key"))); mockMvc.perform(get("/api/v1/skills/team/demo/tags/latest/files")) @@ -232,7 +265,8 @@ class SkillControllerTest { eq("demo"), eq((String) null), eq(Map.of()), - any())) + any(), + anySet())) .thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(version))); when(skillQueryService.isDownloadAvailable(version)).thenReturn(false); @@ -249,7 +283,8 @@ class SkillControllerTest { eq("1.0.0"), eq("1.1.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionCompareDTO( "1.0.0", "1.1.0", @@ -292,7 +327,8 @@ class SkillControllerTest { eq("1.0.0"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenThrow(new DomainBadRequestException("error.skill.version.compare.same")); mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java index 42f0e360..b1ff4dea 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.portal; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; @@ -22,6 +23,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.ratelimit.RateLimiter; import java.io.ByteArrayInputStream; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; @@ -59,7 +61,7 @@ class DownloadRateLimitControllerTest { @Test void anonymousDownloadUsesIpAndSignedCookieBuckets() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of())) + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of(), Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -101,6 +103,7 @@ class DownloadRateLimitControllerTest { .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.code").value(429)); - verify(skillDownloadService, never()).downloadVersion(anyString(), anyString(), anyString(), anyString(), any()); + verify(skillDownloadService, never()).downloadVersion( + anyString(), anyString(), anyString(), anyString(), any(), anySet()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 8945d2a9..ad0c7834 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -59,7 +59,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -80,7 +81,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_streamsWhenPresignedUrlIsInsecureForHttpsRequest() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -102,7 +104,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -123,7 +126,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_allowsAnonymousForGlobalSkill() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", null, java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -143,7 +147,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_forbidsAnonymousWhenServiceRejectsSkill() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("team-ai", "demo-skill", "1.0.0", null, java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "team-ai", "demo-skill", "1.0.0", null, java.util.Map.of(), java.util.Set.of())) .willThrow(new DomainForbiddenException("error.skill.access.denied", "demo-skill")); mockMvc.perform(get("/api/v1/skills/team-ai/demo-skill/versions/1.0.0/download") @@ -155,7 +160,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_redirectDoesNotOpenContentStream() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> { throw new AssertionError("content stream should not be opened for redirects"); @@ -178,7 +184,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_usesPerVersionRateLimitKey() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 30b52a66..9d6b8b6d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -33,6 +33,26 @@ public class VisibilityChecker { }; } + /** + * Applies portal namespace-read semantics without turning the caller into a namespace member. + * The override exposes published namespace-visible skills, but never private, hidden, or + * unpublished skills. + */ + public boolean canAccessForNamespaceRead( + Skill skill, + String currentUserId, + Map userNamespaceRoles, + boolean namespaceReadAllowed) { + if (canAccess(skill, currentUserId, userNamespaceRoles)) { + return true; + } + return namespaceReadAllowed + && !skill.isHidden() + && skill.getStatus() == SkillStatus.ACTIVE + && skill.getLatestVersionId() != null + && skill.getVisibility() == SkillVisibility.NAMESPACE_ONLY; + } + private boolean isOwner(Skill skill, String currentUserId) { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 294563ad..c2433fce 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -22,6 +22,7 @@ import java.time.Duration; import java.util.Comparator; import java.util.Map; import java.util.List; +import java.util.Set; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -91,10 +92,19 @@ public class SkillDownloadService { String skillSlug, String currentUserId, Map userNsRoles) { + return downloadLatest(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadLatest( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); if (skill.getLatestVersionId() == null) { throw new DomainBadRequestException("error.skill.version.latest.unavailable", skillSlug); @@ -116,10 +126,20 @@ public class SkillDownloadService { String versionStr, String currentUserId, Map userNsRoles) { + return downloadVersion(namespaceSlug, skillSlug, versionStr, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadVersion( + String namespaceSlug, + String skillSlug, + String versionStr, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); @@ -136,10 +156,20 @@ public class SkillDownloadService { String tagName, String currentUserId, Map userNsRoles) { + return downloadByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); @@ -278,15 +308,20 @@ public class SkillDownloadService { private void assertCanDownload(Namespace namespace, Skill skill, String currentUserId, - Map userNsRoles) { + Map userNsRoles, + Set platformRoles) { if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + boolean canAccess = isSuperAdmin(platformRoles) + ? visibilityChecker.canAccessForNamespaceRead(skill, currentUserId, userNsRoles, true) + : visibilityChecker.canAccess(skill, currentUserId, userNsRoles); + if (!canAccess) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (namespace.getStatus() == NamespaceStatus.ARCHIVED - && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); } } @@ -299,6 +334,10 @@ public class SkillDownloadService { return currentUserId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { return skillSlugResolutionService.resolve( namespaceId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 66c5e319..df7070df 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -198,15 +198,34 @@ public class SkillQueryService { String skillSlug, String currentUserId, Map userNsRoles) { + return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public SkillDetailDTO getSkillDetail( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED - && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespaceSlug); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (skill.getStatus() != SkillStatus.ACTIVE + && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); + } + + if (!visibilityChecker.canAccessForNamespaceRead( + skill, + currentUserId, + userNsRoles, + isSuperAdmin(platformRoles))) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } @@ -251,15 +270,6 @@ public class SkillQueryService { ); } - public SkillDetailDTO getSkillDetail( - String namespaceSlug, - String skillSlug, - String currentUserId, - Map userNsRoles, - Set platformRoles) { - return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles); - } - /** * Lists skills within a namespace after filtering out records the caller is * not allowed to discover. @@ -296,9 +306,19 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { + return getVersionDetail(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of()); + } + + public SkillVersionDetailDTO getVersionDetail( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -322,13 +342,32 @@ public class SkillQueryService { String toVersion, String currentUserId, Map userNsRoles) { + return compareVersions( + namespaceSlug, + skillSlug, + fromVersion, + toVersion, + currentUserId, + userNsRoles, + Set.of() + ); + } + + public SkillVersionCompareDTO compareVersions( + String namespaceSlug, + String skillSlug, + String fromVersion, + String toVersion, + String currentUserId, + Map userNsRoles, + Set platformRoles) { if (Objects.equals(fromVersion, toVersion)) { throw new DomainBadRequestException("error.skill.version.compare.same"); } Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion from = findVersion(skill, fromVersion); SkillVersion to = findVersion(skill, toVersion); @@ -385,9 +424,19 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { + return listFiles(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of()); + } + + public List listFiles( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -401,9 +450,19 @@ public class SkillQueryService { String tagName, String currentUserId, Map userNsRoles) { + return listFilesByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of()); + } + + public List listFilesByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); return availableFiles(skillVersion.getId()); } @@ -419,9 +478,20 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { + return getFileContent(namespaceSlug, skillSlug, version, filePath, currentUserId, userNsRoles, Set.of()); + } + + public InputStream getFileContent( + String namespaceSlug, + String skillSlug, + String version, + String filePath, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -438,9 +508,20 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { + return getFileContentByTag(namespaceSlug, skillSlug, tagName, filePath, currentUserId, userNsRoles, Set.of()); + } + + public InputStream getFileContentByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String filePath, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); return readFileContent(file); @@ -451,9 +532,18 @@ public class SkillQueryService { String currentUserId, Map userNsRoles, Pageable pageable) { + return listVersions(namespaceSlug, skillSlug, currentUserId, userNsRoles, pageable, Set.of()); + } + + public Page listVersions(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); List visibleVersions; if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() @@ -551,13 +641,25 @@ public class SkillQueryService { String hash, String currentUserId, Map userNsRoles) { + return resolveVersion(namespaceSlug, skillSlug, version, tag, hash, currentUserId, userNsRoles, Set.of()); + } + + public ResolvedVersionDTO resolveVersion( + String namespaceSlug, + String skillSlug, + String version, + String tag, + String hash, + String currentUserId, + Map userNsRoles, + Set platformRoles) { if (version != null && !version.isBlank() && tag != null && !tag.isBlank()) { throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict"); } Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); assertInstallableVersion(resolved, resolved.getVersion()); String fingerprint = computeFingerprint(resolved); @@ -811,7 +913,18 @@ public class SkillQueryService { Skill skill, String currentUserId, Map userNsRoles) { - if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles)) { + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, Set.of()); + } + + private void assertPublishedAccessible( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles, + Set platformRoles) { + if (namespace.getStatus() == NamespaceStatus.ARCHIVED + && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); } if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { @@ -820,7 +933,11 @@ public class SkillQueryService { if (skill.isHidden() && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (!visibilityChecker.canAccessForNamespaceRead( + skill, + currentUserId, + userNsRoles, + isSuperAdmin(platformRoles))) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } } @@ -863,6 +980,10 @@ public class SkillQueryService { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { return currentUserId != null && userNsRoles.containsKey(namespaceId); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index af26901a..79c2fbbc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -189,4 +189,24 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of()); assertFalse(canAccess); } + + @Test + void testNamespaceReadOverrideAllowsOnlyPublishedNamespaceVisibleSkills() { + Skill archivedNamespaceOnlySkill = new Skill( + NAMESPACE_ID, "archived-namespace-skill", OWNER_ID, SkillVisibility.NAMESPACE_ONLY); + archivedNamespaceOnlySkill.setLatestVersionId(14L); + archivedNamespaceOnlySkill.setStatus(SkillStatus.ARCHIVED); + + assertTrue(checker.canAccessForNamespaceRead(namespaceOnlySkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(privateSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(hiddenPublicSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(archivedNamespaceOnlySkill, OTHER_USER_ID, Map.of(), true)); + } + + @Test + void testNamespaceReadOverrideDoesNotChangeOrdinaryAccessWhenDisabled() { + assertFalse(checker.canAccessForNamespaceRead(namespaceOnlySkill, OTHER_USER_ID, Map.of(), false)); + assertTrue(checker.canAccessForNamespaceRead(publicSkill, OTHER_USER_ID, Map.of(), false)); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 89a2e4cb..0ee04ceb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.zip.ZipInputStream; import static org.junit.jupiter.api.Assertions.*; @@ -214,6 +215,56 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadLatest_ShouldAllowSuperAdminForArchivedNamespaceOnlySkillWithoutMembership() throws Exception { + String namespaceSlug = "archived"; + String skillSlug = "namespace-only-skill"; + Map userNsRoles = Map.of(); + Set platformRoles = Set.of("SUPER_ADMIN"); + + Namespace namespace = new Namespace(namespaceSlug, "Archived", "owner-1"); + setId(namespace, 1L); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setDisplayName("Namespace Only Skill"); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + ObjectMetadata metadata = new ObjectMetadata(4L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccessForNamespaceRead(skill, "super-1", userNsRoles, true)).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(true); + when(objectStorageService.getMetadata("packages/1/10/bundle.zip")).thenReturn(metadata); + when(objectStorageService.getObject("packages/1/10/bundle.zip")) + .thenReturn(new ByteArrayInputStream("test".getBytes())); + when(objectStorageService.generatePresignedUrl( + eq("packages/1/10/bundle.zip"), + any(), + eq("Namespace Only Skill-1.0.0.zip"))) + .thenReturn(null); + + SkillDownloadService.DownloadResult result = service.downloadLatest( + namespaceSlug, + skillSlug, + "super-1", + userNsRoles, + platformRoles + ); + + assertEquals("Namespace Only Skill-1.0.0.zip", result.filename()); + assertNotNull(result.openContent()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + } + @Test void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception { Namespace namespace = new Namespace("global", "Global", "owner-1"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 02cf02f1..e75c9ee0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -202,6 +202,63 @@ class SkillQueryServiceTest { service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of())); } + @Test + void testGetSkillDetail_ShouldAllowSuperAdminToReadArchivedNamespaceOnlySkillWithoutMembership() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "namespace-only-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + when(userAccountRepository.findById("owner-1")).thenReturn(Optional.empty()); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + Set.of("SUPER_ADMIN") + ); + + assertEquals(skillSlug, result.slug()); + assertFalse(result.canManageLifecycle()); + } + + @Test + void testGetSkillDetail_ShouldDenySuperAdminArchivedPublicSkillWithoutMembership() throws Exception { + String namespaceSlug = "active-team"; + String skillSlug = "archived-public-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Active Team", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ARCHIVED); + skill.setLatestVersionId(11L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> service.getSkillDetail( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + Set.of("SUPER_ADMIN") + )); + } + @Test void testListSkillsByNamespace() throws Exception { // Arrange @@ -561,6 +618,41 @@ class SkillQueryServiceTest { result.getContent().stream().map(SkillVersion::getVersion).toList()); } + @Test + void testListVersions_ShouldAllowSuperAdminReadWithoutGrantingLifecycleManagement() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "namespace-only-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 10L); + published.setStatus(SkillVersionStatus.PUBLISHED); + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(published)); + + Page result = service.listVersions( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + PageRequest.of(0, 20), + Set.of("SUPER_ADMIN") + ); + + assertEquals(List.of("1.0.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + verify(skillVersionRepository, never()).findBySkillId(1L); + } + @Test void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 71cd9ebd..9e9ddf42 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -61,6 +61,7 @@ export interface SeedSkillOptions { name?: string description?: string version?: string + visibility?: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE' readmeHeading?: string readmeBody?: string extraFiles?: Array<{ @@ -575,7 +576,7 @@ export class E2eTestDataBuilder { mimeType: 'application/zip', buffer: zipBuffer, }, - visibility: 'PUBLIC', + visibility: options?.visibility ?? 'PUBLIC', }, headers: await csrfHeaders(this.page), }), diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index 1f3fdccf..c8286402 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -1,186 +1,122 @@ -import { expect, test, type Route } from '@playwright/test' +import { expect, test, type BrowserContext } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' +import { E2eTestDataBuilder } from './helpers/test-data-builder' -function apiEnvelope(data: unknown) { - return { - code: 0, - msg: 'OK', - data, - timestamp: '2026-07-15T00:00:00Z', - requestId: 'e2e-super-admin-namespaces', - } -} +test.describe('My Namespaces super admin actions (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) -async function fulfillJson(route: Route, data: unknown) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(apiEnvelope(data)), - }) -} - -test.describe('My Namespaces super admin actions', () => { test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - - await page.route('**/api/v1/auth/me', (route) => fulfillJson(route, { - userId: 'super-admin', - username: 'super-admin', - displayName: 'Super Admin', - platformRoles: ['SUPER_ADMIN'], - })) - - await page.route('**/api/web/notifications/sse', (route) => route.fulfill({ - status: 204, - body: '', - })) - - await page.route('**/api/web/notifications/unread-count', (route) => fulfillJson(route, { count: 0 })) - - await page.route('**/api/web/skills/*/star', (route) => fulfillJson(route, false)) - - await page.route('**/api/web/me/namespaces/page?**', (route) => fulfillJson(route, { - items: [ - { - id: 101, - slug: 'visible-no-role', - displayName: 'Visible Without Membership', - description: 'Returned by SUPER_ADMIN namespace visibility', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-07-15T00:00:00Z', - immutable: false, - canFreeze: false, - canUnfreeze: false, - canArchive: false, - canRestore: false, - canDelete: false, - }, - { - id: 102, - slug: 'owned-team', - displayName: 'Owned Team', - description: 'Namespace where the user is a member', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-07-15T00:00:00Z', - currentUserRole: 'OWNER', - immutable: false, - canFreeze: true, - canUnfreeze: false, - canArchive: true, - canRestore: false, - canDelete: true, - }, - { - id: 103, - slug: 'archived-no-role', - displayName: 'Archived Without Membership', - description: 'Archived namespace visible to SUPER_ADMIN', - type: 'TEAM', - status: 'ARCHIVED', - createdAt: '2026-07-15T00:00:00Z', - immutable: false, - canFreeze: false, - canUnfreeze: false, - canArchive: false, - canRestore: false, - canDelete: false, - }, - ], - page: 0, - size: 20, - total: 3, - })) - - await page.route('**/api/web/namespaces/visible-no-role', (route) => fulfillJson(route, { - id: 101, - slug: 'visible-no-role', - displayName: 'Visible Without Membership', - description: 'Returned by SUPER_ADMIN namespace visibility', - type: 'TEAM', - status: 'ACTIVE', - createdAt: '2026-07-15T00:00:00Z', - })) - - await page.route('**/api/web/namespaces/archived-no-role', (route) => fulfillJson(route, { - id: 103, - slug: 'archived-no-role', - displayName: 'Archived Without Membership', - description: 'Archived namespace visible to SUPER_ADMIN', - type: 'TEAM', - status: 'ARCHIVED', - createdAt: '2026-07-15T00:00:00Z', - })) - - await page.route('**/api/web/skills?**', (route) => { - const url = new URL(route.request().url()) - if (url.searchParams.get('namespace') === 'archived-no-role') { - return fulfillJson(route, { - items: [ - { - id: 301, - slug: 'archived-skill', - displayName: 'Archived Namespace Skill', - summary: 'Visible when archived namespace read semantics are consistent', - visibility: 'PUBLIC', - status: 'ACTIVE', - namespace: 'archived-no-role', - downloadCount: 0, - starCount: 0, - ratingCount: 0, - updatedAt: '2026-07-15T00:00:00Z', - publishedVersion: { id: 401, version: '1.0.0', status: 'PUBLISHED' }, - }, - ], - page: 0, - size: 20, - total: 1, - }) - } - return fulfillJson(route, { - items: [], - page: 0, - size: 20, - total: 0, - }) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', }) }) - test('keeps namespace-scoped actions hidden and opens detail for visible namespaces without membership', async ({ page }) => { - await page.goto('/dashboard/namespaces') + test('opens and downloads a published namespace-only skill in an archived non-member namespace', async ({ page, browser }, testInfo) => { + let adminBuilder: E2eTestDataBuilder | undefined + let ownerContext: BrowserContext | undefined + let ownerBuilder: E2eTestDataBuilder | undefined + let namespaceSlug: string | undefined + let namespaceArchived = false - const visibleCard = page.getByTestId('namespace-card-visible-no-role') - await expect(visibleCard.getByText('@visible-no-role')).toBeVisible() - await expect(visibleCard.getByText('Current role: Unknown')).toBeVisible() - await expect(visibleCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) - await expect(visibleCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + try { + adminBuilder = new E2eTestDataBuilder(page, testInfo) + await adminBuilder.init() + const namespace = await adminBuilder.createNamespace('e2e-super-admin-read') + namespaceSlug = namespace.slug - const ownedCard = page.getByTestId('namespace-card-owned-team') - await expect(ownedCard.getByRole('button', { name: 'Manage Members' })).toBeVisible() - await expect(ownedCard.getByRole('button', { name: 'Review Tasks' })).toBeVisible() + ownerContext = await browser.newContext({ + extraHTTPHeaders: { + 'X-Mock-User-Id': 'local-user', + }, + }) + const ownerPage = await ownerContext.newPage() + ownerBuilder = new E2eTestDataBuilder(ownerPage, testInfo) + await ownerBuilder.init() - await visibleCard.click() + await adminBuilder.addNamespaceMember(namespace.slug, 'local-user') + const transferResponse = await page.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/transfer-ownership`, + { + data: { newOwnerId: 'local-user' }, + headers: await csrfHeaders(page), + }, + ) + expect(transferResponse.ok()).toBe(true) - await expect(page).toHaveURL(/\/space\/visible-no-role$/) - await expect(page.getByRole('heading', { name: 'Visible Without Membership' })).toBeVisible() - await expect(page.getByText('@visible-no-role')).toBeVisible() - }) + const removeAdminResponse = await ownerPage.context().request.delete( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/members/local-admin`, + { headers: await csrfHeaders(ownerPage) }, + ) + expect(removeAdminResponse.ok()).toBe(true) - test('opens archived non-member namespaces without showing a false empty skill list', async ({ page }) => { - await page.goto('/dashboard/namespaces') + const skillName = `super-admin-read-${Date.now().toString(36)}` + const skill = await ownerBuilder.publishSkill(namespace.slug, { + name: skillName, + description: 'Published namespace-only skill for the SUPER_ADMIN read-chain regression', + visibility: 'NAMESPACE_ONLY', + readmeHeading: skillName, + }) - const archivedCard = page.getByTestId('namespace-card-archived-no-role') - await expect(archivedCard.getByText('@archived-no-role')).toBeVisible() - await expect(archivedCard.getByText('Current role: Unknown')).toBeVisible() - await expect(archivedCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) - await expect(archivedCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) - await archivedCard.click() + const archiveResponse = await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/archive`, + { + data: { reason: 'Validate SUPER_ADMIN archived namespace reads' }, + headers: await csrfHeaders(ownerPage), + }, + ) + expect(archiveResponse.ok()).toBe(true) + namespaceArchived = true - await expect(page).toHaveURL(/\/space\/archived-no-role$/) - await expect(page.getByRole('heading', { name: 'Archived Without Membership' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Archived Namespace Skill' })).toBeVisible() - await expect(page.getByText('namespace.emptyTitle')).toHaveCount(0) + await page.goto('/dashboard/namespaces') + + const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) + await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible() + await expect(namespaceCard.getByText('Current role: Unknown')).toBeVisible() + await expect(namespaceCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) + await expect(namespaceCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + + await namespaceCard.click() + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}$`)) + await expect(page.getByRole('heading', { name: namespace.displayName })).toBeVisible() + + const skillHeading = page.getByRole('heading', { name: skillName, exact: true }) + await expect(skillHeading).toBeVisible() + await skillHeading.click() + + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByRole('heading', { name: skillName, exact: true }).first()).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download', exact: true }).click() + const download = await downloadPromise + expect(await download.failure()).toBeNull() + expect(download.suggestedFilename()).toContain(skill.version) + } finally { + if (namespaceArchived && namespaceSlug && ownerContext) { + const ownerPage = ownerContext.pages()[0] + await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespaceSlug)}/restore`, + { headers: await csrfHeaders(ownerPage) }, + ).catch(() => undefined) + } + await ownerBuilder?.cleanup() + if (namespaceSlug && ownerContext) { + const ownerPage = ownerContext.pages()[0] + await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespaceSlug)}/archive`, + { + data: { reason: 'E2E cleanup' }, + headers: await csrfHeaders(ownerPage), + }, + ).catch(() => undefined) + } + await adminBuilder?.cleanup() + await ownerContext?.close() + } }) }) From a55264b130dd50a005561ef3fc72e8825dd5dca1 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 16:00:28 +0800 Subject: [PATCH 07/23] fix(namespace): bound filtered namespace queries Signed-off-by: dongmucat <1127093059@qq.com> --- .../NamespacePortalQueryAppService.java | 51 +++++++ .../NamespacePortalQueryAppServiceTest.java | 124 ++++++++++++++++++ .../domain/namespace/NamespaceRepository.java | 8 ++ .../infra/jpa/NamespaceJpaRepository.java | 38 ++++++ 4 files changed, 221 insertions(+) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index f2597960..5a0767dc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -163,6 +163,50 @@ public class NamespacePortalQueryAppService { return PageResponse.from(responsePage); } + @Transactional(readOnly = true) + public PageResponse listMyNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles, + NamespaceStatus status, + String query, + String slug, + Set roles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + Set requestedRoles = roles != null ? roles : Set.of(); + Pageable boundedPageable = normalizeMyNamespacesPageable(pageable); + String normalizedQuery = normalizeFilter(query); + String normalizedSlug = normalizeFilter(slug); + + if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) { + Page visibleNamespaces = namespaceRepository.search( + status, + normalizedQuery, + normalizedSlug, + boundedPageable + ); + return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles))); + } + + List scopedNamespaceIds = namespaceRoles.entrySet().stream() + .filter(entry -> requestedRoles.isEmpty() || requestedRoles.contains(entry.getValue())) + .map(Map.Entry::getKey) + .sorted() + .toList(); + if (scopedNamespaceIds.isEmpty()) { + Page empty = new PageImpl<>(List.of(), boundedPageable, 0); + return PageResponse.from(empty); + } + + Page visibleNamespaces = namespaceRepository.searchByIdIn( + scopedNamespaceIds, + status, + normalizedQuery, + normalizedSlug, + boundedPageable + ); + return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles))); + } + @Transactional(readOnly = true) public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { return getNamespace(slug, userId, userNamespaceRoles, Set.of()); @@ -253,6 +297,13 @@ public class NamespacePortalQueryAppService { return PageRequest.of(page, size, Sort.by(NAMESPACE_SLUG_SORT).ascending()); } + private String normalizeFilter(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + private boolean isSuperAdmin(Set platformRoles) { return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 4567c015..96f4976b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -7,6 +7,9 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.namespace.Namespace; @@ -145,6 +148,127 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.size()).isEqualTo(2); } + @Test + void listMyNamespaces_superAdminWithRequestedRolesSearchesOnlyMatchingMembershipIds() { + Namespace owned = namespace(1L, "team-ai"); + Pageable expectedPageable = PageRequest.of(0, 20); + when(namespaceRepository.searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq("team"), + eq("team-ai"), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(owned), expectedPageable, 1)); + + var response = service.listMyNamespaces( + expectedPageable, + Map.of(1L, NamespaceRole.OWNER, 2L, NamespaceRole.MEMBER), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ACTIVE, + " team ", + " team-ai ", + Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) + ); + + assertThat(response.items()).extracting("slug").containsExactly("team-ai"); + assertThat(response.items()).extracting("currentUserRole").containsExactly(NamespaceRole.OWNER); + verify(namespaceRepository).searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq("team"), + eq("team-ai"), + any(Pageable.class) + ); + verify(namespaceRepository, never()).search(any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_superAdminWithoutRequestedRolesUsesUnrestrictedFilteredSearch() { + Namespace archived = namespace(2L, "ops-team"); + archived.setStatus(NamespaceStatus.ARCHIVED); + Pageable expectedPageable = PageRequest.of(1, 10); + when(namespaceRepository.search( + eq(NamespaceStatus.ARCHIVED), + eq("ops"), + eq("ops-team"), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(archived), expectedPageable, 11)); + + var response = service.listMyNamespaces( + expectedPageable, + Map.of(), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ARCHIVED, + " ops ", + " ops-team ", + Set.of() + ); + + assertThat(response.items()).extracting("slug").containsExactly("ops-team"); + assertThat(response.total()).isEqualTo(11); + verify(namespaceRepository).search( + eq(NamespaceStatus.ARCHIVED), + eq("ops"), + eq("ops-team"), + any(Pageable.class) + ); + verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_nonSuperAdminWithoutRequestedRolesSearchesAllMembershipIds() { + Namespace member = namespace(1L, "member-team"); + Namespace administered = namespace(2L, "admin-team"); + when(namespaceRepository.searchByIdIn( + eq(List.of(1L, 2L)), + eq(null), + eq(null), + eq(null), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(administered, member), PageRequest.of(0, 20), 2)); + + var response = service.listMyNamespaces( + PageRequest.of(0, 20), + Map.of(2L, NamespaceRole.ADMIN, 1L, NamespaceRole.MEMBER), + Set.of(), + null, + " ", + "\t", + Set.of() + ); + + assertThat(response.items()).extracting("slug").containsExactly("admin-team", "member-team"); + assertThat(response.items()).extracting("currentUserRole") + .containsExactly(NamespaceRole.ADMIN, NamespaceRole.MEMBER); + verify(namespaceRepository).searchByIdIn( + eq(List.of(1L, 2L)), + eq(null), + eq(null), + eq(null), + any(Pageable.class) + ); + verify(namespaceRepository, never()).search(any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_emptyRoleRestrictedScopeReturnsEmptyPageWithoutRepositoryQuery() { + var response = service.listMyNamespaces( + PageRequest.of(2, 10), + Map.of(1L, NamespaceRole.MEMBER), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ACTIVE, + " team ", + null, + Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) + ); + + assertThat(response.items()).isEmpty(); + assertThat(response.total()).isZero(); + assertThat(response.page()).isEqualTo(2); + assertThat(response.size()).isEqualTo(10); + verifyNoInteractions(namespaceRepository); + } + @Test void listMyNamespaces_superAdminCompatibilityCollectsAllRepositoryPages() { Namespace first = namespace(1L, "first"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index ae3eb4da..38dcc575 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -15,6 +15,14 @@ public interface NamespaceRepository { Optional findBySlug(String slug); Page findAll(Pageable pageable); Page findByStatus(NamespaceStatus status, Pageable pageable); + Page search(NamespaceStatus status, String query, String slug, Pageable pageable); + Page searchByIdIn( + List ids, + NamespaceStatus status, + String query, + String slug, + Pageable pageable + ); Namespace save(Namespace namespace); void delete(Namespace namespace); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 7e7f3db0..ce918104e 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -6,6 +6,8 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -20,4 +22,40 @@ public interface NamespaceJpaRepository List findByIdIn(List ids); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); + + @Override + @Query(""" + SELECT n + FROM Namespace n + WHERE (:status IS NULL OR n.status = :status) + AND ( + :query IS NULL + OR lower(n.slug) LIKE lower(concat('%', :query, '%')) + OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) + ) + AND (:slug IS NULL OR n.slug = :slug) + """) + Page search(@Param("status") NamespaceStatus status, + @Param("query") String query, + @Param("slug") String slug, + Pageable pageable); + + @Override + @Query(""" + SELECT n + FROM Namespace n + WHERE n.id IN :ids + AND (:status IS NULL OR n.status = :status) + AND ( + :query IS NULL + OR lower(n.slug) LIKE lower(concat('%', :query, '%')) + OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) + ) + AND (:slug IS NULL OR n.slug = :slug) + """) + Page searchByIdIn(@Param("ids") List ids, + @Param("status") NamespaceStatus status, + @Param("query") String query, + @Param("slug") String slug, + Pageable pageable); } From 9de8a51d897d11124273e4c1cd163dab795e317d Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 16:18:47 +0800 Subject: [PATCH 08/23] feat(namespace): filter paged namespace reads Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/NamespaceController.java | 14 +++++++- .../NamespacePortalControllerTest.java | 34 ++++++++++++++++++- web/src/api/generated/schema.d.ts | 8 +++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index b8593149..8b352054 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -79,11 +80,22 @@ public class NamespaceController extends BaseApiController { @GetMapping("/me/namespaces/page") public ApiResponse> listMyNamespacesPage( Pageable pageable, + @RequestParam(required = false) NamespaceStatus status, + @RequestParam(required = false) String q, + @RequestParam(required = false) String slug, + @RequestParam(required = false) Set roles, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { return ok("response.success.read", - namespacePortalQueryAppService.listMyNamespaces(pageable, userNsRoles, normalizePlatformRoles(platformRoles))); + namespacePortalQueryAppService.listMyNamespaces( + pageable, + userNsRoles, + normalizePlatformRoles(platformRoles), + status, + q, + slug, + roles)); } @GetMapping("/namespaces/{slug}") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index aa61ef26..a8dc2b4b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -129,7 +129,7 @@ class NamespacePortalControllerTest { Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); - given(namespaceRepository.findAll(any())) + given(namespaceRepository.search(eq(null), eq(null), eq(null), any())) .willReturn(new org.springframework.data.domain.PageImpl<>( List.of(active, archived), org.springframework.data.domain.PageRequest.of(0, 2), @@ -151,6 +151,38 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data.size").value(2)); } + @Test + void listMyNamespacesPage_bindsAndAppliesOptionalFilters() throws Exception { + Namespace active = namespace(1L, "team-ai", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("owner-1")) + .willReturn(List.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + given(namespaceRepository.searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq("team"), + eq("team-ai"), + any() + )).willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active), + org.springframework.data.domain.PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "20") + .param("status", "ACTIVE") + .param("q", "team") + .param("slug", "team-ai") + .param("roles", "OWNER", "ADMIN") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].slug").value("team-ai")) + .andExpect(jsonPath("$.data.items[0].currentUserRole").value("OWNER")) + .andExpect(jsonPath("$.data.total").value(1)); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 4d9642a5..019f13b1 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -10053,6 +10053,10 @@ export interface operations { parameters: { query: { pageable: components["schemas"]["Pageable"]; + status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + q?: string; + slug?: string; + roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; }; header?: never; path?: never; @@ -10075,6 +10079,10 @@ export interface operations { parameters: { query: { pageable: components["schemas"]["Pageable"]; + status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + q?: string; + slug?: string; + roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; }; header?: never; path?: never; From 18c937031817ef368f3ac3cd647517862179a18e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 16:23:12 +0800 Subject: [PATCH 09/23] fix(frontend): bound namespace page queries Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/api/client.test.ts | 30 ++++++++++++++++ web/src/api/client.ts | 24 +++++++++++-- web/src/pages/dashboard/my-namespaces.tsx | 2 +- .../hooks/use-namespace-queries.test.ts | 34 +++++++++++++++++-- web/src/shared/hooks/use-namespace-queries.ts | 26 ++++++++++---- 5 files changed, 104 insertions(+), 12 deletions(-) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 7236f292..07166c0c 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -238,6 +238,36 @@ describe('namespaceApi.listMinePage', () => { size: 25, }) }) + + it('encodes namespace filters without issuing an unbounded request', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: { items: [], total: 0, page: 1, size: 20 }, + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-filtered', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + await namespaceApi.listMinePage({ + page: 1, + size: 20, + status: 'ACTIVE', + q: 'team ai', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&status=ACTIVE&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', + expect.objectContaining({ headers: expect.any(Headers) }), + ) + }) }) describe('getDirectAuthRuntimeConfig', () => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 463eb36b..6bdb6b91 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -48,6 +48,15 @@ import type { import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' +export interface MyNamespacePageParams { + page?: number + size?: number + status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' + q?: string + slug?: string + roles?: Array<'OWNER' | 'ADMIN' | 'MEMBER'> +} + /** * Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers. * @@ -643,10 +652,21 @@ export const namespaceApi = { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, - async listMinePage(params: { page?: number; size?: number } = {}): Promise> { + async listMinePage(params: MyNamespacePageParams = {}): Promise> { const page = params.page ?? 0 const size = params.size ?? 20 - return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?page=${page}&size=${size}`) + const query = new URLSearchParams({ page: String(page), size: String(size) }) + if (params.status) { + query.set('status', params.status) + } + if (params.q?.trim()) { + query.set('q', params.q.trim()) + } + if (params.slug?.trim()) { + query.set('slug', normalizeNamespaceSlug(params.slug)) + } + params.roles?.forEach((role) => query.append('roles', role)) + return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`) }, async getDetail(slug: string): Promise { diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 4b59e2b0..18a30fa8 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -160,7 +160,7 @@ export function MyNamespacesPage() { const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const [page, setPage] = useState(0) const [pendingAction, setPendingAction] = useState(null) - const { data: namespacePage, isLoading } = useMyNamespacesPage(page, PAGE_SIZE) + const { data: namespacePage, isLoading } = useMyNamespacesPage({ page, size: PAGE_SIZE }) const freezeMutation = useFreezeNamespace() const unfreezeMutation = useUnfreezeNamespace() const archiveMutation = useArchiveNamespace() diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index 42808b0b..2e842f24 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -59,14 +59,42 @@ describe('use-namespace-queries exports', () => { })) }) - it('passes page and size to the paged my namespaces query', async () => { + it('passes bounded filters to a single paged my namespaces query', async () => { const mod = await import('./use-namespace-queries') - mod.useMyNamespacesPage(3, 15) + mod.useMyNamespacesPage({ + page: 3, + size: 15, + status: 'ACTIVE', + q: 'team', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }) expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining({ - queryKey: ['namespaces', 'my', { page: 3, size: 15 }], + queryKey: ['namespaces', 'my', { + page: 3, + size: 15, + status: 'ACTIVE', + q: 'team', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }], })) + const queryOptions = useQueryMock.mock.calls[useQueryMock.mock.calls.length - 1]?.[0] + listMinePageMock.mockResolvedValue({ items: [], total: 101, page: 3, size: 15 }) + + await queryOptions.queryFn() + + expect(listMinePageMock).toHaveBeenCalledTimes(1) + expect(listMinePageMock).toHaveBeenCalledWith({ + page: 3, + size: 15, + status: 'ACTIVE', + q: 'team', + slug: 'team-ai', + roles: ['OWNER', 'ADMIN'], + }) }) it('fetches every page for compatibility consumers instead of truncating after the first page', async () => { diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index 26ca5e8f..193f513c 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole, BatchMemberResponse, PagedResponse } from '@/api/types' -import { namespaceApi } from '@/api/client' +import { namespaceApi, type MyNamespacePageParams } from '@/api/client' import { replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache' import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers' @@ -25,8 +25,21 @@ async function getMyNamespaces(): Promise { return namespaces } -async function getMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE): Promise> { - return namespaceApi.listMinePage({ page, size }) +function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyNamespacePageParams { + const q = params.q?.trim() + const slug = params.slug?.trim() + return { + page: params.page ?? 0, + size: params.size ?? MY_NAMESPACES_PAGE_SIZE, + ...(params.status ? { status: params.status } : {}), + ...(q ? { q } : {}), + ...(slug ? { slug } : {}), + ...(params.roles?.length ? { roles: [...params.roles] } : {}), + } +} + +async function getMyNamespacesPage(params: MyNamespacePageParams): Promise> { + return namespaceApi.listMinePage(params) } async function createNamespace(request: CreateNamespaceRequest): Promise { @@ -80,10 +93,11 @@ export function useMyNamespaces(enabled = true) { }) } -export function useMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE, enabled = true) { +export function useMyNamespacesPage(params: MyNamespacePageParams = {}, enabled = true) { + const normalizedParams = normalizeMyNamespacePageParams(params) return useQuery({ - queryKey: ['namespaces', 'my', { page, size }], - queryFn: () => getMyNamespacesPage(page, size), + queryKey: ['namespaces', 'my', normalizedParams], + queryFn: () => getMyNamespacesPage(normalizedParams), enabled, }) } From 8f6941e31e6de33c9918abc1ea3fc5d47c09695b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 16:27:28 +0800 Subject: [PATCH 10/23] feat(frontend): add paged namespace picker Signed-off-by: dongmucat <1127093059@qq.com> --- web/src/i18n/locales/en.json | 14 ++ web/src/i18n/locales/zh.json | 14 ++ .../components/namespace-picker.test.tsx | 96 +++++++++++++ .../shared/components/namespace-picker.tsx | 126 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 web/src/shared/components/namespace-picker.test.tsx create mode 100644 web/src/shared/components/namespace-picker.tsx diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 23ff842f..c5123d06 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -403,6 +403,20 @@ }, "publishSkill": "Publish Skill" }, + "namespacePicker": { + "placeholder": "Select namespace", + "title": "Select namespace", + "description": "Search or browse namespaces without loading the entire registry.", + "search": "Search namespaces", + "searchPlaceholder": "Search by slug or display name", + "loading": "Loading namespaces...", + "empty": "No namespaces found", + "error": "Failed to load namespaces", + "retry": "Retry", + "previous": "Previous", + "next": "Next", + "page": "Page {{current}} of {{total}}" + }, "myNamespaces": { "title": "My Namespaces", "subtitle": "Manage your namespaces and teams", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 8a743d7d..31b36c2b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -403,6 +403,20 @@ }, "publishSkill": "发布技能" }, + "namespacePicker": { + "placeholder": "选择命名空间", + "title": "选择命名空间", + "description": "通过搜索或分页浏览命名空间,无需加载全部数据。", + "search": "搜索命名空间", + "searchPlaceholder": "按标识或显示名称搜索", + "loading": "正在加载命名空间...", + "empty": "未找到命名空间", + "error": "命名空间加载失败", + "retry": "重试", + "previous": "上一页", + "next": "下一页", + "page": "第 {{current}} / {{total}} 页" + }, "myNamespaces": { "title": "我的命名空间", "subtitle": "管理你的命名空间和团队", diff --git a/web/src/shared/components/namespace-picker.test.tsx b/web/src/shared/components/namespace-picker.test.tsx new file mode 100644 index 00000000..f6ee3af7 --- /dev/null +++ b/web/src/shared/components/namespace-picker.test.tsx @@ -0,0 +1,96 @@ +/** @vitest-environment jsdom */ + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const useMyNamespacesPageMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespacesPage: useMyNamespacesPageMock, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { NamespacePicker } from './namespace-picker' + +const firstPage = { + items: [ + { + id: 1, + slug: 'active-team', + displayName: 'Active Team', + status: 'ACTIVE', + type: 'TEAM', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + }, + ], + total: 21, + page: 0, + size: 20, +} + +describe('NamespacePicker', () => { + beforeEach(() => { + vi.useFakeTimers() + useMyNamespacesPageMock.mockImplementation((params: { page?: number }) => ({ + data: params.page === 1 + ? { ...firstPage, items: [{ ...firstPage.items[0], id: 21, slug: 'next-team', displayName: 'Next Team' }], page: 1 } + : firstPage, + isLoading: false, + error: null, + refetch: vi.fn(), + })) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + useMyNamespacesPageMock.mockReset() + }) + + it('debounces an active-only namespace search without loading all pages', () => { + render() + + expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({ + page: 0, + size: 20, + status: 'ACTIVE', + }, false) + + fireEvent.click(screen.getByRole('button', { name: 'namespacePicker.placeholder' })) + fireEvent.change(screen.getByRole('searchbox', { name: 'namespacePicker.search' }), { + target: { value: 'team ai' }, + }) + + expect(useMyNamespacesPageMock).not.toHaveBeenCalledWith(expect.objectContaining({ q: 'team ai' }), true) + act(() => vi.advanceTimersByTime(300)) + expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({ + page: 0, + size: 20, + status: 'ACTIVE', + q: 'team ai', + }, true) + }) + + it('paginates bounded results and emits the selected slug', () => { + const onValueChange = vi.fn() + render() + + expect(screen.getByRole('button', { name: '@selected-outside-page' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '@selected-outside-page' })) + fireEvent.click(screen.getByRole('button', { name: 'namespacePicker.next' })) + + expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({ page: 1, size: 20 }, true) + fireEvent.click(screen.getByRole('button', { name: 'Next Team (@next-team)' })) + + expect(onValueChange).toHaveBeenCalledWith('next-team') + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) diff --git a/web/src/shared/components/namespace-picker.tsx b/web/src/shared/components/namespace-picker.tsx new file mode 100644 index 00000000..46a4a60a --- /dev/null +++ b/web/src/shared/components/namespace-picker.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { useDebounce } from '@/shared/hooks/use-debounce' +import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' + +const PAGE_SIZE = 20 + +interface NamespacePickerProps { + value: string + onValueChange: (slug: string) => void + status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' + disabled?: boolean +} + +/** + * Server-paged namespace selector that keeps request and render size bounded. + */ +export function NamespacePicker({ value, onValueChange, status, disabled = false }: NamespacePickerProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [page, setPage] = useState(0) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search.trim(), 300) + const query = useMyNamespacesPage({ + page, + size: PAGE_SIZE, + ...(status ? { status } : {}), + ...(debouncedSearch ? { q: debouncedSearch } : {}), + }, open) + const totalPages = query.data ? Math.max(Math.ceil(query.data.total / query.data.size), 1) : 1 + + useEffect(() => { + setPage(0) + }, [debouncedSearch, status]) + + const selectNamespace = (slug: string) => { + onValueChange(slug) + setOpen(false) + } + + return ( + + + + + + + {t('namespacePicker.title')} + {t('namespacePicker.description')} + + + setSearch(event.target.value)} + aria-label={t('namespacePicker.search')} + placeholder={t('namespacePicker.searchPlaceholder')} + /> + +
+ {query.isLoading ? ( +

{t('namespacePicker.loading')}

+ ) : query.error ? ( +
+

{t('namespacePicker.error')}

+ +
+ ) : query.data?.items.length ? ( + query.data.items.map((namespace) => ( + + )) + ) : ( +

{t('namespacePicker.empty')}

+ )} +
+ +
+ + + {t('namespacePicker.page', { current: page + 1, total: totalPages })} + + +
+
+
+ ) +} From 2bd358049b16192f240ba5662c900773ea51b7ef Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 16:37:10 +0800 Subject: [PATCH 11/23] fix(frontend): bound namespace selection Signed-off-by: dongmucat <1127093059@qq.com> --- .../my-namespaces-super-admin-actions.spec.ts | 17 +++++++ .../namespace/use-my-namespaces.test.ts | 17 ------- .../features/namespace/use-my-namespaces.ts | 5 --- web/src/pages/dashboard/my-namespaces.test.ts | 1 - web/src/pages/dashboard/my-skills.test.ts | 16 ++++++- web/src/pages/dashboard/my-skills.tsx | 34 +++++--------- .../pages/dashboard/namespace-members.test.ts | 16 ++++++- web/src/pages/dashboard/namespace-members.tsx | 6 +-- web/src/pages/dashboard/publish.test.ts | 23 +++++++--- web/src/pages/dashboard/publish.tsx | 44 +++++++------------ web/src/pages/dashboard/reviews.test.ts | 16 ++++--- web/src/pages/dashboard/reviews.tsx | 10 +++-- .../components/namespace-picker.test.tsx | 17 +++++++ .../shared/components/namespace-picker.tsx | 18 +++++++- web/src/shared/components/user-menu.test.tsx | 26 +++++++---- web/src/shared/components/user-menu.tsx | 10 +++-- .../hooks/use-namespace-queries.test.ts | 28 ------------ web/src/shared/hooks/use-namespace-queries.ts | 27 ------------ 18 files changed, 167 insertions(+), 164 deletions(-) delete mode 100644 web/src/features/namespace/use-my-namespaces.test.ts delete mode 100644 web/src/features/namespace/use-my-namespaces.ts diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index c8286402..904be64a 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -23,6 +23,7 @@ test.describe('My Namespaces super admin actions (Real API)', () => { try { adminBuilder = new E2eTestDataBuilder(page, testInfo) await adminBuilder.init() + const activeNamespace = await adminBuilder.createNamespace('e2e-super-admin-publish-active') const namespace = await adminBuilder.createNamespace('e2e-super-admin-read') namespaceSlug = namespace.slug @@ -72,6 +73,22 @@ test.describe('My Namespaces super admin actions (Real API)', () => { expect(archiveResponse.ok()).toBe(true) namespaceArchived = true + await page.goto('/dashboard/publish') + await page.getByRole('button', { name: 'Select namespace', exact: true }).click() + await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(activeNamespace.slug) + const activeOption = page.getByRole('button', { + name: `${activeNamespace.displayName} (@${activeNamespace.slug})`, + }) + await expect(activeOption).toBeVisible() + await activeOption.click() + + await page.getByRole('button', { name: `@${activeNamespace.slug}`, exact: true }).click() + await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(namespace.slug) + await expect(page.getByText('No namespaces found')).toBeVisible() + await expect(page.getByRole('button', { + name: `${namespace.displayName} (@${namespace.slug})`, + })).toHaveCount(0) + await page.goto('/dashboard/namespaces') const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) diff --git a/web/src/features/namespace/use-my-namespaces.test.ts b/web/src/features/namespace/use-my-namespaces.test.ts deleted file mode 100644 index ee36c691..00000000 --- a/web/src/features/namespace/use-my-namespaces.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './use-my-namespaces' - -/** - * use-my-namespaces.ts is a feature-local re-export of the - * useMyNamespaces hook from the shared query layer. There is no custom - * logic, transformation, or query-key function to test. - * - * We verify the re-export contract so import paths used by namespace - * dashboard screens break fast if the module shape changes. - */ -describe('use-my-namespaces re-export', () => { - it('re-exports useMyNamespaces as a function', () => { - expect(mod.useMyNamespaces).toBeDefined() - expect(typeof mod.useMyNamespaces).toBe('function') - }) -}) diff --git a/web/src/features/namespace/use-my-namespaces.ts b/web/src/features/namespace/use-my-namespaces.ts deleted file mode 100644 index 6332cb17..00000000 --- a/web/src/features/namespace/use-my-namespaces.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Preserves a feature-local import path for dashboard namespace screens while - * the underlying query implementation still lives in the shared hook module. - */ -export { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index e57790a9..8277cd1b 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -82,7 +82,6 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useArchiveNamespace: () => ({ mutateAsync: archiveMutateAsync }), useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }), useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }), - useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }), useMyNamespacesPage: () => ({ data: mockNamespacePage.total > 0 || mockNamespacePage.items.length > 0 ? mockNamespacePage diff --git a/web/src/pages/dashboard/my-skills.test.ts b/web/src/pages/dashboard/my-skills.test.ts index c82faf93..92fa70dc 100644 --- a/web/src/pages/dashboard/my-skills.test.ts +++ b/web/src/pages/dashboard/my-skills.test.ts @@ -5,6 +5,7 @@ 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 useMyNamespacesPageMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -72,7 +73,7 @@ vi.mock('@/shared/hooks/use-user-queries', () => ({ })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useMyNamespaces: () => ({ data: [] }), + useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args), })) vi.mock('@/shared/hooks/use-debounce', () => ({ @@ -114,6 +115,13 @@ describe('MySkillsPage', () => { beforeEach(() => { navigateMock.mockReset() buttonRecords.length = 0 + useMyNamespacesPageMock.mockReset() + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [], total: 0, page: 0, size: 20 }, + isLoading: false, + error: null, + refetch: vi.fn(), + }) useMySkillsMock.mockReturnValue({ data: { items: [ @@ -137,6 +145,12 @@ describe('MySkillsPage', () => { }) }) + it('loads only the first namespace picker page while the picker is closed', () => { + renderToStaticMarkup(createElement(MySkillsPage)) + + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 20 }, false) + }) + it('navigates to publish page with namespace and visibility when update is clicked', () => { renderToStaticMarkup(createElement(MySkillsPage)) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 4dbe4765..0c70eac7 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -5,13 +5,12 @@ import { useAuth } from '@/features/auth/use-auth' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { NamespacePicker } from '@/shared/components/namespace-picker' import { Pagination } from '@/shared/components/pagination' import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' -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 { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' @@ -22,8 +21,6 @@ import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' const PAGE_SIZE = 10 -const ALL_NAMESPACES_VALUE = '__all_namespaces__' - /** * Dashboard page for skills owned by the current user. * @@ -91,8 +88,6 @@ export function MySkillsPage() { q: keyword || undefined, namespace: namespaceFilter || undefined, }) - const { data: namespaceOptions } = useMyNamespaces() - const skills = skillPage?.items ?? [] const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1 const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN')) @@ -302,24 +297,15 @@ export function MySkillsPage() { aria-label={t('mySkills.searchPlaceholder')} className="sm:max-w-md" /> - +
+ { + updateSearch({ namespace: value || undefined, page: 0 }) + }} + emptyValueLabel={t('mySkills.namespaceFilterAll')} + /> +
{hasActiveSearch ? ( + ) : null} {query.isLoading ? (

{t('namespacePicker.loading')}

) : query.error ? ( diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx index e037dbc2..1474f0bf 100644 --- a/web/src/shared/components/user-menu.test.tsx +++ b/web/src/shared/components/user-menu.test.tsx @@ -5,7 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as mod from './user-menu' import { UserMenu } from './user-menu' -const useMyNamespacesMock = vi.hoisted(() => vi.fn(() => ({ data: [] as ManagedNamespace[] }))) +const useMyNamespacesPageMock = vi.hoisted(() => vi.fn(() => ({ + data: { items: [] as ManagedNamespace[], total: 0, page: 0, size: 1 }, +}))) vi.mock('react', async () => { const actual = await vi.importActual('react') @@ -66,7 +68,7 @@ vi.mock('@/api/client', () => ({ })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useMyNamespaces: useMyNamespacesMock, + useMyNamespacesPage: useMyNamespacesPageMock, })) /** @@ -81,7 +83,7 @@ describe('user-menu module exports', () => { describe('UserMenu security settings visibility', () => { beforeEach(() => { - useMyNamespacesMock.mockClear() + useMyNamespacesPageMock.mockClear() }) it('shows security settings when password changes are allowed, independent of OAuth provider', () => { @@ -114,8 +116,8 @@ describe('UserMenu security settings visibility', () => { }) it('shows reviews for namespace admins without platform review roles', () => { - useMyNamespacesMock.mockReturnValue({ - data: [ + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [ { id: 10, slug: 'team-admin', @@ -131,7 +133,7 @@ describe('UserMenu security settings visibility', () => { currentUserRole: 'ADMIN', createdAt: '', }, - ], + ], total: 1, page: 0, size: 1 }, }) const html = renderToStaticMarkup( @@ -143,7 +145,11 @@ describe('UserMenu security settings visibility', () => { />, ) - expect(useMyNamespacesMock).toHaveBeenCalledWith(true) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, true) expect(html).toContain('user.menu.reviews') }) @@ -157,6 +163,10 @@ describe('UserMenu security settings visibility', () => { />, ) - expect(useMyNamespacesMock).toHaveBeenCalledWith(false) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, false) }) }) diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 69d2800d..a4b84ceb 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' -import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' import { buildGlobalReviewsPath, canAccessGlobalReviewCenter, canAccessReviewCenter } from '@/features/review/review-paths' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' @@ -37,8 +37,12 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user.platformRoles) - const { data: myNamespaces } = useMyNamespaces(!hasGlobalReviewAccess) - const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces) + const { data: myNamespacesPage } = useMyNamespacesPage({ + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, !hasGlobalReviewAccess) + const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespacesPage?.items) const canChangePassword = user.canChangePassword === true const open = isHovered || isClickOpen diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index 2e842f24..bd5f7d93 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -33,7 +33,6 @@ describe('use-namespace-queries exports', () => { it('exports all expected hook functions', async () => { const mod = await import('./use-namespace-queries') - expect(typeof mod.useMyNamespaces).toBe('function') expect(typeof mod.useMyNamespacesPage).toBe('function') expect(typeof mod.useCreateNamespace).toBe('function') expect(typeof mod.useNamespaceDetail).toBe('function') @@ -48,17 +47,6 @@ describe('use-namespace-queries exports', () => { expect(typeof mod.useRestoreNamespace).toBe('function') }) - it('passes the enabled flag to the my namespaces query', async () => { - const mod = await import('./use-namespace-queries') - - mod.useMyNamespaces(false) - - expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining({ - queryKey: ['namespaces', 'my'], - enabled: false, - })) - }) - it('passes bounded filters to a single paged my namespaces query', async () => { const mod = await import('./use-namespace-queries') @@ -97,20 +85,4 @@ describe('use-namespace-queries exports', () => { }) }) - it('fetches every page for compatibility consumers instead of truncating after the first page', async () => { - const firstPageItems = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, slug: `team-${index + 1}` })) - listMinePageMock - .mockResolvedValueOnce({ items: firstPageItems, total: 101, page: 0, size: 100 }) - .mockResolvedValueOnce({ items: [{ id: 101, slug: 'team-101' }], total: 101, page: 1, size: 100 }) - const mod = await import('./use-namespace-queries') - - mod.useMyNamespaces() - const queryOptions = useQueryMock.mock.calls[useQueryMock.mock.calls.length - 1]?.[0] - const result = await queryOptions.queryFn() - - expect(listMinePageMock).toHaveBeenNthCalledWith(1, { page: 0, size: 100 }) - expect(listMinePageMock).toHaveBeenNthCalledWith(2, { page: 1, size: 100 }) - expect(result).toHaveLength(101) - expect(result[result.length - 1]).toEqual({ id: 101, slug: 'team-101' }) - }) }) diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index 193f513c..4bdfa135 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -5,25 +5,6 @@ import { replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache' import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers' const MY_NAMESPACES_PAGE_SIZE = 20 -const MY_NAMESPACES_COMPAT_SIZE = 100 - -async function getMyNamespaces(): Promise { - const namespaces: ManagedNamespace[] = [] - let page = 0 - let total = Number.POSITIVE_INFINITY - - while (namespaces.length < total) { - const response = await namespaceApi.listMinePage({ page, size: MY_NAMESPACES_COMPAT_SIZE }) - namespaces.push(...response.items) - total = response.total - page += 1 - if (response.items.length === 0) { - break - } - } - - return namespaces -} function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyNamespacePageParams { const q = params.q?.trim() @@ -85,14 +66,6 @@ function invalidateNamespaceQueries(queryClient: ReturnType Date: Mon, 27 Jul 2026 16:55:41 +0800 Subject: [PATCH 12/23] fix(namespace): preserve bounded selection semantics Signed-off-by: dongmucat <1127093059@qq.com> --- .../NamespacePortalQueryAppService.java | 13 ++- .../infra/jpa/NamespaceJpaRepositoryTest.java | 67 +++++++++++++ .../NamespacePortalQueryAppServiceTest.java | 28 ++++++ .../infra/jpa/NamespaceJpaRepository.java | 8 +- .../my-namespaces-super-admin-actions.spec.ts | 4 + .../review/use-namespace-review-entry.test.ts | 95 +++++++++++++++++++ .../review/use-namespace-review-entry.ts | 38 ++++++++ web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/dashboard/publish.test.ts | 39 +++++++- web/src/pages/dashboard/publish.tsx | 10 +- web/src/pages/dashboard/reviews.test.ts | 1 + web/src/pages/dashboard/reviews.tsx | 18 ++-- .../components/namespace-picker.test.tsx | 12 +++ .../shared/components/namespace-picker.tsx | 2 +- web/src/shared/components/user-menu.test.tsx | 7 ++ web/src/shared/components/user-menu.tsx | 12 +-- 17 files changed, 324 insertions(+), 32 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java create mode 100644 web/src/features/review/use-namespace-review-entry.test.ts create mode 100644 web/src/features/review/use-namespace-review-entry.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 5a0767dc..c7841d60 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -174,7 +174,7 @@ public class NamespacePortalQueryAppService { Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); Set requestedRoles = roles != null ? roles : Set.of(); Pageable boundedPageable = normalizeMyNamespacesPageable(pageable); - String normalizedQuery = normalizeFilter(query); + String normalizedQuery = normalizeSearchFilter(query); String normalizedSlug = normalizeFilter(slug); if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) { @@ -304,6 +304,17 @@ public class NamespacePortalQueryAppService { return value.trim(); } + private String normalizeSearchFilter(String value) { + String normalized = normalizeFilter(value); + if (normalized == null) { + return null; + } + return normalized + .replace("!", "!!") + .replace("%", "!%") + .replace("_", "!_"); + } + private boolean isSuperAdmin(Set platformRoles) { return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java new file mode 100644 index 00000000..050bdbcc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java @@ -0,0 +1,67 @@ +package com.iflytek.skillhub.infra.jpa; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class NamespaceJpaRepositoryTest { + + @Autowired + private NamespaceJpaRepository repository; + + @Autowired + private EntityManager entityManager; + + private Namespace percentNamespace; + private Namespace underscoreNamespace; + + @BeforeEach + void setUp() { + percentNamespace = persist(new Namespace("percent-team", "50% Tools", "owner-1")); + underscoreNamespace = persist(new Namespace("underscore-team", "50_Tools", "owner-1")); + persist(new Namespace("plain-team", "Plain Tools", "owner-1")); + Namespace archived = new Namespace("archived-percent", "50% Archived", "owner-1"); + archived.setStatus(NamespaceStatus.ARCHIVED); + persist(archived); + entityManager.flush(); + } + + @Test + void search_treatsEscapedWildcardsLiterallyAndAppliesStatus() { + var percentPage = repository.search( + NamespaceStatus.ACTIVE, + "!%", + null, + PageRequest.of(0, 10) + ); + var underscorePage = repository.searchByIdIn( + List.of(percentNamespace.getId(), underscoreNamespace.getId()), + NamespaceStatus.ACTIVE, + "!_", + null, + PageRequest.of(0, 10) + ); + + assertThat(percentPage.getContent()).extracting(Namespace::getSlug) + .containsExactly("percent-team"); + assertThat(percentPage.getTotalElements()).isEqualTo(1); + assertThat(underscorePage.getContent()).extracting(Namespace::getSlug) + .containsExactly("underscore-team"); + } + + private Namespace persist(Namespace namespace) { + entityManager.persist(namespace); + return namespace; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 96f4976b..2253a77f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -215,6 +215,34 @@ class NamespacePortalQueryAppServiceTest { verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any()); } + @Test + void listMyNamespaces_escapesLikeWildcardsForLiteralSubstringSearch() { + Pageable expectedPageable = PageRequest.of(0, 20); + when(namespaceRepository.search( + eq(null), + eq("50!%!_!!off"), + eq(null), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(), expectedPageable, 0)); + + service.listMyNamespaces( + expectedPageable, + Map.of(), + Set.of("SUPER_ADMIN"), + null, + " 50%_!off ", + null, + Set.of() + ); + + verify(namespaceRepository).search( + eq(null), + eq("50!%!_!!off"), + eq(null), + any(Pageable.class) + ); + } + @Test void listMyNamespaces_nonSuperAdminWithoutRequestedRolesSearchesAllMembershipIds() { Namespace member = namespace(1L, "member-team"); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index ce918104e..99e243ba 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -30,8 +30,8 @@ public interface NamespaceJpaRepository WHERE (:status IS NULL OR n.status = :status) AND ( :query IS NULL - OR lower(n.slug) LIKE lower(concat('%', :query, '%')) - OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) + OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' + OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) ESCAPE '!' ) AND (:slug IS NULL OR n.slug = :slug) """) @@ -48,8 +48,8 @@ public interface NamespaceJpaRepository AND (:status IS NULL OR n.status = :status) AND ( :query IS NULL - OR lower(n.slug) LIKE lower(concat('%', :query, '%')) - OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) + OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' + OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) ESCAPE '!' ) AND (:slug IS NULL OR n.slug = :slug) """) diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index 904be64a..ff0679d1 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -89,6 +89,10 @@ test.describe('My Namespaces super admin actions (Real API)', () => { name: `${namespace.displayName} (@${namespace.slug})`, })).toHaveCount(0) + await page.goto(`/dashboard/publish?namespace=${encodeURIComponent(namespace.slug)}&visibility=PUBLIC`) + await expect(page.getByRole('button', { name: `@${namespace.slug}`, exact: true })).toBeVisible() + await expect(page.getByText('The selected namespace is not active or is no longer available.')).toBeVisible() + await page.goto('/dashboard/namespaces') const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) diff --git a/web/src/features/review/use-namespace-review-entry.test.ts b/web/src/features/review/use-namespace-review-entry.test.ts new file mode 100644 index 00000000..97aaf380 --- /dev/null +++ b/web/src/features/review/use-namespace-review-entry.test.ts @@ -0,0 +1,95 @@ +import type { ManagedNamespace } from '@/api/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useMyNamespacesPageMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args), +})) + +import { useNamespaceReviewEntry } from './use-namespace-review-entry' + +function namespace(slug: string, status: ManagedNamespace['status']): ManagedNamespace { + return { + id: slug.length, + slug, + displayName: slug, + type: 'TEAM', + status, + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + currentUserRole: 'ADMIN', + createdAt: '', + } +} + +describe('useNamespaceReviewEntry', () => { + beforeEach(() => { + useMyNamespacesPageMock.mockReset() + }) + + it('uses one bounded ACTIVE query when an active review namespace exists', () => { + const active = namespace('zeta-active', 'ACTIVE') + useMyNamespacesPageMock.mockImplementation((params: { status?: string }) => ({ + data: { + items: params.status === 'ACTIVE' ? [active] : [namespace('alpha-archived', 'ARCHIVED')], + total: 1, + page: 0, + size: 1, + }, + isLoading: false, + error: null, + })) + + const result = useNamespaceReviewEntry(false) + + expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(1, { + page: 0, + size: 1, + status: 'ACTIVE', + roles: ['OWNER', 'ADMIN'], + }, true) + expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, false) + expect(result.namespaceReviewEntry?.slug).toBe('zeta-active') + }) + + it('falls back to one bounded any-status query when no ACTIVE namespace exists', () => { + const archived = namespace('alpha-archived', 'ARCHIVED') + useMyNamespacesPageMock.mockImplementation((params: { status?: string }) => ({ + data: { + items: params.status === 'ACTIVE' ? [] : [archived], + total: params.status === 'ACTIVE' ? 0 : 1, + page: 0, + size: 1, + }, + isLoading: false, + error: null, + })) + + const result = useNamespaceReviewEntry(false) + + expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, true) + expect(result.namespaceReviewEntry?.slug).toBe('alpha-archived') + }) + + it('disables both namespace queries for global reviewers', () => { + useMyNamespacesPageMock.mockReturnValue({ data: undefined, isLoading: false, error: null }) + + const result = useNamespaceReviewEntry(true) + + expect(useMyNamespacesPageMock.mock.calls.every((call) => call[1] === false)).toBe(true) + expect(result.namespaceReviewEntry).toBeNull() + }) +}) diff --git a/web/src/features/review/use-namespace-review-entry.ts b/web/src/features/review/use-namespace-review-entry.ts new file mode 100644 index 00000000..a525cfcc --- /dev/null +++ b/web/src/features/review/use-namespace-review-entry.ts @@ -0,0 +1,38 @@ +import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' +import { getPreferredNamespaceReviewEntry } from './review-paths' + +const REVIEW_ROLES = ['OWNER', 'ADMIN'] as const + +/** + * Resolves a review namespace with at most two one-row requests: an ACTIVE + * namespace first, then any manageable namespace as a read-only fallback. + */ +export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { + const activeQuery = useMyNamespacesPage({ + page: 0, + size: 1, + status: 'ACTIVE', + roles: [...REVIEW_ROLES], + }, !hasGlobalReviewAccess) + const activeEntry = getPreferredNamespaceReviewEntry(activeQuery.data?.items) + const fallbackEnabled = !hasGlobalReviewAccess + && !activeQuery.isLoading + && !activeQuery.error + && activeQuery.data !== undefined + && activeEntry === null + const fallbackQuery = useMyNamespacesPage({ + page: 0, + size: 1, + roles: [...REVIEW_ROLES], + }, fallbackEnabled) + const fallbackEntry = fallbackEnabled + ? getPreferredNamespaceReviewEntry(fallbackQuery.data?.items) + : null + + return { + namespaceReviewEntry: activeEntry ?? fallbackEntry, + isLoadingNamespaces: !hasGlobalReviewAccess + && (activeQuery.isLoading || (fallbackEnabled && fallbackQuery.isLoading)), + hasNamespaceQueryError: Boolean(activeQuery.error || (fallbackEnabled && fallbackQuery.error)), + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c5123d06..49d23b47 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1364,6 +1364,7 @@ }, "namespace": "Namespace", "selectNamespace": "Select namespace", + "namespaceUnavailable": "The selected namespace is not active or is no longer available.", "visibility": "Visibility", "visibilityOptions": { "public": "Public", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 31b36c2b..e95dfc26 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1365,6 +1365,7 @@ }, "namespace": "命名空间", "selectNamespace": "选择命名空间", + "namespaceUnavailable": "所选命名空间未启用或已不可用。", "visibility": "可见性", "visibilityOptions": { "public": "公开", diff --git a/web/src/pages/dashboard/publish.test.ts b/web/src/pages/dashboard/publish.test.ts index b39f7ac4..550d8cf8 100644 --- a/web/src/pages/dashboard/publish.test.ts +++ b/web/src/pages/dashboard/publish.test.ts @@ -77,12 +77,23 @@ describe('PublishPage', () => { beforeEach(() => { selectRecords.length = 0 useMyNamespacesPageMock.mockReset() - useMyNamespacesPageMock.mockReturnValue({ - data: { items: [], total: 0, page: 0, size: 20 }, + useMyNamespacesPageMock.mockImplementation((params: { slug?: string }) => ({ + data: { + items: params.slug ? [{ + id: 1, + slug: params.slug, + displayName: 'Team AI', + status: 'ACTIVE', + type: 'TEAM', + }] : [], + total: params.slug ? 1 : 0, + page: 0, + size: params.slug ? 1 : 20, + }, isLoading: false, error: null, refetch: vi.fn(), - }) + })) useSearchMock.mockReturnValue({ namespace: ' team-ai ', visibility: 'private', @@ -92,7 +103,12 @@ describe('PublishPage', () => { it('prefills namespace and visibility from route search params', () => { renderToStaticMarkup(createElement(PublishPage)) - expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, slug: 'team-ai' }, true) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + status: 'ACTIVE', + slug: 'team-ai', + }, true) expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 20, status: 'ACTIVE' }, false) expect(selectRecords[0]?.value).toBe('PRIVATE') }) @@ -102,10 +118,23 @@ describe('PublishPage', () => { renderToStaticMarkup(createElement(PublishPage)) - expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1 }, false) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, status: 'ACTIVE' }, false) expect(selectRecords[0]?.value).toBe('PUBLIC') }) + it('marks an archived or unavailable prefilled namespace as invalid', () => { + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [], total: 0, page: 0, size: 1 }, + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + const html = renderToStaticMarkup(createElement(PublishPage)) + + expect(html).toContain('publish.namespaceUnavailable') + }) + it('exports a named component function', () => { expect(typeof PublishPage).toBe('function') }) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index cafc316f..4f0d8e63 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -39,9 +39,10 @@ export function PublishPage() { const [warningDialogOpen, setWarningDialogOpen] = useState(false) const [precheckWarnings, setPrecheckWarnings] = useState([]) - const { data: selectedNamespacePage } = useMyNamespacesPage({ + const { data: selectedNamespacePage, isLoading: isLoadingSelectedNamespace } = useMyNamespacesPage({ page: 0, size: 1, + status: 'ACTIVE', ...(namespaceSlug ? { slug: namespaceSlug } : {}), }, !!namespaceSlug) const publishMutation = usePublishSkill() @@ -68,7 +69,7 @@ export function PublishPage() { } const publishSkill = async (confirmWarnings = false) => { - if (!selectedFile || !namespaceSlug) { + if (!selectedFile || !namespaceSlug || !selectedNamespace) { toast.error(t('publish.selectRequired')) return } @@ -164,6 +165,9 @@ export function PublishPage() { status="ACTIVE" disabled={publishMutation.isPending} /> + {namespaceSlug && !isLoadingSelectedNamespace && !selectedNamespace ? ( +

{t('publish.namespaceUnavailable')}

+ ) : null}
@@ -214,7 +218,7 @@ export function PublishPage() { className="w-full text-primary-foreground disabled:text-primary-foreground" size="lg" onClick={handlePublish} - disabled={!selectedFile || !namespaceSlug || publishMutation.isPending} + disabled={!selectedFile || !namespaceSlug || !selectedNamespace || publishMutation.isPending} > {publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')} diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index dd99bca6..aba65e8e 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -204,6 +204,7 @@ describe('ReviewsPage', () => { expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, + status: 'ACTIVE', roles: ['OWNER', 'ADMIN'], }, false) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index e0fe3892..8f6de4d7 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -2,15 +2,14 @@ import { useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { buildNamespaceReviewsPath, canAccessGlobalReviewCenter, - getPreferredNamespaceReviewEntry, } from '@/features/review/review-paths' +import { useNamespaceReviewEntry } from '@/features/review/use-namespace-review-entry' import { Table, TableBody, @@ -51,12 +50,11 @@ export function ReviewsPage() { const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles) - const { data: myNamespacesPage, isLoading: isLoadingNamespaces } = useMyNamespacesPage({ - page: 0, - size: 1, - roles: ['OWNER', 'ADMIN'], - }, !hasGlobalReviewAccess) - const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespacesPage?.items) + const { + namespaceReviewEntry, + isLoadingNamespaces, + hasNamespaceQueryError, + } = useNamespaceReviewEntry(hasGlobalReviewAccess) const showTypeTabs = isSkillAdmin && isUserAdmin // Determine default top-level tab @@ -66,7 +64,7 @@ export function ReviewsPage() { const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill' useEffect(() => { - if (hasGlobalReviewAccess || isLoadingNamespaces) { + if (hasGlobalReviewAccess || isLoadingNamespaces || hasNamespaceQueryError) { return } @@ -76,7 +74,7 @@ export function ReviewsPage() { } void navigate({ to: '/dashboard', replace: true }) - }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) + }, [hasGlobalReviewAccess, hasNamespaceQueryError, isLoadingNamespaces, namespaceReviewEntry, navigate]) const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING') const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED') diff --git a/web/src/shared/components/namespace-picker.test.tsx b/web/src/shared/components/namespace-picker.test.tsx index 064a15a3..ea89e20a 100644 --- a/web/src/shared/components/namespace-picker.test.tsx +++ b/web/src/shared/components/namespace-picker.test.tsx @@ -110,4 +110,16 @@ describe('NamespacePicker', () => { expect(onValueChange).toHaveBeenCalledWith('') expect(screen.queryByRole('dialog')).toBeNull() }) + + it('uses the optional empty label for an empty trigger value', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'All namespaces' })).toBeTruthy() + }) }) diff --git a/web/src/shared/components/namespace-picker.tsx b/web/src/shared/components/namespace-picker.tsx index 99531310..e8a7007c 100644 --- a/web/src/shared/components/namespace-picker.tsx +++ b/web/src/shared/components/namespace-picker.tsx @@ -59,7 +59,7 @@ export function NamespacePicker({ diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx index 1474f0bf..5deefa89 100644 --- a/web/src/shared/components/user-menu.test.tsx +++ b/web/src/shared/components/user-menu.test.tsx @@ -148,8 +148,14 @@ describe('UserMenu security settings visibility', () => { expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, + status: 'ACTIVE', roles: ['OWNER', 'ADMIN'], }, true) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + roles: ['OWNER', 'ADMIN'], + }, false) expect(html).toContain('user.menu.reviews') }) @@ -166,6 +172,7 @@ describe('UserMenu security settings visibility', () => { expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, + status: 'ACTIVE', roles: ['OWNER', 'ADMIN'], }, false) }) diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index a4b84ceb..76847dc8 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' -import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' -import { buildGlobalReviewsPath, canAccessGlobalReviewCenter, canAccessReviewCenter } from '@/features/review/review-paths' +import { buildGlobalReviewsPath, canAccessGlobalReviewCenter } from '@/features/review/review-paths' +import { useNamespaceReviewEntry } from '@/features/review/use-namespace-review-entry' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { cn } from '@/shared/lib/utils' @@ -37,12 +37,8 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user.platformRoles) - const { data: myNamespacesPage } = useMyNamespacesPage({ - page: 0, - size: 1, - roles: ['OWNER', 'ADMIN'], - }, !hasGlobalReviewAccess) - const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespacesPage?.items) + const { namespaceReviewEntry } = useNamespaceReviewEntry(hasGlobalReviewAccess) + const reviewCenterVisible = hasGlobalReviewAccess || namespaceReviewEntry !== null const canChangePassword = user.canChangePassword === true const open = isHovered || isClickOpen From f8df616480bdbc8e6582937edabbcbf2ad8f456a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 17:11:38 +0800 Subject: [PATCH 13/23] fix(namespace): scope review entry queries Signed-off-by: dongmucat <1127093059@qq.com> --- .../portal/NamespaceController.java | 3 +++ .../NamespacePortalQueryAppService.java | 3 +++ .../NamespacePortalControllerTest.java | 4 +++- .../infra/jpa/NamespaceJpaRepositoryTest.java | 6 ++++++ .../NamespacePortalQueryAppServiceTest.java | 19 +++++++++++++++--- .../domain/namespace/NamespaceRepository.java | 9 ++++++++- .../infra/jpa/NamespaceJpaRepository.java | 5 +++++ web/src/api/client.test.ts | 3 ++- web/src/api/client.ts | 4 ++++ web/src/api/generated/schema.d.ts | 2 ++ .../review/use-namespace-review-entry.test.ts | 12 +++++++++-- .../review/use-namespace-review-entry.ts | 5 +++++ web/src/i18n/locales/en.json | 4 ++++ web/src/i18n/locales/zh.json | 4 ++++ web/src/pages/dashboard/publish.test.ts | 15 ++++++++++++++ web/src/pages/dashboard/publish.tsx | 20 ++++++++++++++++--- web/src/pages/dashboard/reviews.test.ts | 18 +++++++++++++++++ web/src/pages/dashboard/reviews.tsx | 13 +++++++++++- web/src/shared/components/user-menu.test.tsx | 3 +++ .../hooks/use-namespace-queries.test.ts | 3 +++ web/src/shared/hooks/use-namespace-queries.ts | 1 + 21 files changed, 144 insertions(+), 12 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 8b352054..2e64eaf7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -81,6 +82,7 @@ public class NamespaceController extends BaseApiController { public ApiResponse> listMyNamespacesPage( Pageable pageable, @RequestParam(required = false) NamespaceStatus status, + @RequestParam(required = false) NamespaceType type, @RequestParam(required = false) String q, @RequestParam(required = false) String slug, @RequestParam(required = false) Set roles, @@ -93,6 +95,7 @@ public class NamespaceController extends BaseApiController { userNsRoles, normalizePlatformRoles(platformRoles), status, + type, q, slug, roles)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index c7841d60..01bdae60 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -168,6 +168,7 @@ public class NamespacePortalQueryAppService { Map userNamespaceRoles, Set platformRoles, NamespaceStatus status, + NamespaceType type, String query, String slug, Set roles) { @@ -180,6 +181,7 @@ public class NamespacePortalQueryAppService { if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) { Page visibleNamespaces = namespaceRepository.search( status, + type, normalizedQuery, normalizedSlug, boundedPageable @@ -200,6 +202,7 @@ public class NamespacePortalQueryAppService { Page visibleNamespaces = namespaceRepository.searchByIdIn( scopedNamespaceIds, status, + type, normalizedQuery, normalizedSlug, boundedPageable diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index a8dc2b4b..456ebd31 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -129,7 +129,7 @@ class NamespacePortalControllerTest { Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); - given(namespaceRepository.search(eq(null), eq(null), eq(null), any())) + given(namespaceRepository.search(eq(null), eq(null), eq(null), eq(null), any())) .willReturn(new org.springframework.data.domain.PageImpl<>( List.of(active, archived), org.springframework.data.domain.PageRequest.of(0, 2), @@ -159,6 +159,7 @@ class NamespacePortalControllerTest { given(namespaceRepository.searchByIdIn( eq(List.of(1L)), eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), eq("team"), eq("team-ai"), any() @@ -172,6 +173,7 @@ class NamespacePortalControllerTest { .param("page", "0") .param("size", "20") .param("status", "ACTIVE") + .param("type", "TEAM") .param("q", "team") .param("slug", "team-ai") .param("roles", "OWNER", "ADMIN") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java index 050bdbcc..2232a894 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import jakarta.persistence.EntityManager; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -34,6 +35,9 @@ class NamespaceJpaRepositoryTest { Namespace archived = new Namespace("archived-percent", "50% Archived", "owner-1"); archived.setStatus(NamespaceStatus.ARCHIVED); persist(archived); + Namespace global = new Namespace("global", "50% Global", "owner-1"); + global.setType(NamespaceType.GLOBAL); + persist(global); entityManager.flush(); } @@ -41,6 +45,7 @@ class NamespaceJpaRepositoryTest { void search_treatsEscapedWildcardsLiterallyAndAppliesStatus() { var percentPage = repository.search( NamespaceStatus.ACTIVE, + NamespaceType.TEAM, "!%", null, PageRequest.of(0, 10) @@ -48,6 +53,7 @@ class NamespaceJpaRepositoryTest { var underscorePage = repository.searchByIdIn( List.of(percentNamespace.getId(), underscoreNamespace.getId()), NamespaceStatus.ACTIVE, + NamespaceType.TEAM, "!_", null, PageRequest.of(0, 10) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 2253a77f..1a48cc0c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -155,6 +155,7 @@ class NamespacePortalQueryAppServiceTest { when(namespaceRepository.searchByIdIn( eq(List.of(1L)), eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), eq("team"), eq("team-ai"), any(Pageable.class) @@ -165,6 +166,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(1L, NamespaceRole.OWNER, 2L, NamespaceRole.MEMBER), Set.of("SUPER_ADMIN"), NamespaceStatus.ACTIVE, + NamespaceType.TEAM, " team ", " team-ai ", Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) @@ -175,11 +177,12 @@ class NamespacePortalQueryAppServiceTest { verify(namespaceRepository).searchByIdIn( eq(List.of(1L)), eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), eq("team"), eq("team-ai"), any(Pageable.class) ); - verify(namespaceRepository, never()).search(any(), any(), any(), any()); + verify(namespaceRepository, never()).search(any(), any(), any(), any(), any()); } @Test @@ -189,6 +192,7 @@ class NamespacePortalQueryAppServiceTest { Pageable expectedPageable = PageRequest.of(1, 10); when(namespaceRepository.search( eq(NamespaceStatus.ARCHIVED), + eq(null), eq("ops"), eq("ops-team"), any(Pageable.class) @@ -199,6 +203,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(), Set.of("SUPER_ADMIN"), NamespaceStatus.ARCHIVED, + null, " ops ", " ops-team ", Set.of() @@ -208,17 +213,19 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.total()).isEqualTo(11); verify(namespaceRepository).search( eq(NamespaceStatus.ARCHIVED), + eq(null), eq("ops"), eq("ops-team"), any(Pageable.class) ); - verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any()); + verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any(), any()); } @Test void listMyNamespaces_escapesLikeWildcardsForLiteralSubstringSearch() { Pageable expectedPageable = PageRequest.of(0, 20); when(namespaceRepository.search( + eq(null), eq(null), eq("50!%!_!!off"), eq(null), @@ -230,12 +237,14 @@ class NamespacePortalQueryAppServiceTest { Map.of(), Set.of("SUPER_ADMIN"), null, + null, " 50%_!off ", null, Set.of() ); verify(namespaceRepository).search( + eq(null), eq(null), eq("50!%!_!!off"), eq(null), @@ -252,6 +261,7 @@ class NamespacePortalQueryAppServiceTest { eq(null), eq(null), eq(null), + eq(null), any(Pageable.class) )).thenReturn(new PageImpl<>(List.of(administered, member), PageRequest.of(0, 20), 2)); @@ -260,6 +270,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(2L, NamespaceRole.ADMIN, 1L, NamespaceRole.MEMBER), Set.of(), null, + null, " ", "\t", Set.of() @@ -273,9 +284,10 @@ class NamespacePortalQueryAppServiceTest { eq(null), eq(null), eq(null), + eq(null), any(Pageable.class) ); - verify(namespaceRepository, never()).search(any(), any(), any(), any()); + verify(namespaceRepository, never()).search(any(), any(), any(), any(), any()); } @Test @@ -285,6 +297,7 @@ class NamespacePortalQueryAppServiceTest { Map.of(1L, NamespaceRole.MEMBER), Set.of("SUPER_ADMIN"), NamespaceStatus.ACTIVE, + NamespaceType.TEAM, " team ", null, Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index 38dcc575..2f53f3da 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -15,10 +15,17 @@ public interface NamespaceRepository { Optional findBySlug(String slug); Page findAll(Pageable pageable); Page findByStatus(NamespaceStatus status, Pageable pageable); - Page search(NamespaceStatus status, String query, String slug, Pageable pageable); + Page search( + NamespaceStatus status, + NamespaceType type, + String query, + String slug, + Pageable pageable + ); Page searchByIdIn( List ids, NamespaceStatus status, + NamespaceType type, String query, String slug, Pageable pageable diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 99e243ba..4476f961 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -28,6 +29,7 @@ public interface NamespaceJpaRepository SELECT n FROM Namespace n WHERE (:status IS NULL OR n.status = :status) + AND (:type IS NULL OR n.type = :type) AND ( :query IS NULL OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' @@ -36,6 +38,7 @@ public interface NamespaceJpaRepository AND (:slug IS NULL OR n.slug = :slug) """) Page search(@Param("status") NamespaceStatus status, + @Param("type") NamespaceType type, @Param("query") String query, @Param("slug") String slug, Pageable pageable); @@ -46,6 +49,7 @@ public interface NamespaceJpaRepository FROM Namespace n WHERE n.id IN :ids AND (:status IS NULL OR n.status = :status) + AND (:type IS NULL OR n.type = :type) AND ( :query IS NULL OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' @@ -55,6 +59,7 @@ public interface NamespaceJpaRepository """) Page searchByIdIn(@Param("ids") List ids, @Param("status") NamespaceStatus status, + @Param("type") NamespaceType type, @Param("query") String query, @Param("slug") String slug, Pageable pageable); diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 07166c0c..ac319d3f 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -257,6 +257,7 @@ describe('namespaceApi.listMinePage', () => { page: 1, size: 20, status: 'ACTIVE', + type: 'TEAM', q: 'team ai', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], @@ -264,7 +265,7 @@ describe('namespaceApi.listMinePage', () => { expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( - 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&status=ACTIVE&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', + 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&status=ACTIVE&type=TEAM&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', expect.objectContaining({ headers: expect.any(Headers) }), ) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6bdb6b91..1e35ba82 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -52,6 +52,7 @@ export interface MyNamespacePageParams { page?: number size?: number status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' + type?: 'GLOBAL' | 'TEAM' q?: string slug?: string roles?: Array<'OWNER' | 'ADMIN' | 'MEMBER'> @@ -659,6 +660,9 @@ export const namespaceApi = { if (params.status) { query.set('status', params.status) } + if (params.type) { + query.set('type', params.type) + } if (params.q?.trim()) { query.set('q', params.q.trim()) } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 019f13b1..efc08e1d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -10054,6 +10054,7 @@ export interface operations { query: { pageable: components["schemas"]["Pageable"]; status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + type?: "GLOBAL" | "TEAM"; q?: string; slug?: string; roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; @@ -10080,6 +10081,7 @@ export interface operations { query: { pageable: components["schemas"]["Pageable"]; status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + type?: "GLOBAL" | "TEAM"; q?: string; slug?: string; roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; diff --git a/web/src/features/review/use-namespace-review-entry.test.ts b/web/src/features/review/use-namespace-review-entry.test.ts index 97aaf380..228abac5 100644 --- a/web/src/features/review/use-namespace-review-entry.test.ts +++ b/web/src/features/review/use-namespace-review-entry.test.ts @@ -34,9 +34,14 @@ describe('useNamespaceReviewEntry', () => { it('uses one bounded ACTIVE query when an active review namespace exists', () => { const active = namespace('zeta-active', 'ACTIVE') - useMyNamespacesPageMock.mockImplementation((params: { status?: string }) => ({ + const global = { ...namespace('global', 'ACTIVE'), type: 'GLOBAL' as const } + useMyNamespacesPageMock.mockImplementation((params: { status?: string; type?: string }) => ({ data: { - items: params.status === 'ACTIVE' ? [active] : [namespace('alpha-archived', 'ARCHIVED')], + items: params.type !== 'TEAM' + ? [global] + : params.status === 'ACTIVE' + ? [active] + : [namespace('alpha-archived', 'ARCHIVED')], total: 1, page: 0, size: 1, @@ -51,11 +56,13 @@ describe('useNamespaceReviewEntry', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, true) expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { page: 0, size: 1, + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) expect(result.namespaceReviewEntry?.slug).toBe('zeta-active') @@ -79,6 +86,7 @@ describe('useNamespaceReviewEntry', () => { expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { page: 0, size: 1, + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, true) expect(result.namespaceReviewEntry?.slug).toBe('alpha-archived') diff --git a/web/src/features/review/use-namespace-review-entry.ts b/web/src/features/review/use-namespace-review-entry.ts index a525cfcc..f797f293 100644 --- a/web/src/features/review/use-namespace-review-entry.ts +++ b/web/src/features/review/use-namespace-review-entry.ts @@ -12,6 +12,7 @@ export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: [...REVIEW_ROLES], }, !hasGlobalReviewAccess) const activeEntry = getPreferredNamespaceReviewEntry(activeQuery.data?.items) @@ -23,6 +24,7 @@ export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { const fallbackQuery = useMyNamespacesPage({ page: 0, size: 1, + type: 'TEAM', roles: [...REVIEW_ROLES], }, fallbackEnabled) const fallbackEntry = fallbackEnabled @@ -34,5 +36,8 @@ export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { isLoadingNamespaces: !hasGlobalReviewAccess && (activeQuery.isLoading || (fallbackEnabled && fallbackQuery.isLoading)), hasNamespaceQueryError: Boolean(activeQuery.error || (fallbackEnabled && fallbackQuery.error)), + retryNamespaceQueries: () => fallbackEnabled + ? fallbackQuery.refetch() + : activeQuery.refetch(), } } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 49d23b47..84f260cf 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -492,6 +492,8 @@ "pageSubtitle": "Manage access credentials for CLI and API" }, "reviews": { + "namespaceLoadError": "Failed to load namespace review access.", + "retryNamespaceLoad": "Retry", "title": "Review Center", "subtitle": "Manage platform review tasks", "typeSkill": "Skill Reviews", @@ -1365,6 +1367,8 @@ "namespace": "Namespace", "selectNamespace": "Select namespace", "namespaceUnavailable": "The selected namespace is not active or is no longer available.", + "namespaceValidationError": "Failed to validate the selected namespace.", + "retryNamespaceValidation": "Retry validation", "visibility": "Visibility", "visibilityOptions": { "public": "Public", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e95dfc26..8fde761a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -492,6 +492,8 @@ "pageSubtitle": "管理 CLI 和 API 使用的访问凭证" }, "reviews": { + "namespaceLoadError": "无法加载命名空间审核权限。", + "retryNamespaceLoad": "重试", "title": "审核中心", "subtitle": "管理平台审核事务", "typeSkill": "技能审核", @@ -1366,6 +1368,8 @@ "namespace": "命名空间", "selectNamespace": "选择命名空间", "namespaceUnavailable": "所选命名空间未启用或已不可用。", + "namespaceValidationError": "无法校验所选命名空间。", + "retryNamespaceValidation": "重新校验", "visibility": "可见性", "visibilityOptions": { "public": "公开", diff --git a/web/src/pages/dashboard/publish.test.ts b/web/src/pages/dashboard/publish.test.ts index 550d8cf8..3f23a08f 100644 --- a/web/src/pages/dashboard/publish.test.ts +++ b/web/src/pages/dashboard/publish.test.ts @@ -135,6 +135,21 @@ describe('PublishPage', () => { expect(html).toContain('publish.namespaceUnavailable') }) + it('distinguishes a namespace validation request failure from an unavailable namespace', () => { + useMyNamespacesPageMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('network down'), + refetch: vi.fn(), + }) + + const html = renderToStaticMarkup(createElement(PublishPage)) + + expect(html).toContain('publish.namespaceValidationError') + expect(html).toContain('publish.retryNamespaceValidation') + expect(html).not.toContain('publish.namespaceUnavailable') + }) + it('exports a named component function', () => { expect(typeof PublishPage).toBe('function') }) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 4f0d8e63..c921b5e1 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -39,7 +39,12 @@ export function PublishPage() { const [warningDialogOpen, setWarningDialogOpen] = useState(false) const [precheckWarnings, setPrecheckWarnings] = useState([]) - const { data: selectedNamespacePage, isLoading: isLoadingSelectedNamespace } = useMyNamespacesPage({ + const { + data: selectedNamespacePage, + isLoading: isLoadingSelectedNamespace, + error: selectedNamespaceError, + refetch: refetchSelectedNamespace, + } = useMyNamespacesPage({ page: 0, size: 1, status: 'ACTIVE', @@ -165,8 +170,17 @@ export function PublishPage() { status="ACTIVE" disabled={publishMutation.isPending} /> - {namespaceSlug && !isLoadingSelectedNamespace && !selectedNamespace ? ( -

{t('publish.namespaceUnavailable')}

+ {namespaceSlug && !isLoadingSelectedNamespace ? ( + selectedNamespaceError ? ( +
+

{t('publish.namespaceValidationError')}

+ +
+ ) : !selectedNamespace ? ( +

{t('publish.namespaceUnavailable')}

+ ) : null ) : null}
diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index aba65e8e..1da5c5cc 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -205,7 +205,25 @@ describe('ReviewsPage', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) }) + + it('renders a recoverable error when namespace review access cannot be loaded', () => { + userMock.platformRoles = ['USER'] + hasRoleMock.mockReturnValue(false) + useMyNamespacesPageMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('network down'), + refetch: vi.fn(), + }) + + const html = renderToStaticMarkup(createElement(ReviewsPage)) + + expect(html).toContain('reviews.namespaceLoadError') + expect(html).toContain('reviews.retryNamespaceLoad') + expect(html).not.toContain('Loading...') + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 8f6de4d7..cd7ad74f 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { @@ -54,6 +55,7 @@ export function ReviewsPage() { namespaceReviewEntry, isLoadingNamespaces, hasNamespaceQueryError, + retryNamespaceQueries, } = useNamespaceReviewEntry(hasGlobalReviewAccess) const showTypeTabs = isSkillAdmin && isUserAdmin @@ -252,7 +254,16 @@ export function ReviewsPage() {
- Loading... + {hasNamespaceQueryError ? ( +
+

{t('reviews.namespaceLoadError')}

+ +
+ ) : ( + 'Loading...' + )}
) diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx index 5deefa89..b8faf624 100644 --- a/web/src/shared/components/user-menu.test.tsx +++ b/web/src/shared/components/user-menu.test.tsx @@ -149,11 +149,13 @@ describe('UserMenu security settings visibility', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, true) expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) expect(html).toContain('user.menu.reviews') @@ -173,6 +175,7 @@ describe('UserMenu security settings visibility', () => { page: 0, size: 1, status: 'ACTIVE', + type: 'TEAM', roles: ['OWNER', 'ADMIN'], }, false) }) diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index bd5f7d93..2f4b39f7 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -54,6 +54,7 @@ describe('use-namespace-queries exports', () => { page: 3, size: 15, status: 'ACTIVE', + type: 'TEAM', q: 'team', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], @@ -64,6 +65,7 @@ describe('use-namespace-queries exports', () => { page: 3, size: 15, status: 'ACTIVE', + type: 'TEAM', q: 'team', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], @@ -79,6 +81,7 @@ describe('use-namespace-queries exports', () => { page: 3, size: 15, status: 'ACTIVE', + type: 'TEAM', q: 'team', slug: 'team-ai', roles: ['OWNER', 'ADMIN'], diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index 4bdfa135..a9c7c91a 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -13,6 +13,7 @@ function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyN page: params.page ?? 0, size: params.size ?? MY_NAMESPACES_PAGE_SIZE, ...(params.status ? { status: params.status } : {}), + ...(params.type ? { type: params.type } : {}), ...(q ? { q } : {}), ...(slug ? { slug } : {}), ...(params.roles?.length ? { roles: [...params.roles] } : {}), From 0cbe33436d2bc070c53fdf00d90a215679b8cca3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 17:29:12 +0800 Subject: [PATCH 14/23] fix(namespace): type nullable search terms Signed-off-by: dongmucat <1127093059@qq.com> --- .../infra/jpa/NamespaceJpaRepositoryTest.java | 14 ++++++++++++++ .../skillhub/infra/jpa/NamespaceJpaRepository.java | 8 ++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java index 2232a894..255387cb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java @@ -66,6 +66,20 @@ class NamespaceJpaRepositoryTest { .containsExactly("underscore-team"); } + @Test + void search_acceptsNullQueryAlongsideOtherFilters() { + var page = repository.search( + NamespaceStatus.ACTIVE, + NamespaceType.TEAM, + null, + "percent-team", + PageRequest.of(0, 1) + ); + + assertThat(page.getContent()).extracting(Namespace::getSlug) + .containsExactly("percent-team"); + } + private Namespace persist(Namespace namespace) { entityManager.persist(namespace); return namespace; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 4476f961..07fa8e94 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -32,8 +32,8 @@ public interface NamespaceJpaRepository AND (:type IS NULL OR n.type = :type) AND ( :query IS NULL - OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' - OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) ESCAPE '!' + OR lower(n.slug) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' + OR lower(n.displayName) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' ) AND (:slug IS NULL OR n.slug = :slug) """) @@ -52,8 +52,8 @@ public interface NamespaceJpaRepository AND (:type IS NULL OR n.type = :type) AND ( :query IS NULL - OR lower(n.slug) LIKE lower(concat('%', :query, '%')) ESCAPE '!' - OR lower(n.displayName) LIKE lower(concat('%', :query, '%')) ESCAPE '!' + OR lower(n.slug) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' + OR lower(n.displayName) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' ) AND (:slug IS NULL OR n.slug = :slug) """) From 91492272061754131b1dfbb41edac2c19297e7af Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 17:56:09 +0800 Subject: [PATCH 15/23] fix(frontend): preserve namespace picker selectors Signed-off-by: dongmucat <1127093059@qq.com> --- web/e2e/publish-flow-ui.spec.ts | 2 +- web/src/pages/dashboard/publish.tsx | 3 ++- web/src/shared/components/namespace-picker.test.tsx | 8 ++++++++ web/src/shared/components/namespace-picker.tsx | 4 +++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index 867c8248..d1e325ce 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -35,7 +35,7 @@ test.describe('Publish Flow UI (Real API)', () => { const namespaceTrigger = page.locator('#namespace') await expect(namespaceTrigger).toBeVisible() await namespaceTrigger.click() - const namespaceOption = page.getByRole('option', { + const namespaceOption = page.getByRole('dialog').getByRole('button', { name: new RegExp(`\\(@${namespace.slug}\\)`), }).first() await expect(namespaceOption).toBeVisible() diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index c921b5e1..2fc2d8d5 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -163,8 +163,9 @@ export function PublishPage() {
- + { }, true) }) + it('forwards the trigger id for labels and stable form selectors', () => { + render() + + expect(document.getElementById('namespace')).toBe( + screen.getByRole('button', { name: 'namespacePicker.placeholder' }), + ) + }) + it('paginates bounded results and emits the selected slug', () => { const onValueChange = vi.fn() render() diff --git a/web/src/shared/components/namespace-picker.tsx b/web/src/shared/components/namespace-picker.tsx index e8a7007c..ab918629 100644 --- a/web/src/shared/components/namespace-picker.tsx +++ b/web/src/shared/components/namespace-picker.tsx @@ -16,6 +16,7 @@ import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' const PAGE_SIZE = 20 interface NamespacePickerProps { + id?: string value: string onValueChange: (slug: string) => void status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' @@ -27,6 +28,7 @@ interface NamespacePickerProps { * Server-paged namespace selector that keeps request and render size bounded. */ export function NamespacePicker({ + id, value, onValueChange, status, @@ -58,7 +60,7 @@ export function NamespacePicker({ return ( - From 1d216e73f09fb8facc720283d69d19f665f321b3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 27 Jul 2026 18:02:07 +0800 Subject: [PATCH 16/23] fix(frontend): expose namespace picker value Signed-off-by: dongmucat <1127093059@qq.com> --- .../my-namespaces-super-admin-actions.spec.ts | 8 +++++--- web/src/pages/dashboard/publish.tsx | 1 + .../shared/components/namespace-picker.test.tsx | 16 +++++++++++++--- web/src/shared/components/namespace-picker.tsx | 14 ++++++++++++-- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index ff0679d1..07ce0bf1 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -74,7 +74,8 @@ test.describe('My Namespaces super admin actions (Real API)', () => { namespaceArchived = true await page.goto('/dashboard/publish') - await page.getByRole('button', { name: 'Select namespace', exact: true }).click() + const namespaceTrigger = page.locator('#namespace') + await namespaceTrigger.click() await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(activeNamespace.slug) const activeOption = page.getByRole('button', { name: `${activeNamespace.displayName} (@${activeNamespace.slug})`, @@ -82,7 +83,8 @@ test.describe('My Namespaces super admin actions (Real API)', () => { await expect(activeOption).toBeVisible() await activeOption.click() - await page.getByRole('button', { name: `@${activeNamespace.slug}`, exact: true }).click() + await expect(namespaceTrigger).toContainText(`@${activeNamespace.slug}`) + await namespaceTrigger.click() await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(namespace.slug) await expect(page.getByText('No namespaces found')).toBeVisible() await expect(page.getByRole('button', { @@ -90,7 +92,7 @@ test.describe('My Namespaces super admin actions (Real API)', () => { })).toHaveCount(0) await page.goto(`/dashboard/publish?namespace=${encodeURIComponent(namespace.slug)}&visibility=PUBLIC`) - await expect(page.getByRole('button', { name: `@${namespace.slug}`, exact: true })).toBeVisible() + await expect(namespaceTrigger).toContainText(`@${namespace.slug}`) await expect(page.getByText('The selected namespace is not active or is no longer available.')).toBeVisible() await page.goto('/dashboard/namespaces') diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 2fc2d8d5..a45f64a1 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -166,6 +166,7 @@ export function PublishPage() { { }, true) }) - it('forwards the trigger id for labels and stable form selectors', () => { - render() + it('keeps the current value in the accessible name when associated with a label', () => { + render( + <> + + + , + ) expect(document.getElementById('namespace')).toBe( - screen.getByRole('button', { name: 'namespacePicker.placeholder' }), + screen.getByRole('button', { name: 'Namespace: @active-team' }), ) }) diff --git a/web/src/shared/components/namespace-picker.tsx b/web/src/shared/components/namespace-picker.tsx index ab918629..153a5e43 100644 --- a/web/src/shared/components/namespace-picker.tsx +++ b/web/src/shared/components/namespace-picker.tsx @@ -17,6 +17,7 @@ const PAGE_SIZE = 20 interface NamespacePickerProps { id?: string + accessibleLabel?: string value: string onValueChange: (slug: string) => void status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' @@ -29,6 +30,7 @@ interface NamespacePickerProps { */ export function NamespacePicker({ id, + accessibleLabel, value, onValueChange, status, @@ -40,6 +42,7 @@ export function NamespacePicker({ const [page, setPage] = useState(0) const [search, setSearch] = useState('') const debouncedSearch = useDebounce(search.trim(), 300) + const triggerText = value ? `@${value}` : emptyValueLabel ?? t('namespacePicker.placeholder') const query = useMyNamespacesPage({ page, size: PAGE_SIZE, @@ -60,8 +63,15 @@ export function NamespacePicker({ return ( - From e066277065b721a093bffc7304fbf1f2f04d3eef Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 10:57:52 +0800 Subject: [PATCH 17/23] fix(search): require published portal results Signed-off-by: dongmucat <1127093059@qq.com> --- .../PostgresFullTextQueryService.java | 8 ++--- .../PostgresFullTextQueryServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 64015844..1e79f041 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -107,9 +107,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("FROM skill_search_document d "); sql.append("JOIN skill s ON s.id = d.skill_id "); sql.append("JOIN namespace n ON n.id = d.namespace_id "); - if (query.requireInstallableLatest()) { - sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); - } + sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); sql.append("WHERE 1=1 "); // Visibility filtering @@ -123,10 +121,10 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND d.status = 'ACTIVE' "); sql.append("AND s.status = 'ACTIVE' "); sql.append("AND s.hidden = FALSE "); + sql.append("AND latest.status = 'PUBLISHED' "); + sql.append("AND latest.yanked_at IS NULL "); if (query.requireInstallableLatest()) { - sql.append("AND latest.status = 'PUBLISHED' "); sql.append("AND latest.download_ready = TRUE "); - sql.append("AND latest.yanked_at IS NULL "); } sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index c84c6cbd..cf73ba74 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -400,6 +400,41 @@ class PostgresFullTextQueryServiceTest { verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); } + @Test + void portalSearchShouldRequirePublishedNonYankedLatestWithoutDownloadReadiness() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "demo", + null, + SearchVisibilityScope.anonymous(), + "newest", + 0, + 12, + List.of(), + false + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues()).allSatisfy(sql -> assertThat(sql) + .contains("JOIN skill_version latest ON latest.id = s.latest_version_id") + .contains("AND latest.status = 'PUBLISHED'") + .contains("AND latest.yanked_at IS NULL") + .doesNotContain("latest.download_ready = TRUE")); + } + @Test void installableLatestFilterShouldApplyToSearchAndCountQueries() { EntityManager entityManager = mock(EntityManager.class); From 082ab489a3bd1fe79b5b0d3d189b2a9964e41a1b Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:06:14 +0800 Subject: [PATCH 18/23] fix(search): refresh index after version yank Signed-off-by: dongmucat <1127093059@qq.com> --- .../event/SearchIndexEventListener.java | 7 +++++ .../PostgresSearchRebuildService.java | 10 ++++++- .../event/SearchIndexEventListenerTest.java | 21 +++++++++++++++ .../PostgresSearchRebuildServiceTest.java | 26 +++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java index b0fcee58..098e1231 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.event; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; @@ -32,6 +33,12 @@ public class SearchIndexEventListener { searchRebuildService.rebuildBySkill(event.skillId()); } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("skillhubEventExecutor") + public void onSkillVersionYanked(SkillVersionYankedEvent event) { + searchRebuildService.rebuildBySkill(event.skillId()); + } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async("skillhubEventExecutor") public void onSkillStatusChanged(SkillStatusChangedEvent event) { diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 97e2d560..f0e09856 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -116,7 +116,12 @@ public class PostgresSearchRebuildService implements SearchRebuildService { return; } - toDocument(skillOpt.get()).ifPresent(searchIndexService::index); + Optional document = toDocument(skillOpt.get()); + if (document.isPresent()) { + searchIndexService.index(document.get()); + } else { + searchIndexService.remove(skillId); + } } private SearchIndexPayload buildSearchPayload(Skill skill) { @@ -269,6 +274,9 @@ public class PostgresSearchRebuildService implements SearchRebuildService { } private Optional toDocument(Skill skill) { + if (skill.getLatestVersionId() == null) { + return Optional.empty(); + } Optional namespaceOpt = namespaceRepository.findById(skill.getNamespaceId()); if (namespaceOpt.isEmpty()) { return Optional.empty(); diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java index 84a2b295..da0ecf55 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java @@ -2,11 +2,16 @@ package com.iflytek.skillhub.search.event; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; import org.junit.jupiter.api.Test; +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -33,4 +38,20 @@ class SearchIndexEventListenerTest { verify(searchIndexService).remove(42L); } + + @Test + void yankedVersionShouldTriggerSkillRebuild() throws Exception { + SearchRebuildService searchRebuildService = mock(SearchRebuildService.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + SearchIndexEventListener listener = new SearchIndexEventListener(searchRebuildService, searchIndexService); + + Method handler = Arrays.stream(SearchIndexEventListener.class.getDeclaredMethods()) + .filter(method -> Arrays.equals(method.getParameterTypes(), new Class[]{SkillVersionYankedEvent.class})) + .findFirst() + .orElse(null); + + assertThat(handler).isNotNull(); + handler.invoke(listener, new SkillVersionYankedEvent(42L, 100L, "admin-1")); + verify(searchRebuildService).rebuildBySkill(42L); + } } diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java index 4abd1e63..806462ed 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java @@ -25,12 +25,38 @@ import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class PostgresSearchRebuildServiceTest { + @Test + void rebuildBySkill_shouldRemoveDocumentWhenNoPublishedVersionExists() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "pending-only", "owner-1", SkillVisibility.NAMESPACE_ONLY); + setField(skill, "id", 42L); + when(skillRepository.findById(42L)).thenReturn(Optional.of(skill)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(42L); + + verify(searchIndexService).remove(42L); + verify(searchIndexService, never()).index(any()); + } + @Test void rebuildBySkill_shouldIndexFrontmatterFieldsAndKeywordsWithoutBody() { SkillRepository skillRepository = mock(SkillRepository.class); From cf744245c631c854513a5e3ec4f86d18aff76943 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Jul 2026 11:19:18 +0800 Subject: [PATCH 19/23] fix(search): filter rebuilds to published versions Signed-off-by: dongmucat <1127093059@qq.com> --- .../PostgresSearchRebuildService.java | 34 +++--- .../event/SearchIndexEventListenerTest.java | 14 +-- .../PostgresSearchRebuildServiceTest.java | 108 ++++++++++++++++++ 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index f0e09856..2f1efd38 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; import com.iflytek.skillhub.search.SearchTextTokenizer; @@ -92,11 +93,17 @@ public class PostgresSearchRebuildService implements SearchRebuildService { @Override public void rebuildAll() { - List documents = skillRepository.findAll().stream() - .filter(skill -> skill.getStatus() == SkillStatus.ACTIVE) - .map(this::toDocument) - .flatMap(Optional::stream) - .toList(); + List documents = new ArrayList<>(); + for (Skill skill : skillRepository.findAll()) { + Optional document = skill.getStatus() == SkillStatus.ACTIVE + ? toDocument(skill) + : Optional.empty(); + if (document.isPresent()) { + documents.add(document.get()); + } else { + searchIndexService.remove(skill.getId()); + } + } searchIndexService.batchIndex(documents); } @@ -124,15 +131,13 @@ public class PostgresSearchRebuildService implements SearchRebuildService { } } - private SearchIndexPayload buildSearchPayload(Skill skill) { + private SearchIndexPayload buildSearchPayload(Skill skill, SkillVersion latestVersion) { List searchParts = new ArrayList<>(); addPart(searchParts, skill.getSlug()); addPart(searchParts, skill.getSummary()); Set keywords = new TreeSet<>(); - resolveLatestVersion(skill) - .map(this::extractParsedMetadata) - .map(metadata -> metadata.get("frontmatter")) + Optional.ofNullable(extractParsedMetadata(latestVersion).get("frontmatter")) .map(this::asMap) .ifPresent(frontmatter -> appendFrontmatter(frontmatter, keywords, searchParts)); appendLabelKeywords(skill.getId(), keywords); @@ -143,11 +148,13 @@ public class PostgresSearchRebuildService implements SearchRebuildService { ); } - private Optional resolveLatestVersion(Skill skill) { + private Optional resolvePublishedLatestVersion(Skill skill) { if (skill.getLatestVersionId() == null) { return Optional.empty(); } - return skillVersionRepository.findById(skill.getLatestVersionId()); + return skillVersionRepository.findById(skill.getLatestVersionId()) + .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED) + .filter(version -> version.getYankedAt() == null); } private Map extractParsedMetadata(SkillVersion version) { @@ -274,7 +281,8 @@ public class PostgresSearchRebuildService implements SearchRebuildService { } private Optional toDocument(Skill skill) { - if (skill.getLatestVersionId() == null) { + Optional latestVersion = resolvePublishedLatestVersion(skill); + if (latestVersion.isEmpty()) { return Optional.empty(); } Optional namespaceOpt = namespaceRepository.findById(skill.getNamespaceId()); @@ -283,7 +291,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService { } Namespace namespace = namespaceOpt.get(); - SearchIndexPayload payload = buildSearchPayload(skill); + SearchIndexPayload payload = buildSearchPayload(skill, latestVersion.get()); return Optional.of(new SkillSearchDocument( skill.getId(), diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java index da0ecf55..5788136c 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java @@ -8,10 +8,6 @@ import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; import org.junit.jupiter.api.Test; -import java.lang.reflect.Method; -import java.util.Arrays; - -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -40,18 +36,12 @@ class SearchIndexEventListenerTest { } @Test - void yankedVersionShouldTriggerSkillRebuild() throws Exception { + void yankedVersionShouldTriggerSkillRebuild() { SearchRebuildService searchRebuildService = mock(SearchRebuildService.class); SearchIndexService searchIndexService = mock(SearchIndexService.class); SearchIndexEventListener listener = new SearchIndexEventListener(searchRebuildService, searchIndexService); - Method handler = Arrays.stream(SearchIndexEventListener.class.getDeclaredMethods()) - .filter(method -> Arrays.equals(method.getParameterTypes(), new Class[]{SkillVersionYankedEvent.class})) - .findFirst() - .orElse(null); - - assertThat(handler).isNotNull(); - handler.invoke(listener, new SkillVersionYankedEvent(42L, 100L, "admin-1")); + listener.onSkillVersionYanked(new SkillVersionYankedEvent(42L, 100L, "admin-1")); verify(searchRebuildService).rebuildBySkill(42L); } } diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java index 806462ed..dc8ceac1 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchTextTokenizer; @@ -21,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import java.lang.reflect.Field; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -33,6 +35,107 @@ import static org.mockito.Mockito.when; class PostgresSearchRebuildServiceTest { + @Test + void rebuildAll_shouldRemoveDocumentWhenLatestVersionIsNotPublished() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "private-upload", "owner-1", SkillVisibility.PRIVATE); + setField(skill, "id", 42L); + skill.setLatestVersionId(99L); + + SkillVersion version = new SkillVersion(42L, "1.0.0", "owner-1"); + version.setStatus(SkillVersionStatus.UPLOADED); + + when(skillRepository.findAll()).thenReturn(List.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of( + new Namespace("team-ai", "Team AI", "owner-1") + )); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildAll(); + + verify(searchIndexService).remove(42L); + verify(searchIndexService).batchIndex(List.of()); + verify(searchIndexService, never()).index(any()); + } + + @Test + void rebuildBySkill_shouldRemoveDocumentWhenLatestVersionIsNotPublished() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "private-upload", "owner-1", SkillVisibility.PRIVATE); + setField(skill, "id", 42L); + skill.setLatestVersionId(99L); + + SkillVersion version = new SkillVersion(42L, "1.0.0", "owner-1"); + version.setStatus(SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(42L)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of( + new Namespace("team-ai", "Team AI", "owner-1") + )); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(42L); + + verify(searchIndexService).remove(42L); + verify(searchIndexService, never()).index(any()); + } + + @Test + void rebuildBySkill_shouldRemoveDocumentWhenLatestPublishedVersionIsYanked() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "yanked", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 42L); + skill.setLatestVersionId(99L); + + SkillVersion version = new SkillVersion(42L, "1.0.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setYankedAt(Instant.parse("2026-07-28T00:00:00Z")); + + when(skillRepository.findById(42L)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of( + new Namespace("team-ai", "Team AI", "owner-1") + )); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(42L); + + verify(searchIndexService).remove(42L); + verify(searchIndexService, never()).index(any()); + } + @Test void rebuildBySkill_shouldRemoveDocumentWhenNoPublishedVersionExists() { SkillRepository skillRepository = mock(SkillRepository.class); @@ -72,6 +175,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -142,6 +246,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion latestVersion = new SkillVersion(1L, "1.3.0", "owner-1"); + latestVersion.setStatus(SkillVersionStatus.PUBLISHED); latestVersion.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -199,6 +304,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.4.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -257,6 +363,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.5.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -309,6 +416,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.6.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "frontmatter": { From 7fc0e2226c3a4934f2e3102a65876365820158a0 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 29 Jul 2026 11:50:01 +0800 Subject: [PATCH 20/23] fix(namespace): refresh session roles for namespace visibility Signed-off-by: dongmucat <1127093059@qq.com> --- docs/05-business-flows.md | 12 +- docs/06-api-design.md | 11 +- .../portal/NamespaceController.java | 50 +++++- .../skillhub/filter/AuthContextFilter.java | 45 ++++- .../NamespacePortalQueryAppService.java | 13 +- .../NamespacePortalControllerTest.java | 164 +++++++++++++++++- .../filter/AuthContextFilterTest.java | 6 + web/e2e/my-namespaces-pagination.spec.ts | 140 +++++++++++++++ web/src/api/client.test.ts | 3 +- web/src/api/client.ts | 29 ++-- web/src/api/generated/schema.d.ts | 24 ++- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/dashboard/reviews.tsx | 2 +- .../hooks/use-namespace-queries.test.ts | 3 + web/src/shared/hooks/use-namespace-queries.ts | 1 + 16 files changed, 474 insertions(+), 31 deletions(-) create mode 100644 web/e2e/my-namespaces-pagination.spec.ts diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index 3cd6a7cb..4ee551f3 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -82,11 +82,21 @@ | namespace MEMBER | 可读 | 可读 | 不可读 | 不可读 | 仅自己是 owner 时可读 | | skill owner | 可读 | 可读 | 可读 | 可读 | 可读 | | namespace ADMIN / OWNER | 可读 | 可读 | 可读 | 可读 | 不可读,除非本人也是 skill owner | -| SKILL_ADMIN / SUPER_ADMIN(仅平台角色) | 与普通登录用户一致;普通读路径不会因为平台角色自动穿透 private / hidden / unpublished | +| SKILL_ADMIN(仅平台角色) | 与普通登录用户一致;普通读路径不会因为平台角色自动穿透 private / hidden / unpublished | +| SUPER_ADMIN(仅平台角色) | 可通过平台 namespace 读取语义读取已发布 `NAMESPACE_ONLY`;不可读 `PRIVATE`、`hidden=true`、未发布版本,除非同时满足 owner 或 namespace 管理者条件 | 补充: - `hidden=true` 时,可读权限会收敛为“skill owner 或 namespace `ADMIN` / `OWNER`” - `visibility=PUBLIC` 也不意味着未发布 skill 可见;当 `latest_version_id` 为空时,只有 owner 能读 +- `SUPER_ADMIN` 的 namespace 读取语义用于平台可见性与详情巡检,可覆盖 archived namespace 的读取入口;它不会把非成员自动提升为 namespace 成员,也不会扩大成员管理、团队审核任务或 namespace 写权限 + +#### 1.3.1.1 Namespace 读取与会话角色刷新 + +- `GET /api/v1/me/namespaces` 保持历史数组响应;`GET /api/v1/me/namespaces/page` 提供分页响应 `{ items, total, page, size }`。 +- namespace 成员只看到自己所属 namespace;`SUPER_ADMIN` 可看到全部 `ACTIVE`、`FROZEN`、`ARCHIVED` namespace。 +- 非成员 `SUPER_ADMIN` 在 namespace 卡片与详情中的 `currentUserRole` 为空,成员管理、团队审核入口、冻结/归档/删除等写能力仍按 namespace membership 或专门平台治理权限判断。 +- 已登录 session 在受保护请求入口会用持久化平台角色刷新权限快照;授予或撤销 `SUPER_ADMIN` 后,namespace 读链路不依赖用户先访问 `/api/v1/auth/me` 才生效。 +- `/me/namespaces/page` 的分页 query 为 `page`、`size`、`sort`;`size` 后端上限为 100,`sort` 仅支持 `slug,asc` / `slug,desc`,默认 `slug,asc`。 #### 1.3.2 Version 状态读取 diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 56cb5541..ad5871be 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -87,10 +87,11 @@ Public API 的可见性规则: - `PUBLIC` 技能:若存在已发布版本,则已登录用户可访问;匿名访问仍受下载/resolve 端点的 namespace 类型限制 -- `NAMESPACE_ONLY` 技能:仅该命名空间成员可访问(需登录) +- `NAMESPACE_ONLY` 技能:该命名空间成员可访问(需登录);非成员 `SUPER_ADMIN` 仅在平台 namespace 读取语义下可读取已发布版本 - `PRIVATE` 技能:owner 本人 + 该 namespace 的 ADMIN 以上可访问(需登录) - 若 `latest_version_id = null`,即使 `visibility=PUBLIC`,skill 也不会对外公开,只有 owner 可访问 - `hidden=true` 时,普通访客不可访问;仅 owner 或该 namespace 的 `ADMIN` / `OWNER` 可访问 +- `SUPER_ADMIN` 的平台 namespace 读取语义不会穿透 `PRIVATE`、`hidden=true` 或未发布版本,也不会赋予非成员 namespace 写权限、成员管理权限或团队审核任务处理权限 `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` 的 `data` 字段除版本基础信息外,还必须包含: @@ -330,10 +331,14 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/admin/audit-logs` | 审计日志查询 | -## 7.7 Namespace 管理 API(需命名空间 OWNER 或 ADMIN) +## 7.7 Namespace API | 方法 | 路径 | 说明 | |------|------|------| +| GET | `/api/v1/me/namespaces` | 当前用户可见 namespace,保持历史数组响应;成员看到所属 namespace,`SUPER_ADMIN` 可见全部 namespace | +| GET | `/api/v1/me/namespaces/page` | 当前用户可见 namespace 分页响应 `{ items, total, page, size }`;query 支持 `page`、`size`、`sort`、`status`、`type`、`q`、`slug`、`roles` | +| GET | `/api/v1/namespaces` | namespace 列表;普通用户按 membership 返回 active namespace,`SUPER_ADMIN` 可读 active namespace | +| GET | `/api/v1/namespaces/{slug}` | namespace 详情;成员可读所属 namespace,`SUPER_ADMIN` 可读非成员 namespace,包括 archived namespace | | POST | `/api/v1/namespaces` | 创建命名空间 | | PUT | `/api/v1/namespaces/{slug}` | 更新命名空间信息 | | GET | `/api/v1/namespaces/{slug}/members` | 成员列表 | @@ -345,6 +350,8 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: | POST | `/api/v1/reviews/{id}/reject` | 空间管理员审核拒绝 | | POST | `/api/v1/promotions` | 申请提升到全局 | +`/api/web` 暴露同名 Web 端点,契约与 `/api/v1` 一致。`/me/namespaces/page` 的 `page` 为 0 基页码,`size` 默认 20 且后端上限 100;`sort` 仅支持 `slug,asc` / `slug,desc`,默认 `slug,asc`。非成员 `SUPER_ADMIN` 读取 namespace 时 `currentUserRole` 为空,成员管理、团队审核入口和写操作仍需 namespace `OWNER` / `ADMIN` membership 或对应平台治理权限。 + ## 7.8 `latest` 语义说明 `latest` 自动跟随最新已发布版本,不可手动移动。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 2e64eaf7..77ea3e80 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -25,12 +25,17 @@ import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -80,7 +85,12 @@ public class NamespaceController extends BaseApiController { @GetMapping("/me/namespaces/page") public ApiResponse> listMyNamespacesPage( - Pageable pageable, + @Parameter(description = "Zero-based page index.", schema = @Schema(type = "integer", format = "int32", defaultValue = "0", minimum = "0")) + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Page size. Values above the namespace list limit are bounded by the backend.", schema = @Schema(type = "integer", format = "int32", defaultValue = "20", minimum = "1")) + @RequestParam(defaultValue = "20") int size, + @Parameter(description = "Sort criteria in property,direction form. Only slug sorting is honored; defaults to slug,asc.", example = "slug,asc") + @RequestParam(required = false) List sort, @RequestParam(required = false) NamespaceStatus status, @RequestParam(required = false) NamespaceType type, @RequestParam(required = false) String q, @@ -91,7 +101,7 @@ public class NamespaceController extends BaseApiController { @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces( - pageable, + myNamespacesPageable(page, size, sort), userNsRoles, normalizePlatformRoles(platformRoles), status, @@ -201,6 +211,42 @@ public class NamespaceController extends BaseApiController { : Set.of(); } + private Pageable myNamespacesPageable(int page, int size, List sort) { + return PageRequest.of( + Math.max(page, 0), + Math.max(size, 1), + myNamespacesSort(sort) + ); + } + + private Sort myNamespacesSort(List sort) { + if (sort == null || sort.isEmpty()) { + return Sort.unsorted(); + } + List orders = new ArrayList<>(); + for (String rawSort : sort) { + Sort.Order order = slugOrder(rawSort); + if (order != null) { + orders.add(order); + } + } + return orders.isEmpty() ? Sort.unsorted() : Sort.by(orders); + } + + private Sort.Order slugOrder(String rawSort) { + if (rawSort == null || rawSort.isBlank()) { + return null; + } + String[] tokens = rawSort.split(","); + if (!"slug".equals(tokens[0].trim())) { + return null; + } + Sort.Direction direction = tokens.length > 1 + ? Sort.Direction.fromOptionalString(tokens[1].trim()).orElse(Sort.Direction.ASC) + : Sort.Direction.ASC; + return new Sort.Order(direction, "slug"); + } + @GetMapping("/namespaces/{slug}/member-candidates") public ApiResponse> searchMemberCandidates( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 1c16fe89..394ca3b6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -1,8 +1,12 @@ package com.iflytek.skillhub.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -14,7 +18,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import java.io.IOException; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.beans.factory.annotation.Value; @@ -35,6 +41,8 @@ public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final PlatformSessionService platformSessionService; private final ApiResponseFactory apiResponseFactory; private final ObjectMapper objectMapper; private final boolean enforceActiveUserCheck; @@ -42,12 +50,16 @@ public class AuthContextFilter extends OncePerRequestFilter { public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository, UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + PlatformSessionService platformSessionService, ApiResponseFactory apiResponseFactory, ObjectMapper objectMapper, @Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck, RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { this.namespaceMemberRepository = namespaceMemberRepository; this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.platformSessionService = platformSessionService; this.apiResponseFactory = apiResponseFactory; this.objectMapper = objectMapper; this.enforceActiveUserCheck = enforceActiveUserCheck; @@ -75,8 +87,9 @@ public class AuthContextFilter extends OncePerRequestFilter { ); return; } + principal = refreshSessionRolesIfNeeded(principal, request); request.setAttribute("userId", principal.userId()); - request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of()); + request.setAttribute("platformRoles", platformRoles(principal)); Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( NamespaceMember::getNamespaceId, @@ -97,6 +110,36 @@ public class AuthContextFilter extends OncePerRequestFilter { .orElse(true); } + private PlatformPrincipal refreshSessionRolesIfNeeded(PlatformPrincipal principal, HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null || !(session.getAttribute("platformPrincipal") instanceof PlatformPrincipal)) { + return principal; + } + + List roleBindings = userRoleBindingRepository.findByUserId(principal.userId()); + Set freshRoles = PlatformRoleDefaults.withDefaultUserRole( + (roleBindings != null ? roleBindings : List.of()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet())); + if (freshRoles.equals(platformRoles(principal))) { + return principal; + } + + PlatformPrincipal refreshedPrincipal = new PlatformPrincipal( + principal.userId(), + principal.displayName(), + principal.email(), + principal.avatarUrl(), + principal.oauthProvider(), + freshRoles); + platformSessionService.establishSession(refreshedPrincipal, request, false); + return refreshedPrincipal; + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal.platformRoles() != null ? principal.platformRoles() : Set.of(); + } + private void clearAuthentication(HttpServletRequest request) { SecurityContextHolder.clearContext(); HttpSession session = request.getSession(false); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 01bdae60..6b13fa22 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -297,7 +297,18 @@ public class NamespacePortalQueryAppService { ? pageable.getPageSize() : DEFAULT_MY_NAMESPACE_PAGE_SIZE; int size = Math.min(Math.max(requestedSize, 1), MAX_MY_NAMESPACE_PAGE_SIZE); - return PageRequest.of(page, size, Sort.by(NAMESPACE_SLUG_SORT).ascending()); + return PageRequest.of(page, size, normalizeMyNamespacesSort(pageable)); + } + + private Sort normalizeMyNamespacesSort(Pageable pageable) { + if (pageable == null || pageable.getSort().isUnsorted()) { + return Sort.by(NAMESPACE_SLUG_SORT).ascending(); + } + Sort.Order slugOrder = pageable.getSort().getOrderFor(NAMESPACE_SLUG_SORT); + if (slugOrder == null) { + return Sort.by(NAMESPACE_SLUG_SORT).ascending(); + } + return Sort.by(new Sort.Order(slugOrder.getDirection(), NAMESPACE_SLUG_SORT)); } private String normalizeFilter(String value) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 456ebd31..28fc2e56 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -1,7 +1,12 @@ package com.iflytek.skillhub.controller; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; @@ -24,19 +29,26 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.mock.web.MockHttpSession; import java.util.List; import java.util.Map; import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -53,6 +65,9 @@ class NamespacePortalControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean private NamespaceService namespaceService; @@ -77,6 +92,9 @@ class NamespacePortalControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + @Test void listNamespaces_superAdminReturnsAllActiveNamespacesWithoutMembership() throws Exception { Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); @@ -185,6 +203,74 @@ class NamespacePortalControllerTest { .andExpect(jsonPath("$.data.total").value(1)); } + @Test + void listMyNamespacesPage_refreshesGrantedSuperAdminRoleForExistingSession() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace archived = namespace(2L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + PlatformPrincipal stalePrincipal = principal("target-1", Set.of("USER")); + MockHttpSession session = sessionWithPrincipal(stalePrincipal); + + given(userRoleBindingRepository.findByUserId("target-1")) + .willReturn(List.of(new UserRoleBinding("target-1", role("SUPER_ADMIN")))); + given(namespaceMemberRepository.findByUserId("target-1")).willReturn(List.of()); + given(namespaceRepository.search(eq(null), eq(null), eq(null), eq(null), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active, archived), + org.springframework.data.domain.PageRequest.of(0, 20), + 2 + )); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "20") + .session(session) + .with(auth(stalePrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].slug").value("active")) + .andExpect(jsonPath("$.data.items[1].slug").value("archived")) + .andExpect(jsonPath("$.data.total").value(2)); + + PlatformPrincipal refreshedPrincipal = (PlatformPrincipal) session.getAttribute("platformPrincipal"); + assertThat(refreshedPrincipal.platformRoles()).containsExactlyInAnyOrder("SUPER_ADMIN"); + assertThat(sessionAuthorities(session)).containsExactly("ROLE_SUPER_ADMIN"); + } + + @Test + void listMyNamespacesPage_refreshesRevokedSuperAdminRoleForExistingSession() throws Exception { + PlatformPrincipal stalePrincipal = principal("target-2", Set.of("SUPER_ADMIN")); + MockHttpSession session = sessionWithPrincipal(stalePrincipal); + + given(userRoleBindingRepository.findByUserId("target-2")).willReturn(List.of()); + given(namespaceMemberRepository.findByUserId("target-2")).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "20") + .session(session) + .with(auth(stalePrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isEmpty()) + .andExpect(jsonPath("$.data.total").value(0)); + + verify(namespaceRepository, never()).search(eq(null), eq(null), eq(null), eq(null), any()); + PlatformPrincipal refreshedPrincipal = (PlatformPrincipal) session.getAttribute("platformPrincipal"); + assertThat(refreshedPrincipal.platformRoles()).containsExactly("USER"); + assertThat(sessionAuthorities(session)).containsExactly("ROLE_USER"); + } + + @Test + void openApi_myNamespacesPageExposesFlatPagingParameters() throws Exception { + String body = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + JsonNode document = objectMapper.readTree(body); + + assertMyNamespacesPageParameters(document, "/api/v1/me/namespaces/page"); + assertMyNamespacesPageParameters(document, "/api/web/me/namespaces/page"); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); @@ -457,7 +543,21 @@ class NamespacePortalControllerTest { } private RequestPostProcessor auth(String userId, Set platformRoles) { - PlatformPrincipal principal = new PlatformPrincipal( + PlatformPrincipal principal = principal(userId, platformRoles); + return auth(principal); + } + + private RequestPostProcessor auth(PlatformPrincipal principal) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + authorities(principal.platformRoles()) + ); + return authentication(authenticationToken); + } + + private PlatformPrincipal principal(String userId, Set platformRoles) { + return new PlatformPrincipal( userId, userId, userId + "@example.com", @@ -465,12 +565,55 @@ class NamespacePortalControllerTest { "session", platformRoles ); - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( - principal, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); - return authentication(authenticationToken); + } + + private MockHttpSession sessionWithPrincipal(PlatformPrincipal principal) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("platformPrincipal", principal); + return session; + } + + private List authorities(Set platformRoles) { + Set roles = platformRoles == null || platformRoles.isEmpty() ? Set.of("USER") : platformRoles; + return roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + } + + private List sessionAuthorities(MockHttpSession session) { + SecurityContext context = (SecurityContext) session.getAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + return context.getAuthentication().getAuthorities().stream() + .map(Object::toString) + .toList(); + } + + private void assertMyNamespacesPageParameters(JsonNode document, String path) { + JsonNode parameters = document.at("/paths/" + escapeJsonPointer(path) + "/get/parameters"); + assertThat(parameters.isArray()).isTrue(); + List parameterNames = parameterNames(parameters); + assertThat(parameterNames) + .contains("page", "size", "sort", "status", "type", "q", "slug", "roles") + .doesNotContain("pageable"); + assertThat(parameter(parameters, "page").path("schema").path("type").asText()).isEqualTo("integer"); + assertThat(parameter(parameters, "size").path("schema").path("type").asText()).isEqualTo("integer"); + } + + private List parameterNames(JsonNode parameters) { + return java.util.stream.StreamSupport.stream(parameters.spliterator(), false) + .map(parameter -> parameter.path("name").asText()) + .toList(); + } + + private JsonNode parameter(JsonNode parameters, String name) { + return java.util.stream.StreamSupport.stream(parameters.spliterator(), false) + .filter(parameter -> name.equals(parameter.path("name").asText())) + .findFirst() + .orElseThrow(); + } + + private String escapeJsonPointer(String path) { + return path.replace("~", "~0").replace("/", "~1"); } private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { @@ -490,4 +633,11 @@ class NamespacePortalControllerTest { throw new RuntimeException(e); } } + + private Role role(String code) { + Role role = new Role(); + ReflectionTestUtils.setField(role, "code", code); + ReflectionTestUtils.setField(role, "name", code); + return role; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index 6bf56337..2736872f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -3,7 +3,9 @@ package com.iflytek.skillhub.filter; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -41,6 +43,8 @@ class AuthContextFilterTest { private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class); + private final PlatformSessionService platformSessionService = mock(PlatformSessionService.class); private final AuthContextFilter filter; AuthContextFilterTest() { @@ -51,6 +55,8 @@ class AuthContextFilterTest { filter = new AuthContextFilter( namespaceMemberRepository, userAccountRepository, + userRoleBindingRepository, + platformSessionService, apiResponseFactory, new ObjectMapper().registerModule(new JavaTimeModule()), true, diff --git a/web/e2e/my-namespaces-pagination.spec.ts b/web/e2e/my-namespaces-pagination.spec.ts new file mode 100644 index 00000000..eca0abab --- /dev/null +++ b/web/e2e/my-namespaces-pagination.spec.ts @@ -0,0 +1,140 @@ +import { expect, test, type Page, type Route } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +function apiEnvelope(data: unknown) { + return { + code: 0, + msg: 'success', + data, + timestamp: new Date().toISOString(), + requestId: 'e2e-my-namespaces-pagination', + } +} + +function namespaceForPage(page: number) { + return { + id: page + 1, + slug: `team-page-${page}`, + displayName: `Team Page ${page}`, + description: `Namespace page ${page}`, + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-07-29T00:00:00Z', + updatedAt: '2026-07-29T00:00:00Z', + currentUserRole: 'OWNER', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: page === 2, + } +} + +async function fulfillJson(route: Route, data: unknown) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope(data)), + }) +} + +test.describe('My Namespaces pagination', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('backs up to the previous valid page after deleting the only item on the last page', async ({ page }) => { + let totalNamespaces = 41 + const requestedPages: number[] = [] + + await installDashboardMocks(page, requestedPages, () => totalNamespaces, () => { + totalNamespaces = 40 + }) + + await page.goto('/dashboard/namespaces') + await expect(page.getByRole('heading', { name: 'My Namespaces' })).toBeVisible() + await expect(page.getByText('@team-page-0')).toBeVisible() + + await page.getByRole('button', { name: 'Go to page 3' }).click() + await expect.poll(() => requestedPages).toContain(2) + await expect(page.getByText('@team-page-2')).toBeVisible() + + await page.getByTestId('delete-namespace-team-page-2').click() + await expect(page.getByTestId('namespace-action-dialog-delete')).toBeVisible() + await page.getByTestId('namespace-action-confirm-delete').click() + + await expect.poll(() => requestedPages.slice(-2)).toEqual([2, 1]) + await expect(page.getByText('@team-page-1')).toBeVisible() + await expect(page.getByText('@team-page-2')).toHaveCount(0) + }) +}) + +async function installDashboardMocks( + page: Page, + requestedPages: number[], + getTotalNamespaces: () => number, + deleteLastNamespace: () => void, +) { + await page.route('**/api/v1/auth/me', async (route) => { + await fulfillJson(route, { + userId: 'local-admin', + displayName: 'Local Admin', + email: 'local-admin@example.com', + avatarUrl: '', + oauthProvider: 'mock', + platformRoles: ['SUPER_ADMIN'], + }) + }) + + await page.route('**/api/v1/auth/providers**', async (route) => { + await fulfillJson(route, []) + }) + + await page.route('**/api/web/notifications/unread-count', async (route) => { + await fulfillJson(route, { count: 0 }) + }) + + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }) + }) + + await page.route('**/api/web/me/namespaces', async (route) => { + await fulfillJson(route, []) + }) + + await page.route('**/api/web/me/namespaces/page?**', async (route) => { + const url = new URL(route.request().url()) + const pageIndex = Number(url.searchParams.get('page') ?? '0') + const size = Number(url.searchParams.get('size') ?? '20') + const isPrimaryDashboardQuery = !url.searchParams.has('roles') + if (isPrimaryDashboardQuery) { + requestedPages.push(pageIndex) + } + + const total = getTotalNamespaces() + const isEmptiedLastPage = total === 40 && pageIndex === 2 + await fulfillJson(route, { + items: isEmptiedLastPage ? [] : [namespaceForPage(pageIndex)], + total, + page: pageIndex, + size, + }) + }) + + await page.route('**/api/web/namespaces/team-page-2', async (route) => { + if (route.request().method() === 'DELETE') { + deleteLastNamespace() + await fulfillJson(route, { message: 'Namespace deleted successfully' }) + return + } + await route.fallback() + }) +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index ac319d3f..52fb1125 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -260,12 +260,13 @@ describe('namespaceApi.listMinePage', () => { type: 'TEAM', q: 'team ai', slug: 'team-ai', + sort: ['slug,desc'], roles: ['OWNER', 'ADMIN'], }) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( - 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&status=ACTIVE&type=TEAM&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', + 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&sort=slug%2Cdesc&status=ACTIVE&type=TEAM&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', expect.objectContaining({ headers: expect.any(Headers) }), ) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1e35ba82..77d40569 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -48,15 +48,21 @@ import type { import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' -export interface MyNamespacePageParams { - page?: number - size?: number - status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' - type?: 'GLOBAL' | 'TEAM' - q?: string - slug?: string - roles?: Array<'OWNER' | 'ADMIN' | 'MEMBER'> -} +type OperationQuery = Operation extends { parameters: { query?: infer Query } } ? NonNullable : never +type OperationData = Operation extends { + responses: { + 200: { + content: { + '*/*': infer Envelope + } + } + } +} ? Envelope extends { data?: infer Data } ? NonNullable : never : never + +type ListMyNamespacesPageOperation = paths['/api/web/me/namespaces/page']['get'] + +export type MyNamespacePageParams = OperationQuery +export type MyNamespacePageResponse = OperationData & PagedResponse /** * Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers. @@ -653,10 +659,11 @@ export const namespaceApi = { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, - async listMinePage(params: MyNamespacePageParams = {}): Promise> { + async listMinePage(params: MyNamespacePageParams = {}): Promise { const page = params.page ?? 0 const size = params.size ?? 20 const query = new URLSearchParams({ page: String(page), size: String(size) }) + params.sort?.forEach((sort) => query.append('sort', sort)) if (params.status) { query.set('status', params.status) } @@ -670,7 +677,7 @@ export const namespaceApi = { query.set('slug', normalizeNamespaceSlug(params.slug)) } params.roles?.forEach((role) => query.append('roles', role)) - return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`) + return fetchJson(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`) }, async getDetail(slug: string): Promise { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index efc08e1d..2976e116 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -10051,8 +10051,16 @@ export interface operations { }; listMyNamespacesPage: { parameters: { - query: { - pageable: components["schemas"]["Pageable"]; + query?: { + /** @description Zero-based page index. */ + page?: number; + /** @description Page size. Values above the namespace list limit are bounded by the backend. */ + size?: number; + /** + * @description Sort criteria in property,direction form. Only slug sorting is honored; defaults to slug,asc. + * @example slug,asc + */ + sort?: string[]; status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; type?: "GLOBAL" | "TEAM"; q?: string; @@ -10078,8 +10086,16 @@ export interface operations { }; listMyNamespacesPage_1: { parameters: { - query: { - pageable: components["schemas"]["Pageable"]; + query?: { + /** @description Zero-based page index. */ + page?: number; + /** @description Page size. Values above the namespace list limit are bounded by the backend. */ + size?: number; + /** + * @description Sort criteria in property,direction form. Only slug sorting is honored; defaults to slug,asc. + * @example slug,asc + */ + sort?: string[]; status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; type?: "GLOBAL" | "TEAM"; q?: string; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 84f260cf..f326bfde 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -493,6 +493,7 @@ }, "reviews": { "namespaceLoadError": "Failed to load namespace review access.", + "loadingNamespaceAccess": "Loading namespace review access...", "retryNamespaceLoad": "Retry", "title": "Review Center", "subtitle": "Manage platform review tasks", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 8fde761a..96360848 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -493,6 +493,7 @@ }, "reviews": { "namespaceLoadError": "无法加载命名空间审核权限。", + "loadingNamespaceAccess": "正在加载命名空间审核权限...", "retryNamespaceLoad": "重试", "title": "审核中心", "subtitle": "管理平台审核事务", diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index cd7ad74f..98921cef 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -262,7 +262,7 @@ export function ReviewsPage() {
) : ( - 'Loading...' + t('reviews.loadingNamespaceAccess') )}
diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index 2f4b39f7..cf90900f 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -57,6 +57,7 @@ describe('use-namespace-queries exports', () => { type: 'TEAM', q: 'team', slug: 'team-ai', + sort: ['slug,desc'], roles: ['OWNER', 'ADMIN'], }) @@ -68,6 +69,7 @@ describe('use-namespace-queries exports', () => { type: 'TEAM', q: 'team', slug: 'team-ai', + sort: ['slug,desc'], roles: ['OWNER', 'ADMIN'], }], })) @@ -84,6 +86,7 @@ describe('use-namespace-queries exports', () => { type: 'TEAM', q: 'team', slug: 'team-ai', + sort: ['slug,desc'], roles: ['OWNER', 'ADMIN'], }) }) diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index a9c7c91a..2afa6709 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -16,6 +16,7 @@ function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyN ...(params.type ? { type: params.type } : {}), ...(q ? { q } : {}), ...(slug ? { slug } : {}), + ...(params.sort?.length ? { sort: [...params.sort] } : {}), ...(params.roles?.length ? { roles: [...params.roles] } : {}), } } From ba7c242d69474cc709ad771981faff3ba764a9cc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 29 Jul 2026 12:04:33 +0800 Subject: [PATCH 21/23] test(namespace): align smoke with super admin visibility Signed-off-by: dongmucat <1127093059@qq.com> --- scripts/namespace-smoke-test.sh | 144 +++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 41 deletions(-) diff --git a/scripts/namespace-smoke-test.sh b/scripts/namespace-smoke-test.sh index 838551cb..e9bf8c6c 100755 --- a/scripts/namespace-smoke-test.sh +++ b/scripts/namespace-smoke-test.sh @@ -8,8 +8,20 @@ FAIL=0 USER_COOKIE="$(mktemp)" ADMIN_COOKIE="$(mktemp)" SLUG="nsmoke$(date +%s)" +USER_ROLE_GRANTED=0 cleanup() { + if [[ "$USER_ROLE_GRANTED" -eq 1 ]] && [[ -f "$ADMIN_COOKIE" ]]; then + local admin_csrf + admin_csrf="$(csrf_token "$ADMIN_COOKIE" 2>/dev/null || true)" + if [[ -n "$admin_csrf" ]]; then + curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $admin_csrf" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/v1/admin/users/local-user/role" \ + -d '{"role":"USER"}' >/dev/null || true + fi + fi rm -f "$USER_COOKIE" "$ADMIN_COOKIE" } @@ -91,17 +103,17 @@ if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then exit 1 fi -CREATE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +CREATE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces" \ -d "{\"slug\":\"$SLUG\",\"displayName\":\"Namespace Smoke $SLUG\",\"description\":\"namespace workflow smoke test\"}")" -assert_code "Owner can create namespace" "$CREATE_RESPONSE" "0" +assert_code "SUPER_ADMIN can create namespace" "$CREATE_RESPONSE" "0" NAMESPACE_ID="$(json_field "$CREATE_RESPONSE" "data.id")" -MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" -assert_code "Owner can list my namespaces" "$MINE_RESPONSE" "0" -if JSON_INPUT="$MINE_RESPONSE" python3 - "$SLUG" <<'PY' +ADMIN_MINE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Owner can list my namespaces" "$ADMIN_MINE_RESPONSE" "0" +if JSON_INPUT="$ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' import json import os import sys @@ -122,9 +134,34 @@ else fail "Created namespace should appear in owner namespace list with OWNER role" fi -ADMIN_MINE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" -assert_code "Other user can list my namespaces" "$ADMIN_MINE_RESPONSE" "0" -if JSON_INPUT="$ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' +USER_MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Regular user can list my namespaces" "$USER_MINE_RESPONSE" "0" +if JSON_INPUT="$USER_MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +PY +then + pass "Regular user cannot see non-member team namespace" +else + fail "Regular user should not see non-member team namespace" +fi + +GRANT_SUPER_ADMIN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/v1/admin/users/local-user/role" \ + -d '{"role":"SUPER_ADMIN"}')" +assert_code "SUPER_ADMIN can grant platform SUPER_ADMIN role" "$GRANT_SUPER_ADMIN_RESPONSE" "0" +USER_ROLE_GRANTED=1 + +USER_SUPER_ADMIN_MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Granted SUPER_ADMIN session can list my namespaces" "$USER_SUPER_ADMIN_MINE_RESPONSE" "0" +if JSON_INPUT="$USER_SUPER_ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' import json import os import sys @@ -140,17 +177,42 @@ if match["status"] != "ACTIVE": raise SystemExit(3) PY then - pass "SUPER_ADMIN can see namespace without namespace membership" + pass "Granted SUPER_ADMIN can see namespace without namespace membership" else - fail "SUPER_ADMIN should see team namespace in my namespaces without namespace role" + fail "Granted SUPER_ADMIN should see team namespace without namespace role" fi -FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ +FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" -assert_code "Unrelated user cannot freeze namespace" "$FREEZE_FORBIDDEN_RESPONSE" "403" +assert_code "Non-member SUPER_ADMIN cannot freeze namespace" "$FREEZE_FORBIDDEN_RESPONSE" "403" -CANDIDATES_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/member-candidates?search=local")" +REVOKE_SUPER_ADMIN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/v1/admin/users/local-user/role" \ + -d '{"role":"USER"}')" +assert_code "SUPER_ADMIN can revoke platform role to USER" "$REVOKE_SUPER_ADMIN_RESPONSE" "0" +USER_ROLE_GRANTED=0 + +USER_AFTER_REVOKE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Revoked session can list my namespaces" "$USER_AFTER_REVOKE_RESPONSE" "0" +if JSON_INPUT="$USER_AFTER_REVOKE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +PY +then + pass "Revoked SUPER_ADMIN session no longer sees non-member namespace" +else + fail "Revoked SUPER_ADMIN session should lose non-member namespace visibility" +fi + +CANDIDATES_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/member-candidates?search=local")" assert_code "Owner can search namespace member candidates" "$CANDIDATES_RESPONSE" "0" if JSON_INPUT="$CANDIDATES_RESPONSE" python3 - <<'PY' import json @@ -158,22 +220,22 @@ import os import sys data = json.loads(os.environ["JSON_INPUT"]) ids = {item["userId"] for item in data["data"]} -raise SystemExit(0 if "local-admin" in ids else 1) +raise SystemExit(0 if "local-user" in ids else 1) PY then - pass "Candidate search returns local-admin" + pass "Candidate search returns local-user" else - fail "Candidate search should include local-admin" + fail "Candidate search should include local-user" fi -ADD_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +ADD_MEMBER_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ - -d '{"userId":"local-admin","role":"MEMBER"}')" + -d '{"userId":"local-user","role":"MEMBER"}')" assert_code "Owner can add namespace members" "$ADD_MEMBER_RESPONSE" "0" -MEMBERS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/members")" +MEMBERS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/members")" assert_code "Owner can list namespace members" "$MEMBERS_RESPONSE" "0" if JSON_INPUT="$MEMBERS_RESPONSE" python3 - <<'PY' import json @@ -190,18 +252,18 @@ else fail "Member list should contain owner and invited user" fi -REVIEWS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +REVIEWS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" assert_code "Owner can open namespace review list" "$REVIEWS_RESPONSE" "0" -PROMOTE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +PROMOTE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ - -X PUT "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin/role" \ + -X PUT "$BASE_URL/api/web/namespaces/$SLUG/members/local-user/role" \ -d '{"role":"ADMIN"}')" assert_code "Owner can promote member to admin" "$PROMOTE_RESPONSE" "0" -ADMIN_FREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ +ADMIN_FREEZE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" assert_code "Namespace admin can freeze namespace" "$ADMIN_FREEZE_RESPONSE" "0" if [[ "$(json_field "$ADMIN_FREEZE_RESPONSE" "data.status")" == "FROZEN" ]]; then @@ -210,27 +272,27 @@ else fail "Freeze should set namespace status to FROZEN" fi -ADD_WHILE_FROZEN_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +ADD_WHILE_FROZEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ -d '{"userId":"local-user","role":"MEMBER"}')" assert_code "Frozen namespace rejects member mutation" "$ADD_WHILE_FROZEN_RESPONSE" "400" -ADMIN_UNFREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ +ADMIN_UNFREEZE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/unfreeze")" assert_code "Namespace admin can unfreeze namespace" "$ADMIN_UNFREEZE_RESPONSE" "0" -ADMIN_ARCHIVE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ +ADMIN_ARCHIVE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ -d '{"reason":"smoke"}')" assert_code "Namespace admin cannot archive namespace" "$ADMIN_ARCHIVE_RESPONSE" "403" -OWNER_ARCHIVE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +OWNER_ARCHIVE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ -d '{"reason":"smoke"}')" @@ -241,8 +303,8 @@ else fail "Archive should set namespace status to ARCHIVED" fi -OWNER_RESTORE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +OWNER_RESTORE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/restore")" assert_code "Owner can restore archived namespace" "$OWNER_RESTORE_RESPONSE" "0" if [[ "$(json_field "$OWNER_RESTORE_RESPONSE" "data.status")" == "ACTIVE" ]]; then @@ -251,9 +313,9 @@ else fail "Restore should set namespace status back to ACTIVE" fi -REMOVE_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ - -X DELETE "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin")" +REMOVE_MEMBER_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X DELETE "$BASE_URL/api/web/namespaces/$SLUG/members/local-user")" assert_code "Owner can remove namespace admin" "$REMOVE_MEMBER_RESPONSE" "0" echo From a3336fe13b43b66bf4a9dfe9c780ff8f5de89c56 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 29 Jul 2026 12:13:40 +0800 Subject: [PATCH 22/23] test(search): publish label sync fixture versions Signed-off-by: dongmucat <1127093059@qq.com> --- .../LabelSearchSyncIntegrationTest.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java index 257d33d2..ebef05f9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java @@ -13,6 +13,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; @@ -64,6 +67,9 @@ class LabelSearchSyncIntegrationTest { @Autowired private SkillRepository skillRepository; + @Autowired + private SkillVersionRepository skillVersionRepository; + @Autowired private LabelDefinitionRepository labelDefinitionRepository; @@ -106,7 +112,7 @@ class LabelSearchSyncIntegrationTest { skill.setCreatedBy(ownerId); skill.setUpdatedBy(ownerId); skill = skillRepository.save(skill); - skillRepository.flush(); + publishLatestVersion(skill, ownerId); LabelDefinition label = labelDefinitionRepository.save( new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); @@ -243,7 +249,7 @@ class LabelSearchSyncIntegrationTest { skill.setCreatedBy(ownerId); skill.setUpdatedBy(ownerId); skill = skillRepository.save(skill); - skillRepository.flush(); + publishLatestVersion(skill, ownerId); LabelDefinition label = labelDefinitionRepository.save( new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); @@ -257,6 +263,18 @@ class LabelSearchSyncIntegrationTest { Map.of(namespace.getId(), NamespaceRole.OWNER)); } + private void publishLatestVersion(Skill skill, String ownerId) { + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.now()); + version = skillVersionRepository.save(version); + + skill.setLatestVersionId(version.getId()); + skillRepository.save(skill); + skillVersionRepository.flush(); + skillRepository.flush(); + } + private record Fixture( String namespaceSlug, String skillSlug, From b630f871a1befcfef7dc0b498c5d076069f5c505 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 29 Jul 2026 12:18:01 +0800 Subject: [PATCH 23/23] test(security): make access denied locale deterministic Signed-off-by: dongmucat <1127093059@qq.com> --- .../iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java index db8982b7..057280d4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java @@ -39,6 +39,7 @@ class ApiAccessDeniedHandlerTest { ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); messageSource.setBasename("messages"); messageSource.setDefaultEncoding("UTF-8"); + messageSource.setFallbackToSystemLocale(false); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)