fix(namespace): preserve bounded selection semantics

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-27 16:55:41 +08:00
parent 2bd358049b
commit 78cbe05ebe
17 changed files with 324 additions and 32 deletions

View file

@ -174,7 +174,7 @@ public class NamespacePortalQueryAppService {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Set<NamespaceRole> 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<String> platformRoles) {
return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1365,6 +1365,7 @@
},
"namespace": "命名空间",
"selectNamespace": "选择命名空间",
"namespaceUnavailable": "所选命名空间未启用或已不可用。",
"visibility": "可见性",
"visibilityOptions": {
"public": "公开",

View file

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

View file

@ -39,9 +39,10 @@ export function PublishPage() {
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
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 ? (
<p className="text-sm text-destructive">{t('publish.namespaceUnavailable')}</p>
) : null}
</div>
<div className="space-y-3">
@ -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')}
</Button>

View file

@ -204,6 +204,7 @@ describe('ReviewsPage', () => {
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({
page: 0,
size: 1,
status: 'ACTIVE',
roles: ['OWNER', 'ADMIN'],
}, false)
})

View file

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

View file

@ -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(
<NamespacePicker
value=""
onValueChange={vi.fn()}
emptyValueLabel="All namespaces"
/>,
)
expect(screen.getByRole('button', { name: 'All namespaces' })).toBeTruthy()
})
})

View file

@ -59,7 +59,7 @@ export function NamespacePicker({
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" disabled={disabled} className="w-full justify-start">
{value ? `@${value}` : t('namespacePicker.placeholder')}
{value ? `@${value}` : emptyValueLabel ?? t('namespacePicker.placeholder')}
</Button>
</DialogTrigger>
<DialogContent aria-label={t('namespacePicker.title')}>

View file

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

View file

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