fix(namespace): preserve my namespace compatibility

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-20 15:54:21 +08:00
parent 123d0d5f53
commit daba212271
15 changed files with 295 additions and 39 deletions

View file

@ -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[@]}" \

View file

@ -68,7 +68,16 @@ public class NamespaceController extends BaseApiController {
}
@GetMapping("/me/namespaces")
public ApiResponse<PageResponse<MyNamespaceResponse>> listMyNamespaces(
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
return ok("response.success.read",
namespacePortalQueryAppService.listMyNamespaces(userNsRoles, normalizePlatformRoles(platformRoles)));
}
@GetMapping("/me/namespaces/page")
public ApiResponse<PageResponse<MyNamespaceResponse>> listMyNamespacesPage(
Pageable pageable,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,

View file

@ -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<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
return listMyNamespaces(
PageRequest.of(0, MAX_MY_NAMESPACE_PAGE_SIZE),
userNamespaceRoles,
platformRoles
).items();
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty() && !isSuperAdmin(platformRoles)) {
return List.of();
}
List<Namespace> 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<Namespace> listAllNamespacesByPage() {
List<Namespace> namespaces = new ArrayList<>();
int pageNumber = 0;
Page<Namespace> 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)

View file

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

View file

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

View file

@ -44,13 +44,6 @@ interface NamespaceCandidate {
status: string
}
interface PageResponse<T> {
items: T[]
total: number
page: number
size: number
}
interface ApiEnvelope<T> {
code: number
msg: string
@ -256,10 +249,9 @@ export class E2eTestDataBuilder {
}
async listMyNamespaces(): Promise<SeededNamespace[]> {
const page = await parseEnvelope<PageResponse<SeededNamespace>>(
await this.request.get('/api/web/me/namespaces?page=0&size=100'),
return parseEnvelope<SeededNamespace[]>(
await this.request.get('/api/web/me/namespaces'),
)
return page.items
}
private isTeamNamespace(namespace: SeededNamespace): boolean {

View file

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

View file

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

View file

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

View file

@ -639,10 +639,14 @@ export const namespaceApi = {
return namespace
},
async listMine(params: { page?: number; size?: number } = {}): Promise<PagedResponse<ManagedNamespace>> {
async listMine(): Promise<ManagedNamespace[]> {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
},
async listMinePage(params: { page?: number; size?: number } = {}): Promise<PagedResponse<ManagedNamespace>> {
const page = params.page ?? 0
const size = params.size ?? 20
return fetchJson<PagedResponse<ManagedNamespace>>(`${WEB_API_PREFIX}/me/namespaces?page=${page}&size=${size}`)
return fetchJson<PagedResponse<ManagedNamespace>>(`${WEB_API_PREFIX}/me/namespaces/page?page=${page}&size=${size}`)
},
async getDetail(slug: string): Promise<Namespace> {

View file

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

View file

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

View file

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

View file

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

View file

@ -8,12 +8,25 @@ const MY_NAMESPACES_PAGE_SIZE = 20
const MY_NAMESPACES_COMPAT_SIZE = 100
async function getMyNamespaces(): Promise<ManagedNamespace[]> {
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<PagedResponse<ManagedNamespace>> {
return namespaceApi.listMine({ page, size })
return namespaceApi.listMinePage({ page, size })
}
async function createNamespace(request: CreateNamespaceRequest): Promise<Namespace> {