fix(namespace): scope review entry queries

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-27 17:11:38 +08:00
parent 78cbe05ebe
commit f8df616480
21 changed files with 144 additions and 12 deletions

View file

@ -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<PageResponse<MyNamespaceResponse>> 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<NamespaceRole> roles,
@ -93,6 +95,7 @@ public class NamespaceController extends BaseApiController {
userNsRoles,
normalizePlatformRoles(platformRoles),
status,
type,
q,
slug,
roles));

View file

@ -168,6 +168,7 @@ public class NamespacePortalQueryAppService {
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles,
NamespaceStatus status,
NamespaceType type,
String query,
String slug,
Set<NamespaceRole> roles) {
@ -180,6 +181,7 @@ public class NamespacePortalQueryAppService {
if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) {
Page<Namespace> visibleNamespaces = namespaceRepository.search(
status,
type,
normalizedQuery,
normalizedSlug,
boundedPageable
@ -200,6 +202,7 @@ public class NamespacePortalQueryAppService {
Page<Namespace> visibleNamespaces = namespaceRepository.searchByIdIn(
scopedNamespaceIds,
status,
type,
normalizedQuery,
normalizedSlug,
boundedPageable

View file

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

View file

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

View file

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

View file

@ -15,10 +15,17 @@ public interface NamespaceRepository {
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findAll(Pageable pageable);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
Page<Namespace> search(NamespaceStatus status, String query, String slug, Pageable pageable);
Page<Namespace> search(
NamespaceStatus status,
NamespaceType type,
String query,
String slug,
Pageable pageable
);
Page<Namespace> searchByIdIn(
List<Long> ids,
NamespaceStatus status,
NamespaceType type,
String query,
String slug,
Pageable pageable

View file

@ -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<Namespace> 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<Namespace> searchByIdIn(@Param("ids") List<Long> ids,
@Param("status") NamespaceStatus status,
@Param("type") NamespaceType type,
@Param("query") String query,
@Param("slug") String slug,
Pageable pageable);

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
}
}

View file

@ -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",

View file

@ -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": "公开",

View file

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

View file

@ -39,7 +39,12 @@ export function PublishPage() {
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
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 ? (
<p className="text-sm text-destructive">{t('publish.namespaceUnavailable')}</p>
{namespaceSlug && !isLoadingSelectedNamespace ? (
selectedNamespaceError ? (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-destructive">{t('publish.namespaceValidationError')}</p>
<Button type="button" size="sm" variant="outline" onClick={() => refetchSelectedNamespace()}>
{t('publish.retryNamespaceValidation')}
</Button>
</div>
) : !selectedNamespace ? (
<p className="text-sm text-destructive">{t('publish.namespaceUnavailable')}</p>
) : null
) : null}
</div>

View file

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

View file

@ -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() {
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
<Card className="p-8 text-center text-muted-foreground">
Loading...
{hasNamespaceQueryError ? (
<div className="space-y-3">
<p>{t('reviews.namespaceLoadError')}</p>
<Button type="button" variant="outline" onClick={() => retryNamespaceQueries()}>
{t('reviews.retryNamespaceLoad')}
</Button>
</div>
) : (
'Loading...'
)}
</Card>
</div>
)

View file

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

View file

@ -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'],

View file

@ -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] } : {}),