diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index a6eac6b07..d85e5a8c8 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -34,6 +34,7 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotListQuery, AdminProjectSnapshotListResponse, AdminRechargeOrderListQuery, @@ -214,6 +215,7 @@ export function listAdminProjectSnapshots( ) { const params = new URLSearchParams(); if (query.cursor) params.set('cursor', query.cursor); + if (query.channel) params.set('channel', query.channel); params.set('limit', String(query.limit ?? 20)); return request( `/admin/api/project-snapshots?${params.toString()}`, @@ -221,13 +223,27 @@ export function listAdminProjectSnapshots( ); } +export function getAdminProjectSnapshotChannels( + token: string, + signal?: AbortSignal, +) { + return request( + '/admin/api/project-snapshots/channels', + { token, signal }, + ); +} + export async function downloadAdminProjectSnapshot( token: string, + channel: string, userId: string, projectId: string, signal?: AbortSignal, ) { - const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`; + const params = new URLSearchParams(); + if (channel) params.set('channel', channel); + const query = params.toString(); + const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`; const response = await fetch(buildRequestUrl(path), { headers: { Authorization: `Bearer ${token.trim()}`, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 322bc1d62..44b833586 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry { fileCount: number; totalBytes: number; status: 'ready' | 'partial' | 'unverified'; + channel: string; + authorDisplayName?: string | null; + authorPublicUserCode?: string | null; } export interface AdminProjectSnapshotListQuery { cursor?: string | null; limit?: number; + channel?: string | null; } export interface AdminProjectSnapshotListResponse { @@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse { nextCursor: string | null; } +export interface AdminProjectSnapshotChannelsResponse { + defaultChannel: string; + channels: string[]; +} + export interface AdminErrorReportEntry { batchId: string; eventCount: number; diff --git a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts index bee81d5f9..5ae88ce97 100644 --- a/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts +++ b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from './adminApiClient'; @@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () => expect( await listAdminProjectSnapshots( 'admin-token', - { cursor: 'user/a+项目', limit: 20 }, + { cursor: 'user/a+项目', limit: 20, channel: 'release' }, controller.signal, ), ).toEqual(payload); expect(fetchMock).toHaveBeenCalledWith( - '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20', + '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), signal: controller.signal, @@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () => ); }); +test('渠道列表按后台授权读取,解析本部署渠道', async () => { + const payload = { defaultChannel: 'release', channels: ['dev', 'release'] }; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: payload })), + ); + vi.stubGlobal('fetch', fetchMock); + expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload); + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/project-snapshots/channels', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + }), + ); +}); + test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response('PK\u0003\u0004', { @@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () = const controller = new AbortController(); const archive = await downloadAdminProjectSnapshot( 'admin-token', + 'release', 'user/a', 'project/b', controller.signal, @@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () = expect(archive.filename).toBe('三消-r2.zip'); expect(archive.blob.type).toBe('application/zip'); expect(fetchMock).toHaveBeenCalledWith( - '/admin/api/project-snapshots/user%2Fa/project%2Fb/download', + '/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token', @@ -81,7 +100,8 @@ test.each([ vi.fn().mockResolvedValue(new Response('PK', { headers })), ); expect( - (await downloadAdminProjectSnapshot('token', 'user', 'project')).filename, + (await downloadAdminProjectSnapshot('token', 'release', 'user', 'project')) + .filename, ).toBe(expected.replace('工程', 'project')); }); @@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])( ), ); await expect( - downloadAdminProjectSnapshot('token', 'user', 'project'), + downloadAdminProjectSnapshot('token', 'release', 'user', 'project'), ).rejects.toMatchObject({ status, code: 'SNAPSHOT_FAILURE', @@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => { ), ); await expect( - downloadAdminProjectSnapshot('token', 'user', 'project'), + downloadAdminProjectSnapshot('token', 'release', 'user', 'project'), ).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' }); }); diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 30e4ba2ae..7916e60e3 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { AdminApiError, downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; @@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({ '../api/adminApiClient', )), downloadAdminProjectSnapshot: vi.fn(), + getAdminProjectSnapshotChannels: vi.fn(), listAdminProjectSnapshots: vi.fn(), })); @@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = { fileCount: 12, totalBytes: 2048, status: 'ready', + channel: 'dev', + authorDisplayName: '陶泥作者', + authorPublicUserCode: 'SY-00000007', }; beforeEach(() => { vi.mocked(listAdminProjectSnapshots) .mockReset() .mockResolvedValue({ items: [entry], nextCursor: null }); + vi.mocked(getAdminProjectSnapshotChannels) + .mockReset() + .mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] }); vi.mocked(downloadAdminProjectSnapshot).mockReset(); }); afterEach(() => { @@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => { ).toBe(false); }); -test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => { +test('按游标翻页回到上一页时复用已取得的游标', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }); + render(); + await screen.findByText('三消工程'); + const pagination = await screen.findByRole('navigation', { + name: '项目工程分页', + }); + expect(pagination.textContent).toContain('第 1 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect( + screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'), + ).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 2, + 'token', + { cursor: 'page-2', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.queryByText('三消工程')).toBeNull(); + expect(pagination.textContent).toContain('第 2 页'); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(pagination.textContent).toContain('第 1 页'); +}); + +test('切换每页条数从第一页按新条数重新加载', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], + nextCursor: 'page-3', + }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第二工程'); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); + // 重新加载时页脚会重建,必须重新取节点再断言。 + expect( + screen.getByRole('navigation', { name: '项目工程分页' }).textContent, + ).toContain('第 1 页'); +}); + +test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => { vi.mocked(listAdminProjectSnapshots) .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) .mockResolvedValueOnce({ items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }], nextCursor: null, }) - .mockRejectedValueOnce(new Error('远端清单读取失败')) - .mockResolvedValueOnce({ items: [], nextCursor: null }); + .mockRejectedValueOnce(new Error('翻页读取失败')); render(); - fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); await screen.findByText('第二工程'); - expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( - 2, - 'token', - { cursor: 'page-2', limit: 20 }, - expect.any(AbortSignal), - ); - expect(screen.getByText('三消工程')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); await screen.findByRole('alert'); - expect(screen.getByText('第二工程')).toBeTruthy(); - expect(screen.queryByText('暂无已上传项目')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '刷新' })); - await screen.findByText('暂无已上传项目'); - expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith( + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, 'token', - { cursor: null, limit: 20 }, + { cursor: 'page-2', limit: 20, channel: 'dev' }, expect.any(AbortSignal), ); + const pagination = screen.getByRole('navigation', { name: '项目工程分页' }); + expect(pagination.textContent).toContain('第 2 页'); + expect(screen.getByText('第二工程')).toBeTruthy(); + expect(screen.queryByText('暂无已上传项目')).toBeNull(); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('暂无已上传项目'); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => { + render(); + const row = (await screen.findByText('三消工程')).closest('tr')!; + expect(within(row).getByText('陶泥作者')).toBeTruthy(); + expect(within(row).getByText('SY-00000007')).toBeTruthy(); + expect( + within(row).getByRole('button', { name: '查看用户信息' }), + ).toBeTruthy(); + expect(within(row).queryByText('user-1')).toBeNull(); +}); + +test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'release', + channels: ['dev', 'release'], + }); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 1, + 'token', + { cursor: null, limit: 20, channel: 'release' }, + expect.any(AbortSignal), + ); + const channelSelect = screen.getByLabelText('项目工程渠道'); + expect((channelSelect as HTMLSelectElement).value).toBe('release'); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('第 2 页,本页 1 个项目'); + fireEvent.change(channelSelect, { target: { value: 'dev' } }); + await screen.findByText('dev 工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 3, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy(); }); test('下载使用返回的中文文件名,随后释放对象 URL', async () => { @@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = expect(createObjectURL).toHaveBeenCalledWith(blob); expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith( 'token', + 'dev', 'user-1', 'project-1', expect.any(AbortSignal), @@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () = test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => { vi.mocked(downloadAdminProjectSnapshot).mockImplementation( - (_token, _user, _project, signal) => + (_token, _channel, _user, _project, signal) => new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), @@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); fireEvent.click(await screen.findByRole('button', { name: '取消下载' })); expect( - vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted, + vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted, ).toBe(true); await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {})); @@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => { onUnauthorized={onUnauthorized} />, ); + // 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。 + await waitFor(() => + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1), + ); const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2]; view.rerender( { , ); fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); - const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]; + const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]; view.unmount(); expect(signal?.aborted).toBe(true); await act(async () => { diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 489956117..30621d554 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -1,11 +1,19 @@ -import { Download, RefreshCcw, X } from 'lucide-react'; +import { + ChevronLeft, + ChevronRight, + Download, + RefreshCcw, + X, +} from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { downloadAdminProjectSnapshot, + getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; interface AdminProjectSnapshotsPageProps { @@ -31,21 +39,32 @@ const snapshotStatuses = { }, }; +const DEFAULT_PAGE_SIZE = 20; +const PAGE_SIZE_OPTIONS = [20, 50, 100]; + export function AdminProjectSnapshotsPage({ token, onUnauthorized, }: AdminProjectSnapshotsPageProps) { const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + // null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。 + const [channel, setChannel] = useState(null); + const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); const downloadController = useRef(null); + // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, + // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 + const pageCursors = useRef<(string | null)[]>([null]); const loadPage = useCallback( - async (cursor: string | null = null) => { + async (cursor: string | null, limit: number, page: number) => { listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({ try { const response = await listAdminProjectSnapshots( token, - { cursor, limit: 20 }, + { cursor, limit, channel }, controller.signal, ); if (controller.signal.aborted) return; - setItems((current) => { - if (!cursor) return response.items; - const entries = new Map( - current.map((entry) => [snapshotKey(entry), entry]), - ); - response.items.forEach((entry) => - entries.set(snapshotKey(entry), entry), - ); - return [...entries.values()]; - }); + setItems(response.items); setNextCursor(response.nextCursor); + setPageIndex(page); setHasLoaded(true); } catch (error: unknown) { + // 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。 if (!controller.signal.aborted) handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({ } } }, - [token, onUnauthorized], + [token, onUnauthorized, channel], ); useEffect(() => { + // 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。 + const controller = new AbortController(); + void (async () => { + try { + const response = await getAdminProjectSnapshotChannels( + token, + controller.signal, + ); + if (controller.signal.aborted) return; + setChannelOptions(response.channels); + setChannel((current) => current ?? response.defaultChannel); + } catch (error: unknown) { + if (controller.signal.aborted) return; + // 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。 + handlePageError(error, onUnauthorized, setErrorMessage); + setChannel((current) => current ?? ''); + } + })(); + return () => controller.abort(); + }, [token, onUnauthorized]); + + useEffect(() => { + if (channel === null) return undefined; + pageCursors.current = [null]; setItems([]); setNextCursor(null); + setPageIndex(1); setHasLoaded(false); setDownloadingKey(null); - void loadPage(); + void loadPage(null, pageSize, 1); return () => { listController.current?.abort(); listController.current = null; downloadController.current?.abort(); downloadController.current = null; }; - }, [loadPage]); + }, [loadPage, pageSize, channel]); + + function goToNextPage() { + if (!nextCursor) return; + pageCursors.current[pageIndex] = nextCursor; + void loadPage(nextCursor, pageSize, pageIndex + 1); + } + + function goToPreviousPage() { + if (pageIndex <= 1) return; + void loadPage( + pageCursors.current[pageIndex - 2] ?? null, + pageSize, + pageIndex - 1, + ); + } + + function refreshCurrentPage() { + void loadPage( + pageCursors.current[pageIndex - 1] ?? null, + pageSize, + pageIndex, + ); + } async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; @@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({ try { const archive = await downloadAdminProjectSnapshot( token, + channel ?? '', entry.userId, entry.projectId, controller.signal, @@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({ setDownloadingKey(null); } + // 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。 + const visibleChannelOptions = channelOptions.length + ? channelOptions + : channel + ? [channel] + : []; + return (

项目工程

- +
+ + +
{errorMessage ? (
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({ 项目 - 用户 ID + 用户 同步时间 文件数 体积 @@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({ {entry.projectName || entry.projectId} {entry.projectId} - {entry.userId} + +
+
+ {projectOwnerDisplayName(entry)} + + {entry.authorPublicUserCode?.trim() || '-'} + +
+ +
+ {new Date(entry.syncedAtMs).toLocaleString('zh-CN', { @@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({ {hasLoaded && items.length === 0 && !errorMessage ? (

暂无已上传项目

) : null} - {nextCursor ? ( -
- -
+ {hasLoaded ? ( + ) : null}
); } +function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) { + return ( + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' + ); +} + function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index caa2dedaa..2198adbdf 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1594,15 +1594,15 @@ button:disabled { } .admin-project-snapshot-table th:first-child { - width: 20%; + width: 18%; } .admin-project-snapshot-table th:nth-child(2) { - width: 14%; + width: 18%; } .admin-project-snapshot-table th:nth-child(3) { - width: 18%; + width: 16%; } .admin-project-snapshot-table th:nth-child(4) { @@ -1689,6 +1689,21 @@ button:disabled { } } +.admin-project-snapshot-pagination { + justify-content: space-between; + gap: 12px; +} + +.admin-project-snapshot-pagination-info { + color: #755a49; + font-size: 13px; + font-weight: 700; +} + +.admin-project-snapshot-pagination .admin-field { + min-width: 92px; +} + .admin-recharge-table { min-width: 1080px; table-layout: fixed; diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index eefb1ae55..c97ad913c 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -163,14 +163,17 @@ ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520 ALIYUN_OSS_SUCCESS_ACTION_STATUS=200 # AGC 项目定时快照上传目标。对象只落在服务端私有前缀 -# agc/project-snapshots/v1/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*, +# agc/project-snapshots/v2/{channel}/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*, # 因此回退凭据必须对目标 bucket 具备该前缀的 PutObject/GetObject/DeleteObject 权限。 # bucket 未单独配置时默认 agc-dev,未配置凭据时 api-server 跳过该客户端, # 接口返回 503 且客户端失败关闭(不写空对象、不推进本地索引)。 +# channel 是部署渠道:缺省沿用 GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL,正式部署必须显式设为 +# release,否则本部署上传会落在 dev 渠道;渠道名非法时接口返回 503,不回落。 GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev # SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb diff --git a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md index ae4a90f62..04bfa55a1 100644 --- a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md +++ b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md @@ -23,7 +23,7 @@ - [ ] 正式项目窗口可被同步调度识别;周期与关闭触发保持有界,失败有项目级诊断。 - [ ] 未单独配置快照目标时仍使用 agc-dev,只复用资源存储凭据;显式快照目标保持有效,不迁移现存对象。 -- [ ] 后台列表显示项目名/ID、用户 ID、同步时间、文件数、体积和完整性,支持刷新与分页。 +- [ ] 后台列表按部署渠道查询,显示项目名/ID、用户(昵称 + 陶泥号)、同步时间、文件数、体积和完整性,支持刷新与游标分页(每页 20/50/100 + 上一页/下一页)。 - [ ] ZIP 按清单还原相对路径;不含对象存储摘要目录;空文件可上传与导出。 - [ ] 清单名称/完整性变化在无内容差异时也提交,partial 可恢复 ready,历史缺字段不冒充 ready。 - [ ] 缺失、损坏、越界路径和非完整清单失败关闭;历史未声明完整性的清单明确标记,允许导出已有文件但不称为完整工程。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 229407df7..77d706d4b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-09-21 项目快照按部署渠道分区,后台按渠道查看并按素材查询口径展示用户 + +- 背景:AGC 项目快照此前统一写在 `agc/project-snapshots/v1/{user}/{project}/`,而开发与正式两套部署共用同一个 bucket(都默认 `agc-dev`)。结果是渠道混在一层前缀里:正式后台会列出开发渠道上传的项目,列表上也看不出项目属于哪个渠道;同时“项目工程”列表只有裸用户 ID,且用“加载更多”逐段追加,翻页与定位都困难。 +- 决策(存储):对象键升级为 `agc/project-snapshots/v2/{channel}/{user}/{project}/`。渠道是**部署渠道**,由服务端 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL` 决定(缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`,当前缺省 `dev`),客户端不上报渠道、也不需要发新版;渠道名必须是小写字母开头的 `[a-z0-9-]{1,32}`,非法值在上传与后台查询处失败关闭(`503`/`400`),不悄悄回落。正式部署必须在 api-server 环境里显式写 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=release`。 +- 决策(后台):新增 `GET /admin/api/project-snapshots/channels` 返回本部署渠道与远端已存在渠道的并集;列表与下载接口都接受 `channel`(缺省本部署渠道),游标里带上渠道并在解码时校验一致,跨渠道复用游标一律 400。页面顶部提供“渠道”选择框,切换渠道回到第 1 页。 +- 决策(列表形态):“用户 ID”列改为与“素材查询”同口径的“用户”列——昵称 + 陶泥号 + 用户详情入口,昵称/陶泥号由 api-server 用 `resolve_work_author_by_user_id` 解析(账号不可读时回退占位作者),原始 `userId` 不再直接铺在列里;“加载更多”改为游标分页(每页 20/50/100 + 上一页/下一页 + 当前页),前端按页记录游标链,换令牌或换每页条数从第 1 页重来;翻页失败保留当前页。 +- 原因:渠道属于“这套部署服务哪个客户端渠道”,客户端正式包按构建渠道连接不同平台服务(release → 正式站,dev → 开发站),所以服务端是唯一可靠的渠道来源;把渠道放进对象键第一层,既不需要迁移历史对象就能让两套部署互不可见,也让后台的渠道维度天然对齐存储布局。 +- 影响范围:`server-rs/crates/platform-oss/src/{lib.rs,project_snapshots.rs,template_library.rs,examples/agc_project_snapshot_live_smoke.rs}`、`server-rs/crates/api-server/src/{config.rs,project_snapshots.rs,admin_project_snapshots.rs,modules/admin.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/{api/adminApiClient.ts,api/adminApiTypes.ts,pages/AdminProjectSnapshotsPage.tsx,pages/AdminProjectSnapshotsPage.test.tsx,styles/admin.css}`、`deploy/env/api-server.env.example` 与本仓运维/技术文档。 +- 未迁移的历史对象:`agc/project-snapshots/v1/` 下现存对象(只读核对过的两份历史清单)保留在 OSS,但不再写入、不再进入后台列表;需要取回时按旧前缀在 OSS 侧直接读取,确实要在后台看到时再单独开一个只读兼容视图。 +- 验证方式:`cargo test -p platform-oss snapshot` 7 passed(渠道校验、键布局、v2 根渠道枚举、v1 历史键仍必须私有);`cargo test -p api-server project_snapshot` 18 passed/1 ignored(渠道失败关闭、游标跨渠道拒绝、用户昵称/陶泥号解析、归档与配额回归);`cargo test -p api-server protected_route_matrix`、`route_contract` 通过(新路由纳入后台鉴权矩阵);`npm run admin-web:typecheck` 与 `npx vitest run apps/admin-web/src` 202 passed。 + ## 2026-09-21 模板正文目录门禁:CLI 打包与后台上传同一份段名单 - 背景:模板包组织指南把 `.agent/`、`.git/`、`node_modules/`、根目录 `dist/` 等列为「不要放进 ZIP」,但两条发布路径此前只校验路径安全与 `entry` 是否存在,放进去的东西会跟着建到用户项目里(模板自带 `.agent/` 会让新项目继承一个陌生身份)。这条约定只靠作者自觉。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5417661f7..7020c59e2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1783,7 +1783,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 本地索引是增量对比的唯一依据:`/project-snapshots//index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。 - 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state` 与 `sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。 - 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。 -- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v1/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v1/{userId}/{projectId}/manifest.json`。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。 +- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/manifest.json`;`channel` 是本部署渠道(`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL`,缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`),同一 bucket 因此天然按渠道分区,开发与正式部署互不可见对方项目。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀(含历史无渠道的 `agc/project-snapshots/v1/`)继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。后台“项目工程”按渠道查询与下载,渠道名非法时失败关闭,历史 v1 对象不再列出。 - 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`。只允许凭据回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`;bucket 与 endpoint 不跟随资源存储的 `ALIYUN_OSS_BUCKET` / `_ENDPOINT`,避免默认写入其它 bucket。显式快照目标配置继续优先;不自动搬迁其它 bucket 的现存数据。 ### 正常、失败、重试与幂等行为 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ca6d7ec82..ccb2bbd32 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -552,7 +552,7 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null ### AGC 项目快照上传目标 -后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 +后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 @@ -561,15 +561,23 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null AGC 客户端按周期与项目关闭时机把用户项目增量上传到 `agc-dev`。客户端只持有平台登录态 Access Token,经 `POST /api/agc/project-snapshots/files`(单文件原始字节)与 `POST /api/agc/project-snapshots/manifest`(本次同步清单)交给 `api-server`,由服务端写入私有前缀 -`agc/project-snapshots/v1/{user}/{project}/`;客户端不直连 OSS,也不持有 OSS 凭据。 +`agc/project-snapshots/v2/{channel}/{user}/{project}/`;`channel` 是本部署渠道,客户端不上报渠道、也不直连 OSS, +因此同一 bucket 可以由开发与正式两套部署分别落在 `dev/` 与 `release/` 下。 ```env GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev ``` +`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL` 未配置时沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(缺省 `dev`); +正式部署必须显式设成 `release`,否则正式上传会写进 `dev/` 渠道。渠道名必须是小写字母开头的 +小写字母 / 数字 / 连字符(≤32 字符),非法时上传与后台查询都返回 `503`/`400`,不会回落到别的渠道。 +历史无渠道前缀 `agc/project-snapshots/v1/` 只保留对象、不再写入也不再进入后台列表;需要取回历史对象时 +按旧前缀直接在 OSS 侧读取。 + 专用凭据为空时回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,bucket 与 endpoint 仍默认指向 AGC 发行 bucket,因此回退凭据必须具备目标 bucket 该前缀的 `PutObject` / `GetObject` / `DeleteObject` 权限;`api-server` 启动时会打印一行 `AGC 项目快照 OSS 客户端已启用`(含 bucket、 @@ -578,7 +586,7 @@ endpoint 与凭据来源,不含密钥),凭据缺失或只配一半则跳 远端占用按当前清单收敛:每次清单写入成功后,服务端用上一版清单反推不再被引用的对象键并删除,单次最多 回收 2000 个,上一版清单不可读时整轮跳过(不会误删)。因此不需要额外配置 bucket 生命周期来防止无界 -增长;`agc/project-snapshots/v1/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、 +增长;`agc/project-snapshots/v2/{channel}/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、 单次同步上传预算 512 MiB、单项目常驻 2 GiB;超限分别表现为跳过/延后/413。服务端另有进程内小时配额 (文件 3000 次、清单 120 次)与同一项目 5 秒最小清单间隔,超限返回 `429` 并带 `Retry-After`。 diff --git a/server-rs/crates/api-server/src/admin_project_snapshots.rs b/server-rs/crates/api-server/src/admin_project_snapshots.rs index 427281191..74aa9b684 100644 --- a/server-rs/crates/api-server/src/admin_project_snapshots.rs +++ b/server-rs/crates/api-server/src/admin_project_snapshots.rs @@ -1,6 +1,7 @@ //! 后台按项目读取私有快照,并在完整性验证后导出原始工程目录。 use std::{ + collections::BTreeSet, future::Future, io::Write, sync::{Arc, OnceLock}, @@ -18,11 +19,13 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use platform_oss::{ OssClient, OssError, OssGetObjectRequest, agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key, project_snapshots::SnapshotDirectoryPage, + validate_agc_project_snapshot_channel, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared_contracts::{ admin::{ + AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotDownloadQuery, AdminProjectSnapshotItem, AdminProjectSnapshotStatus, AdminProjectSnapshotsQuery, AdminProjectSnapshotsResponse, }, @@ -45,6 +48,7 @@ use crate::{ project_snapshots::{MAX_MANIFEST_REQUEST_BODY_BYTES, project_snapshot_oss, validate_manifest}, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; const MAX_DIRECTORY_REQUESTS: usize = 100; @@ -55,6 +59,7 @@ static DOWNLOAD_PERMITS: OnceLock> = OnceLock::new(); #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct SnapshotCursor { + channel: String, user: Option, project: Option, finished_user: bool, @@ -62,9 +67,10 @@ struct SnapshotCursor { } impl SnapshotCursor { - fn decode(value: Option<&str>) -> Result { + fn decode(value: Option<&str>, channel: &str) -> Result { let Some(value) = value.filter(|value| !value.is_empty()) else { return Ok(Self { + channel: channel.to_string(), finished_user: true, ..Self::default() }); @@ -77,6 +83,10 @@ impl SnapshotCursor { .map_err(|_| bad_request("项目列表游标无效"))?; let cursor: Self = serde_json::from_slice(&bytes).map_err(|_| bad_request("项目列表游标无效"))?; + // 游标只在同一渠道内有效:跨渠道复用会让目录推进落在别的渠道上。 + if cursor.channel != channel { + return Err(bad_request("项目列表游标与渠道不一致")); + } for segment in [&cursor.user, &cursor.project].into_iter().flatten() { validate_agc_project_snapshot_project_id(segment) .map_err(|_| bad_request("项目列表游标无效"))?; @@ -115,6 +125,7 @@ trait SnapshotStore: Sync { struct OssSnapshotStore<'a> { oss: &'a OssClient, client: &'a reqwest::Client, + channel: String, } impl SnapshotStore for OssSnapshotStore<'_> { @@ -125,7 +136,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { limit: usize, ) -> Result { self.oss - .list_project_snapshot_directories(self.client, user, after, limit) + .list_project_snapshot_directories(self.client, &self.channel, user, after, limit) .await .map_err(|_| upstream("读取项目工程目录失败")) } @@ -135,7 +146,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { user: &str, project: &str, ) -> Result, AppError> { - let object_key = agc_project_snapshot_manifest_object_key(user, project) + let object_key = agc_project_snapshot_manifest_object_key(&self.channel, user, project) .map_err(|_| bad_request("项目工程身份无效"))?; let bytes = match self .oss @@ -169,6 +180,7 @@ impl SnapshotStore for OssSnapshotStore<'_> { ) -> Result, AppError> { let digest = file.checksum.strip_prefix("fnv1a64:").unwrap_or_default(); let object_key = agc_project_snapshot_file_object_key( + &self.channel, user, project, file.size_bytes, @@ -189,7 +201,11 @@ impl SnapshotStore for OssSnapshotStore<'_> { } } -fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProjectSnapshotItem { +fn item( + channel: &str, + user: String, + manifest: &AgcProjectSnapshotManifestRequest, +) -> AdminProjectSnapshotItem { AdminProjectSnapshotItem { user_id: user, project_id: manifest.project_id.clone(), @@ -206,6 +222,19 @@ fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProj Some(_) => AdminProjectSnapshotStatus::Partial, None => AdminProjectSnapshotStatus::Unverified, }, + channel: channel.to_string(), + author_display_name: None, + author_public_user_code: None, + } +} + +/// 后台列表的“用户”列与素材查询同口径:昵称 + 陶泥号,账号不可解析时沿用占位作者。 +/// 查账号只影响展示,失败不回滚已经读到的清单统计。 +fn attach_snapshot_authors(state: &AppState, items: &mut [AdminProjectSnapshotItem]) { + for entry in items.iter_mut() { + let author = resolve_work_author_by_user_id(state, &entry.user_id, None, None); + entry.author_display_name = Some(author.display_name); + entry.author_public_user_code = author.public_user_code; } } @@ -231,6 +260,7 @@ async fn list_snapshots( }); }; cursor = SnapshotCursor { + channel: cursor.channel.clone(), user: Some(user), project: None, finished_user: false, @@ -255,7 +285,7 @@ async fn list_snapshots( for project in projects.directories { scanned += 1; if let Some(manifest) = store.manifest(user, &project).await? { - items.push(item(user.to_string(), &manifest)); + items.push(item(&cursor.channel, user.to_string(), &manifest)); } cursor.project = Some(project); } @@ -284,15 +314,69 @@ pub async fn admin_list_project_snapshots( Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { - let cursor = SnapshotCursor::decode(query.cursor.as_deref())?; + let channel = requested_snapshot_channel(&state, query.channel.as_deref())?; + let cursor = SnapshotCursor::decode(query.cursor.as_deref(), &channel)?; let store = OssSnapshotStore { oss: project_snapshot_oss(&state)?, client: state.editor_oss_http_client(), + channel, }; - let response = list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?; + let mut response = + list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?; + attach_snapshot_authors(&state, &mut response.items); Ok(json_success_body(Some(&ctx), response)) } +/// 目标渠道:显式传入时必须合法(空串按未提供处理),缺省用本部署渠道。 +/// 非法渠道失败关闭,不落到别的渠道。 +fn requested_snapshot_channel( + state: &AppState, + requested: Option<&str>, +) -> Result { + let requested = requested.map(str::trim).filter(|value| !value.is_empty()); + match requested { + Some(channel) => validate_agc_project_snapshot_channel(channel) + .map_err(|_| bad_request("项目工程渠道无效")), + None => crate::project_snapshots::project_snapshot_channel(state), + } +} + +/// 后台渠道列表:本部署渠道与远端已存在渠道的并集。 +/// 远端目录里不符合渠道命名的条目直接跳过,不作为可查询渠道暴露。 +pub async fn admin_list_project_snapshot_channels( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let default_channel = crate::project_snapshots::project_snapshot_channel(&state)?; + let oss = project_snapshot_oss(&state)?; + let client = state.editor_oss_http_client(); + let mut channels = BTreeSet::from([default_channel.clone()]); + let mut after: Option = None; + for _ in 0..MAX_DIRECTORY_REQUESTS { + let page = oss + .list_project_snapshot_channels(client, after.as_deref(), 100) + .await + .map_err(|_| upstream("读取项目工程渠道失败"))?; + channels.extend( + page.directories + .into_iter() + .filter(|name| validate_agc_project_snapshot_channel(name).is_ok()), + ); + match page.next_marker { + Some(next) => after = Some(next), + None => break, + } + } + Ok(json_success_body( + Some(&ctx), + AdminProjectSnapshotChannelsResponse { + default_channel, + channels: channels.into_iter().collect(), + }, + )) +} + fn verify_file(file: &AgcProjectSnapshotManifestFile, bytes: &[u8]) -> Result<(), AppError> { if bytes.len() as u64 != file.size_bytes || !agc_project_snapshot_checksum(bytes).eq_ignore_ascii_case(&file.checksum) @@ -441,6 +525,7 @@ pub async fn admin_download_project_snapshot( State(state): State, Extension(_admin): Extension, Path((user, project)): Path<(String, String)>, + Query(query): Query, ) -> Result { validate_agc_project_snapshot_project_id(&user).map_err(bad_request)?; validate_agc_project_snapshot_project_id(&project).map_err(bad_request)?; @@ -457,6 +542,7 @@ pub async fn admin_download_project_snapshot( let store = OssSnapshotStore { oss: project_snapshot_oss(&state)?, client: state.editor_oss_http_client(), + channel: requested_snapshot_channel(&state, query.channel.as_deref())?, }; let manifest = store.manifest(&user, &project).await?.ok_or_else(|| { AppError::from_status(StatusCode::NOT_FOUND).with_message("项目工程清单不存在") @@ -581,23 +667,63 @@ mod tests { "project-1", &[("game/empty", b""), ("game/main.js", b"123")], ); - let projection = item("user-1".to_string(), &manifest); + let projection = item("dev", "user-1".to_string(), &manifest); assert_eq!(projection.status, AdminProjectSnapshotStatus::Ready); assert_eq!((projection.file_count, projection.total_bytes), (2, 3)); + assert_eq!(projection.channel, "dev"); manifest.pending_files = Some(2); assert_eq!( - item("user-1".into(), &manifest).status, + item("release", "user-1".into(), &manifest).status, AdminProjectSnapshotStatus::Partial ); let mut legacy = serde_json::to_value(manifest).unwrap(); legacy.as_object_mut().unwrap().remove("pendingFiles"); legacy.as_object_mut().unwrap().remove("projectName"); let manifest = serde_json::from_value(legacy).unwrap(); - let projection = item("user-1".into(), &manifest); + let projection = item("dev", "user-1".into(), &manifest); assert_eq!(projection.status, AdminProjectSnapshotStatus::Unverified); assert_eq!(projection.project_name, "project-1"); } + /// 后台“用户”列与素材查询同口径:能查到账号时给昵称与陶泥号,查不到时给占位作者。 + #[test] + fn project_snapshots_items_resolve_author_profile_for_admin_list() { + use crate::{ + config::AppConfig, + work_author::{ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE}, + }; + + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .auth_user_service() + .ensure_orphan_work_owner_user("user-1", "user-1", "陶泥用户", "SY-00000007") + .expect("fixture user should be inserted"); + + let mut items = vec![ + item("dev", "user-1".to_string(), &manifest("project-1", &[])), + item( + "dev", + "user-missing".to_string(), + &manifest("project-2", &[]), + ), + ]; + attach_snapshot_authors(&state, &mut items); + + assert_eq!(items[0].author_display_name.as_deref(), Some("陶泥用户")); + assert_eq!( + items[0].author_public_user_code.as_deref(), + Some("SY-00000007") + ); + assert_eq!( + items[1].author_display_name.as_deref(), + Some(ORPHAN_WORK_AUTHOR_DISPLAY_NAME) + ); + assert_eq!( + items[1].author_public_user_code.as_deref(), + Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE) + ); + } + #[tokio::test] async fn project_snapshots_pagination_continues_directories_without_rescan() { let mut store = Store::default(); @@ -610,13 +736,14 @@ mod tests { .manifests .insert((user.into(), project.into()), manifest(project, &[])); } - let first = list_snapshots(&store, SnapshotCursor::decode(None).unwrap(), 1) + let first = list_snapshots(&store, SnapshotCursor::decode(None, "dev").unwrap(), 1) .await .unwrap(); assert_eq!(first.items[0].project_id, "project-a"); + assert_eq!(first.items[0].channel, "dev"); let second = list_snapshots( &store, - SnapshotCursor::decode(first.next_cursor.as_deref()).unwrap(), + SnapshotCursor::decode(first.next_cursor.as_deref(), "dev").unwrap(), 1, ) .await @@ -624,7 +751,7 @@ mod tests { assert_eq!(second.items[0].project_id, "project-b"); let third = list_snapshots( &store, - SnapshotCursor::decode(second.next_cursor.as_deref()).unwrap(), + SnapshotCursor::decode(second.next_cursor.as_deref(), "dev").unwrap(), 1, ) .await @@ -638,13 +765,17 @@ mod tests { (Some("user-a".into()), Some("project-a".into()), 1) ); assert_eq!(calls[3], (None, Some("user-a".into()), 1)); - assert!(SnapshotCursor::decode(Some("not-json")).is_err()); + assert!(SnapshotCursor::decode(Some("not-json"), "dev").is_err()); + // 游标不能跨渠道复用,否则目录推进会落在别的渠道上。 + let next = first.next_cursor.as_deref().expect("first page cursor"); + assert!(SnapshotCursor::decode(Some(next), "release").is_err()); let escaped = SnapshotCursor { + channel: "dev".into(), user: Some("../user".into()), ..SnapshotCursor::default() } .encode(); - assert!(SnapshotCursor::decode(Some(&escaped)).is_err()); + assert!(SnapshotCursor::decode(Some(&escaped), "dev").is_err()); } #[tokio::test] @@ -887,11 +1018,14 @@ mod tests { .timeout(Duration::from_secs(60)) .build() .unwrap(); + let channel = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") + .unwrap_or_else(|_| "dev".to_string()); let store = OssSnapshotStore { oss: &oss, client: &client, + channel: channel.clone(), }; - let mut cursor = SnapshotCursor::decode(None).unwrap(); + let mut cursor = SnapshotCursor::decode(None, &channel).unwrap(); let mut projects = 0; let mut total_files = 0; let mut total_bytes = 0_u64; @@ -938,7 +1072,7 @@ mod tests { } let Some(next) = page.next_cursor else { break }; assert!(page_index < 99, "只读 smoke 已达到分页上限"); - cursor = SnapshotCursor::decode(Some(&next)).unwrap(); + cursor = SnapshotCursor::decode(Some(&next), &channel).unwrap(); } assert!(projects > 0, "真实 bucket 未发现项目清单"); eprintln!( diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b3ff4c366..6bf7edc92 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -92,6 +92,8 @@ pub struct AppConfig { pub editor_bgfilter_circuit_cooldown: Duration, pub image_editor_agent_sidebar_enabled: bool, pub client_download_channel: String, + /// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。 + pub project_snapshot_channel: String, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -402,6 +404,7 @@ impl Default for AppConfig { ), image_editor_agent_sidebar_enabled: false, client_download_channel: "dev".to_string(), + project_snapshot_channel: "dev".to_string(), log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, @@ -724,6 +727,12 @@ impl AppConfig { // 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。 config.client_download_channel = channel.trim().to_string(); } + // 快照渠道缺省沿用同一个部署渠道(本部署的客户端渠道),显式配置优先; + // 显式空值或非法值同样保留,由快照入口失败关闭。 + config.project_snapshot_channel = config.client_download_channel.clone(); + if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") { + config.project_snapshot_channel = channel.trim().to_string(); + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"]) { @@ -3092,6 +3101,43 @@ mod tests { } } + #[test] + fn project_snapshot_channel_follows_client_download_channel_unless_overridden() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock"); + let previous_snapshot = std::env::var_os("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); + let previous_download = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); + unsafe { + std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"); + std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"); + } + // 未显式配置快照渠道时跟随部署的客户端渠道,缺省是 dev。 + assert_eq!(AppConfig::from_env().project_snapshot_channel, "dev"); + unsafe { + std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", "release"); + } + assert_eq!(AppConfig::from_env().project_snapshot_channel, "release"); + // 显式配置优先;空值与非法值同样保留,由快照入口失败关闭。 + for (value, expected) in [(" qa-2026 ", "qa-2026"), ("", ""), ("Dev", "Dev")] { + unsafe { + std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value); + } + assert_eq!(AppConfig::from_env().project_snapshot_channel, expected); + } + unsafe { + match previous_snapshot { + Some(value) => std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL", value), + None => std::env::remove_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL"), + } + match previous_download { + Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value), + None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"), + } + } + } + #[test] fn from_env_reads_prefixed_character_animation_ffmpeg_paths() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 39f6a03e8..e16b4a82b 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -43,6 +43,10 @@ pub fn router(state: AppState) -> Router { "/admin/api/project-snapshots", get(crate::admin_project_snapshots::admin_list_project_snapshots), ), + ( + "/admin/api/project-snapshots/channels", + get(crate::admin_project_snapshots::admin_list_project_snapshot_channels), + ), ( "/admin/api/project-snapshots/{user_id}/{project_id}/download", get(crate::admin_project_snapshots::admin_download_project_snapshot), @@ -230,6 +234,7 @@ mod route_contract_tests { const PROTECTED_ROUTES: &[(&str, &[&str])] = &[ ("/admin/api/project-snapshots", &["GET"]), + ("/admin/api/project-snapshots/channels", &["GET"]), ( "/admin/api/project-snapshots/{user_id}/{project_id}/download", &["GET"], diff --git a/server-rs/crates/api-server/src/project_snapshots.rs b/server-rs/crates/api-server/src/project_snapshots.rs index 32a12fa21..6125328a7 100644 --- a/server-rs/crates/api-server/src/project_snapshots.rs +++ b/server-rs/crates/api-server/src/project_snapshots.rs @@ -16,6 +16,7 @@ use axum::{ use platform_oss::{ OssDeleteObjectRequest, OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess, agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key, + validate_agc_project_snapshot_channel, }; use serde_json::Value; use shared_contracts::agc_project_snapshots::{ @@ -67,7 +68,9 @@ pub async fn upload_project_snapshot_file( .strip_prefix("fnv1a64:") .unwrap_or_default() .to_string(); + let channel = project_snapshot_channel(&state)?; let object_key = agc_project_snapshot_file_object_key( + &channel, auth.claims().user_id(), &query.project_id, size_bytes, @@ -146,8 +149,10 @@ pub async fn upload_project_snapshot_manifest( consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::Manifest)?; validate_manifest(&payload)?; let user_id = auth.claims().user_id().to_string(); - let object_key = agc_project_snapshot_manifest_object_key(&user_id, &payload.project_id) - .map_err(|error| bad_request(error.to_string()))?; + let channel = project_snapshot_channel(&state)?; + let object_key = + agc_project_snapshot_manifest_object_key(&channel, &user_id, &payload.project_id) + .map_err(|error| bad_request(error.to_string()))?; // 上一版清单同时承担两个职责:项目级写入频率闸门,以及本轮远端对象回收的引用基线。 // 读不到或解析失败时只跳过回收,绝不据此删除任何对象。 let previous = read_project_snapshot_manifest(&state, &object_key).await; @@ -181,7 +186,8 @@ pub async fn upload_project_snapshot_manifest( })?; // 清单写入成功之后再回收:任何时刻远端对象集合都是当前清单的超集, // 不会出现清单引用了刚被删掉的对象。 - reclaim_unreferenced_objects(&state, oss, &user_id, previous.as_ref(), &payload).await; + reclaim_unreferenced_objects(&state, oss, &channel, &user_id, previous.as_ref(), &payload) + .await; Ok(json_success_body( Some(&ctx), @@ -293,6 +299,7 @@ async fn read_project_snapshot_manifest( async fn reclaim_unreferenced_objects( state: &AppState, oss: &platform_oss::OssClient, + channel: &str, user_id: &str, previous: Option<&AgcProjectSnapshotManifestRequest>, next: &AgcProjectSnapshotManifestRequest, @@ -330,6 +337,7 @@ async fn reclaim_unreferenced_objects( continue; }; let Ok(object_key) = agc_project_snapshot_file_object_key( + channel, user_id, &previous.project_id, file.size_bytes, @@ -433,6 +441,15 @@ pub(crate) fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::Os }) } +/// 本部署的快照渠道:配置文件缺省跟随客户端下载渠道,显式配置优先。 +/// 渠道名非法时失败关闭(503),不静默改写到其它渠道。 +pub(crate) fn project_snapshot_channel(state: &AppState) -> Result { + validate_agc_project_snapshot_channel(&state.config.project_snapshot_channel).map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("AGC 项目快照渠道配置无效") + }) +} + fn bad_request(m: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_message(m) } @@ -567,4 +584,26 @@ mod tests { consume_user_upload_quota(user, ProjectSnapshotUploadKind::Manifest) .expect("清单配额独立计数"); } + + /// 渠道决定对象键的第一层目录:非法配置必须失败关闭,不能落到别的渠道。 + #[test] + fn project_snapshot_channel_fails_closed_on_invalid_configuration() { + use crate::{config::AppConfig, state::AppState}; + + let mut config = AppConfig::default(); + config.project_snapshot_channel = "release".to_string(); + let state = AppState::new(config).expect("state should build"); + assert_eq!( + project_snapshot_channel(&state).expect("valid channel"), + "release" + ); + + for invalid in ["", " Dev", "dev/2", "dev-"] { + let mut config = AppConfig::default(); + config.project_snapshot_channel = invalid.to_string(); + let state = AppState::new(config).expect("state should build"); + let error = project_snapshot_channel(&state).expect_err("非法渠道必须失败关闭"); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + } } diff --git a/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs index 2ebc8f144..67cf2c49d 100644 --- a/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs +++ b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs @@ -8,7 +8,7 @@ //! cargo run -p platform-oss --example agc_project_snapshot_live_smoke --manifest-path server-rs/Cargo.toml //! ``` //! -//! 冒烟只写入 `agc/project-snapshots/v1/` 下的固定探针对象,并在结束时删除; +//! 冒烟只写入 `agc/project-snapshots/v2/dev/` 下的固定探针对象,并在结束时删除; //! 任何一步失败都会打印 `[FAIL]` 并以非 0 退出码结束,方便 CI 或人工判定。 use std::{ @@ -29,6 +29,7 @@ const DEFAULT_BUCKET: &str = "agc-dev"; const DEFAULT_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com"; const SMOKE_USER_ID: &str = "smoke-user"; const SMOKE_PROJECT_ID: &str = "smoke-project"; +const SMOKE_CHANNEL: &str = "dev"; const SMOKE_RELATIVE_PATH: &str = "smoke/README.txt"; const SMOKE_BODY: &[u8] = b"agc project snapshot live smoke\n"; const SMOKE_CHECKSUM_DIGEST: &str = "0123456789abcdef"; @@ -117,6 +118,7 @@ async fn run() -> SmokeResult<()> { // 3. 项目快照文件键:写入 → 读回。 let file_key = agc_project_snapshot_file_object_key( + SMOKE_CHANNEL, SMOKE_USER_ID, SMOKE_PROJECT_ID, SMOKE_BODY.len() as u64, @@ -124,8 +126,9 @@ async fn run() -> SmokeResult<()> { SMOKE_RELATIVE_PATH, ) .map_err(|error| format!("构造项目快照文件键失败({})", oss_error_label(&error)))?; - let manifest_key = agc_project_snapshot_manifest_object_key(SMOKE_USER_ID, SMOKE_PROJECT_ID) - .map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?; + let manifest_key = + agc_project_snapshot_manifest_object_key(SMOKE_CHANNEL, SMOKE_USER_ID, SMOKE_PROJECT_ID) + .map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?; let result = write_and_verify(&client, &http, &bucket, &file_key, &manifest_key).await; for key in [&file_key, &manifest_key] { diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 8c911a531..61b6cc213 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -1926,44 +1926,69 @@ fn normalize_editor_agent_messages_object_key(raw: &str) -> Result Result { + let allowed = !raw.is_empty() + && raw.len() <= 32 + && raw.as_bytes()[0].is_ascii_lowercase() + && raw + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !raw.ends_with('-'); + if !allowed { + return Err(OssError::InvalidRequest("项目快照渠道名非法".to_string())); + } + Ok(raw.to_string()) +} + /// 项目快照文件对象键: -/// `agc/project-snapshots/v1/{user}/{project}/files/{size}-{digest}/{relativePath}`。 +/// `agc/project-snapshots/v2/{channel}/{user}/{project}/files/{size}-{digest}/{relativePath}`。 /// /// 键里同时带字节数与内容摘要,既让同一内容重复提交落在同一个对象上,也让 /// "对象已存在且长度一致" 可以作为内容一致的判据;相对路径按原始大小写保留, /// 不走 `put_object` 的低位规范化。 pub fn agc_project_snapshot_file_object_key( + channel: &str, user_id: &str, project_id: &str, size_bytes: u64, checksum_digest: &str, relative_path: &str, ) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; let user = validate_internal_key_segment(user_id, "用户标识")?; let project = validate_internal_key_segment(project_id, "项目标识")?; let digest = validate_internal_checksum_digest(checksum_digest)?; let relative_path = validate_internal_relative_path(relative_path)?; Ok(format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/files/{size_bytes}-{digest}/{relative_path}" + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/files/{size_bytes}-{digest}/{relative_path}" )) } -/// 项目快照清单对象键:`agc/project-snapshots/v1/{user}/{project}/manifest.json`。 +/// 项目快照清单对象键: +/// `agc/project-snapshots/v2/{channel}/{user}/{project}/manifest.json`。 pub fn agc_project_snapshot_manifest_object_key( + channel: &str, user_id: &str, project_id: &str, ) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; let user = validate_internal_key_segment(user_id, "用户标识")?; let project = validate_internal_key_segment(project_id, "项目标识")?; Ok(format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/manifest.json" + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/{user}/{project}/manifest.json" )) } @@ -3496,7 +3521,7 @@ mod tests { ); assert_eq!( LegacyAssetPrefix::from_object_key( - "agc/project-snapshots/v1/user-1/project-1/manifest.json" + "agc/project-snapshots/v2/dev/user-1/project-1/manifest.json" ), None, "AGC 内部前缀不能经由通用对象键解析变成客户端可写前缀" @@ -3524,6 +3549,7 @@ mod tests { #[test] fn agc_project_snapshot_object_keys_preserve_case_and_reject_traversal() { let file_key = agc_project_snapshot_file_object_key( + "dev", "user-1", "gameagent-1a2b3c4d", 1234, @@ -3533,12 +3559,12 @@ mod tests { .expect("file key"); assert_eq!( file_key, - "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML" + "agc/project-snapshots/v2/dev/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML" ); assert_eq!( - agc_project_snapshot_manifest_object_key("user-1", "gameagent-1a2b3c4d") + agc_project_snapshot_manifest_object_key("release", "user-1", "gameagent-1a2b3c4d") .expect("manifest key"), - "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/manifest.json" + "agc/project-snapshots/v2/release/user-1/gameagent-1a2b3c4d/manifest.json" ); for (user, project, path) in [ @@ -3549,20 +3575,64 @@ mod tests { ("user-1", "project-1", "game\\index.html"), ] { assert!( - agc_project_snapshot_file_object_key(user, project, 1, "abcdef", path).is_err(), + agc_project_snapshot_file_object_key("dev", user, project, 1, "abcdef", path) + .is_err(), "越界键片段必须被拒绝:{user} {project} {path}" ); } assert!( - agc_project_snapshot_file_object_key("user-1", "project-1", 1, "not-hex", "game/a.txt") - .is_err(), + agc_project_snapshot_file_object_key( + "dev", + "user-1", + "project-1", + 1, + "not-hex", + "game/a.txt" + ) + .is_err(), "摘要必须是十六进制" ); } + /// 渠道是对象键的第一层:非法渠道必须失败关闭,不能悄悄换成一个默认渠道。 + #[test] + fn agc_project_snapshot_channel_is_validated_before_it_reaches_the_key() { + assert_eq!( + validate_agc_project_snapshot_channel("release").expect("channel"), + "release" + ); + assert_eq!( + validate_agc_project_snapshot_channel("dev-internal-2").expect("channel"), + "dev-internal-2" + ); + for invalid in [ + "", + " dev", + "dev ", + "Dev", + "dev_internal", + "dev/internal", + "-dev", + "dev-", + "2dev", + "dev.", + &"d".repeat(33), + ] { + assert!( + validate_agc_project_snapshot_channel(invalid).is_err(), + "非法渠道必须被拒绝:{invalid}" + ); + assert!( + agc_project_snapshot_manifest_object_key(invalid, "user-1", "project-1").is_err(), + "非法渠道不能进入对象键:{invalid}" + ); + } + } + #[test] fn internal_object_prefixes_cover_agc_snapshots_but_reject_everything_else() { let file_key = agc_project_snapshot_file_object_key( + "dev", "user-1", "project-1", 7, @@ -3574,6 +3644,14 @@ mod tests { normalize_internal_object_key(&file_key).expect("snapshot key is internal"), file_key ); + // 无渠道的历史项目快照对象仍然必须保持服务端私有。 + assert_eq!( + normalize_internal_object_key( + "agc/project-snapshots/v1/user-1/project-1/manifest.json" + ) + .expect("legacy snapshot key stays internal"), + "agc/project-snapshots/v1/user-1/project-1/manifest.json" + ); assert_eq!( normalize_internal_object_key("agc/error-reports/v1/batch.zip") .expect("error report key stays internal"), diff --git a/server-rs/crates/platform-oss/src/project_snapshots.rs b/server-rs/crates/platform-oss/src/project_snapshots.rs index 0466591ad..1345c2c15 100644 --- a/server-rs/crates/platform-oss/src/project_snapshots.rs +++ b/server-rs/crates/platform-oss/src/project_snapshots.rs @@ -37,7 +37,7 @@ fn directory_segment(value: &str) -> Result { } fn list_query( - user_id: Option<&str>, + prefix: &str, after: Option<&str>, limit: usize, ) -> Result, OssError> { @@ -46,15 +46,8 @@ fn list_query( "项目目录分页大小必须为 1 到 100".to_string(), )); } - let prefix = match user_id { - Some(user) => format!( - "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{}/", - directory_segment(user)? - ), - None => AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX.to_string(), - }; let mut query = BTreeMap::from([ - ("prefix".to_string(), prefix.clone()), + ("prefix".to_string(), prefix.to_string()), ("delimiter".to_string(), "/".to_string()), ("max-keys".to_string(), limit.to_string()), ]); @@ -67,6 +60,12 @@ fn list_query( Ok(query) } +/// 渠道根前缀:`agc/project-snapshots/v2/{channel}/`。 +fn channel_root_prefix(channel: &str) -> Result { + let channel = validate_agc_project_snapshot_channel(channel)?; + Ok(format!("{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{channel}/")) +} + fn parse_directory_page( body: &[u8], query: &BTreeMap, @@ -119,15 +118,40 @@ fn parse_directory_page( } impl OssClient { - /// 第一层只列用户目录,第二层只列项目目录,不枚举文件对象或其它 bucket 前缀。 + /// 在本部署渠道下逐层列目录:`{channel}/` 下只列用户,用户下只列项目, + /// 不枚举文件对象,也不跨渠道。 pub async fn list_project_snapshot_directories( &self, client: &reqwest::Client, + channel: &str, user_id: Option<&str>, after: Option<&str>, limit: usize, ) -> Result { - let query = list_query(user_id, after, limit)?; + let mut prefix = channel_root_prefix(channel)?; + if let Some(user) = user_id { + prefix.push_str(&format!("{}/", directory_segment(user)?)); + } + self.list_project_snapshot_prefix(client, list_query(&prefix, after, limit)?) + .await + } + + /// 列出远端已存在的渠道目录:只在 v2 根下取第一层,不进入用户或项目。 + pub async fn list_project_snapshot_channels( + &self, + client: &reqwest::Client, + after: Option<&str>, + limit: usize, + ) -> Result { + let query = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, after, limit)?; + self.list_project_snapshot_prefix(client, query).await + } + + async fn list_project_snapshot_prefix( + &self, + client: &reqwest::Client, + query: BTreeMap, + ) -> Result { let mut target = build_object_url(&self.config.bucket, &self.config.endpoint, "") .map_err(OssError::InvalidRequest)?; target.set_query(Some(&build_canonical_query_string(&query))); @@ -177,11 +201,12 @@ pub(super) fn is_snapshot_file_key(key: &str) -> bool { return false; }; let parts = path.split('/').collect::>(); - parts.len() >= 5 - && directory_segment(parts[0]).is_ok() + parts.len() >= 6 + && validate_agc_project_snapshot_channel(parts[0]).is_ok() && directory_segment(parts[1]).is_ok() - && parts[2] == "files" - && parts[3].split_once('-').is_some_and(|(size, digest)| { + && directory_segment(parts[2]).is_ok() + && parts[3] == "files" + && parts[4].split_once('-').is_some_and(|(size, digest)| { size == "0" && digest.eq_ignore_ascii_case("cbf29ce484222325") }) } @@ -192,7 +217,7 @@ mod tests { #[test] fn snapshot_object_url_preserves_literal_reserved_characters() { - let key = "agc/project-snapshots/v1/user/project/files/1-abcd/game/图像 #100%.txt"; + let key = "agc/project-snapshots/v2/dev/user/project/files/1-abcd/game/图像 #100%.txt"; let url = build_object_url("bucket", "oss-cn-shanghai.aliyuncs.com", key).unwrap(); assert_eq!(url.fragment(), None); assert_eq!(url.query(), None); @@ -202,22 +227,26 @@ mod tests { #[test] fn snapshot_directory_query_is_prefix_scoped_and_bounded() { - let query = list_query(Some("user-1"), Some("project-1"), 20).unwrap(); - assert_eq!(query["prefix"], "agc/project-snapshots/v1/user-1/"); - assert_eq!( - query["marker"], - "agc/project-snapshots/v1/user-1/project-1/" - ); + let prefix = channel_root_prefix("dev").unwrap(); + assert_eq!(prefix, "agc/project-snapshots/v2/dev/"); + let query = list_query(&prefix, Some("project-1"), 20).unwrap(); + assert_eq!(query["prefix"], "agc/project-snapshots/v2/dev/"); + assert_eq!(query["marker"], "agc/project-snapshots/v2/dev/project-1/"); assert_eq!(query["delimiter"], "/"); - assert!(list_query(Some("../user"), None, 20).is_err()); - assert!(list_query(None, Some("escape/path"), 20).is_err()); - assert!(list_query(None, None, 101).is_err()); + assert!(list_query(&prefix, Some("escape/path"), 20).is_err()); + assert!(list_query(&prefix, None, 101).is_err()); + assert!(channel_root_prefix("../escape").is_err()); + assert!(channel_root_prefix("Dev").is_err()); } #[test] fn snapshot_directory_page_decodes_only_direct_children_and_advancing_cursor() { - let query = list_query(Some("user-1"), None, 20).unwrap(); - let xml = br#"agc/project-snapshots/v1/user-1//trueagc/project-snapshots/v1/user-1/project-1/agc/project-snapshots/v1/user-1/project-1/"#; + let query = list_query("agc/project-snapshots/v2/dev/user-1/", None, 20).unwrap(); + // 渠道枚举只读 v2 根的第一层,不进入任何渠道目录内部。 + let channels = list_query(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX, Some("dev"), 20).unwrap(); + assert_eq!(channels["prefix"], "agc/project-snapshots/v2/"); + assert_eq!(channels["marker"], "agc/project-snapshots/v2/dev/"); + let xml = br#"agc/project-snapshots/v2/dev/user-1//trueagc/project-snapshots/v2/dev/user-1/project-1/agc/project-snapshots/v2/dev/user-1/project-1/"#; let page = parse_directory_page(xml, &query).unwrap(); assert_eq!(page.directories, ["project-1"]); assert_eq!(page.next_marker.as_deref(), Some("project-1")); @@ -238,14 +267,18 @@ mod tests { #[test] fn empty_internal_put_is_only_allowed_for_empty_snapshot_content_key() { assert!(is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" + "agc/project-snapshots/v2/dev/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" )); assert!(!is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/manifest.json" + "agc/project-snapshots/v2/dev/user-1/project-1/manifest.json" )); assert!(!is_snapshot_file_key("agc/error-reports/v1/report.zip")); assert!(!is_snapshot_file_key( - "agc/project-snapshots/v1/user-1/project-1/files/1-cbf29ce484222325/game/file.txt" + "agc/project-snapshots/v2/dev/user-1/project-1/files/1-cbf29ce484222325/game/file.txt" + )); + // 历史(无渠道)布局的对象键不是当前快照文件键。 + assert!(!is_snapshot_file_key( + "agc/project-snapshots/v1/user-1/project-1/files/0-cbf29ce484222325/game/empty.txt" )); } } diff --git a/server-rs/crates/platform-oss/src/template_library.rs b/server-rs/crates/platform-oss/src/template_library.rs index d85d8326b..668cddaea 100644 --- a/server-rs/crates/platform-oss/src/template_library.rs +++ b/server-rs/crates/platform-oss/src/template_library.rs @@ -1222,6 +1222,7 @@ mod tests { LOCK_KEY, INDEX_KEY, "agc/project-snapshots/v1/a/b", + "agc/project-snapshots/v2/dev/a/b", "templates/v1/a/../index.json", "templates/v1/a/%2e%2e/index.json", "templates/v1/a/file?x", diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 60d7ca511..e899fefbb 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -37,6 +37,26 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 18] = [ pub struct AdminProjectSnapshotsQuery { pub cursor: Option, pub limit: Option, + /// 目标渠道;缺省用本部署渠道。 + pub channel: Option, +} + +/// 后台可查看的快照渠道列表。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotChannelsResponse { + /// 本部署渠道:上传与默认查询都用它。 + pub default_channel: String, + /// 本部署渠道与远端已存在渠道的并集(升序,去重)。 + pub channels: Vec, +} + +/// 私有工程快照下载查询:只接受渠道。 +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotDownloadQuery { + /// 目标渠道;缺省用本部署渠道。 + pub channel: Option, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -59,6 +79,14 @@ pub struct AdminProjectSnapshotItem { pub file_count: u32, pub total_bytes: u64, pub status: AdminProjectSnapshotStatus, + /// 项目所在渠道。 + pub channel: String, + /// 项目归属用户昵称,与素材查询同口径;账号不可解析时为占位作者。 + #[serde(default)] + pub author_display_name: Option, + /// 项目归属用户陶泥号,与素材查询同口径;账号不可解析时为占位账号。 + #[serde(default)] + pub author_public_user_code: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]