From 15774a1c020f5d93003e2d506051a4f6a6e61ade Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:07:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E4=B8=8A=E4=BC=A0=E5=B9=B6=E5=A2=9E=E5=8A=A0=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=AE=8C=E6=95=B4=E5=B7=A5=E7=A8=8B=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入真实项目打开、切换和关闭生命周期并修复退出等待竞态 新增后台项目列表及按原目录校验导出的 ZIP 下载 补充快照完整性元数据、后台权限和取消清理 固定默认 AGC 存储目标并支持空文件与特殊字符路径 补充自动化测试及真实 OSS 只读导出验证 记录本地数据库 404 导致完整 HTTP 联调和安装版实机验证待补 --- apps/admin-web/src/api/adminApiClient.ts | 88 ++ apps/admin-web/src/api/adminApiTypes.ts | 21 + .../src/api/adminProjectSnapshotApi.test.ts | 128 +++ apps/admin-web/src/app/AdminApp.tsx | 7 + apps/admin-web/src/app/AdminShell.tsx | 2 + apps/admin-web/src/app/adminRoutes.test.ts | 25 + apps/admin-web/src/app/adminRoutes.ts | 2 + .../pages/AdminProjectSnapshotsPage.test.tsx | 288 ++++++ .../src/pages/AdminProjectSnapshotsPage.tsx | 270 +++++ apps/admin-web/src/styles/admin.css | 106 ++ .../scripts/check-config.mjs | 3 +- .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project_snapshot/index.rs | 6 + .../src/project_snapshot/lifecycle.rs | 135 +++ .../src-tauri/src/project_snapshot/mod.rs | 138 +-- .../src-tauri/src/project_snapshot/scan.rs | 11 +- .../src-tauri/src/project_snapshot/tests.rs | 166 ++- .../features/app-shell/WorkspaceLauncher.tsx | 6 + .../app-shell/useProjectSnapshotWorkspace.ts | 16 + .../src/services/projectSnapshotWorkspace.ts | 64 ++ .../tests/projectSnapshotWorkspace.test.tsx | 108 ++ .../workspaceLauncherManifestMerge.test.tsx | 26 + ...®¡划】项目自动上传与后台工程下载-2026-09-19.md | 32 + ...‹碑】项目自动上传与后台工程下载-2026-09-19.md | 55 + docs/project-memory/shared-memory/pitfalls.md | 6 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 22 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 6 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 6 + server-rs/Cargo.lock | 14 +- server-rs/Cargo.toml | 2 + server-rs/crates/api-server/Cargo.toml | 1 + server-rs/crates/api-server/src/admin.rs | 83 ++ .../api-server/src/admin_project_snapshots.rs | 948 ++++++++++++++++++ server-rs/crates/api-server/src/config.rs | 135 ++- server-rs/crates/api-server/src/main.rs | 1 + .../crates/api-server/src/modules/admin.rs | 13 + .../api-server/src/project_snapshots.rs | 89 +- server-rs/crates/platform-oss/Cargo.toml | 1 + server-rs/crates/platform-oss/src/lib.rs | 24 +- .../platform-oss/src/project_snapshots.rs | 251 +++++ .../crates/shared-contracts/src/admin.rs | 40 +- .../src/agc_project_snapshots.rs | 33 + 42 files changed, 3259 insertions(+), 120 deletions(-) create mode 100644 apps/admin-web/src/api/adminProjectSnapshotApi.test.ts create mode 100644 apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/useProjectSnapshotWorkspace.ts create mode 100644 apps/ai-game-creator-shell/src/services/projectSnapshotWorkspace.ts create mode 100644 apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx create mode 100644 docs/project-memory/plans/【实施计划】项目自动上传与后台工程下载-2026-09-19.md create mode 100644 docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md create mode 100644 server-rs/crates/api-server/src/admin_project_snapshots.rs create mode 100644 server-rs/crates/platform-oss/src/project_snapshots.rs diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 1f53fd340..aa35d2d33 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -32,6 +32,8 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminProjectSnapshotListQuery, + AdminProjectSnapshotListResponse, AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, @@ -198,6 +200,92 @@ export function listAdminAccounts(token: string) { return request('/admin/api/accounts', { token }); } +export function listAdminProjectSnapshots( + token: string, + query: AdminProjectSnapshotListQuery = {}, + signal?: AbortSignal, +) { + const params = new URLSearchParams(); + if (query.cursor) params.set('cursor', query.cursor); + params.set('limit', String(query.limit ?? 20)); + return request( + `/admin/api/project-snapshots?${params.toString()}`, + { token, signal }, + ); +} + +export async function downloadAdminProjectSnapshot( + token: string, + userId: string, + projectId: string, + signal?: AbortSignal, +) { + const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`; + const response = await fetch(buildRequestUrl(path), { + headers: { + Authorization: `Bearer ${token.trim()}`, + Accept: 'application/zip', + [API_RESPONSE_ENVELOPE_HEADER]: 'v1', + }, + signal, + }); + if (!response.ok) { + const responseText = await response.text(); + throw buildAdminApiError( + response, + parseJsonResponse(responseText), + responseText, + ); + } + const contentType = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + if (contentType !== 'application/zip') { + await response.body?.cancel(); + throw new AdminApiError({ + message: '下载失败:服务端未返回 ZIP 工程文件', + status: response.status, + code: 'INVALID_PROJECT_ARCHIVE_RESPONSE', + }); + } + return { + blob: await response.blob(), + filename: projectArchiveFilename( + response.headers.get('content-disposition'), + ), + }; +} + +function projectArchiveFilename(contentDisposition: string | null): string { + const extended = contentDisposition?.match( + /(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i, + ); + const ordinary = contentDisposition?.match( + /(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i, + ); + let filename = + ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? ''; + if (extended?.[1]) { + try { + filename = decodeURIComponent(extended[1].trim()); + } catch { + // 非法扩展编码继续使用普通文件名。 + } + } + const safeName = Array.from(filename, (character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127 ? '_' : character; + }) + .join('') + .replace(/[<>:"/\\|?*]/g, '_') + .trim() + .replace(/[. ]+$/, ''); + if (!safeName || safeName.length > 240) return 'project.zip'; + return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`; +} + export function createAdminAccount( token: string, payload: AdminCreateAccountRequest, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 1973d77a5..452ad30f3 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -96,6 +96,27 @@ export interface AdminMeResponse { admin: AdminSessionPayload; } +export interface AdminProjectSnapshotEntry { + userId: string; + projectId: string; + projectName: string | null; + syncRevision: number; + syncedAtMs: number; + fileCount: number; + totalBytes: number; + status: 'ready' | 'partial' | 'unverified'; +} + +export interface AdminProjectSnapshotListQuery { + cursor?: string | null; + limit?: number; +} + +export interface AdminProjectSnapshotListResponse { + items: AdminProjectSnapshotEntry[]; + nextCursor: string | null; +} + 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 new file mode 100644 index 000000000..bee81d5f9 --- /dev/null +++ b/apps/admin-web/src/api/adminProjectSnapshotApi.test.ts @@ -0,0 +1,128 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +import { + downloadAdminProjectSnapshot, + listAdminProjectSnapshots, +} from './adminApiClient'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('项目列表携带分页与后台授权,解析标准响应', async () => { + const payload = { items: [], nextCursor: 'next' }; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: payload })), + ); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + expect( + await listAdminProjectSnapshots( + 'admin-token', + { cursor: 'user/a+项目', limit: 20 }, + controller.signal, + ), + ).toEqual(payload); + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + signal: controller.signal, + }), + ); +}); + +test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('PK\u0003\u0004', { + headers: { + 'content-type': 'application/zip', + 'content-disposition': + "attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip", + }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + const archive = await downloadAdminProjectSnapshot( + 'admin-token', + 'user/a', + 'project/b', + controller.signal, + ); + 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', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + Accept: 'application/zip', + }), + signal: controller.signal, + }), + ); +}); + +test.each([ + ['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'], + ['attachment; filename="../secret.zip"', '.._secret.zip'], + ["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'], + [null, 'project.zip'], +])('ZIP 附件名兼容安全回退 %s', async (header, expected) => { + const headers: Record = { 'content-type': 'application/zip' }; + // Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。 + if (header) + headers['content-disposition'] = header.replace('工程', 'project'); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('PK', { headers })), + ); + expect( + (await downloadAdminProjectSnapshot('token', 'user', 'project')).filename, + ).toBe(expected.replace('工程', 'project')); +}); + +test.each([401, 403, 409, 500])( + '下载 HTTP %s 保留后台错误,不返回 ZIP', + async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + ok: false, + error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' }, + }), + { + status, + headers: { 'content-type': 'application/json' }, + }, + ), + ), + ); + await expect( + downloadAdminProjectSnapshot('token', 'user', 'project'), + ).rejects.toMatchObject({ + status, + code: 'SNAPSHOT_FAILURE', + message: '工程尚未同步完成', + }); + }, +); + +test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{"ok":false}', { + headers: { 'content-type': 'application/json' }, + }), + ), + ); + await expect( + downloadAdminProjectSnapshot('token', 'user', 'project'), + ).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' }); +}); diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index afb62b22f..6fac5b208 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -31,6 +31,7 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; +import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage'; import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage'; import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage'; import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage'; @@ -308,6 +309,12 @@ export function AdminApp() { {activeRouteId === 'accounts' ? ( ) : null} + {activeRouteId === 'project-snapshots' ? ( + + ) : null} ); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 86f23f140..97554b25e 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -4,6 +4,7 @@ import { Bug, Coins, Database, + FolderArchive, GitBranch, Images, LayoutDashboard, @@ -49,6 +50,7 @@ const routeIcons = { 'editor-generation-pricing': Coins, 'editor-showcase': Star, 'editor-assets': Images, + 'project-snapshots': FolderArchive, accounts: Users, 'agc-models': ListChecks, } satisfies Record; diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index 34486f7b4..2e1a0e20a 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -122,3 +122,28 @@ test('零权限 member 不回落到 Dashboard', () => { expect(routes).toEqual([]); expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull(); }); + +test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => { + const route = { + id: 'project-snapshots', + label: '项目工程', + hash: '#project-snapshots', + }; + expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route); + expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots'); + expect( + getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }), + ).toContainEqual(route); + expect( + getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['project-snapshots'], + }), + ).toEqual([route]); + expect( + getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['tracking'], + }), + ).not.toContainEqual(route); +}); diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 1110bb05d..10503df72 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -16,6 +16,7 @@ export type AdminRouteId = | 'editor-generation-pricing' | 'editor-showcase' | 'editor-assets' + | 'project-snapshots' | 'agc-models' | 'accounts'; @@ -54,6 +55,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, + { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, ]; diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx new file mode 100644 index 000000000..30e4ba2ae --- /dev/null +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -0,0 +1,288 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import { + AdminApiError, + downloadAdminProjectSnapshot, + listAdminProjectSnapshots, +} from '../api/adminApiClient'; +import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage'; + +vi.mock('../api/adminApiClient', async () => ({ + ...(await vi.importActual( + '../api/adminApiClient', + )), + downloadAdminProjectSnapshot: vi.fn(), + listAdminProjectSnapshots: vi.fn(), +})); + +const entry: AdminProjectSnapshotEntry = { + userId: 'user-1', + projectId: 'project-1', + projectName: '三消工程', + syncRevision: 3, + syncedAtMs: 1_700_000_000_000, + fileCount: 12, + totalBytes: 2048, + status: 'ready', +}; + +beforeEach(() => { + vi.mocked(listAdminProjectSnapshots) + .mockReset() + .mockResolvedValue({ items: [entry], nextCursor: null }); + vi.mocked(downloadAdminProjectSnapshot).mockReset(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +test('按项目展示完整性并限制未完成工程下载', async () => { + vi.mocked(listAdminProjectSnapshots).mockResolvedValue({ + items: [ + entry, + { + ...entry, + projectId: 'partial-project', + projectName: '未完成工程', + status: 'partial', + }, + { + ...entry, + projectId: 'legacy-project', + projectName: null, + status: 'unverified', + }, + ], + nextCursor: null, + }); + render(); + const completeRow = (await screen.findByText('三消工程')).closest('tr')!; + expect(within(completeRow).getByText('2 KiB')).toBeTruthy(); + expect( + within(completeRow) + .getByRole('button', { name: '下载完整工程' }) + .hasAttribute('disabled'), + ).toBe(false); + expect( + screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'), + ).toBe(true); + expect(screen.getByText('完整性未知')).toBeTruthy(); + expect( + screen + .getByRole('button', { name: '下载已存文件' }) + .hasAttribute('disabled'), + ).toBe(false); +}); + +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 }); + render(); + fireEvent.click(await screen.findByRole('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( + 'token', + { cursor: null, limit: 20 }, + expect.any(AbortSignal), + ); +}); + +test('下载使用返回的中文文件名,随后释放对象 URL', async () => { + const createObjectURL = vi.fn(() => 'blob:archive'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + 'URL', + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); + let savedFilename = ''; + let savedHref = ''; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement, + ) { + savedFilename = this.download; + savedHref = this.href; + }); + const blob = new Blob(['PK'], { type: 'application/zip' }); + vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({ + blob, + filename: '三消工程-r3.zip', + }); + render(); + const button = await screen.findByRole('button', { name: '下载完整工程' }); + vi.useFakeTimers(); + await act(async () => { + fireEvent.click(button); + }); + expect(savedFilename).toBe('三消工程-r3.zip'); + expect(savedHref).toBe('blob:archive'); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith( + 'token', + 'user-1', + 'project-1', + expect.any(AbortSignal), + ); + act(() => vi.advanceTimersByTime(1000)); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive'); +}); + +test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => { + vi.mocked(downloadAdminProjectSnapshot).mockImplementation( + (_token, _user, _project, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')), + ); + }), + ); + const view = render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); + fireEvent.click(await screen.findByRole('button', { name: '取消下载' })); + expect( + vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted, + ).toBe(true); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {})); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2]; + view.unmount(); + expect(signal?.aborted).toBe(true); +}); + +test('下载登录失效走现有会话处理,403 错误保留页面', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(downloadAdminProjectSnapshot) + .mockRejectedValueOnce( + new AdminApiError({ status: 403, message: '无项目工程权限' }), + ) + .mockRejectedValueOnce( + new AdminApiError({ status: 401, message: '已过期' }), + ); + render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); + expect(await screen.findByText('无项目工程权限')).toBeTruthy(); + expect(onUnauthorized).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '下载完整工程' })); + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); +}); + +test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(listAdminProjectSnapshots) + .mockRejectedValueOnce(new Error('清单存储不可用')) + .mockRejectedValueOnce( + new AdminApiError({ status: 401, message: '已过期' }), + ); + render( + , + ); + await screen.findByText('清单存储不可用'); + expect(screen.queryByText('暂无已上传项目')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); +}); + +test('更换登录令牌丢弃旧列表和晚返回请求', async () => { + let finishOldRequest!: (value: { + items: AdminProjectSnapshotEntry[]; + nextCursor: null; + }) => void; + vi.mocked(listAdminProjectSnapshots) + .mockReturnValueOnce( + new Promise((resolve) => { + finishOldRequest = resolve; + }), + ) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '新账号工程' }], + nextCursor: null, + }); + const onUnauthorized = vi.fn(); + const view = render( + , + ); + const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2]; + view.rerender( + , + ); + await screen.findByText('新账号工程'); + expect(oldSignal?.aborted).toBe(true); + await act(async () => { + finishOldRequest({ items: [entry], nextCursor: null }); + }); + expect(screen.queryByText('三消工程')).toBeNull(); + expect(screen.getByText('新账号工程')).toBeTruthy(); +}); + +test('卸载后完成的下载不会创建浏览器文件', async () => { + let finishDownload!: (value: { blob: Blob; filename: string }) => void; + vi.mocked(downloadAdminProjectSnapshot).mockReturnValue( + new Promise((resolve) => { + finishDownload = resolve; + }), + ); + const click = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}); + const view = render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' })); + const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]; + view.unmount(); + expect(signal?.aborted).toBe(true); + await act(async () => { + finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' }); + }); + expect(click).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx new file mode 100644 index 000000000..489956117 --- /dev/null +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -0,0 +1,270 @@ +import { Download, RefreshCcw, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + downloadAdminProjectSnapshot, + listAdminProjectSnapshots, +} from '../api/adminApiClient'; +import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import { handlePageError } from './pageUtils'; + +interface AdminProjectSnapshotsPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const snapshotStatuses = { + ready: { + label: '已同步', + className: 'admin-status-ok', + action: '下载完整工程', + }, + partial: { + label: '同步未完成', + className: 'admin-status-pending', + action: '同步未完成', + }, + unverified: { + label: '完整性未知', + className: 'admin-status-pending', + action: '下载已存文件', + }, +}; + +export function AdminProjectSnapshotsPage({ + token, + onUnauthorized, +}: AdminProjectSnapshotsPageProps) { + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + 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); + + const loadPage = useCallback( + async (cursor: string | null = null) => { + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminProjectSnapshots( + token, + { cursor, limit: 20 }, + 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()]; + }); + setNextCursor(response.nextCursor); + setHasLoaded(true); + } catch (error: unknown) { + if (!controller.signal.aborted) + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + } + } + }, + [token, onUnauthorized], + ); + + useEffect(() => { + setItems([]); + setNextCursor(null); + setHasLoaded(false); + setDownloadingKey(null); + void loadPage(); + return () => { + listController.current?.abort(); + listController.current = null; + downloadController.current?.abort(); + downloadController.current = null; + }; + }, [loadPage]); + + async function downloadProject(entry: AdminProjectSnapshotEntry) { + if (downloadController.current || entry.status === 'partial') return; + const controller = new AbortController(); + downloadController.current = controller; + setDownloadingKey(snapshotKey(entry)); + setErrorMessage(''); + try { + const archive = await downloadAdminProjectSnapshot( + token, + entry.userId, + entry.projectId, + controller.signal, + ); + if (controller.signal.aborted) return; + const objectUrl = URL.createObjectURL(archive.blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = archive.filename; + document.body.append(link); + try { + link.click(); + } finally { + link.remove(); + // 给浏览器时间接管下载,随后释放临时 URL。 + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } + } catch (error: unknown) { + if (!controller.signal.aborted) + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + if (downloadController.current === controller) { + downloadController.current = null; + setDownloadingKey(null); + } + } + } + + function cancelDownload() { + downloadController.current?.abort(); + downloadController.current = null; + setDownloadingKey(null); + } + + return ( +
+
+

项目工程

+ +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ + + + + + + + + + + + + + {items.map((entry) => { + const status = snapshotStatuses[entry.status]; + const isDownloading = downloadingKey === snapshotKey(entry); + return ( + + + + + + + + + + ); + })} + +
项目用户 ID同步时间文件数体积完整性操作
+ {entry.projectName || entry.projectId} + {entry.projectId} + {entry.userId} + + {new Date(entry.syncedAtMs).toLocaleString('zh-CN', { + hour12: false, + })} + 版本 {entry.syncRevision} + + + {entry.fileCount.toLocaleString('zh-CN')} + {formatBytes(entry.totalBytes)} + + {status.label} + + + {isDownloading ? ( + + ) : ( + + )} +
+
+ {hasLoaded && items.length === 0 && !errorMessage ? ( +

暂无已上传项目

+ ) : null} + {nextCursor ? ( +
+ +
+ ) : null} +
+
+ ); +} + +function snapshotKey(entry: AdminProjectSnapshotEntry) { + return `${entry.userId}/${entry.projectId}`; +} + +function formatBytes(bytes: number) { + const units = ['B', 'KiB', 'MiB', 'GiB']; + const unit = Math.min( + Math.floor(Math.log2(Math.max(1, bytes)) / 10), + units.length - 1, + ); + return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 61c02e4d6..127ea095a 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1452,6 +1452,112 @@ button:disabled { min-width: 1180px; } +.admin-project-snapshot-table { + min-width: 0; + table-layout: fixed; +} + +.admin-project-snapshot-table th, +.admin-project-snapshot-table td { + overflow-wrap: anywhere; +} + +.admin-project-snapshot-table th:first-child { + width: 20%; +} + +.admin-project-snapshot-table th:nth-child(2) { + width: 14%; +} + +.admin-project-snapshot-table th:nth-child(3) { + width: 18%; +} + +.admin-project-snapshot-table th:nth-child(4) { + width: 7%; +} + +.admin-project-snapshot-table th:nth-child(5) { + width: 9%; +} + +.admin-project-snapshot-table th:nth-child(6) { + width: 12%; +} + +.admin-project-snapshot-table th:last-child { + width: 20%; +} + +@media (max-width: 1200px) { + .admin-project-snapshot-table, + .admin-project-snapshot-table tbody { + display: block; + } + + .admin-project-snapshot-table thead { + display: none; + } + + .admin-project-snapshot-table tr { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px 16px; + border-bottom: 1px solid #eaded2; + padding: 18px 0; + } + + .admin-project-snapshot-table tr:first-child { + padding-top: 0; + } + + .admin-project-snapshot-table tr:last-child { + border-bottom: 0; + padding-bottom: 0; + } + + .admin-project-snapshot-table td { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; + border: 0; + padding: 0; + font-size: 14px; + } + + .admin-project-snapshot-table td::before { + flex-shrink: 0; + color: #8f7868; + font-size: 12px; + content: attr(data-label); + } + + .admin-project-snapshot-table td:first-child, + .admin-project-snapshot-table td:nth-child(2), + .admin-project-snapshot-table td:nth-child(3), + .admin-project-snapshot-table td:nth-child(6), + .admin-project-snapshot-table td:last-child { + grid-column: 1 / -1; + } + + .admin-project-snapshot-table td:first-child { + display: block; + font-size: 16px; + } + + .admin-project-snapshot-table td:first-child::before, + .admin-project-snapshot-table td:last-child::before { + display: none; + } + + .admin-project-snapshot-table td:last-child button { + width: 100%; + justify-content: center; + } +} + .admin-recharge-table { min-width: 1080px; table-layout: fixed; diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c6985710a..cc77c7b23 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -127,8 +127,7 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', - // 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用; - // 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。 + // 前端登记工程生命周期,上传由 Rust 调度;以下两个命令仅供本机排障。 'read_local_project_snapshot_state', 'sync_local_project_snapshot', 'reset_design_agent_session', diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 9576afd49..5b806ed6e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2723,6 +2723,7 @@ fn main() { ack_error_reports, sync_local_project_snapshot, read_local_project_snapshot_state, + set_active_project_snapshot_workspace, ]) .build(tauri_context); let app = match app { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs index f81f9e747..010642de6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs @@ -22,6 +22,10 @@ pub(crate) struct ProjectSnapshotIndex { pub(crate) sync_revision: u64, pub(crate) synced_at_ms: u64, #[serde(default)] + pub(crate) project_name: Option, + #[serde(default)] + pub(crate) pending_files: Option, + #[serde(default)] pub(crate) files: BTreeMap, } @@ -35,6 +39,8 @@ pub(crate) fn empty_project_snapshot_index( user_id: user_id.to_string(), sync_revision: 0, synced_at_ms: 0, + project_name: None, + pending_files: None, files: BTreeMap::new(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs new file mode 100644 index 000000000..ece545897 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs @@ -0,0 +1,135 @@ +use super::*; + +/// 显式空登记保留在表中,避免返回首页后又从窗口的旧 URL 恢复项目。 +#[derive(Clone, Default)] +pub(crate) struct ProjectSnapshotWorkspaces { + windows: BTreeMap>, +} + +impl ProjectSnapshotWorkspaces { + pub(crate) fn project_for_window( + &self, + label: &str, + url_project: Option, + ) -> Option { + self.windows.get(label).cloned().unwrap_or(url_project) + } + + pub(crate) fn set_project( + &mut self, + label: &str, + project_path: Option, + ) -> Vec<(PathBuf, ProjectSnapshotSyncTrigger)> { + let previous = self + .windows + .insert(label.to_string(), project_path.clone()) + .flatten(); + let key = |path: &String| project_snapshot_sync_key(Path::new(path)); + if previous.as_ref().map(key) == project_path.as_ref().map(key) { + return Vec::new(); + } + let mut requests = Vec::new(); + if let Some(previous) = previous { + requests.push(( + PathBuf::from(previous), + ProjectSnapshotSyncTrigger::ProjectClose, + )); + } + if let Some(project_path) = project_path { + requests.push(( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectOpen, + )); + } + requests + } +} + +static PROJECT_SNAPSHOT_WORKSPACES: OnceLock> = OnceLock::new(); + +fn project_snapshot_workspaces() -> &'static Mutex { + PROJECT_SNAPSHOT_WORKSPACES.get_or_init(|| Mutex::new(ProjectSnapshotWorkspaces::default())) +} + +/// 窗口身份由 Tauri 注入,前端只能登记自身已经打开的普通项目目录。 +#[tauri::command] +pub(crate) fn set_active_project_snapshot_workspace( + window: tauri::Window, + project_path: Option, +) -> Result<(), String> { + let project_path = project_path + .map(|path| { + let root = resolve_project_snapshot_root(&path)?; + let manifest = read_existing_manifest_for_project(&root)?; + validate_project_snapshot_project_id(manifest.project_id.trim())?; + Ok::<_, String>(root.to_string_lossy().into_owned()) + }) + .transpose() + .inspect_err(|_| { + app_log!( + "project_snapshot.workspace.registration.failed window={} reason=invalid-project", + window.label() + ); + })?; + let requests = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .set_project(window.label(), project_path); + for (root, trigger) in requests { + request_project_snapshot_sync(root, trigger); + } + Ok(()) +} + +pub(crate) fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { + let registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // WebView URL 读取可能回到主线程,不能持登记锁等待它,否则会与关窗/登记互锁。 + let mut paths = BTreeMap::new(); + for window in app.webview_windows().into_values() { + let url_project = window + .url() + .ok() + .and_then(|url| project_snapshot_project_path_from_url(&url)); + if let Some(path) = registry.project_for_window(window.label(), url_project) { + paths.insert(project_snapshot_sync_key(Path::new(&path)), path); + } + } + paths.into_values().collect() +} + +pub(crate) fn handle_project_snapshot_window_event( + window: &tauri::Window, + event: &tauri::WindowEvent, +) { + if matches!(event, tauri::WindowEvent::Destroyed) { + project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .windows + .remove(window.label()); + return; + } + if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + return; + } + let url_project = window + .app_handle() + .get_webview_window(window.label()) + .and_then(|webview| webview.url().ok()) + .and_then(|url| project_snapshot_project_path_from_url(&url)); + let mut registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let project_path = registry.project_for_window(window.label(), url_project); + registry.set_project(window.label(), None); + drop(registry); + if let Some(project_path) = project_path { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectClose, + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index bcdf2ce21..503682525 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -11,6 +11,7 @@ use std::time::Instant; mod diff; mod index; +mod lifecycle; mod scan; mod transport; @@ -19,6 +20,7 @@ mod tests; pub(crate) use diff::*; pub(crate) use index::*; +pub(crate) use lifecycle::*; pub(crate) use scan::*; pub(crate) use transport::*; @@ -50,6 +52,7 @@ const PROJECT_SNAPSHOT_DISABLED_ENV: &str = "GENARRATIVE_AGC_PROJECT_SNAPSHOT_DI #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ProjectSnapshotSyncTrigger { + ProjectOpen, Periodic, ProjectClose, Manual, @@ -58,6 +61,7 @@ pub(crate) enum ProjectSnapshotSyncTrigger { impl ProjectSnapshotSyncTrigger { fn as_str(self) -> &'static str { match self { + Self::ProjectOpen => "project-open", Self::Periodic => "periodic", Self::ProjectClose => "project-close", Self::Manual => "manual", @@ -215,6 +219,19 @@ pub(crate) fn wait_for_project_snapshot_syncs(timeout: Duration) -> bool { } /// 把项目同步请求交给后台线程:窗口关闭与应用退出路径都不能被网络等待阻塞。 +fn spawn_project_snapshot_sync_task( + run: impl FnOnce() + Send + 'static, +) -> std::io::Result> { + // 退出等待必须连已排队但尚未开始执行的线程一起计入。 + let scheduled = ProjectSnapshotInFlightGuard::begin(); + std::thread::Builder::new() + .name("agc-project-snapshot".to_string()) + .spawn(move || { + let _scheduled = scheduled; + run(); + }) +} + pub(crate) fn request_project_snapshot_sync( project_root: PathBuf, trigger: ProjectSnapshotSyncTrigger, @@ -223,22 +240,22 @@ pub(crate) fn request_project_snapshot_sync( return; } let key = project_snapshot_sync_key(&project_root); - let spawn = std::thread::Builder::new() - .name("agc-project-snapshot".to_string()) - .spawn(move || { - let run = || sync_project_snapshot_blocking(&project_root, trigger); - let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { - try_run_project_snapshot_sync(&key, run) - } else { - Some(run_project_snapshot_sync(&key, run)) - }; - if let Some(Err(error)) = outcome { - app_log!( - "project_snapshot.sync.failed trigger={}: {error}", - trigger.as_str() - ); - } - }); + let spawn = spawn_project_snapshot_sync_task(move || { + let run = || sync_project_snapshot_blocking(&project_root, trigger); + let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { + try_run_project_snapshot_sync(&key, run) + } else { + Some(run_project_snapshot_sync(&key, run)) + }; + if let Some(Err(error)) = outcome { + let error = error.replace(project_root.to_string_lossy().as_ref(), ""); + app_log!( + "project_snapshot.sync.failed projectKey={:016x} trigger={}: {error}", + fnv1a64(key.as_bytes()), + trigger.as_str() + ); + } + }); if let Err(error) = spawn { app_log!("project_snapshot.sync.spawn.failed: {error}"); } @@ -296,7 +313,9 @@ async fn sync_project_snapshot_async( } let diff = compute_project_snapshot_diff(&scan, &previous.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)?; - if !diff.has_changes() { + let project_name = (!manifest.name.trim().is_empty()).then(|| manifest.name.trim().to_string()); + let pending_files = project_snapshot_pending_file_count(&diff, &[]); + if !project_snapshot_manifest_needs_sync(&previous, &diff, &project_name, pending_files) { return Ok(ProjectSnapshotSyncReport { project_id, trigger: trigger.as_str().to_string(), @@ -316,6 +335,7 @@ async fn sync_project_snapshot_async( } let upload = upload_project_snapshot_diff(&session, &project_id, &diff).await; + let pending_files = project_snapshot_pending_file_count(&diff, &upload.failures); let synced_files = build_project_snapshot_synced_files(&diff, &upload.uploaded_paths); let next_revision = previous.sync_revision.saturating_add(1); let synced_at_ms = u64::try_from(unix_millis()).unwrap_or(u64::MAX); @@ -325,6 +345,8 @@ async fn sync_project_snapshot_async( project_id: project_id.clone(), sync_revision: next_revision, synced_at_ms, + project_name: project_name.clone(), + pending_files: Some(pending_files), files: synced_files .iter() .map(|(relative_path, file)| { @@ -346,12 +368,14 @@ async fn sync_project_snapshot_async( user_id: session.user_id.clone(), sync_revision: next_revision, synced_at_ms, + project_name, + pending_files: Some(pending_files), files: synced_files, })?; - let status = if upload.failures.is_empty() { + let status = if pending_files == 0 { "synced" - } else if upload.uploaded_paths.is_empty() { + } else if !upload.failures.is_empty() && upload.uploaded_paths.is_empty() { "failed" } else { "partial" @@ -381,7 +405,8 @@ async fn sync_project_snapshot_async( synced_at_ms, }; app_log!( - "project_snapshot.sync.completed trigger={} status={} revision={} uploaded={} skippedRemote={} deleted={} deferred={} failed={}", + "project_snapshot.sync.completed projectId={} trigger={} status={} revision={} uploaded={} skippedRemote={} deleted={} deferred={} failed={} pending={}", + report.project_id, report.trigger, report.status, report.sync_revision, @@ -389,7 +414,8 @@ async fn sync_project_snapshot_async( report.remote_skipped_files, report.deleted_files, report.deferred_files, - report.failed_files.len() + report.failed_files.len(), + pending_files ); Ok(report) } @@ -405,53 +431,39 @@ fn failure_views(skipped: &[ProjectSnapshotSkippedPath]) -> Vec u32 { + let paths = diff + .skipped + .iter() + .chain(&diff.deferred) + .chain(&diff.pending) + .map(|entry| entry.relative_path.as_str()) + .chain(failures.iter().map(|entry| entry.relative_path.as_str())) + .collect::>(); + u32::try_from(paths.len()).unwrap_or(u32::MAX) +} + +fn project_snapshot_manifest_needs_sync( + previous: &ProjectSnapshotIndex, + diff: &ProjectSnapshotDiff, + project_name: &Option, + pending_files: u32, +) -> bool { + diff.has_changes() + || previous.project_name != *project_name + || previous.pending_files != Some(pending_files) +} + +/// 独立 supervisor-chat 窗口尚未登记时,从其 URL 读取项目路径。 pub(crate) fn project_snapshot_project_path_from_url(url: &url::Url) -> Option { url.query_pairs() .find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned())) .filter(|value| !value.trim().is_empty()) } -fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { - let mut paths = BTreeSet::new(); - for window in app.webview_windows().into_values() { - let Ok(url) = window.url() else { - continue; - }; - if let Some(project_path) = project_snapshot_project_path_from_url(&url) { - paths.insert(project_path); - } - } - paths.into_iter().collect() -} - -/// 工作区窗口关闭即视为项目关闭:立刻补一次同步。 -pub(crate) fn handle_project_snapshot_window_event( - window: &tauri::Window, - event: &tauri::WindowEvent, -) { - if !project_snapshot_sync_enabled() { - return; - } - if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { - return; - } - // `tauri::Window` 不暴露 WebView 地址,按标签取回对应的 WebView 窗口再读 URL。 - let Some(webview) = window.app_handle().get_webview_window(window.label()) else { - return; - }; - let Ok(url) = webview.url() else { - return; - }; - let Some(project_path) = project_snapshot_project_path_from_url(&url) else { - return; - }; - request_project_snapshot_sync( - PathBuf::from(project_path), - ProjectSnapshotSyncTrigger::ProjectClose, - ); -} - /// 应用退出前等待在途同步收尾。 /// /// 退出时刻窗口已销毁,按窗口重新枚举项目只会得到空集,因此这里不重复发起同步: @@ -465,7 +477,7 @@ pub(crate) fn wait_for_project_snapshot_syncs_on_exit() { } } -/// 周期定时器:只为当前仍打开的项目触发,进程内项目集合由窗口 URL 决定。 +/// 周期定时器:只为当前窗口登记的项目触发,不扫描其它本地项目。 pub(crate) fn spawn_project_snapshot_scheduler(app: tauri::AppHandle) { if !project_snapshot_sync_enabled() { app_log!("project_snapshot.scheduler.disabled"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs index 2163c4b24..964e10a38 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs @@ -52,6 +52,9 @@ pub(crate) fn scan_project_snapshot_files( let Ok(relative_path) = relative_project_path(root, &path) else { continue; }; + if should_skip_project_snapshot_path(&relative_path) { + continue; + } let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) => { @@ -62,14 +65,8 @@ pub(crate) fn scan_project_snapshot_files( continue; } }; - if should_skip_project_snapshot_path(&relative_path) { - continue; - } if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - result.skipped.push(ProjectSnapshotSkippedPath { - relative_path, - reason: "符号链接或重解析点不参与项目快照".to_string(), - }); + // 与凭据和构建缓存一样属于明确排除范围,不计入工程缺失数量。 continue; } if metadata.is_dir() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs index ff57bdfae..a39b2b8b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -280,6 +280,28 @@ fn project_snapshot_periodic_trigger_yields_while_a_sync_is_in_flight() { assert!(try_run_project_snapshot_sync(key, || ()).is_some()); } +#[test] +fn project_snapshot_exit_wait_includes_scheduled_tasks_waiting_for_the_project_lock() { + let key = "snapshot-close-enqueue-exit-test"; + let lock = project_snapshot_project_lock(key); + let held = lock.lock().unwrap(); + let completed = Arc::new(AtomicUsize::new(0)); + let observed = completed.clone(); + let task = spawn_project_snapshot_sync_task(move || { + run_project_snapshot_sync(key, || { + observed.fetch_add(1, Ordering::SeqCst); + }); + }) + .expect("enqueue closing sync"); + // 线程尚未拿到项目锁时,退出也必须等待;不能等开始上传才计入。 + assert!(!wait_for_project_snapshot_syncs(Duration::from_millis(5))); + assert_eq!(completed.load(Ordering::SeqCst), 0); + drop(held); + task.join().expect("closing sync completed"); + assert_eq!(completed.load(Ordering::SeqCst), 1); + assert!(wait_for_project_snapshot_syncs(Duration::from_secs(2))); +} + fn read_http_request_with_body(stream: &mut TcpStream) -> String { stream .set_read_timeout(Some(Duration::from_secs(5))) @@ -518,7 +540,7 @@ fn project_snapshot_upload_stops_after_a_deterministic_authentication_failure() } #[test] -fn project_snapshot_project_path_is_read_from_window_urls_only() { +fn project_snapshot_independent_window_url_supplies_a_fallback_project() { let url = url::Url::parse("tauri://localhost/index.html?main&projectPath=C%3A%5Cgames%5Cdemo") .expect("parse fixture url"); assert_eq!( @@ -529,6 +551,148 @@ fn project_snapshot_project_path_is_read_from_window_urls_only() { assert_eq!(project_snapshot_project_path_from_url(&launcher), None); } +#[test] +fn project_snapshot_workspace_lifecycle_tracks_the_single_client_window() { + let mut registry = ProjectSnapshotWorkspaces::default(); + let first = "/projects/first".to_string(); + let second = "/projects/second".to_string(); + assert_eq!(registry.project_for_window("client", None), None); + assert_eq!( + registry.set_project("client", Some(first.clone())), + vec![( + PathBuf::from(&first), + ProjectSnapshotSyncTrigger::ProjectOpen + )] + ); + assert_eq!( + registry.project_for_window("client", None), + Some(first.clone()) + ); + assert!(registry + .set_project("client", Some(first.clone())) + .is_empty()); + assert_eq!( + registry.set_project("client", Some(second.clone())), + vec![ + ( + PathBuf::from(&first), + ProjectSnapshotSyncTrigger::ProjectClose + ), + ( + PathBuf::from(&second), + ProjectSnapshotSyncTrigger::ProjectOpen + ), + ] + ); + registry.set_project("other", Some(first.clone())); + assert_eq!( + registry.set_project("client", None), + vec![( + PathBuf::from(&second), + ProjectSnapshotSyncTrigger::ProjectClose + )] + ); + assert_eq!(registry.project_for_window("client", Some(second)), None); + assert_eq!(registry.project_for_window("other", None), Some(first)); + assert!(registry.set_project("client", None).is_empty()); +} + +#[test] +fn project_snapshot_manifest_metadata_changes_sync_without_content_changes() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/empty.txt", b""); + let initial = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &BTreeMap::new(), + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .unwrap(); + assert_eq!(initial.uploads[0].size_bytes, 0); + let mut previous = empty_project_snapshot_index("project-1", "user-1"); + previous.files = initial.current; + let mut unchanged = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &previous.files, + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .unwrap(); + assert!(!unchanged.has_changes()); + let name = Some("完整工程".to_string()); + assert!(project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, 0 + )); + previous.project_name = name.clone(); + previous.pending_files = Some(0); + assert!(!project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, 0 + )); + assert!(project_snapshot_manifest_needs_sync( + &previous, + &unchanged, + &Some("已改名".to_string()), + 0 + )); + + unchanged.skipped.push(ProjectSnapshotSkippedPath { + relative_path: "assets/large.bin".into(), + reason: "单文件超限".into(), + }); + let pending = project_snapshot_pending_file_count(&unchanged, &[]); + assert_eq!(pending, 1); + assert!(project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, pending + )); + previous.pending_files = Some(pending); + assert!(!project_snapshot_manifest_needs_sync( + &previous, &unchanged, &name, pending + )); + unchanged.skipped.clear(); + assert!(project_snapshot_manifest_needs_sync( + &previous, + &unchanged, + &name, + project_snapshot_pending_file_count(&unchanged, &[]) + )); +} + +#[test] +fn project_snapshot_pending_files_include_every_unsynced_path_once() { + let skipped = |path: &str| ProjectSnapshotSkippedPath { + relative_path: path.into(), + reason: "暂未同步".into(), + }; + let diff = ProjectSnapshotDiff { + skipped: vec![skipped("assets/large.bin")], + deferred: vec![skipped("assets/later.png")], + pending: vec![skipped("game/changing.js")], + ..Default::default() + }; + let failures = vec![ + ProjectSnapshotUploadFailure { + relative_path: "assets/failed.png".into(), + code: "transport-failed".into(), + detail: "失败".into(), + }, + ProjectSnapshotUploadFailure { + relative_path: "game/changing.js".into(), + code: "file-changed".into(), + detail: "变化".into(), + }, + ]; + assert_eq!(project_snapshot_pending_file_count(&diff, &failures), 4); +} + +#[test] +fn project_snapshot_legacy_index_keeps_completeness_unknown() { + let index: ProjectSnapshotIndex = serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, "projectId": "project-1", "userId": "user-1", + "syncRevision": 1, "syncedAtMs": 1, "files": {} + })) + .unwrap(); + assert_eq!(index.project_name, None); + assert_eq!(index.pending_files, None); +} + /// 真实链路冒烟:客户端差异引擎 → 本地 api-server → 真实 OSS。 /// /// 默认忽略;需要显式提供目标项目与登录态: diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 85107f7b9..a58eb294e 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -49,6 +49,7 @@ import { DESIGN_ARTIFACTS_BUILD_PROMPT, useHomeProjectCreation, } from './useHomeProjectCreation'; +import { useProjectSnapshotWorkspace } from './useProjectSnapshotWorkspace'; import { useRecentProjects } from './useRecentProjects'; export function WorkspaceLauncherShell({ @@ -115,6 +116,11 @@ export function WorkspaceLauncherShell({ homeCreationBusy, homeCreationRecoverableProjectPath, } = homeProject; + useProjectSnapshotWorkspace( + launcherView === 'project-development' + ? (currentProjectContext?.projectPath ?? null) + : null, + ); const directInvoke = resolveTauriInvoke(); const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({ invoke: directInvoke, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useProjectSnapshotWorkspace.ts b/apps/ai-game-creator-shell/src/features/app-shell/useProjectSnapshotWorkspace.ts new file mode 100644 index 000000000..006988026 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/useProjectSnapshotWorkspace.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import { projectSnapshotWorkspaceRegistration } from '../../services/projectSnapshotWorkspace'; + +export function useProjectSnapshotWorkspace(projectPath: string | null) { + const invoke = resolveTauriInvoke(); + const registration = invoke + ? projectSnapshotWorkspaceRegistration(invoke) + : null; + + useEffect(() => registration?.acquire(), [registration]); + useEffect(() => { + void registration?.setProject(projectPath); + }, [projectPath, registration]); +} diff --git a/apps/ai-game-creator-shell/src/services/projectSnapshotWorkspace.ts b/apps/ai-game-creator-shell/src/services/projectSnapshotWorkspace.ts new file mode 100644 index 000000000..bbd7bdca5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/projectSnapshotWorkspace.ts @@ -0,0 +1,64 @@ +type WorkspaceInvoke = ( + command: string, + args?: Record, +) => Promise; + +/** 同一 WebView 的登记串行提交;旧请求完成后不能覆盖更新的工程状态。 */ +export function createProjectSnapshotWorkspaceRegistration( + invoke: WorkspaceInvoke, +) { + let requestedPath: string | null | undefined; + let revision = 0; + let ownerRevision = 0; + let queue = Promise.resolve(); + + function setProject(projectPath: string | null) { + if (requestedPath === projectPath) return queue; + requestedPath = projectPath; + const requestRevision = ++revision; + queue = queue.then(async () => { + try { + await invoke('set_active_project_snapshot_workspace', { projectPath }); + } catch { + // 失败不能被记作已登记;下一次相同路径登记仍可重新提交。 + if (revision === requestRevision) requestedPath = undefined; + try { + await invoke('append_application_log', { + level: 'error', + source: 'project-snapshot', + message: 'project_snapshot.workspace.registration.failed', + }); + } catch { + // 原生桥本身不可用时也不阻断项目创作。 + } + } + }); + return queue; + } + + function acquire() { + const owner = ++ownerRevision; + return () => { + // StrictMode 的同轮卸载/重挂以及工作台替换不代表工程真的关闭。 + queueMicrotask(() => { + if (owner === ownerRevision) void setProject(null); + }); + }; + } + + return { setProject, acquire }; +} + +const registrations = new WeakMap< + WorkspaceInvoke, + ReturnType +>(); + +export function projectSnapshotWorkspaceRegistration(invoke: WorkspaceInvoke) { + let registration = registrations.get(invoke); + if (!registration) { + registration = createProjectSnapshotWorkspaceRegistration(invoke); + registrations.set(invoke, registration); + } + return registration; +} diff --git a/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx b/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx new file mode 100644 index 000000000..88d12ef53 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx @@ -0,0 +1,108 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import React, { StrictMode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useProjectSnapshotWorkspace } from '../src/features/app-shell/useProjectSnapshotWorkspace'; +import { createProjectSnapshotWorkspaceRegistration } from '../src/services/projectSnapshotWorkspace'; + +afterEach(() => { + cleanup(); + delete window.__TAURI__; +}); + +describe('项目快照窗口登记', () => { + it('在无 projectPath URL 的单窗口登记、切换、离开,StrictMode 不误关项目', async () => { + const invoke = vi.fn(async () => undefined); + window.__TAURI__ = { core: { invoke: invoke as never } }; + function Workspace({ path }: { path: string | null }) { + useProjectSnapshotWorkspace(path); + return null; + } + const view = (path: string | null) => ( + + + + ); + expect(new URL(window.location.href).searchParams.has('projectPath')).toBe( + false, + ); + const rendered = render(view('/projects/first')); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); + expect(invoke).toHaveBeenLastCalledWith( + 'set_active_project_snapshot_workspace', + { projectPath: '/projects/first' }, + ); + rendered.rerender(view('/projects/first')); + await act(async () => { + await Promise.resolve(); + }); + expect(invoke).toHaveBeenCalledTimes(1); + rendered.rerender(view('/projects/second')); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2)); + expect(invoke).toHaveBeenLastCalledWith( + 'set_active_project_snapshot_workspace', + { projectPath: '/projects/second' }, + ); + rendered.rerender(view(null)); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(3)); + expect(invoke).toHaveBeenLastCalledWith( + 'set_active_project_snapshot_workspace', + { projectPath: null }, + ); + rendered.rerender(view('/projects/third')); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(4)); + rendered.unmount(); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(5)); + expect(invoke).toHaveBeenLastCalledWith( + 'set_active_project_snapshot_workspace', + { projectPath: null }, + ); + }); + + it('原生登记未完成时顺序提交后续切换,不让迟到回包覆盖新项目', async () => { + let release!: () => void; + const first = new Promise((resolve) => { + release = resolve; + }); + const invoke = vi + .fn() + .mockImplementationOnce(() => first) + .mockResolvedValue(undefined); + const registration = createProjectSnapshotWorkspaceRegistration(invoke); + void registration.setProject('/projects/first'); + void registration.setProject('/projects/second'); + const idle = registration.setProject(null); + await Promise.resolve(); + expect(invoke).toHaveBeenCalledTimes(1); + release(); + await idle; + expect(invoke.mock.calls.map((call) => call[1]?.projectPath)).toEqual([ + '/projects/first', + '/projects/second', + null, + ]); + }); + + it('失败写固定诊断并允许再次登记,不能泄漏原生错误中的路径和凭据', async () => { + const invoke = vi + .fn() + .mockRejectedValueOnce(new Error('C:\\private\\project token=secret')) + .mockResolvedValue(undefined); + const registration = createProjectSnapshotWorkspaceRegistration(invoke); + await expect( + registration.setProject('/projects/first'), + ).resolves.toBeUndefined(); + expect(invoke).toHaveBeenLastCalledWith('append_application_log', { + level: 'error', + source: 'project-snapshot', + message: 'project_snapshot.workspace.registration.failed', + }); + await registration.setProject('/projects/first'); + expect(invoke).toHaveBeenCalledTimes(3); + expect(invoke).toHaveBeenLastCalledWith( + 'set_active_project_snapshot_workspace', + { projectPath: '/projects/first' }, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index 5ea530842..e776828dd 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -108,6 +108,8 @@ function installInvokeMock() { return null; case 'list_game_creator_direct_active_turns': return []; + case 'set_active_project_snapshot_workspace': + return undefined; case 'read_project_permission_policy': return { projectPath: PROJECT_PATH, @@ -204,6 +206,30 @@ describe('清单快照被拒收时的用户可见性与恢复', () => { vi.restoreAllMocks(); }); + it('真实启动器打开项目后登记原生快照工作区,返回首页补交关闭', async () => { + const invoke = installInvokeMock(); + await openProjectThroughLauncher(); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'set_active_project_snapshot_workspace', + { projectPath: PROJECT_PATH }, + ), + ); + fireEvent.click(screen.getByRole('button', { name: '首页' })); + await waitFor(() => + expect( + invoke.mock.calls + .filter( + ([command]) => command === 'set_active_project_snapshot_workspace', + ) + .at(-1), + ).toEqual([ + 'set_active_project_snapshot_workspace', + { projectPath: null }, + ]), + ); + }); + it('shows a rejection notice, rereads disk truth and adopts the new asset', async () => { const invoke = installInvokeMock(); await openProjectThroughLauncher(); diff --git a/docs/project-memory/plans/【实施计划】项目自动上传与后台工程下载-2026-09-19.md b/docs/project-memory/plans/【实施计划】项目自动上传与后台工程下载-2026-09-19.md new file mode 100644 index 000000000..3fb6c6e41 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】项目自动上传与后台工程下载-2026-09-19.md @@ -0,0 +1,32 @@ +# 项目自动上传与后台工程下载实施计划 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md` | +| Status | implemented-awaiting-runtime-validation | +| Owner | 当前任务 Agent | + +## 修改边界 + +- 客户端 project_snapshot 与实际窗口生命周期、相关定向测试。 +- shared-contracts 快照清单及后台 DTO、platform-oss 的受控清单枚举、api-server 管理员列表与 ZIP 下载、后台权限映射。 +- api-server 快照存储配置解析:默认目标与素材 bucket 分离,只有凭据可回退;不变更部署配置或搬迁对象。 +- admin-web 项目列表、现有路由/导航/API client 与相应测试。 +- 主规范、运维说明及必要共享约定。不修改 SpacetimeDB、External API、用户会话权威或线上配置。 + +## 实现顺序 + +1. 评审主规范与本里程碑,复现客户端项目枚举故障。 +2. 并行实现后台列表/ZIP、后台 UI;客户端只修已证实上传缺陷并补清单元数据。 +3. 集成契约、运行定向测试和 UI smoke,核查真实清单可还原目录;按证据更新验收状态。 + +## 验证命令 + +- `cargo test --locked -p platform-oss`、`cargo test --locked -p api-server project_snapshot`、`cargo test --locked -p shared-contracts`(server-rs)。 +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot`。 +- `npm run admin-web:typecheck`、`npm run admin-web:build`、后台相关 Vitest。 +- `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + +## 风险与回滚 + +旧清单无完整性声明,只能标记未知;完整工程含项目源码、素材和配置,排除依赖/构建缓存、凭据、会话与运行日志。下载失败不修改 OSS 或清单。本轮按用户授权提交推送,不部署;代码可独立回退。 diff --git a/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md new file mode 100644 index 000000000..ae4a90f62 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】项目自动上传与后台工程下载-2026-09-19.md @@ -0,0 +1,55 @@ +# 项目自动上传与后台工程下载 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented-awaiting-runtime-validation | +| Date | 2026-09-19 | +| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的项目快照上传与后台工程下载合同 | + +## 目标与范围 + +修复实际项目自动上传的已证实故障,并让有权限的后台管理员按项目查看远端快照、一键下载按原始目录还原的工程 ZIP。 + +## 不在范围内 + +客户端上传 UI、跨设备恢复、版本历史、数据库 schema、线上部署、自动补传全部未打开项目。 + +## 依赖与前置条件 + +延续现有私有 OSS 项目快照、平台会话与后台权限。真实 bucket 已只读确认只有两份历史模板清单;上传根因必须经确定性复现后修正。 + +## 验收标准 + +- [ ] 正式项目窗口可被同步调度识别;周期与关闭触发保持有界,失败有项目级诊断。 +- [ ] 未单独配置快照目标时仍使用 agc-dev,只复用资源存储凭据;显式快照目标保持有效,不迁移现存对象。 +- [ ] 后台列表显示项目名/ID、用户 ID、同步时间、文件数、体积和完整性,支持刷新与分页。 +- [ ] ZIP 按清单还原相对路径;不含对象存储摘要目录;空文件可上传与导出。 +- [ ] 清单名称/完整性变化在无内容差异时也提交,partial 可恢复 ready,历史缺字段不冒充 ready。 +- [ ] 缺失、损坏、越界路径和非完整清单失败关闭;历史未声明完整性的清单明确标记,允许导出已有文件但不称为完整工程。 +- [ ] 无后台权限不能读取项目或 ZIP;不泄漏凭据;ZIP 构建有体积、并发和临时文件清理边界。 +- [ ] 定向 Rust 测试、后台类型检查/构建、UI smoke、编码、文档索引和 diff 检查完成,运行时证据与未验证部分分别列出。 + +## 证据要求 + +自动化覆盖窗口身份、上传零字节、清单统计、目录还原、路径与校验和校验、后台路由权限。运行时优先只读现有 OSS 清单;测试不上传真实用户工程、不修改线上数据。 + +## 已取得证据 + +| 层次 | 结果 | +| --- | --- | +| 客户端 Rust `project_snapshot` | 22 通过、1 忽略(写入式真实上传 smoke 未运行);含排队退出等待回归 | +| 客户端前端生命周期与启动器 | 3 文件、10 测试通过;完整 AGC typecheck、skill-pack、check-config 通过 | +| 后台页面、API client、路由与样式 | 44 测试通过;admin-web typecheck/build 通过 | +| 后端快照与权限 | 14 定向测试通过;未认证路由矩阵与页签映射 2 测试通过 | +| 默认存储目标 | 真实 AppConfig::from_env 配置回归 1 通过 | +| OSS 存储层 | 全量 55 测试通过,含签名、特殊字符路径、读取上限与零字节 | +| 真实 OSS 只读导出 | `project_snapshots_live_readonly_list_and_archive` 通过;limit=1 分页、2 清单、12 文件、73,424 字节,ZIP 解压路径/长度/摘要全匹配、临时文件清理通过;两份均为历史 unverified | +| 浏览器 smoke | 模拟 API 的 1280 桌面与 390 窄屏通过;下载按钮可见,无整页横向溢出;中文 ZIP 文件名、Authorization、409 错误呈现通过 | +| 通用门禁 | 编码、文档索引、production-ops、Rust 格式、客户端 Prettier 与 diff 检查通过 | + +## 剩余验证与环境边界 + +`npm run dev:api-server -- --api-port 4198 --bgfilter-worker-port 4199 --api-timeout-seconds 600` 编译成功,但当前工作区配置的本地数据库 `xushi-p4wfr` 在 `127.0.0.1:3101` 返回 404,启动认证投影无法完成,故 `/healthz` 及完整 HTTP smoke 未通过。仅本任务启动的 API/worker 已停止;没有清库、迁移数据库、改 `.env` 或替换其它项目的验证目标。 + +尚未替换安装版、构建新客户端发布包或部署;提交推送按用户本轮授权执行。当前条款已有上述自动化与只读存储证据,最终验收仍等待当前工作区数据库就绪后的 HTTP 联调,以及新客户端的隔离实机自动上传验证;完成后再关闭里程碑并清理两份计划。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4219729f1..28ef41020 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## AGC 自动同步必须绑定真实项目生命周期 + +- 正式客户端在单窗口中用 React 状态打开/切换工程,窗口 URL 不代表当前工程。原生后台同步应读取由当前窗口显式登记的活动工程;首次打开、离开、切换、关窗及退出等待分别验证,不能只用携带 `projectPath` 的独立测试窗口证明正式入口可用。 +- 增量文件没有变化不等于远端清单没有变化。项目名和完整性元数据也参与提交判据,避免临时跳过恢复后永久停留在 partial,或新出现超限文件后仍显示 ready。历史清单缺少完整性字段属于未知,不能默认成完整。 +- ZIP 导出按一次冻结清单恢复相对路径并逐文件核验;直接下载内容寻址的 OSS 目录不能得到可用工程。源码/素材归档不包含依赖缓存、凭据和 AGC 对话运行状态。 + ## 2026-09-19 资源 kind 词汇收敛后,前端判据与 fixture 必须一起按 canonical 成员重写 - **现象**:工具栏入口的 `assetKind` 换成共享 `GameCreationAppAssetKind`(图集从平台词 `art-spritesheet` 改成 `icon-spritesheet`)后,「图集不接受用户参考」的判据仍写在旧的 `['art-spritesheet']` 字符串清单里,判据恒假:生成面板重新给图集渲染参考图选择器,原生提交再按合同显式拒绝多余参考。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 06a73bd40..7eac15f9d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1616,23 +1616,23 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 ### 目标与非目标 - 目标:AGC 在项目工作区打开期间按固定周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;重复内容不重复上传,远端占用跟随当前清单收敛。 -- 非目标:不做云端下载/恢复、不做跨设备合并、不保留多版本历史、不新增面向用户的上传界面、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。 +- 非目标:不做客户端云端恢复、不做跨设备合并、不保留多版本历史、不新增面向用户的上传界面、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。 - 非目标:不把 OSS AccessKey 放进客户端;客户端不直连 OSS。 ### 参与入口、状态与跨模块边界 -- 触发入口有两个:工作区窗口 `main` 存活期间的周期定时器、工作区窗口关闭事件(`CloseRequested`)。两者共用同一个进程内同步器,同一项目的同步串行执行,周期触发在已有同步进行时直接让位,不排队堆积。 -- 应用退出(`RunEvent::Exit`)不重复发起同步:该时刻窗口已销毁,按窗口重新枚举项目只会得到空集;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步能写完索引再退出。 +- 触发入口为项目生命周期登记、已登记项目的周期定时器及窗口关闭事件(`CloseRequested`)。它们共用同一个进程内同步器,同一项目的同步串行执行,周期触发在已有同步进行时直接让位,不排队堆积。 +- 应用退出(`RunEvent::Exit`)不重复发起同步;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步有机会写完索引再退出。超过预算不能声明最后状态已经上传。 - 客户端扫描、差异对比、索引持久化与上传编排都在 Tauri Rust 进程(`src-tauri/src/project_snapshot/`);WebView 只读状态,不参与差异计算。 - 本地索引是增量对比的唯一依据:`/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` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。 -- 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`,未配置时回退 `ALIYUN_OSS_*`;与"资源 bucket 与备份 bucket 分离"的既有口径一致。 +- 目标 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 的现存数据。 ### 正常、失败、重试与幂等行为 -- 差异对比口径:先按 `相对路径 + 字节数 + 修改时间` 判定是否候选变更,命中旧记录则复用已存 `sha256`,只有 `(size, mtime)` 变化才重算摘要。产出新增、修改、删除三类集合,只上传新增与修改的文件。 +- 差异对比口径:先按 `相对路径 + 字节数 + 修改时间` 判定是否候选变更,命中旧记录则复用已存 `fnv1a64`,只有 `(size, mtime)` 变化才重算摘要。产出新增、修改、删除三类集合,只上传新增与修改的文件;清单元数据变化单独提交。 - 每次成功同步的最后一步上传该项目的 `manifest.json`(当前全量文件清单:相对路径、摘要、字节数、同步序号)。清单描述的是项目当前全量内容,因此清单体积就是该项目在 OSS 上的常驻占用。 - 远端回收:清单写入成功后,服务端读取上一版清单,按 `(路径, 字节数, 摘要)` 反推出不再被当前清单引用的对象键并删除。只处理上一版清单登记过的键,不做 LIST,因此不可能误删其它项目或其它功能的对象;单次最多回收 2000 个对象,剩余部分留到下一次清单写入继续;上一版清单读不到或解析失败时整轮跳过回收(fail-closed)。单个删除失败只记日志,不影响本次同步语义。 - 因此本功能是"当前状态镜像 + 清单",不保留历史版本:同一路径的内容变化会覆盖式替换远端对象,回滚能力不在本轮范围内。 @@ -1656,6 +1656,18 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 运行时 smoke:AGC 开发态打开项目、观察索引写入与同步日志、关闭工作区窗口后确认关闭触发的那次同步执行;报告为"客户端 diff 已验证 / 服务端已配置环境联调"两层,不合并成一句"已通"。 - 边界:新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。 +### 后台工程列表与下载 + +- 正式客户端在单窗口内切换启动器和项目,不以窗口 URL 判断活动项目。前端把当前窗口的已打开项目登记给原生同步器;打开后发起首轮同步,离开/切换项目和物理关窗为旧项目补同步,周期扫描只读这份窗口登记。重复登记同一路径不重复发起;注册失败写诊断,不能假装已登记。 +- 清单补充可选 `projectName` 与 `pendingFiles`:名称来自本地 manifest;`pendingFiles` 是本轮失败、延后、并发变动与非策略排除的跳过文件数量。`0` 表示扫描范围已同步;大文件等被跳过不能标成完整。旧清单字段缺失表示完整性未知,维持可读取兼容,不反写旧清单。 +- 项目名称或完整性发生变化时,即使文件内容没有差异也要提交新清单;本机索引记录上次已提交的这两个字段。实际客户端下一次正常同步可补齐历史清单元数据;后台只读访问不迁移旧清单。缺失 `pendingFiles` 不能默认成 0,临时跳过原因消失后允许无文件上传的 `partial → ready` 转换。 +- 后台增加“项目工程”入口,仅 owner 及拥有 `project-snapshots` 页签权限的管理员可访问。`GET /admin/api/project-snapshots?cursor=&limit=20` 读取私有 OSS 清单并返回 `{items,nextCursor}`;单页最多 100 个,游标由服务端校验,目录与清单读取有界。条目为 `{userId,projectId,projectName,syncRevision,syncedAtMs,fileCount,totalBytes,status}`,状态为 `ready / partial / unverified`,名称缺失时显示 projectId。 +- `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 只读取该用户/项目的固定清单与其引用对象,返回 `application/zip` 附件。ZIP 中路径直接使用原始相对路径,不包含 userId、摘要目录或 OSS 前缀;名称使用经过安全处理的项目名和 revision。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。 +- `partial` 快照下载返回 409;`unverified` 历史快照可导出已同步文件,列表明确显示“完整性未知”,动作称“下载已存文件”。`ready` 才显示“下载完整工程”。ZIP 构建核验每一文件的长度与 fnv1a64 摘要,拒绝穿越、绝对路径、重复/大小写冲突路径、非法项目身份;缺失或损坏整体失败,不返回成功的残缺 ZIP。 +- ZIP 使用服务端临时文件并限制并发,不将 2 GiB 工程整体驻留内存;成功、失败、客户端取消均清理临时文件。单文件、总量、文件数沿用上传上限,超限明确拒绝。零字节工程文件可以上传和导出。OSS 凭据与签名不下发浏览器,列表失败保留错误而非伪造空列表。 +- 工程归档范围与上传策略一致:源码、素材及引擎配置按原路径保存,依赖/构建缓存、凭据、`.agent` 会话与运行日志排除;此 ZIP 不声明能恢复 AGC 对话历史。后台读取需要目标前缀的 ListObjects 与 GetObject 权限,不新增数据库表,不改 External API。 +- 验收覆盖真实单窗口生命周期登记、首次/增量/零字节上传、后台列表分页和权限、ZIP 解压目录及摘要、部分/历史清单、缺失对象、路径拒绝、下载取消清理,并分别报告定向测试和真实环境证据。 + ### 未决问题 - 用户侧看不到同步状态与失败原因(界面按产品口径不暴露),排障只能读 AppData 诊断日志或调用 native-only 命令;如果后续要支持用户自助排查,需要先确认是否允许在客户端出现上传相关 UI。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a65dd79ef..ec4c761fa 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -64,6 +64,12 @@ npm run check:server-rs-ddd ## API 路由分组 +### AGC 项目工程后台读取 + +`GET /admin/api/project-snapshots` 与 `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 复用后台鉴权,要求 owner 或 `project-snapshots` 页签权限。数据来自私有 OSS 项目清单,不复用图片编辑器项目表、不新增 SpacetimeDB schema。`platform-oss` 只提供固定 AGC 前缀下的有界目录枚举和对象读取,`api-server` 负责列表投影、归档与下载响应,后台 DTO 留在 `shared-contracts/admin`。 + +列表按清单中的 `pendingFiles` 区分 `ready / partial / unverified`;历史字段缺失必须为 `unverified`。下载冻结本次清单,按原始目录构造 ZIP;逐项核对长度和摘要,部分同步、对象缺失、损坏或危险路径不能返回完整工程。客户端不接触 OSS 凭据。完整契约见 [AGC 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“后台工程列表与下载”。 + 路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组: - 健康检查:`GET /healthz`、`GET /readyz`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 4c4e7ec65..8c7d81365 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -548,6 +548,12 @@ 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}/` 目录下载当成工程。 + +自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 + +后台枚举另外需要 AGC 私有前缀的 `ListObjects`(RAM `oss:ListObjects`,限制 prefix)与 `GetObject` 权限;下载不需要写入权限。客户端修复、后台页面与 api-server 必须分别发布才能在安装版和线上后台使用。本地定向测试及页面模拟不能代替发布后的真实上传与 ZIP 下载验收。 + AGC 客户端按周期与项目关闭时机把用户项目增量上传到 `agc-dev`。客户端只持有平台登录态 Access Token,经 `POST /api/agc/project-snapshots/files`(单文件原始字节)与 `POST /api/agc/project-snapshots/manifest`(本次同步清单)交给 `api-server`,由服务端写入私有前缀 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 614e6da6b..69643a84f 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -243,6 +243,7 @@ dependencies = [ "shared-logging", "socket2", "spacetime-client", + "tempfile", "time", "tokio", "tokio-stream", @@ -4132,6 +4133,7 @@ dependencies = [ "base64", "bytes", "hmac", + "quick-xml 0.38.4", "reqwest", "serde", "serde_json", @@ -4405,6 +4407,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -6738,7 +6750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.4", "quote", ] diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 45e78c939..6f312b2ed 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -131,6 +131,8 @@ uuid = "1" webp = "0.3" x509-parser = "0.16" zip = { version = "2", default-features = false } +quick-xml = { version = "0.38", features = ["serialize"] } +tempfile = "3" [profile.dev] opt-level = 0 # 默认 0,有人手滑改 1/2 会慢 diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 415afd5b9..d7587be2d 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -57,6 +57,7 @@ url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true, features = ["v4"] } zip = { workspace = true, features = ["deflate"] } +tempfile = { workspace = true } [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] } diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 0fac037b9..8e70570f8 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2195,6 +2195,8 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/tracking/events" => AnyTab(&["tracking"]), "/admin/api/tracking/event-keys" => AnyTab(&["tracking", "tasks"]), "/admin/api/error-reports" => AnyTab(&["error-reports"]), + "/admin/api/project-snapshots" => AnyTab(&["project-snapshots"]), + path if path.starts_with("/admin/api/project-snapshots/") => AnyTab(&["project-snapshots"]), path if path.starts_with("/admin/api/error-reports/") => AnyTab(&["error-reports"]), "/admin/api/feature-gates" => AnyTab(&["gray-release"]), "/admin/api/editor-generation-pricing" => AnyTab(&["editor-generation-pricing"]), @@ -6876,6 +6878,11 @@ mod tests { ("debug", Method::POST, "/admin/api/debug/http"), ("tracking", Method::GET, "/admin/api/tracking/events"), ("error-reports", Method::GET, "/admin/api/error-reports"), + ( + "project-snapshots", + Method::GET, + "/admin/api/project-snapshots", + ), ("gray-release", Method::GET, "/admin/api/feature-gates"), ("redeem", Method::GET, "/admin/api/profile/redeem-codes"), ("invite", Method::GET, "/admin/api/profile/invite-codes"), @@ -6994,4 +7001,80 @@ mod agc_model_permissions_tests { )); } } + + #[tokio::test] + async fn project_snapshots_handlers_require_the_tab_for_authenticated_members() { + use crate::{config::AppConfig, request_context::attach_request_context}; + use axum::{Router, body::Body, middleware, routing::get}; + use tower::ServiceExt; + + // 以已认证会话为边界注入身份,实际权限函数和两个正式 handler 均参与路由测试。 + // JWT 缺失/错误由 modules::admin 的完整 protected-route matrix 单独覆盖。 + for (role, tabs, expected) in [ + ( + "member", + vec!["overview".to_string()], + StatusCode::FORBIDDEN, + ), + ( + "member", + vec!["project-snapshots".to_string()], + StatusCode::SERVICE_UNAVAILABLE, + ), + ("owner", vec![], StatusCode::SERVICE_UNAVAILABLE), + ] { + let state = AppState::new(AppConfig::default()).unwrap(); + let app = Router::new() + .route( + "/admin/api/project-snapshots", + get(crate::admin_project_snapshots::admin_list_project_snapshots), + ) + .route( + "/admin/api/project-snapshots/{user}/{project}/download", + get(crate::admin_project_snapshots::admin_download_project_snapshot), + ) + .route_layer(middleware::from_fn( + move |mut request: Request, next: Next| { + let tabs = tabs.clone(); + async move { + enforce_admin_request_permission( + role, + &tabs, + &[], + request.method(), + request.uri().path(), + )?; + let now = OffsetDateTime::now_utc(); + request.extensions_mut().insert(AuthenticatedAdmin::new( + build_admin_session_payload(crate::state::AdminSession { + subject: "fixture".into(), + username: "fixture".into(), + display_name: "fixture".into(), + roles: vec!["admin".into()], + account_role: role.into(), + tab_permissions: tabs, + action_permissions: vec![], + issued_at: now, + expires_at: now + time::Duration::minutes(5), + }), + )); + Ok::<_, AppError>(next.run(request).await) + } + }, + )) + .layer(middleware::from_fn(attach_request_context)) + .with_state(state); + for path in [ + "/admin/api/project-snapshots", + "/admin/api/project-snapshots/user-1/project-1/download", + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected, "{role}: {path}"); + } + } + } } diff --git a/server-rs/crates/api-server/src/admin_project_snapshots.rs b/server-rs/crates/api-server/src/admin_project_snapshots.rs new file mode 100644 index 000000000..427281191 --- /dev/null +++ b/server-rs/crates/api-server/src/admin_project_snapshots.rs @@ -0,0 +1,948 @@ +//! 后台按项目读取私有快照,并在完整性验证后导出原始工程目录。 + +use std::{ + future::Future, + io::Write, + sync::{Arc, OnceLock}, + time::Duration, +}; + +use axum::{ + Json, + body::{Body, Bytes}, + extract::{Extension, Path, Query, State}, + http::{StatusCode, header}, + response::Response, +}; +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, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use shared_contracts::{ + admin::{ + AdminProjectSnapshotItem, AdminProjectSnapshotStatus, AdminProjectSnapshotsQuery, + AdminProjectSnapshotsResponse, + }, + agc_project_snapshots::{ + AgcProjectSnapshotManifestFile, AgcProjectSnapshotManifestRequest, + agc_project_snapshot_checksum, validate_agc_project_snapshot_project_id, + }, +}; +use tempfile::NamedTempFile; +use tokio::{ + io::AsyncReadExt, + sync::{OwnedSemaphorePermit, Semaphore}, +}; +use zip::{ZipWriter, write::SimpleFileOptions}; + +use crate::{ + admin::AuthenticatedAdmin, + api_response::json_success_body, + http_error::AppError, + project_snapshots::{MAX_MANIFEST_REQUEST_BODY_BYTES, project_snapshot_oss, validate_manifest}, + request_context::RequestContext, + state::AppState, +}; + +const MAX_DIRECTORY_REQUESTS: usize = 100; +const MAX_SCANNED_PROJECTS: usize = 100; +static DOWNLOAD_PERMITS: OnceLock> = OnceLock::new(); + +/// 游标只含已验证的两层目录标识,不接受任意 OSS 前缀或对象键。 +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct SnapshotCursor { + user: Option, + project: Option, + finished_user: bool, + last_user: bool, +} + +impl SnapshotCursor { + fn decode(value: Option<&str>) -> Result { + let Some(value) = value.filter(|value| !value.is_empty()) else { + return Ok(Self { + finished_user: true, + ..Self::default() + }); + }; + if value.len() > 1024 { + return Err(bad_request("项目列表游标无效")); + } + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| bad_request("项目列表游标无效"))?; + let cursor: Self = + serde_json::from_slice(&bytes).map_err(|_| bad_request("项目列表游标无效"))?; + for segment in [&cursor.user, &cursor.project].into_iter().flatten() { + validate_agc_project_snapshot_project_id(segment) + .map_err(|_| bad_request("项目列表游标无效"))?; + } + if cursor.user.is_none() || (cursor.finished_user && cursor.project.is_some()) { + return Err(bad_request("项目列表游标无效")); + } + Ok(cursor) + } + + fn encode(&self) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).expect("fixed cursor serializes")) + } +} + +trait SnapshotStore: Sync { + fn directories( + &self, + user: Option<&str>, + after: Option<&str>, + limit: usize, + ) -> impl Future> + Send; + fn manifest( + &self, + user: &str, + project: &str, + ) -> impl Future, AppError>> + Send; + fn file( + &self, + user: &str, + project: &str, + file: &AgcProjectSnapshotManifestFile, + ) -> impl Future, AppError>> + Send; +} + +struct OssSnapshotStore<'a> { + oss: &'a OssClient, + client: &'a reqwest::Client, +} + +impl SnapshotStore for OssSnapshotStore<'_> { + async fn directories( + &self, + user: Option<&str>, + after: Option<&str>, + limit: usize, + ) -> Result { + self.oss + .list_project_snapshot_directories(self.client, user, after, limit) + .await + .map_err(|_| upstream("读取项目工程目录失败")) + } + + async fn manifest( + &self, + user: &str, + project: &str, + ) -> Result, AppError> { + let object_key = agc_project_snapshot_manifest_object_key(user, project) + .map_err(|_| bad_request("项目工程身份无效"))?; + let bytes = match self + .oss + .get_object( + self.client, + OssGetObjectRequest { + object_key, + max_bytes: MAX_MANIFEST_REQUEST_BODY_BYTES, + }, + ) + .await + { + Ok(bytes) => bytes, + Err(OssError::ObjectNotFound(_)) => return Ok(None), + Err(_) => return Err(upstream("读取项目工程清单失败")), + }; + let manifest: AgcProjectSnapshotManifestRequest = + serde_json::from_slice(&bytes).map_err(|_| upstream("项目工程清单格式无效"))?; + validate_manifest(&manifest).map_err(|_| upstream("项目工程清单未通过安全校验"))?; + if manifest.project_id != project { + return Err(upstream("项目工程清单身份不一致")); + } + Ok(Some(manifest)) + } + + async fn file( + &self, + user: &str, + project: &str, + file: &AgcProjectSnapshotManifestFile, + ) -> Result, AppError> { + let digest = file.checksum.strip_prefix("fnv1a64:").unwrap_or_default(); + let object_key = agc_project_snapshot_file_object_key( + user, + project, + file.size_bytes, + digest, + &file.relative_path, + ) + .map_err(|_| upstream("项目工程文件路径无效"))?; + self.oss + .get_object( + self.client, + OssGetObjectRequest { + object_key, + max_bytes: file.size_bytes as usize, + }, + ) + .await + .map_err(|_| upstream("项目工程文件缺失或读取失败,请刷新后重试")) + } +} + +fn item(user: String, manifest: &AgcProjectSnapshotManifestRequest) -> AdminProjectSnapshotItem { + AdminProjectSnapshotItem { + user_id: user, + project_id: manifest.project_id.clone(), + project_name: manifest + .project_name + .clone() + .unwrap_or_else(|| manifest.project_id.clone()), + sync_revision: manifest.sync_revision, + synced_at_ms: manifest.synced_at_ms, + file_count: manifest.files.len() as u32, + total_bytes: manifest.files.iter().map(|file| file.size_bytes).sum(), + status: match manifest.pending_files { + Some(0) => AdminProjectSnapshotStatus::Ready, + Some(_) => AdminProjectSnapshotStatus::Partial, + None => AdminProjectSnapshotStatus::Unverified, + }, + } +} + +async fn list_snapshots( + store: &impl SnapshotStore, + mut cursor: SnapshotCursor, + limit: usize, +) -> Result { + let mut items = Vec::new(); + let mut directory_requests = 0; + let mut scanned = 0; + while items.len() < limit + && directory_requests < MAX_DIRECTORY_REQUESTS + && scanned < MAX_SCANNED_PROJECTS + { + if cursor.finished_user { + let users = store.directories(None, cursor.user.as_deref(), 1).await?; + directory_requests += 1; + let Some(user) = users.directories.into_iter().next() else { + return Ok(AdminProjectSnapshotsResponse { + items, + next_cursor: None, + }); + }; + cursor = SnapshotCursor { + user: Some(user), + project: None, + finished_user: false, + last_user: users.next_marker.is_none(), + }; + if directory_requests >= MAX_DIRECTORY_REQUESTS { + break; + } + } + let user = cursor + .user + .as_deref() + .ok_or_else(|| bad_request("项目列表游标无效"))?; + let projects = store + .directories( + Some(user), + cursor.project.as_deref(), + (limit - items.len()).min(MAX_SCANNED_PROJECTS - scanned), + ) + .await?; + directory_requests += 1; + for project in projects.directories { + scanned += 1; + if let Some(manifest) = store.manifest(user, &project).await? { + items.push(item(user.to_string(), &manifest)); + } + cursor.project = Some(project); + } + if let Some(next) = projects.next_marker { + cursor.project = Some(next); + } else { + if cursor.last_user { + return Ok(AdminProjectSnapshotsResponse { + items, + next_cursor: None, + }); + } + cursor.project = None; + cursor.finished_user = true; + } + } + Ok(AdminProjectSnapshotsResponse { + items, + next_cursor: Some(cursor.encode()), + }) +} + +pub async fn admin_list_project_snapshots( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, AppError> { + let cursor = SnapshotCursor::decode(query.cursor.as_deref())?; + let store = OssSnapshotStore { + oss: project_snapshot_oss(&state)?, + client: state.editor_oss_http_client(), + }; + let response = list_snapshots(&store, cursor, query.limit.unwrap_or(20).clamp(1, 100)).await?; + Ok(json_success_body(Some(&ctx), response)) +} + +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) + { + return Err(upstream("项目工程文件长度或摘要不一致,无法导出")); + } + Ok(()) +} + +async fn archive_blocking( + permit: Option>, + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(move || { + // 取消等待不会停止 blocking 线程;额度必须由实际工作持有到退出。 + let _permit = permit; + work() + }) + .await + .map_err(|_| internal())? +} + +async fn build_archive_guarded( + store: &impl SnapshotStore, + user: &str, + manifest: &AgcProjectSnapshotManifestRequest, + permit: Option>, +) -> Result { + let tempfile = archive_blocking(permit.clone(), || { + tempfile::Builder::new() + .prefix("agc-project-") + .suffix(".zip") + .tempfile() + .map_err(|_| internal()) + }) + .await?; + write_archive(store, user, manifest, tempfile, permit).await +} + +#[cfg(test)] +async fn build_archive( + store: &impl SnapshotStore, + user: &str, + manifest: &AgcProjectSnapshotManifestRequest, +) -> Result { + build_archive_guarded(store, user, manifest, None).await +} + +async fn write_archive( + store: &impl SnapshotStore, + user: &str, + manifest: &AgcProjectSnapshotManifestRequest, + tempfile: NamedTempFile, + permit: Option>, +) -> Result { + validate_manifest(manifest)?; + if manifest.pending_files.is_some_and(|pending| pending > 0) { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_message("项目尚未完整上传,请等待客户端同步完成")); + } + // ZipWriter 直接拥有临时文件;任何 await 被取消或后台写入失败都会释放该文件。 + let mut archive = ZipWriter::new(tempfile); + for file in &manifest.files { + let bytes = store.file(user, &manifest.project_id, file).await?; + verify_file(file, &bytes)?; + let path = file.relative_path.clone(); + archive = archive_blocking(permit.clone(), move || { + archive + .start_file( + path, + SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o644), + ) + .map_err(|_| internal())?; + archive.write_all(&bytes).map_err(|_| internal())?; + Ok::<_, AppError>(archive) + }) + .await?; + } + archive_blocking(permit, move || archive.finish().map_err(|_| internal())).await +} + +fn archive_filename(manifest: &AgcProjectSnapshotManifestRequest) -> String { + let name = manifest + .project_name + .as_deref() + .unwrap_or(&manifest.project_id); + let safe: String = name + .chars() + .map(|c| { + if c.is_control() || "<>:\"/\\|?*".contains(c) { + '_' + } else { + c + } + }) + .take(80) + .collect(); + let safe = safe.trim_matches([' ', '.']); + format!( + "{}-r{}.zip", + if safe.is_empty() { "project" } else { safe }, + manifest.sync_revision + ) +} + +fn archive_response( + archive: NamedTempFile, + permit: Arc, + filename: &str, +) -> Result { + let size = archive.as_file().metadata().map_err(|_| internal())?.len(); + let reader = archive.reopen().map_err(|_| internal())?; + let stream = async_stream::stream! { + let _archive = archive; + let _permit = permit; + let mut reader = tokio::fs::File::from_std(reader); + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let count = match reader.read(&mut buffer).await { + Ok(count) => count, + Err(error) => { yield Err(error); break; } + }; + if count == 0 { break; } + yield Ok::<_, std::io::Error>(Bytes::copy_from_slice(&buffer[..count])); + } + }; + let body = Body::from_stream(stream); + Response::builder() + .header(header::CONTENT_TYPE, "application/zip") + .header(header::CONTENT_LENGTH, size.to_string()) + .header(header::CACHE_CONTROL, "private, no-store") + .header( + header::CONTENT_DISPOSITION, + format!( + "attachment; filename=\"project.zip\"; filename*=UTF-8''{}", + urlencoding::encode(filename) + ), + ) + .body(body) + .map_err(|_| internal()) +} + +pub async fn admin_download_project_snapshot( + State(state): State, + Extension(_admin): Extension, + Path((user, project)): Path<(String, String)>, +) -> Result { + validate_agc_project_snapshot_project_id(&user).map_err(bad_request)?; + validate_agc_project_snapshot_project_id(&project).map_err(bad_request)?; + let permit = Arc::new( + DOWNLOAD_PERMITS + .get_or_init(|| Arc::new(Semaphore::new(2))) + .clone() + .try_acquire_owned() + .map_err(|_| { + AppError::from_status(StatusCode::TOO_MANY_REQUESTS) + .with_message("工程下载任务已满,请稍后重试") + })?, + ); + let store = OssSnapshotStore { + oss: project_snapshot_oss(&state)?, + client: state.editor_oss_http_client(), + }; + let manifest = store.manifest(&user, &project).await?.ok_or_else(|| { + AppError::from_status(StatusCode::NOT_FOUND).with_message("项目工程清单不存在") + })?; + let filename = archive_filename(&manifest); + let archive = tokio::time::timeout( + Duration::from_secs(600), + build_archive_guarded(&store, &user, &manifest, Some(permit.clone())), + ) + .await + .map_err(|_| { + AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_message("项目工程打包超时") + })??; + archive_response(archive, permit, &filename) +} + +fn bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) +} +fn upstream(message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message) +} +fn internal() -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message("项目工程归档失败") +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + use shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION; + use std::{collections::BTreeMap, io::Read, sync::Mutex}; + + #[derive(Default)] + struct Store { + manifests: BTreeMap<(String, String), AgcProjectSnapshotManifestRequest>, + files: BTreeMap>, + list_calls: Mutex, Option, usize)>>, + stall: bool, + } + + impl SnapshotStore for Store { + async fn directories( + &self, + user: Option<&str>, + after: Option<&str>, + limit: usize, + ) -> Result { + self.list_calls.lock().unwrap().push(( + user.map(str::to_string), + after.map(str::to_string), + limit, + )); + let directories = self + .manifests + .keys() + .filter_map(|(owner, project)| match user { + Some(user) if user == owner => Some(project.clone()), + Some(_) => None, + None => Some(owner.clone()), + }) + .filter(|key| after.is_none_or(|after| key.as_str() > after)) + .collect::>() + .into_iter() + .collect::>(); + let has_more = directories.len() > limit; + let directories = directories.into_iter().take(limit).collect::>(); + let next_marker = has_more.then(|| directories.last().unwrap().clone()); + Ok(SnapshotDirectoryPage { + directories, + next_marker, + }) + } + async fn manifest( + &self, + user: &str, + project: &str, + ) -> Result, AppError> { + Ok(self + .manifests + .get(&(user.to_string(), project.to_string())) + .cloned()) + } + async fn file( + &self, + _user: &str, + _project: &str, + file: &AgcProjectSnapshotManifestFile, + ) -> Result, AppError> { + if self.stall { + std::future::pending::<()>().await; + } + self.files + .get(&file.relative_path) + .cloned() + .ok_or_else(|| upstream("fixture missing")) + } + } + + fn manifest(project: &str, files: &[(&str, &[u8])]) -> AgcProjectSnapshotManifestRequest { + AgcProjectSnapshotManifestRequest { + schema_version: AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION, + project_id: project.to_string(), + project_name: Some("我的工程".to_string()), + sync_revision: 7, + synced_at_ms: 1_700_000_000_000, + pending_files: Some(0), + files: files + .iter() + .map(|(path, bytes)| AgcProjectSnapshotManifestFile { + relative_path: path.to_string(), + size_bytes: bytes.len() as u64, + checksum: agc_project_snapshot_checksum(bytes), + }) + .collect(), + } + } + + #[test] + fn project_snapshots_status_preserves_unknown_and_partial() { + let mut manifest = manifest( + "project-1", + &[("game/empty", b""), ("game/main.js", b"123")], + ); + let projection = item("user-1".to_string(), &manifest); + assert_eq!(projection.status, AdminProjectSnapshotStatus::Ready); + assert_eq!((projection.file_count, projection.total_bytes), (2, 3)); + manifest.pending_files = Some(2); + assert_eq!( + item("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); + assert_eq!(projection.status, AdminProjectSnapshotStatus::Unverified); + assert_eq!(projection.project_name, "project-1"); + } + + #[tokio::test] + async fn project_snapshots_pagination_continues_directories_without_rescan() { + let mut store = Store::default(); + for (user, project) in [ + ("user-a", "project-a"), + ("user-a", "project-b"), + ("user-b", "project-c"), + ] { + store + .manifests + .insert((user.into(), project.into()), manifest(project, &[])); + } + let first = list_snapshots(&store, SnapshotCursor::decode(None).unwrap(), 1) + .await + .unwrap(); + assert_eq!(first.items[0].project_id, "project-a"); + let second = list_snapshots( + &store, + SnapshotCursor::decode(first.next_cursor.as_deref()).unwrap(), + 1, + ) + .await + .unwrap(); + assert_eq!(second.items[0].project_id, "project-b"); + let third = list_snapshots( + &store, + SnapshotCursor::decode(second.next_cursor.as_deref()).unwrap(), + 1, + ) + .await + .unwrap(); + assert_eq!(third.items[0].project_id, "project-c"); + assert!(third.next_cursor.is_none()); + let calls = store.list_calls.lock().unwrap(); + assert_eq!(calls.len(), 5); + assert_eq!( + calls[2], + (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()); + let escaped = SnapshotCursor { + user: Some("../user".into()), + ..SnapshotCursor::default() + } + .encode(); + assert!(SnapshotCursor::decode(Some(&escaped)).is_err()); + } + + #[tokio::test] + async fn project_snapshots_zip_restores_paths_and_empty_files() { + let files: [(&str, &[u8]); 3] = [ + ("game/src/main.js", b"export const ready=true"), + ("assets/图像.txt", b"asset"), + ("game/empty.txt", b""), + ]; + let store = Store { + files: files + .iter() + .map(|(path, bytes)| (path.to_string(), bytes.to_vec())) + .collect(), + ..Store::default() + }; + let mut manifest = manifest("project-1", &files); + // 历史清单可以导出已存文件,但状态不能冒充完整。 + manifest.pending_files = None; + let archive = build_archive(&store, "user-1", &manifest).await.unwrap(); + let mut zip = zip::ZipArchive::new(archive.reopen().unwrap()).unwrap(); + assert_eq!(zip.len(), 3); + for (path, expected) in files { + let mut actual = Vec::new(); + zip.by_name(path).unwrap().read_to_end(&mut actual).unwrap(); + assert_eq!(actual, expected); + } + } + + #[tokio::test] + async fn project_snapshots_zip_fails_closed_and_cleans_tempfiles() { + let files: [(&str, &[u8]); 1] = [("game/main.js", b"good")]; + let mut manifest = manifest("project-1", &files); + for (data, pending) in [ + (None, Some(0)), + (Some(b"bad!".to_vec()), Some(0)), + (Some(b"good".to_vec()), Some(1)), + ] { + manifest.pending_files = pending; + let store = Store { + files: data + .map(|bytes| BTreeMap::from([("game/main.js".into(), bytes)])) + .unwrap_or_default(), + ..Store::default() + }; + let tempfile = NamedTempFile::new().unwrap(); + let path = tempfile.path().to_path_buf(); + let error = write_archive(&store, "user-1", &manifest, tempfile, None) + .await + .unwrap_err(); + assert_eq!( + error.status_code(), + if pending == Some(1) { + StatusCode::CONFLICT + } else { + StatusCode::BAD_GATEWAY + } + ); + assert!(!path.exists()); + } + manifest.pending_files = Some(0); + let tempfile = NamedTempFile::new().unwrap(); + let path = tempfile.path().to_path_buf(); + let stalled = Store { + stall: true, + ..Store::default() + }; + assert!( + tokio::time::timeout( + Duration::from_millis(10), + write_archive(&stalled, "user-1", &manifest, tempfile, None) + ) + .await + .is_err() + ); + assert!(!path.exists(), "打包取消必须清理临时文件"); + } + + #[tokio::test] + async fn project_snapshots_response_releases_file_and_permit_on_finish_or_cancel() { + for cancel in [false, true] { + let mut tempfile = NamedTempFile::new().unwrap(); + tempfile.write_all(&vec![42; 130_000]).unwrap(); + let path = tempfile.path().to_path_buf(); + let permits = Arc::new(Semaphore::new(1)); + let response = archive_response( + tempfile, + Arc::new(permits.clone().acquire_owned().await.unwrap()), + "我的工程.zip", + ) + .unwrap(); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/zip"); + assert!( + response.headers()[header::CONTENT_DISPOSITION] + .to_str() + .unwrap() + .contains("filename*=UTF-8''%") + ); + assert_eq!(permits.available_permits(), 0); + let mut body = response.into_body(); + if cancel { + assert!(body.frame().await.unwrap().is_ok()); + drop(body); + } else { + assert_eq!(body.collect().await.unwrap().to_bytes().len(), 130_000); + } + assert!(!path.exists()); + assert_eq!(permits.available_permits(), 1); + } + } + + #[test] + fn project_snapshots_archive_rejects_ambiguous_paths_and_unsafe_names() { + for paths in [ + vec!["../x"], + vec!["/absolute"], + vec!["game/CON.txt"], + vec!["game/name."], + vec!["game/name "], + vec!["game/a", "game/A"], + vec!["Game/a", "game/b"], + vec!["game/a", "game/a/b"], + ] { + let files = paths + .iter() + .map(|path| (*path, b"".as_slice())) + .collect::>(); + assert!( + validate_manifest(&manifest("project-1", &files)).is_err(), + "{paths:?}" + ); + } + let mut manifest = manifest("project-1", &[]); + manifest.project_name = Some("../我的\\工程\r\n.zip".into()); + let name = archive_filename(&manifest); + assert!(!name.contains(['/', '\\', '\r', '\n'])); + assert!(name.ends_with("-r7.zip")); + } + + #[tokio::test] + async fn project_snapshots_cancelled_wait_keeps_permit_until_blocking_work_exits() { + let permits = Arc::new(Semaphore::new(1)); + let permit = Arc::new(permits.clone().acquire_owned().await.unwrap()); + let entered = Arc::new(tokio::sync::Notify::new()); + let started = entered.clone(); + let (release, wait) = std::sync::mpsc::channel(); + let task = tokio::spawn(archive_blocking(Some(permit), move || { + started.notify_one(); + wait.recv().unwrap(); + Ok(()) + })); + entered.notified().await; + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!( + permits.available_permits(), + 0, + "取消等待不得归还仍在压缩的额度" + ); + release.send(()).unwrap(); + let permit = tokio::time::timeout(Duration::from_secs(2), permits.clone().acquire_owned()) + .await + .unwrap() + .unwrap(); + drop(permit); + assert_eq!(permits.available_permits(), 1); + } + + /// 显式运行的只读 OSS 验证;配置仅进入内存,禁止输出凭据和用户文件内容。 + #[tokio::test] + #[ignore = "requires explicitly supplied private OSS environment files; read-only"] + async fn project_snapshots_live_readonly_list_and_archive() { + let paths = std::env::var_os("GENARRATIVE_PROJECT_SNAPSHOT_SMOKE_ENV_FILES") + .expect("显式提供只读 smoke 配置文件列表"); + let mut settings = BTreeMap::new(); + for path in std::env::split_paths(&paths) { + let source = + std::fs::read_to_string(path).unwrap_or_else(|_| panic!("无法读取 smoke 配置文件")); + // 其它服务配置可能使用不同转义语法;这里只解析当前验证实际消费的键。 + let source = source + .trim_start_matches('\u{feff}') + .lines() + .filter(|line| { + let line = line + .trim_start() + .strip_prefix("export ") + .unwrap_or(line.trim_start()); + line.split_once('=').is_some_and(|(key, _)| { + matches!( + key.trim(), + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET" + | "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT" + | "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID" + | "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET" + | "ALIYUN_OSS_ACCESS_KEY_ID" + | "ALIYUN_OSS_ACCESS_KEY_SECRET" + ) + }) + }) + .collect::>() + .join("\n"); + let entries = dotenvy::from_read_iter(source.as_bytes()); + for entry in entries { + let (key, value) = entry.unwrap_or_else(|_| panic!("无法解析 smoke 配置行")); + settings.insert(key, value); + } + } + let resolve = |specific: &str, fallback: &str, default: &str| { + settings + .get(specific) + .or_else(|| settings.get(fallback)) + .cloned() + .unwrap_or_else(|| default.to_string()) + }; + let config = platform_oss::OssConfig::new( + resolve("GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", "", "agc-dev"), + resolve( + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", + "", + "oss-rg-china-mainland.aliyuncs.com", + ), + resolve( + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", + "ALIYUN_OSS_ACCESS_KEY_ID", + "", + ), + resolve( + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", + "ALIYUN_OSS_ACCESS_KEY_SECRET", + "", + ), + 600, + 600, + 64 * 1024 * 1024, + 200, + ) + .unwrap_or_else(|_| panic!("只读 smoke OSS 配置无效")); + let oss = OssClient::new(config); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .unwrap(); + let store = OssSnapshotStore { + oss: &oss, + client: &client, + }; + let mut cursor = SnapshotCursor::decode(None).unwrap(); + let mut projects = 0; + let mut total_files = 0; + let mut total_bytes = 0_u64; + for page_index in 0..100 { + let page = list_snapshots(&store, cursor, 1) + .await + .expect("真实目录读取失败"); + for item in page.items { + let manifest = store + .manifest(&item.user_id, &item.project_id) + .await + .unwrap() + .expect("清单应存在"); + assert_ne!( + item.status, + AdminProjectSnapshotStatus::Partial, + "只读 smoke 遇到未完成项目" + ); + let archive = build_archive(&store, &item.user_id, &manifest) + .await + .expect("真实归档失败"); + let path = archive.path().to_path_buf(); + { + let mut zip = zip::ZipArchive::new(archive.reopen().unwrap()).unwrap(); + assert_eq!(zip.len(), manifest.files.len()); + for file in &manifest.files { + let mut bytes = Vec::new(); + zip.by_name(&file.relative_path) + .expect("ZIP 应保留原路径") + .read_to_end(&mut bytes) + .unwrap(); + verify_file(file, &bytes).expect("解压后大小和摘要应匹配"); + } + } + drop(archive); + assert!(!path.exists()); + projects += 1; + total_files += item.file_count; + total_bytes += item.total_bytes; + eprintln!( + "read-only snapshot {projects}: files={}, bytes={}, status={:?}", + item.file_count, item.total_bytes, item.status + ); + } + let Some(next) = page.next_cursor else { break }; + assert!(page_index < 99, "只读 smoke 已达到分页上限"); + cursor = SnapshotCursor::decode(Some(&next)).unwrap(); + } + assert!(projects > 0, "真实 bucket 未发现项目清单"); + eprintln!( + "read-only snapshot verification passed: projects={projects}, files={total_files}, bytes={total_bytes}" + ); + } +} diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 6a50319a2..a2200d2a0 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -172,8 +172,8 @@ pub struct AppConfig { pub oss_post_expire_seconds: u64, pub oss_post_max_size_bytes: u64, pub oss_success_action_status: u16, - /// AGC 项目快照上传目标。默认指向 AGC 发行用的公开 bucket,可用独立凭据覆盖; - /// 未单独配置时回退 `ALIYUN_OSS_*`,与数据库备份 bucket 的分离开关同口径。 + /// AGC 项目快照的 bucket 与 endpoint 独立配置,默认使用 AGC 目标; + /// 仅凭据回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`。 pub project_snapshot_oss_bucket: String, pub project_snapshot_oss_endpoint: String, pub project_snapshot_oss_access_key_id: Option, @@ -1134,16 +1134,13 @@ impl AppConfig { { config.oss_success_action_status = oss_success_action_status; } - config.project_snapshot_oss_bucket = read_first_non_empty_env(&[ - "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", - "ALIYUN_OSS_BUCKET", - ]) - .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string()); - config.project_snapshot_oss_endpoint = read_first_non_empty_env(&[ - "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", - "ALIYUN_OSS_ENDPOINT", - ]) - .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string()); + // 快照与资源存储的目标独立;只复用凭据,不让通用 OSS 配置改变快照落点。 + config.project_snapshot_oss_bucket = + read_first_non_empty_env(&["GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET"]) + .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string()); + config.project_snapshot_oss_endpoint = + read_first_non_empty_env(&["GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT"]) + .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string()); config.project_snapshot_oss_access_key_id = read_first_non_empty_env(&[ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_ID", @@ -1787,6 +1784,120 @@ mod tests { static ENV_LOCK: OnceLock> = OnceLock::new(); + #[test] + fn from_env_project_snapshot_target_is_independent_and_only_credentials_fallback() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + let keys = [ + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", + "ALIYUN_OSS_BUCKET", + "ALIYUN_OSS_ENDPOINT", + "ALIYUN_OSS_ACCESS_KEY_ID", + "ALIYUN_OSS_ACCESS_KEY_SECRET", + ]; + struct RestoreEnv(Vec<(&'static str, Option)>); + impl Drop for RestoreEnv { + fn drop(&mut self) { + for (key, value) in &self.0 { + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + } + let _restore = RestoreEnv( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + for key in keys { + unsafe { std::env::remove_var(key) }; + } + + let defaults = AppConfig::from_env(); + assert_eq!(defaults.project_snapshot_oss_bucket, "agc-dev"); + assert_eq!( + defaults.project_snapshot_oss_endpoint, + "oss-rg-china-mainland.aliyuncs.com" + ); + assert!(defaults.project_snapshot_oss_access_key_id.is_none()); + assert!(defaults.project_snapshot_oss_access_key_secret.is_none()); + + unsafe { + std::env::set_var("ALIYUN_OSS_BUCKET", "resource-fixture"); + std::env::set_var("ALIYUN_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com"); + std::env::set_var("ALIYUN_OSS_ACCESS_KEY_ID", "shared-fixture-id"); + std::env::set_var("ALIYUN_OSS_ACCESS_KEY_SECRET", "shared-fixture-secret"); + } + let shared = AppConfig::from_env(); + assert_eq!(shared.oss_bucket.as_deref(), Some("resource-fixture")); + assert_eq!(shared.project_snapshot_oss_bucket, "agc-dev"); + assert_eq!( + shared.project_snapshot_oss_endpoint, + "oss-rg-china-mainland.aliyuncs.com" + ); + assert_eq!( + shared.project_snapshot_oss_access_key_id.as_deref(), + Some("shared-fixture-id") + ); + assert_eq!( + shared.project_snapshot_oss_access_key_secret.as_deref(), + Some("shared-fixture-secret") + ); + + unsafe { + std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", " "); + std::env::set_var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", ""); + } + let empty = AppConfig::from_env(); + assert_eq!(empty.project_snapshot_oss_bucket, "agc-dev"); + assert_eq!( + empty.project_snapshot_oss_endpoint, + "oss-rg-china-mainland.aliyuncs.com" + ); + + unsafe { + std::env::set_var( + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", + "snapshot-fixture", + ); + std::env::set_var( + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", + "oss-cn-shanghai.aliyuncs.com", + ); + std::env::set_var( + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", + "snapshot-fixture-id", + ); + std::env::set_var( + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", + "snapshot-fixture-secret", + ); + } + let explicit = AppConfig::from_env(); + assert_eq!(explicit.project_snapshot_oss_bucket, "snapshot-fixture"); + assert_eq!( + explicit.project_snapshot_oss_endpoint, + "oss-cn-shanghai.aliyuncs.com" + ); + assert_eq!( + explicit.project_snapshot_oss_access_key_id.as_deref(), + Some("snapshot-fixture-id") + ); + assert_eq!( + explicit.project_snapshot_oss_access_key_secret.as_deref(), + Some("snapshot-fixture-secret") + ); + } + #[test] fn default_keeps_non_public_model_and_base_url_empty() { let config = AppConfig::default(); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index c0980823f..566a5fe9a 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -2,6 +2,7 @@ mod admin; mod admin_accounts; +mod admin_project_snapshots; mod admin_recharge; mod agc_models; mod ai_tasks; diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 77925d2e6..230c0c66d 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -39,6 +39,14 @@ use crate::{ pub fn router(state: AppState) -> Router { let auth = middleware::from_fn_with_state(state, require_admin_auth); let protected_routes = [ + ( + "/admin/api/project-snapshots", + get(crate::admin_project_snapshots::admin_list_project_snapshots), + ), + ( + "/admin/api/project-snapshots/{user_id}/{project_id}/download", + get(crate::admin_project_snapshots::admin_download_project_snapshot), + ), ( "/admin/api/agc-models", get(crate::agc_models::admin_get_agc_models) @@ -204,6 +212,11 @@ mod route_contract_tests { use crate::{config::AppConfig, request_context::attach_request_context, state::AppState}; const PROTECTED_ROUTES: &[(&str, &[&str])] = &[ + ("/admin/api/project-snapshots", &["GET"]), + ( + "/admin/api/project-snapshots/{user_id}/{project_id}/download", + &["GET"], + ), ("/admin/api/agc-models", &["GET", "PUT"]), ("/admin/api/accounts", &["GET", "POST"]), ("/admin/api/accounts/{account_id}", &["PUT"]), diff --git a/server-rs/crates/api-server/src/project_snapshots.rs b/server-rs/crates/api-server/src/project_snapshots.rs index d745d7493..32a12fa21 100644 --- a/server-rs/crates/api-server/src/project_snapshots.rs +++ b/server-rs/crates/api-server/src/project_snapshots.rs @@ -23,8 +23,8 @@ use shared_contracts::agc_project_snapshots::{ AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES, AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION, AgcProjectSnapshotFileUploadQuery, AgcProjectSnapshotFileUploadResponse, AgcProjectSnapshotManifestRequest, AgcProjectSnapshotManifestResponse, - validate_agc_project_snapshot_checksum, validate_agc_project_snapshot_project_id, - validate_agc_project_snapshot_relative_path, + agc_project_snapshot_checksum, validate_agc_project_snapshot_checksum, + validate_agc_project_snapshot_project_id, validate_agc_project_snapshot_relative_path, }; use std::{ collections::{HashMap, HashSet}, @@ -61,20 +61,7 @@ pub async fn upload_project_snapshot_file( body: Bytes, ) -> Result, AppError> { consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::File)?; - validate_agc_project_snapshot_project_id(&query.project_id).map_err(bad_request)?; - validate_agc_project_snapshot_relative_path(&query.relative_path).map_err(bad_request)?; - validate_agc_project_snapshot_checksum(&query.checksum).map_err(bad_request)?; - if body.is_empty() { - return Err(bad_request("项目快照文件内容不能为空")); - } - let size_bytes = - u64::try_from(body.len()).map_err(|_| bad_request("项目快照文件长度超出可支持范围"))?; - if size_bytes > AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES { - return Err(bad_request("项目快照文件超过单文件上限")); - } - if size_bytes != query.size_bytes { - return Err(bad_request("项目快照文件长度与声明不一致")); - } + let size_bytes = validate_uploaded_file(&query, &body)?; let digest = query .checksum .strip_prefix("fnv1a64:") @@ -128,6 +115,27 @@ pub async fn upload_project_snapshot_file( )) } +fn validate_uploaded_file( + query: &AgcProjectSnapshotFileUploadQuery, + body: &[u8], +) -> Result { + validate_agc_project_snapshot_project_id(&query.project_id).map_err(bad_request)?; + validate_agc_project_snapshot_relative_path(&query.relative_path).map_err(bad_request)?; + validate_agc_project_snapshot_checksum(&query.checksum).map_err(bad_request)?; + let size_bytes = + u64::try_from(body.len()).map_err(|_| bad_request("项目快照文件长度超出可支持范围"))?; + if size_bytes > AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES { + return Err(bad_request("项目快照文件超过单文件上限")); + } + if size_bytes != query.size_bytes { + return Err(bad_request("项目快照文件长度与声明不一致")); + } + if !agc_project_snapshot_checksum(body).eq_ignore_ascii_case(&query.checksum) { + return Err(bad_request("项目快照文件内容与声明摘要不一致")); + } + Ok(size_bytes) +} + /// 覆盖写入该项目的远端清单。删除文件只在这里消失,本期不删除远端对象。 pub async fn upload_project_snapshot_manifest( State(state): State, @@ -355,7 +363,9 @@ async fn reclaim_unreferenced_objects( } } -fn validate_manifest(payload: &AgcProjectSnapshotManifestRequest) -> Result<(), AppError> { +pub(crate) fn validate_manifest( + payload: &AgcProjectSnapshotManifestRequest, +) -> Result<(), AppError> { if payload.schema_version != AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION { return Err(bad_request("项目快照清单版本不受支持")); } @@ -363,10 +373,18 @@ fn validate_manifest(payload: &AgcProjectSnapshotManifestRequest) -> Result<(), if payload.synced_at_ms == 0 { return Err(bad_request("项目快照清单缺少同步时刻")); } + if payload.project_name.as_ref().is_some_and(|name| { + name.trim().is_empty() || name.len() > 512 || name.chars().any(char::is_control) + }) { + return Err(bad_request( + "项目快照名称必须是 1 到 512 字节且不含控制字符", + )); + } if payload.files.len() > AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES { return Err(bad_request("项目快照清单文件数量超过上限")); } let mut seen = HashSet::with_capacity(payload.files.len()); + let mut paths = HashMap::new(); let mut total_bytes = 0_u64; for file in &payload.files { if file.relative_path.chars().count() > MAX_MANIFEST_PATH_CHARS { @@ -380,6 +398,23 @@ fn validate_manifest(payload: &AgcProjectSnapshotManifestRequest) -> Result<(), if !seen.insert(file.relative_path.as_str()) { return Err(bad_request("项目快照清单包含重复路径")); } + let mut path = String::new(); + let components = file.relative_path.split('/').collect::>(); + for (index, component) in components.iter().enumerate() { + if index > 0 { + path.push('/'); + } + path.push_str(component); + let is_file = index + 1 == components.len(); + let identity = path.to_lowercase(); + if let Some((original, previous_is_file)) = paths.get(&identity) { + if original != &path || *previous_is_file || is_file { + return Err(bad_request("项目快照清单包含大小写或文件目录冲突")); + } + } else { + paths.insert(identity, (path.clone(), is_file)); + } + } total_bytes = total_bytes.saturating_add(file.size_bytes); } // 清单描述的是项目当前全量文件,所以这里的累计体积就是该项目的常驻占用; @@ -391,7 +426,7 @@ fn validate_manifest(payload: &AgcProjectSnapshotManifestRequest) -> Result<(), Ok(()) } -fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { +pub(crate) fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { state.project_snapshot_oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("AGC 项目快照 OSS 未配置") @@ -411,6 +446,22 @@ mod tests { use super::*; use shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestFile; + #[test] + fn project_snapshots_upload_accepts_empty_files_and_rejects_false_checksums() { + let mut query = AgcProjectSnapshotFileUploadQuery { + project_id: "project-1".into(), + relative_path: "game/empty.txt".into(), + checksum: "fnv1a64:cbf29ce484222325".into(), + size_bytes: 0, + }; + assert_eq!(validate_uploaded_file(&query, b"").unwrap(), 0); + assert!(validate_uploaded_file(&query, b"x").is_err()); + query.size_bytes = 1; + assert!(validate_uploaded_file(&query, b"x").is_err()); + query.checksum = agc_project_snapshot_checksum(b"x"); + assert_eq!(validate_uploaded_file(&query, b"x").unwrap(), 1); + } + fn manifest_file(relative_path: &str, size_bytes: u64) -> AgcProjectSnapshotManifestFile { AgcProjectSnapshotManifestFile { relative_path: relative_path.to_string(), @@ -426,6 +477,8 @@ mod tests { sync_revision: 1, synced_at_ms: 1_700_000_000_000, files, + project_name: None, + pending_files: None, } } diff --git a/server-rs/crates/platform-oss/Cargo.toml b/server-rs/crates/platform-oss/Cargo.toml index 1d507cf0b..05fca3087 100644 --- a/server-rs/crates/platform-oss/Cargo.toml +++ b/server-rs/crates/platform-oss/Cargo.toml @@ -11,6 +11,7 @@ hmac = { workspace = true } reqwest = { workspace = true, features = ["rustls-tls"] } serde = { workspace = true } serde_json = { workspace = true } +quick-xml = { workspace = true } sha2 = { workspace = true } time = { workspace = true, features = ["formatting"] } tokio = { workspace = true, features = ["macros", "sync", "time"] } diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 5223010cf..7a8aabaa1 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -12,6 +12,7 @@ use tokio::{sync::Semaphore, time::sleep}; use tracing::{info, warn}; pub mod client_downloads; +pub mod project_snapshots; type HmacSha256 = Hmac; @@ -945,18 +946,18 @@ impl OssClient { )) } - /// 按内部前缀写入服务端专用对象。内容为空、键越界都直接失败,不写空对象。 + /// 按内部前缀写入服务端专用对象;项目快照允许合法的零字节工程文件。 pub async fn put_internal_object( &self, client: &reqwest::Client, request: OssInternalPutObjectRequest, ) -> Result { - if request.body.is_empty() { + let object_key = normalize_internal_object_key(&request.object_key)?; + if request.body.is_empty() && !project_snapshots::is_snapshot_file_key(&object_key) { return Err(OssError::InvalidRequest( "服务端内部对象内容不能为空".to_string(), )); } - let object_key = normalize_internal_object_key(&request.object_key)?; let content_type = normalize_optional_value(request.content_type); let headers = build_put_object_headers(request.metadata)?; let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) @@ -1856,12 +1857,10 @@ fn build_object_url( endpoint: &str, object_key: &str, ) -> Result { - let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/")) - .map_err(|error| error.to_string())?; - url = url - .join(object_key.trim_start_matches('/')) - .map_err(|error| error.to_string())?; - Ok(url) + // 对象键是原始路径,#、% 和中文不能被 URL 解析器当作片段或已有转义。 + let path = encode_url_path(object_key.trim_start_matches('/')); + reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/{path}")) + .map_err(|error| error.to_string()) } fn build_object_key( @@ -2304,10 +2303,15 @@ fn signed_request_builder( let canonical_headers = build_v4_canonical_headers(&signed_headers); let additional_headers = build_v4_additional_headers(&signed_headers); + let query = target_url + .query_pairs() + .into_owned() + .collect::>(); + let canonical_query = build_canonical_query_string(&query); let canonical_request = build_v4_canonical_request( method.as_str(), &canonical_uri, - "", + &canonical_query, &canonical_headers, &additional_headers, &body_sha256, diff --git a/server-rs/crates/platform-oss/src/project_snapshots.rs b/server-rs/crates/platform-oss/src/project_snapshots.rs new file mode 100644 index 000000000..0466591ad --- /dev/null +++ b/server-rs/crates/platform-oss/src/project_snapshots.rs @@ -0,0 +1,251 @@ +//! AGC 私有项目目录枚举,只允许固定快照前缀和两层 delimiter 查询。 + +use super::*; + +const MAX_LIST_BODY_BYTES: usize = 256 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SnapshotDirectoryPage { + pub directories: Vec, + pub next_marker: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct ListBucketResult { + prefix: String, + delimiter: String, + is_truncated: bool, + #[serde(default)] + next_marker: Option, + #[serde(default, rename = "CommonPrefixes")] + common_prefixes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct CommonPrefix { + prefix: String, +} + +fn directory_segment(value: &str) -> Result { + let segment = validate_internal_key_segment(value, "项目目录标识")?; + if segment != value || !value.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return Err(OssError::InvalidRequest("项目目录标识非法".to_string())); + } + Ok(segment) +} + +fn list_query( + user_id: Option<&str>, + after: Option<&str>, + limit: usize, +) -> Result, OssError> { + if !(1..=100).contains(&limit) { + return Err(OssError::InvalidRequest( + "项目目录分页大小必须为 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()), + ("delimiter".to_string(), "/".to_string()), + ("max-keys".to_string(), limit.to_string()), + ]); + if let Some(after) = after { + query.insert( + "marker".to_string(), + format!("{prefix}{}/", directory_segment(after)?), + ); + } + Ok(query) +} + +fn parse_directory_page( + body: &[u8], + query: &BTreeMap, +) -> Result { + let invalid = || OssError::InvalidRequest("OSS 项目目录响应格式非法".to_string()); + if body.len() > MAX_LIST_BODY_BYTES { + return Err(invalid()); + } + let parsed: ListBucketResult = quick_xml::de::from_reader(body).map_err(|_| invalid())?; + let prefix = &query["prefix"]; + if &parsed.prefix != prefix || parsed.delimiter != "/" { + return Err(invalid()); + } + let mut previous = query.get("marker").cloned().unwrap_or_default(); + let mut directories = Vec::new(); + for item in parsed.common_prefixes { + let segment = item + .prefix + .strip_prefix(prefix) + .and_then(|value| value.strip_suffix('/')) + .ok_or_else(invalid)?; + directory_segment(segment)?; + if item.prefix <= previous { + return Err(invalid()); + } + directories.push(segment.to_string()); + previous = item.prefix; + } + if directories.len() > query["max-keys"].parse::().map_err(|_| invalid())? { + return Err(invalid()); + } + let next_marker = if parsed.is_truncated { + // delimiter 列表只接受目录推进,拒绝上游返回越界或不前进的游标。 + let marker = parsed + .next_marker + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or(&previous); + if directories.is_empty() || marker != previous { + return Err(invalid()); + } + directories.last().cloned() + } else { + None + }; + Ok(SnapshotDirectoryPage { + directories, + next_marker, + }) +} + +impl OssClient { + /// 第一层只列用户目录,第二层只列项目目录,不枚举文件对象或其它 bucket 前缀。 + pub async fn list_project_snapshot_directories( + &self, + client: &reqwest::Client, + user_id: Option<&str>, + after: Option<&str>, + limit: usize, + ) -> Result { + let query = list_query(user_id, after, limit)?; + 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))); + let mut response = send_signed_request( + client, + &self.config, + Method::GET, + None, + target, + OssRequestOperation::Get, + ) + .await?; + if !response.status().is_success() { + return Err(request_status_error( + OssRequestOperation::Get, + response.status().as_u16(), + "OSS 项目目录读取失败".to_string(), + )); + } + if response + .content_length() + .is_some_and(|size| size > MAX_LIST_BODY_BYTES as u64) + { + return Err(OssError::InvalidRequest( + "OSS 项目目录超过读取上限".to_string(), + )); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| request_error_from_reqwest(OssRequestOperation::Get, error))? + { + if body.len().saturating_add(chunk.len()) > MAX_LIST_BODY_BYTES { + return Err(OssError::InvalidRequest( + "OSS 项目目录超过读取上限".to_string(), + )); + } + body.extend_from_slice(&chunk); + } + parse_directory_page(&body, &query) + } +} + +pub(super) fn is_snapshot_file_key(key: &str) -> bool { + let Some(path) = key.strip_prefix(AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX) else { + return false; + }; + let parts = path.split('/').collect::>(); + parts.len() >= 5 + && directory_segment(parts[0]).is_ok() + && directory_segment(parts[1]).is_ok() + && parts[2] == "files" + && parts[3].split_once('-').is_some_and(|(size, digest)| { + size == "0" && digest.eq_ignore_ascii_case("cbf29ce484222325") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_object_url_preserves_literal_reserved_characters() { + let key = "agc/project-snapshots/v1/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); + assert_eq!(url.path(), format!("/{}", encode_url_path(key))); + assert!(url.path().ends_with("%E5%9B%BE%E5%83%8F%20%23100%25.txt")); + } + + #[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/" + ); + 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()); + } + + #[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 page = parse_directory_page(xml, &query).unwrap(); + assert_eq!(page.directories, ["project-1"]); + assert_eq!(page.next_marker.as_deref(), Some("project-1")); + let text = String::from_utf8(xml.to_vec()).unwrap(); + assert!( + parse_directory_page( + text.replace("project-1/", "../project-1/").as_bytes(), + &query + ) + .is_err() + ); + assert!( + parse_directory_page(text.replace("user-1/", "user-2/").as_bytes(), &query).is_err() + ); + assert!(parse_directory_page(&vec![b' '; MAX_LIST_BODY_BYTES + 1], &query).is_err()); + } + + #[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" + )); + assert!(!is_snapshot_file_key( + "agc/project-snapshots/v1/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" + )); + } +} diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 02e7616da..96d916ee5 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -10,7 +10,7 @@ use crate::creation_entry_config::{ }; /// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。 -pub const ADMIN_TAB_PERMISSIONS: [&str; 16] = [ +pub const ADMIN_TAB_PERMISSIONS: [&str; 17] = [ "dashboard", "overview", "tables", @@ -27,8 +27,46 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 16] = [ "editor-showcase", "editor-assets", "error-reports", + "project-snapshots", ]; +/// 私有工程快照的后台分页查询。 +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotsQuery { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AdminProjectSnapshotStatus { + Ready, + Partial, + Unverified, +} + +/// 仅投影清单统计,不向浏览器下发私有对象键或签名。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotItem { + pub user_id: String, + pub project_id: String, + pub project_name: String, + pub sync_revision: u64, + pub synced_at_ms: u64, + pub file_count: u32, + pub total_bytes: u64, + pub status: AdminProjectSnapshotStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProjectSnapshotsResponse { + pub items: Vec, + pub next_cursor: Option, +} + /// 不随一级 Tab 自动授予的后台高风险操作权限。 pub const ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE: &str = "profile-wallet-consumption-reconcile"; diff --git a/server-rs/crates/shared-contracts/src/agc_project_snapshots.rs b/server-rs/crates/shared-contracts/src/agc_project_snapshots.rs index 7bc6c7dde..b55125364 100644 --- a/server-rs/crates/shared-contracts/src/agc_project_snapshots.rs +++ b/server-rs/crates/shared-contracts/src/agc_project_snapshots.rs @@ -61,6 +61,12 @@ pub struct AgcProjectSnapshotManifestRequest { pub sync_revision: u64, pub synced_at_ms: u64, pub files: Vec, + /// 项目展示名称;历史清单缺失时使用项目 ID。 + #[serde(default)] + pub project_name: Option, + /// 本轮未同步的文件数;缺失表示历史清单完整性未知。 + #[serde(default)] + pub pending_files: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -113,6 +119,25 @@ pub fn validate_agc_project_snapshot_relative_path(value: &str) -> Result<(), St if part.ends_with('.') || part.ends_with(' ') { return Err("项目快照路径组件不能以点或空格结尾".to_string()); } + let stem = part + .split('.') + .next() + .unwrap_or_default() + .trim_end() + .to_ascii_uppercase(); + if matches!( + stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) || ["COM", "LPT"].iter().any(|prefix| { + stem.strip_prefix(prefix).is_some_and(|suffix| { + matches!( + suffix, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }) + }) { + return Err("项目快照路径不能包含 Windows 保留设备名".to_string()); + } if part .chars() .any(|character| matches!(character, ':' | '<' | '>' | '"' | '|' | '?' | '*')) @@ -123,6 +148,14 @@ pub fn validate_agc_project_snapshot_relative_path(value: &str) -> Result<(), St Ok(()) } +/// 与客户端增量索引一致的内容摘要,上传及下载均按实际字节核验。 +pub fn agc_project_snapshot_checksum(bytes: &[u8]) -> String { + let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + format!("fnv1a64:{hash:016x}") +} + /// 校验和校验:只接受与项目索引同口径的 `fnv1a64:<16 位十六进制>`。 pub fn validate_agc_project_snapshot_checksum(value: &str) -> Result<(), String> { let Some(digest) = value.strip_prefix("fnv1a64:") else {