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 1/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E4=B8=8A=E4=BC=A0=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AE=8C=E6=95=B4=E5=B7=A5=E7=A8=8B=E4=B8=8B?= =?UTF-8?q?=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 { From c1d26f11df5aa86e999a08a3ea11da7989309f03 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:53:16 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20Cocos=20=E5=92=8C=20Un?= =?UTF-8?q?ity=20=E6=8F=92=E4=BB=B6=E7=9A=84=E5=B7=A5=E7=A8=8B=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一插件列表、启动、面板和 Agent 工具的可用性判断,保留开关与平台约束 调整前端自动启动并修复 Cocos 项目切换的旧连接与订阅快照竞态 补充插件宿主、工具目录和前端回归测试,同步插件技术规范与共享决策 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 17 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 111 ++++++++- .../provider_request_builders.rs | 8 +- .../runtime_actions/provider_tool_plan.rs | 2 +- .../runtime_actions/tool_policy_snapshot.rs | 70 +++++- .../src/agent/runtime_tools/cocos_editor.rs | 4 +- .../src/agent/runtime_tools/unity_editor.rs | 4 +- .../src-tauri/src/agent_native_tools.rs | 12 - .../src-tauri/src/builtin_plugins.rs | 91 +------ .../src-tauri/src/editor_adapters.rs | 6 +- .../src-tauri/src/plugin_host.rs | 229 ++++++++++++------ apps/ai-game-creator-shell/src/App.tsx | 18 +- .../src/services/pluginHost.ts | 36 ++- .../appSurface/project-development.suite.ts | 72 ++++++ .../tests/pluginHost.test.ts | 85 +++++-- .../shared-memory/decision-log.md | 9 +- ...AGC Cocos Creator 编辑器桥接模块-2026-09-09.md | 2 +- ...方案】AGC Unity编辑器插件接入-2026-09-18.md | 6 +- ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 14 +- plugins/agc-cocos-editor/README.md | 10 +- plugins/agc-cocos-editor/src/entry.mjs | 7 +- plugins/agc-cocos-editor/src/entry.test.mjs | 52 ++++ 22 files changed, 596 insertions(+), 269 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 82c1f7c51..c418832f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2611,8 +2611,8 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str) #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value { let prepared = (|| { - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("当前 Unity 插件不可用".to_string()); } enforce_project_permission_policy(&state.root, "unity.editor.execute")?; bridge_reject_unknown_fields(arguments, &["code"])?; @@ -2637,7 +2637,7 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) }; let root = state.root.clone(); let result = tokio::task::spawn_blocking(move || { - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) { + if !crate::builtin_plugins::unity_editor_agent_tool_available() { return Err("当前 Unity 插件不可用".to_string()); } crate::editor_adapters::execute_unity_editor_code(&root, &code) @@ -2667,10 +2667,9 @@ async fn bridge_cocos_call( if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) { return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true); } - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return bridge_tool_result( - "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用" - .to_string(), + "当前 Cocos 插件不可用,agc_cocos_execute 不可用".to_string(), Vec::new(), true, ); @@ -2735,9 +2734,9 @@ async fn bridge_cocos_call( // validated Inspector/pipe bridge. It does not mutate AGC's project // files or manifest, so it must not wait on `.agent/project.lock`. // File-writing tools keep their own project lock separately. - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return Err(cocos_editor_bridge::BridgeError::InvalidInput( - "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(), + "当前 Cocos 插件不可用".to_string(), )); } cocos_editor_bridge::execute_cocos_editor_code_for_project( @@ -2840,7 +2839,7 @@ async fn handle_direct_tool_bridge( let result = match request.tool.as_str() { // 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。 "builtin.plugins.tools" => bridge_tool_result( - json!({"tools": crate::builtin_plugins::available_agent_tools_for_project(&state.root)}).to_string(), + json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(), Vec::new(), false, ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index e4436fff5..f90209a70 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -2057,6 +2057,110 @@ mod tests { } } + #[tokio::test] + async fn builtin_editor_tools_follow_independent_switches_for_non_engine_projects() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-"); + std::fs::create_dir_all(project.path().join(".agent")).unwrap(); + std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); + let bridge = + super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) + .await + .unwrap(); + for (cocos_enabled, unity_enabled) in + [(false, false), (true, false), (false, true), (true, true)] + { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + cocos_enabled, + ) + .unwrap(); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + unity_enabled, + ) + .unwrap(); + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + call_client_tool_bridge("builtin.plugins.tools", &json!({})), + ) + .await; + assert_eq!(response["isError"], false); + let available: Value = + serde_json::from_str(response["content"][0]["text"].as_str().unwrap()).unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + let cocos_expected = + cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")); + for (runtime_tool, mcp_tool, expected) in [ + ( + crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME, + "agc_cocos_execute", + cocos_expected, + ), + ( + crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME, + "agc_unity_execute", + unity_enabled + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )), + ), + ] { + assert_eq!( + available["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool == runtime_tool), + expected, + "{runtime_tool}" + ); + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == mcp_tool), + expected, + "{mcp_tool}" + ); + } + assert_eq!( + specs["tools"] + .as_array() + .unwrap() + .iter() + .filter(|tool| tool["name"] + .as_str() + .is_some_and(cocos_editor_bridge::is_cocos_operation)) + .count(), + if cocos_expected { + cocos_editor_bridge::cocos_operation_catalog().len() + } else { + 0 + }, + ); + } + std::fs::write(config.path().join("extensions/builtin-plugins.json"), "{").unwrap(); + let specs = EXTERNAL_MCP_BRIDGE_URL + .scope(bridge.url().to_string(), direct_tools_mcp_specs()) + .await; + for tool in ["agc_cocos_execute", "agc_unity_execute"] { + assert!(!specs["tools"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["name"] == tool)); + } + } + #[cfg(all(windows, feature = "cocos-editor-execute"))] #[test] fn builtin_mcp_process_probe() { @@ -2097,13 +2201,6 @@ mod tests { let config = tempfile::tempdir().unwrap(); crate::builtin_plugins::initialize(config.path()).unwrap(); let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-"); - // 工具目录现在按当前项目类型过滤,fixture 必须具备最小 Cocos Creator 结构。 - std::fs::write( - project.path().join("package.json"), - r#"{"creator":{"version":"3.8.8"}}"#, - ) - .unwrap(); - std::fs::create_dir(project.path().join("assets")).unwrap(); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); let bridge = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index b7931e115..af618da53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -248,8 +248,8 @@ fn build_game_creator_agent_background_tool_plan_request_at( "你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}" ); let mut function_tools = - crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project( - root, agent_id, + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent( + agent_id, )?; remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?; // Platform-backed generation remains an optional capability. A @@ -487,9 +487,7 @@ fn build_game_creator_agent_background_tool_plan_request_at( .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) .with_function_tools( - crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project( - root, agent_id, - )?, + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?, ) .with_tool_choice(platform_llm::LlmToolChoice::Required); if runtime_owner_artifact_validation_available { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 697738daa..a2eaea3f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -969,7 +969,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_pre_mutation { request.function_tools = - crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(root, agent_id)?; + crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?; if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools( &mut request.function_tools, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index c851f0d70..86829193b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -162,11 +162,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( let mut confirm_tools = Vec::new(); let mut denied_tools = Vec::new(); for tool in agent_runtime_executable_tools() { - if tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME - && !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) - { - continue; - } if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) { denied_tools.push(tool.to_string()); continue; @@ -209,10 +204,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( run_profile_binding_fingerprint: String::new(), allowed_tools: agent_runtime_executable_tools() .into_iter() - .filter(|tool| { - *tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME - || crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) - }) .map(str::to_string) .collect(), auto_tools, @@ -222,6 +213,67 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( }) } +#[cfg(test)] +mod builtin_editor_policy_tests { + use super::*; + + #[test] + fn builtin_editor_tools_follow_switches_for_non_engine_projects() { + let _guard = crate::builtin_plugins::test_lock(); + let config = tempfile::tempdir().unwrap(); + crate::builtin_plugins::initialize(config.path()).unwrap(); + let project = crate::tests::canonical_test_tempdir("builtin-editor-policy-"); + for (cocos_enabled, unity_enabled) in + [(false, false), (true, false), (false, true), (true, true)] + { + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, + cocos_enabled, + ) + .unwrap(); + crate::builtin_plugins::set_enabled( + crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID, + unity_enabled, + ) + .unwrap(); + let snapshot = + agent_runtime_tool_policy_snapshot_at(project.path(), "project-supervisor") + .unwrap(); + for (tool, expected) in [ + ( + crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME, + cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")), + ), + ( + crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME, + unity_enabled + && cfg!(all( + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" + )), + ), + ] { + assert_eq!( + snapshot.allowed_tools.iter().any(|entry| entry == tool), + expected, + "{tool}" + ); + assert_eq!( + snapshot + .auto_tools + .iter() + .chain(&snapshot.confirm_tools) + .chain(&snapshot.denied_tools) + .any(|entry| entry == tool), + expected, + "{tool}", + ); + } + } + } +} + pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs index ffefb5398..9d39ae45d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/cocos_editor.rs @@ -33,11 +33,11 @@ pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute( detail: None, }; } - if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(root) { + if !crate::builtin_plugins::cocos_editor_agent_tool_available() { return AgentRuntimeToolObservation { tool: "cocos.editor.execute".to_string(), status: "failed".to_string(), - summary: "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(), + summary: "当前 Cocos 插件不可用".to_string(), detail: None, }; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs index d0cd9fe55..d82575582 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/unity_editor.rs @@ -17,8 +17,8 @@ pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute( if pending_action.is_none() { return Err("unity.editor.execute 必须绑定 durable pending action".to_string()); } - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("当前 Unity 插件不可用".to_string()); } crate::editor_adapters::execute_unity_editor_code(root, &input.code) })(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 5375f5055..6ac95a993 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -306,18 +306,6 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( Ok(functions) } -pub(crate) fn build_agent_runtime_native_function_tools_for_project( - root: &std::path::Path, - agent_id: &str, -) -> Result, String> { - let mut tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?; - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) { - let name = native_runtime_function_name_for_tool("unity.editor.execute"); - tools.retain(|tool| tool.name != name); - } - Ok(tools) -} - pub(crate) fn agent_runtime_native_tool_allowed_for_agent(tool: &str) -> bool { agent_runtime_native_capability_registry() .ok() diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index a3a002a60..d7fbc1ae9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -259,17 +259,6 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool { agent_tool_available(BuiltinPlugin::CocosEditor) } -/// Cocos 编辑器插件只对当前确认为 Cocos Creator 的项目可用。 -/// -/// 项目类型以项目根的真实结构为准,不能仅凭插件开关或编译 feature 推断。 -pub(crate) fn cocos_editor_agent_tool_available_for_project(root: &Path) -> bool { - cocos_editor_agent_tool_available() - && crate::project::discover_local_cocos_project_root(root) - .ok() - .flatten() - .is_some() -} - pub(crate) fn available_agent_tools() -> Vec<&'static str> { let mut available = Vec::new(); if cocos_editor_agent_tool_available() { @@ -287,33 +276,10 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> { available } -/// Project-scoped variant used by the isolated DirectProject MCP bridge. -/// Without a project root the safe result is an empty Cocos tool set. -pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> { - available_agent_tools() - .into_iter() - .filter(|tool| { - if *tool == AGC_UNITY_EDITOR_TOOL_NAME { - unity_editor_agent_tool_available_for_project(root) - } else { - cocos_editor_agent_tool_available_for_project(root) - } - }) - .collect() -} - pub(crate) fn unity_editor_agent_tool_available() -> bool { agent_tool_available(BuiltinPlugin::UnityEditor) } -pub(crate) fn unity_editor_agent_tool_available_for_project(root: &Path) -> bool { - unity_editor_agent_tool_available() - && crate::project::discover_local_unity_project_root(root) - .ok() - .flatten() - .is_some() -} - #[cfg(test)] pub(crate) use tests::test_lock; @@ -330,44 +296,25 @@ mod tests { } #[test] - fn unity_tool_visibility_requires_project_platform_and_independent_toggle() { + fn unity_tool_visibility_requires_platform_and_independent_toggle() { let _guard = test_lock(); let config = tempdir().unwrap(); initialize(config.path()).unwrap(); - let project = tempdir().unwrap(); - for directory in ["Assets", "Packages", "ProjectSettings"] { - fs::create_dir(project.path().join(directory)).unwrap(); - } - fs::write( - project.path().join("ProjectSettings/ProjectVersion.txt"), - "m_EditorVersion: 6000.0.1f1", - ) - .unwrap(); let supported = cfg!(all( windows, target_arch = "x86_64", feature = "unity-editor-execute" )); assert_eq!( - available_agent_tools_for_project(project.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME), + available_agent_tools().contains(&AGC_UNITY_EDITOR_TOOL_NAME), supported ); - assert!( - !available_agent_tools_for_project(config.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME) - ); set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap(); - assert_eq!( - unity_editor_agent_tool_available_for_project(project.path()), - supported - ); + assert_eq!(unity_editor_agent_tool_available(), supported); set_enabled(AGC_UNITY_EDITOR_PLUGIN_ID, false).unwrap(); - assert!(!unity_editor_agent_tool_available_for_project( - project.path() - )); + assert!(!unity_editor_agent_tool_available()); set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap(); - assert!(!unity_editor_agent_tool_available_for_project( - project.path() - )); + assert!(!unity_editor_agent_tool_available()); } #[test] @@ -531,32 +478,4 @@ mod tests { tool_visible_when_enabled ); } - - #[test] - fn project_scoped_availability_requires_a_cocos_creator_root() { - let _guard = test_lock(); - let directory = tempdir().expect("temp config"); - initialize(directory.path()).expect("initialize"); - set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); - let non_cocos = tempdir().expect("non-cocos project"); - assert!(!cocos_editor_agent_tool_available_for_project( - non_cocos.path() - )); - - let cocos = tempdir().expect("cocos project"); - fs::write( - cocos.path().join("package.json"), - r#"{"creator":{"version":"3.8.8"}}"#, - ) - .expect("cocos package"); - fs::create_dir(cocos.path().join("assets")).expect("cocos assets"); - assert_eq!( - cocos_editor_agent_tool_available_for_project(cocos.path()), - cfg!(feature = "cocos-editor-execute") - ); - assert_eq!( - available_agent_tools_for_project(non_cocos.path()), - Vec::<&'static str>::new() - ); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index b77ec5a7a..ba8cb2143 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -152,12 +152,12 @@ pub(crate) fn unity_editor_rpc_owned( if method == "connect" { unity_editor_bridge::disconnect_unity_editor(); } - let project = params + params .get("projectPath") .and_then(Value::as_str) .ok_or("缺少 projectPath")?; - if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(Path::new(project)) { - return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string()); + if !crate::builtin_plugins::unity_editor_agent_tool_available() { + return Err("Unity 插件不可用".to_string()); } let mut delivery = if method == "execute" { let mut pending = match unity_pending_delivery().try_lock() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 71117b0df..6c8ab9099 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -791,37 +791,6 @@ fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), Stri Ok(()) } -fn plugin_matches_project(id: &str, project: Option<&Path>) -> bool { - match id { - crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => project.is_some_and(|path| { - crate::project::discover_local_cocos_project_root(path) - .ok() - .flatten() - .is_some() - }), - crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => project.is_some_and(|path| { - crate::project::discover_local_unity_project_root(path) - .ok() - .flatten() - .is_some() - }), - _ => true, - } -} - -fn require_plugin_project(id: &str, project: &ProjectContext) -> Result<(), String> { - if !plugin_matches_project( - id, - project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .as_deref(), - ) { - return Err("编辑器插件与当前项目类型不匹配".to_string()); - } - Ok(()) -} - fn controlled_editor_params(project: &Path, mut params: Value) -> Result { if params.is_null() { params = json!({}); @@ -1020,18 +989,10 @@ impl PluginHost { .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; self.scan_locked(&mut state, &root)?; - let project = state - .active_project - .lock() - .map_err(|_| "项目上下文锁已损坏".to_string())? - .clone(); state .plugins .values() - .filter(|record| { - plugin_matches_project(&record.id, project.as_deref()) - && require_plugin_adapter(&record.id, &state.editors).is_ok() - }) + .filter(|record| require_plugin_adapter(&record.id, &state.editors).is_ok()) .map(|record| self.summary_locked(record)) .collect() } @@ -1096,7 +1057,6 @@ impl PluginHost { .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); require_plugin_adapter(id, &state.editors)?; - require_plugin_project(id, &active_project)?; let editors = state.editors.clone(); let record = state .plugins @@ -1198,7 +1158,6 @@ impl PluginHost { .plugins .get(id) .ok_or_else(|| "插件不存在".to_string())?; - require_plugin_project(id, &state.active_project)?; if record.running.is_none() || !record.manifest.permissions.contains("ui.register") { return Err("插件面板未激活".to_string()); } @@ -1244,7 +1203,6 @@ impl PluginHost { .root .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; - require_plugin_project(id, &state.active_project)?; let record = state .plugins .get_mut(id) @@ -1619,9 +1577,6 @@ impl PluginHost { let project = active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())?; - if !plugin_matches_project(&manifest.id, project.as_deref()) { - return Err("编辑器插件与当前项目类型不匹配".to_string()); - } let project = project .as_deref() .ok_or_else(|| "尚未设置当前项目".to_string())?; @@ -1672,7 +1627,7 @@ impl PluginHost { } pub(crate) fn set_active_project(&self, project_path: Option) -> Result<(), String> { - let mut state = self + let state = self .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; @@ -1692,23 +1647,19 @@ impl PluginHost { .map_err(|_| "项目上下文锁已损坏".to_string())? .clone(); if previous != project { + let mut editors = state + .editors + .try_lock() + .map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?; + if let Some(editor) = editors.get_mut("cocos-editor") { + editor.disconnect(); + } crate::editor_adapters::disconnect_unity_editor_connection(); } *state .active_project .lock() .map_err(|_| "项目上下文锁已损坏".to_string())? = project.clone(); - for record in state.plugins.values_mut() { - if !plugin_matches_project(&record.id, project.as_deref()) { - if let Some(mut running) = record.running.take() { - let _ = running.child.kill(); - let _ = running.child.wait(); - } - if record.manifest.enabled { - record.status = "stopped".to_string(); - } - } - } for record in state.plugins.values() { if let Some(running) = record.running.as_ref() { let subscribed = running @@ -1930,6 +1881,7 @@ pub(crate) async fn set_agc_plugin_project_path( #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::Ordering; use tempfile::tempdir; fn manifest() -> PluginManifest { @@ -2152,7 +2104,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let project = Arc::new(Mutex::new(Some(root.path().to_path_buf()))); let editors: EditorRegistry = Arc::new(Mutex::new(BTreeMap::from([( "cocos-editor".to_string(), - Box::new(StubCocosAdapter) as Box, + Box::new(StubCocosAdapter::default()) as Box, )]))); let registrations = Arc::new(Mutex::new(PluginRegistrations::default())); let mut manifest = manifest(); @@ -2187,7 +2139,10 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(start.elapsed() < Duration::from_millis(100)); } - struct StubCocosAdapter; + #[derive(Default)] + struct StubCocosAdapter { + disconnects: Arc, + } struct StubUnityAdapter; @@ -2218,7 +2173,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p } #[test] - fn workspace_unity_plugin_round_trips_and_stops_when_leaving_project() { + fn workspace_unity_plugin_round_trips_across_project_contexts() { let _guard = crate::builtin_plugins::test_lock(); let config = tempdir().unwrap(); let project = tempdir().unwrap(); @@ -2237,13 +2192,11 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p .unwrap(); host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) .unwrap(); - assert!(!host + assert!(host .list() .unwrap() .iter() .any(|plugin| plugin.id == "agc-unity-editor")); - host.set_active_project(Some(project.path().to_string_lossy().into_owned())) - .unwrap(); host.start("agc-unity-editor").unwrap(); let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -2257,6 +2210,14 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(Instant::now() < deadline); thread::sleep(Duration::from_millis(20)); } + let plugin_pid = host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .child + .id(); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .unwrap(); let response = host .call( "agc-unity-editor", @@ -2274,15 +2235,51 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p .to_string_lossy() .as_ref() ); + let other = tempdir().unwrap(); + host.set_active_project(Some(other.path().to_string_lossy().into_owned())) + .unwrap(); + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 3;"}), + ) + .unwrap(); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["result"]["projectPath"], + other + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); host.set_active_project(None).unwrap(); - assert!(!host + assert!(host .list() .unwrap() .iter() .any(|plugin| plugin.id == "agc-unity-editor")); - assert!(host.state.lock().unwrap().plugins["agc-unity-editor"] - .running - .is_none()); + assert_eq!( + host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .child + .id(), + plugin_pid + ); + let response = host + .call( + "agc-unity-editor", + "unity.editor.execute".to_string(), + json!({"code":"return 4;"}), + ) + .unwrap(); + assert_eq!(response["status"], "failed"); + assert_eq!(response["dispatched"], false); + host.stop("agc-unity-editor").unwrap(); } impl EditorAdapter for StubCocosAdapter { @@ -2303,7 +2300,9 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p Err("stub adapter 不建立连接".to_string()) } - fn disconnect(&mut self) {} + fn disconnect(&mut self) { + self.disconnects.fetch_add(1, Ordering::SeqCst); + } fn translate_rpc(&self, _method: &str, params: Value) -> Result { Ok(params) @@ -2329,7 +2328,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); @@ -2381,7 +2380,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p host.initialize(directory.path()).expect("initialize"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .expect("register adapter"); let project = fs::canonicalize(directory.path()) .expect("canonical project") @@ -2425,26 +2424,98 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p } #[test] - fn cocos_plugin_is_hidden_and_cannot_start_for_non_cocos_project() { + fn cocos_plugin_stays_available_across_project_contexts() { let _guard = crate::builtin_plugins::test_lock(); let directory = tempdir().expect("temp config"); crate::builtin_plugins::initialize(directory.path()).expect("builtin state"); let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + let adapter = StubCocosAdapter::default(); + let disconnects = Arc::clone(&adapter.disconnects); + host.register_editor_adapter(Box::new(adapter)) .expect("register adapter"); host.set_plugin_workspace(workspace).expect("set workspace"); - let project = tempdir().expect("web project"); - host.set_active_project(Some(project.path().to_string_lossy().into_owned())) - .expect("set active project"); assert!(host .list() .expect("list plugins") .into_iter() - .all(|plugin| plugin.id != "agc-cocos-editor")); - assert!(host.start("agc-cocos-editor").is_err()); + .any(|plugin| plugin.id == "agc-cocos-editor")); + host.start("agc-cocos-editor") + .expect("start without a project"); + let deadline = Instant::now() + Duration::from_secs(15); + while host.read_panel("agc-cocos-editor", "cocos-editor").is_err() + || !host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .registrations + .lock() + .unwrap() + .subscriptions + .values() + .any(|event| event == "project.changed") + { + assert!(Instant::now() < deadline, "Cocos panel was not registered"); + thread::sleep(Duration::from_millis(25)); + } + let plugin_pid = host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .child + .id(); + let project = tempdir().expect("web project"); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .expect("set active project"); + assert_eq!(disconnects.load(Ordering::SeqCst), 1); + host.set_active_project(Some(project.path().to_string_lossy().into_owned())) + .expect("keep the same active project"); + assert_eq!(disconnects.load(Ordering::SeqCst), 1); + let response = host + .call( + "agc-cocos-editor", + "cocos.editor.execute".to_string(), + json!({"code":"return 1;"}), + ) + .expect("RPC reaches the adapter without a project type gate"); + assert_eq!(response["status"], "completed"); + assert_eq!( + response["response"]["params"]["projectPath"], + project + .path() + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + host.set_active_project(None).unwrap(); + assert_eq!(disconnects.load(Ordering::SeqCst), 2); + assert!(host + .list_extensions() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-cocos-editor")); + host.read_panel("agc-cocos-editor", "cocos-editor") + .expect("panel remains available"); + assert_eq!( + host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .as_ref() + .unwrap() + .child + .id(), + plugin_pid + ); + assert!(host + .call( + "agc-cocos-editor", + "cocos.editor.execute".to_string(), + json!({"code":"return 1;"}), + ) + .is_err()); + host.stop("agc-cocos-editor").unwrap(); } #[test] @@ -2487,7 +2558,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"] .running .is_none()); - host.register_editor_adapter(Box::new(StubCocosAdapter)) + host.register_editor_adapter(Box::new(StubCocosAdapter::default())) .unwrap(); assert!(host .list() diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4b29e7fb3..42c202fc7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -307,7 +307,7 @@ import { } from './services/platformSession'; import { setAgcPluginProjectPath, - startAvailableAgcPlugin, + startAvailableAgcEditorPlugins, } from './services/pluginHost'; import { canSubscribeTauriEvents, @@ -617,24 +617,18 @@ export function App({ // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 if (!nextProjectPath && !previousProjectPath) return; let active = true; - const editorPlugin = - workspaceProjectKind === 'cocos' - ? { id: 'agc-cocos-editor', title: 'Cocos Creator' } - : workspaceProjectKind === 'unity' - ? { id: 'agc-unity-editor', title: 'Unity' } - : null; void setAgcPluginProjectPath(nextProjectPath) .then(async () => { - if (active && editorPlugin && nextProjectPath) { - await startAvailableAgcPlugin(editorPlugin.id); + if (active && nextProjectPath) { + await startAvailableAgcEditorPlugins(() => active); } }) .catch((error) => { - if (!active || !editorPlugin || !nextProjectPath) { + if (!active || !nextProjectPath) { return; } setWorkspaceStatus( - `${editorPlugin.title} 插件未就绪:${ + `编辑器插件未就绪:${ error instanceof Error ? error.message : String(error) }`, ); @@ -646,7 +640,7 @@ export function App({ } localProjectPathRef.current = null; }; - }, [localProject?.projectPath, supervisorChatOnly, workspaceProjectKind]); + }, [localProject?.projectPath, supervisorChatOnly]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( diff --git a/apps/ai-game-creator-shell/src/services/pluginHost.ts b/apps/ai-game-creator-shell/src/services/pluginHost.ts index b3aee5fc5..44c2209e8 100644 --- a/apps/ai-game-creator-shell/src/services/pluginHost.ts +++ b/apps/ai-game-creator-shell/src/services/pluginHost.ts @@ -33,14 +33,38 @@ export async function startAgcPlugin(id: string) { }) as Promise; } -/** 只消费宿主的能力投影,不因项目类型自行推断原生适配器是否存在。 */ -export async function startAvailableAgcPlugin(id: string) { +/** 只消费宿主的能力投影;项目类型与平台支持均不在前端再次判断。 */ +export async function startAvailableAgcEditorPlugins( + isActive: () => boolean = () => true, +) { const plugins = await listAgcPlugins(); - const plugin = plugins.find((candidate) => candidate.id === id); - if (!plugin?.enabled || !plugin.hasRuntime || plugin.status === 'invalid') { - return; + const available = plugins.filter( + (plugin) => + plugin.builtin && + (plugin.id === 'agc-cocos-editor' || plugin.id === 'agc-unity-editor') && + plugin.enabled && + plugin.hasRuntime && + (plugin.status === 'stopped' || plugin.status === 'discovered'), + ); + const results = await Promise.allSettled( + available.map((plugin) => + isActive() ? startAgcPlugin(plugin.id) : Promise.resolve(), + ), + ); + const errors = results.flatMap((result, index) => + result.status === 'rejected' + ? [ + `${available[index]!.name}:${ + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + }`, + ] + : [], + ); + if (errors.length) { + throw new Error(errors.join(';')); } - return startAgcPlugin(id); } export async function stopAgcPlugin(id: string) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 46df34801..af260f08a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6980,6 +6980,78 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it.each(['web', 'godot', 'cocos', 'unity'] as const)( + 'starts available editor plugins for a %s project without opening panels', + async (projectKind) => { + const projectPath = `/tmp/editor-plugin-${projectKind}`; + const manifest = createGameCreationAppManifest( + 'editor-plugin-project', + '编辑器插件项目', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const plugins = ['agc-cocos-editor', 'agc-unity-editor'].map((id) => ({ + id, + name: id, + builtin: true, + enabled: true, + hasRuntime: true, + status: 'stopped', + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') return manifest; + if (command === 'set_agc_plugin_project_path') return null; + if (command === 'list_agc_plugins') return plugins; + if (command === 'start_agc_plugin') { + const plugin = plugins.find(({ id }) => id === args?.id)!; + plugin.status = 'running'; + return plugin; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + const element = React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + initialProjectKind: projectKind, + projectSupervisorOnly: true, + }); + const { rerender } = render(element); + + await waitFor(() => { + for (const plugin of plugins) { + expect(invoke).toHaveBeenCalledWith('start_agc_plugin', { + id: plugin.id, + }); + } + }); + rerender(element); + expect( + invoke.mock.calls.filter(([command]) => command === 'start_agc_plugin'), + ).toHaveLength(2); + expect( + invoke.mock.calls.some( + ([command]) => command === 'read_agc_plugin_panel', + ), + ).toBe(false); + const projectBinding = invoke.mock.calls.findIndex( + ([command]) => command === 'set_agc_plugin_project_path', + ); + const firstStart = invoke.mock.calls.findIndex( + ([command]) => command === 'start_agc_plugin', + ); + expect(projectBinding).toBeGreaterThanOrEqual(0); + expect(projectBinding).toBeLessThan(firstStart); + }, + ); + it('allows selecting the model on the first direct-project entry', async () => { const projectPath = '/tmp/first-entry-model-select'; const manifest = createGameCreationAppManifest( diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts index c01ef820e..669ec6747 100644 --- a/apps/ai-game-creator-shell/tests/pluginHost.test.ts +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -2,18 +2,27 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { setAgcPluginProjectPath, - startAvailableAgcPlugin, + startAvailableAgcEditorPlugins, } from '../src/services/pluginHost'; afterEach(() => vi.unstubAllGlobals()); +const editorPlugins = ['agc-cocos-editor', 'agc-unity-editor'].map((id) => ({ + id, + name: id === 'agc-cocos-editor' ? 'Cocos Creator' : 'Unity', + builtin: true, + enabled: true, + hasRuntime: true, + status: 'stopped', +})); + describe('插件自动启动使用后端能力投影', () => { it.each( [ [], [ { - id: 'agc-cocos-editor', + ...editorPlugins[0], enabled: false, hasRuntime: true, status: 'stopped', @@ -21,7 +30,7 @@ describe('插件自动启动使用后端能力投影', () => { ], [ { - id: 'agc-cocos-editor', + ...editorPlugins[0], enabled: true, hasRuntime: false, status: 'package', @@ -29,44 +38,78 @@ describe('插件自动启动使用后端能力投影', () => { ], [ { - id: 'agc-cocos-editor', + ...editorPlugins[0], enabled: true, hasRuntime: true, status: 'invalid', }, ], + [{ ...editorPlugins[0], status: 'running' }], + [{ ...editorPlugins[0], status: 'failed' }], + [{ ...editorPlugins[0], status: 'disabled' }], + [{ ...editorPlugins[0], builtin: false }], + [{ ...editorPlugins[0], id: 'other-plugin' }], ].map((plugins) => ({ plugins })), - )('隐藏、禁用或不可执行的插件不启动(%j)', async ({ plugins }) => { + )('只启动宿主投影允许自动启动的内置编辑器插件(%j)', async ({ plugins }) => { const invoke = vi.fn(async () => plugins); vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); - await startAvailableAgcPlugin('agc-cocos-editor'); + await startAvailableAgcEditorPlugins(); expect(invoke).toHaveBeenCalledTimes(1); expect(invoke).toHaveBeenCalledWith('list_agc_plugins'); }); - it.each(['agc-cocos-editor', 'agc-unity-editor'])( - '支持的编辑器插件按原入口启动:%s', - async (pluginId) => { + it.each(['stopped', 'discovered'])( + '从同一份宿主列表启动所有可用的编辑器插件:%s', + async (status) => { const invoke = vi.fn(async (command: string) => command === 'list_agc_plugins' - ? [ - { - id: pluginId, - enabled: true, - hasRuntime: true, - status: 'stopped', - }, - ] + ? editorPlugins.map((plugin) => ({ ...plugin, status })) : {}, ); vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); - await startAvailableAgcPlugin(pluginId); - expect(invoke).toHaveBeenLastCalledWith('start_agc_plugin', { - id: pluginId, - }); + await startAvailableAgcEditorPlugins(); + expect(invoke.mock.calls).toEqual([ + ['list_agc_plugins'], + ['start_agc_plugin', { id: 'agc-cocos-editor' }], + ['start_agc_plugin', { id: 'agc-unity-editor' }], + ]); }, ); + it('一个编辑器插件启动失败仍启动另一个,且不自动重试', async () => { + const invoke = vi.fn(async (command: string, params?: { id: string }) => { + if (command === 'list_agc_plugins') return editorPlugins; + if (params?.id === 'agc-cocos-editor') throw new Error('进程已退出'); + return {}; + }); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + await expect(startAvailableAgcEditorPlugins()).rejects.toThrow( + 'Cocos Creator:进程已退出', + ); + expect(invoke.mock.calls).toEqual([ + ['list_agc_plugins'], + ['start_agc_plugin', { id: 'agc-cocos-editor' }], + ['start_agc_plugin', { id: 'agc-unity-editor' }], + ]); + }); + + it('项目切换后迟到的插件列表不会启动旧项目的插件', async () => { + let finishList!: (plugins: typeof editorPlugins) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + finishList = resolve; + }), + ); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + let active = true; + const start = startAvailableAgcEditorPlugins(() => active); + active = false; + finishList(editorPlugins); + await start; + expect(invoke).toHaveBeenCalledTimes(1); + }); + it('快速切换项目时旧 cleanup 不能晚于新项目设置抵达宿主', async () => { const received: Array = []; let finishFirst: () => void = () => undefined; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 61fb4144e..bbe39908a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8855,11 +8855,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(423 passed,含新增 6 条:栏目分流与总览不渲染、UI 栏 5 个入口载荷、前置缺失可点击说明且零请求、角色栏 2 个入口、音频入口走既有链路、上传 + 配对读清单,另 1 条工具栏与 Dock 的 CSS 几何契约);`resourceCanvasBottomToolbar.test.tsx` 15 passed(新增);`resourceCanvasGenerationEntry.test.tsx` 11 passed(新增单类型用例 1 条);`projectResourceLiveIntegration.test.tsx` 25 passed(「生成素材」面板改名断言同步更新);`npm run agc:typecheck` 全绿(**其中的 `check-config.mjs` 报错已因本轮落地调用方而消失**)、`npm run check:encoding`、`git diff --check` 干净。未 commit。 - 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.10 / §7.9 / §8)、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11 / S11a / §7.3)。 -## 2026-09-13 Cocos 插件按当前项目类型暴露 +## 2026-09-20 Cocos 与 Unity 插件独立于工程类型 -- 决策:`agc-cocos-editor` 只有在当前受控项目通过 Cocos Creator 根目录识别(`package.json.creator.version` + 普通 `assets/`)时才暴露插件、面板和 Cocos 工具;无项目或其它项目类型均隐藏并失败关闭。 -- 决策:项目切换离开 Cocos 时立即停止已运行的插件实例;启动、面板读取、插件 RPC、Runtime execute 和 DirectProject MCP 工具目录/执行入口全部再次校验项目类型。Cocos 编辑器操作优先经内置插件入口,禁止回退到项目 `extensions/`、`package.json` 插件或第三方 MCP。 -- 验证:新增 builtin/plugin host 项目级门禁测试,Direct MCP fixture 补最小 Cocos 工程结构;Rust 定向测试、显式 `cocos-editor-execute` feature 编译、编码检查和 `git diff --check` 已执行。 +- 决策:`agc-cocos-editor` 与 `agc-unity-editor` 的插件列表、启动、面板、插件 RPC、Runtime 与 DirectProject 工具暴露不按当前工程类型过滤;无项目、普通 AGC、Godot、Cocos、Unity 上下文遵循同一套 enable、原生适配器、平台与 feature 规则。前端根据宿主列表中各插件状态分别自动启动,不按项目类型二选一,也不自动展开面板。 +- 决策:跨工程类型切换保留插件实例及管理能力,继续更新受控项目上下文、失效旧连接并隔离旧请求回执。实际编辑器操作仍要求当前受控项目匹配真实引擎工程与编辑器目标;显式跨项目路径、缺失项目、无目标进程、身份或握手不匹配均在派发前失败,权限、并发、期限与执行不确定阻断保持有效。 +- 边界:插件可见和工具可调用不能证明任意非引擎目录可成为编辑器执行目标;不改变项目类型、导入或持久数据。Cocos 编辑器操作继续使用内置插件,禁止回退到项目 `extensions/`、`package.json` 插件或第三方 MCP。 +- 验收口径:分别取得宿主/内置开关、工具目录、前端启动投影与真实目标拒绝证据;真实编辑器、安装包和 CI 与定向测试分层报告。 ## 2026-09-14 DirectProject Codex 取消路径白名单并启用完整 sandbox diff --git a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md index c1d7604c9..df5f29201 100644 --- a/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md +++ b/docs/technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md @@ -71,7 +71,7 @@ plugins/agc-cocos-editor/ 宿主按 `AGC_PLUGIN_WORKSPACE`、随包 `/plugins`、开发构建仓库 `plugins/` 的顺序解析工作区;插件包内的 `native/payload` 由构建脚本随包映射,生成的 DLL 不入库。插件协议、权限和面板挂载全部复用通用宿主,Cocos 专属逻辑只存在于本插件包:进程名与 `--project` 解析、Creator 版本校验、named pipe 协议和 Windows 注入。 -该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。除开关和 feature 外,还必须满足“当前受控项目已识别为 Cocos Creator 项目”这一门禁;没有当前项目或项目类型不是 Cocos 时,插件不出现在插件列表、面板、Agent 工具目录或 MCP tools/list 中,启动、面板读取、RPC 和编辑器执行也会失败关闭。项目切换离开 Cocos 后,已运行实例立即停止。Cocos 编辑器操作统一优先通过该内置插件的 `cocos.editor.execute` / `cocos.editor.operation`(DirectProject 对应 `agc_cocos_execute`);不得改走项目目录 `extensions/`、`package.json` 插件或第三方 MCP。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前状态与项目门禁。 +该插件是**内置插件**:随客户端分发、不能卸载,只能通过 `set_agc_plugin_enabled` 控制是否可用。插件列表、启动、面板读取、插件 RPC、Agent 工具目录和 MCP tools/list 不按当前工程类型过滤;无当前项目或非 Cocos 项目仍沿用相同的开关、原生适配器、平台和 feature 规则。项目切换不因工程类型不同而停止插件,仍更新受控项目上下文并失效旧连接。实际编辑器操作必须取得当前受控项目对应的真实 Creator 目标并通过原有目录、PID、版本与握手校验;无项目或非引擎目录不能仅凭工具可见就通过执行校验。Cocos 编辑器操作统一优先通过该内置插件的 `cocos.editor.execute` / `cocos.editor.operation`(DirectProject 对应 `agc_cocos_execute`);不得改走项目目录 `extensions/`、`package.json` 插件或第三方 MCP。开关状态保存在 AppData `extensions/builtin-plugins.json`,隔离 MCP 每次 tools/list 都向绑定宿主询问当前可用状态,不自行按工程类型过滤。 ## AGC 项目打开入口 diff --git a/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md b/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md index 3cb49fcbe..f443a6f27 100644 --- a/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md +++ b/docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md @@ -3,7 +3,7 @@ > 文档状态:`current` > 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范 -更新时间:`2026-09-18` +更新时间:`2026-09-20` ## 目标与非目标 @@ -18,7 +18,7 @@ ## 入口与行为合同 - Unity 项目以当前受控根目录中的普通 `ProjectSettings/ProjectVersion.txt`、`Assets/` 和 `Packages/` 识别;复用现有打开项目入口,不创建平行工作台。 -- 只有当前项目为 Unity、内置插件启用且平台适配器可用时才显示插件并向 Agent 暴露 Unity 工具。禁用或离开 Unity 项目后停止插件实例,执行入口再次检查开关和项目身份。 +- 插件列表、启动、面板、插件 RPC 和 Agent 工具暴露不按当前工程类型过滤;无项目或非 Unity 项目也沿用相同的内置开关及平台适配器规则。禁用会停止插件实例,跨工程类型切换保留插件管理能力并失效旧连接。前端按宿主列表中各插件自身状态投影自动启动,不因项目类型在 Cocos/Unity 之间二选一,也不自动展开面板。执行入口继续检查开关、权限和真实项目身份;工具可见不代表任意目录可作为 Unity 执行目标。 - 探测只读取进程和项目身份。连接必须匹配规范化项目路径、PID、进程启动身份与实际握手;多个候选时失败,不选择任意实例。助手只接受受控项目、操作和代码,不接受任意可执行文件或 payload 路径。 - 执行接收 UTF-8 C# 代码,最多 128 KiB,拒绝空值和 NUL。连接及执行有总期限,消息最多 2 MiB,并发执行直接拒绝,不积压写请求。 - 成功仅由 Unity 真实执行回执决定;编译或运行错误返回结构化失败与脱敏诊断。主线程同步代码不承诺可硬中止。 @@ -51,7 +51,7 @@ helper 使用一行一条 JSON 请求/响应,请求包含 `id`、`method`、`p | 条款 | 必须取得的证据 | | --- | --- | | 来源与构建 | 固定源码版本、许可、helper 构建和自包含发布检查 | -| 插件接入 | manifest/协议测试,发现、启停、开关和项目级工具过滤测试 | +| 插件接入 | manifest/协议测试,发现、启停、开关、无项目/跨工程类型的工具暴露和前端启动投影测试 | | 执行闭环 | helper 与原生适配器定向测试,Agent 参数及结果映射测试 | | 失败边界 | 跨项目、并发、超时、损坏回执、不确定阻断、重启插件不解除阻断测试 | | 分发 | Windows 构建脚本准备 helper,staging 仅包含目标平台运行文件及许可 | diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 50e5b1c23..9e147ae2d 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -4,7 +4,7 @@ > 规范关系:AGC 插件与编辑器适配主规范 > 验收范围:插件 manifest、宿主生命周期、RPC、Capability/权限审计、UI 挂载和编辑器适配器边界 -更新时间:`2026-09-18` +更新时间:`2026-09-20` ## 目标与边界 @@ -72,7 +72,16 @@ OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 M Windows 与 macOS 构建都将内置插件的清单、JS 入口与面板复制到应用资源目录;staging 每次重建,避免已删除插件或跨目标原生 payload 残留。macOS 不携带 Windows native payload。Cocos 进程桥接仍仅按既有 Windows 平台实现提供,插件文件可被发现不代表 macOS 已支持编辑器控制;JS 入口的系统 Node 前提不变。 -Cocos 插件对用户可见与可启动必须同时满足当前为 Cocos 项目、宿主已注册 `cocos-editor` 原生适配器;没有适配器时从插件/扩展列表隐藏,直接启动或读取面板也在产生子进程前拒绝。正式适配器仅在 Windows 且编译 `cocos-editor-execute` 时注册;Agent 工具使用相同平台与 feature 门禁。前端只按后端列表投影判断是否自动启动,不自行推断平台能力。 +Cocos 与 Unity 插件的可见性、启动、面板、插件 RPC 和 Agent 工具暴露不按当前工程类型过滤;无当前项目以及普通 AGC、Godot、Cocos、Unity 项目使用同一套插件可用性规则。宿主必须已注册插件声明的原生适配器;没有适配器时从插件/扩展列表隐藏,直接启动或读取面板也在产生子进程前拒绝。正式适配器与 Agent 工具继续受各自平台和编译 feature 约束,禁用开关继续阻止启动与工具执行。前端只按后端列表投影判断是否自动启动,不自行推断平台能力或再次按工程类型过滤。 + +### 工程上下文与真实编辑器目标 + +- 工程类型只用于工程识别及对应工程工作流,不作为 `agc-cocos-editor` / `agc-unity-editor` 的管理、面板或工具目录门禁。Runtime、DirectProject MCP、工具策略快照和模型上下文使用一致规则;工具已暴露不代表真实编辑器已连接或操作已成功。 +- 前端对宿主列表中的 Cocos 与 Unity 插件分别根据适配器支持、启用、Runtime 入口和运行状态投影自动启动,不按项目类型二选一;自动启动不自动展开编辑器面板。 +- 项目切换不因新工程类型不同而停止插件或隐藏工具;当前受控项目上下文仍须按既有顺序更新,旧编辑器连接失效。旧请求与回执保留原项目归属,不能更新新项目连接状态。 +- 实际编辑器操作仍须取得有效的当前受控项目和与之匹配的真实编辑器目标。宿主注入项目路径,显式路径必须与当前受控项目一致;适配器继续校验真实工程结构、目标 PID、进程身份、版本与握手。无项目、不匹配的工程目录、无编辑器或不支持的平台均应在发送编辑器操作前明确失败,不回退到其它项目或任意编辑器进程。 +- 插件启用状态、manifest 适配器绑定、`editor.rpc` 权限、超时与并发拒绝、执行结果不确定阻断均保持原合同。取消工程类型过滤不增加自动重试,不清除项目切换或重启插件前已经产生的不确定状态。 +- 不新增或迁移项目类型、内置插件开关、API/DTO、SpacetimeDB schema 或持久项目数据;不扩大 Cocos/Unity 原生适配器的平台支持,也不把任意非引擎目录解释为可执行的编辑器工程。 ### 内置插件与可用开关 @@ -141,6 +150,7 @@ OpenAI 官方 Plugins 文档将 Skills、MCP Server 和可选 UI 定义为同一 - Rust:manifest 路径/权限校验、目录扫描、权限拒绝和通用适配器 registry 边界单测;`cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 - 前端:`agc-plugin-sdk` TypeScript 编译、宿主服务类型检查,以及 `PluginPanelHost` 的挂载/卸载测试。 - 内置插件开关:`builtin_plugins` 单测覆盖默认值、持久化往返、坏文件失败关闭,以及“禁用后工具目录里不再出现该工具”;`plugin_host` 单测覆盖禁用后不能启动、启用后回到 stopped。 +- 工程类型独立性:覆盖无当前项目、普通 AGC、Godot、Cocos、Unity 上下文中的插件列表、启动、面板、RPC 与 Runtime/DirectProject 工具目录一致性;项目切换不因类型变化停止插件。保留禁用开关、缺失原生适配器、平台/feature、显式跨项目路径拒绝与真实编辑器目标校验的独立反例;非引擎目录不能仅因工具可见就通过实际操作校验。 - 插件工作区:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 覆盖工作区扫描与 manifest 启用状态;`cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml` 覆盖 Cocos 适配器;`node --test plugins/agc-cocos-editor/src/entry.test.mjs` 覆盖插件入口协议与 manifest 一致性。 - 通用仓库门禁:`npm run check:encoding`、`git diff --check`;发布前仍需单独执行 AGC package smoke 和安装包 smoke。 diff --git a/plugins/agc-cocos-editor/README.md b/plugins/agc-cocos-editor/README.md index 68e885a80..07e29b4ae 100644 --- a/plugins/agc-cocos-editor/README.md +++ b/plugins/agc-cocos-editor/README.md @@ -3,11 +3,13 @@ Cocos Creator 编辑器桥接插件。用户侧看到的是一个普通 AGC 插件:插件生命周期、UI、 RPC、权限和能力注册全部由通用宿主负责,只有“如何连接 Cocos Creator”属于本插件。 -它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。只有当前受控目录被识别为 Cocos -Creator 项目时才会暴露插件、面板和工具;切换到其它项目会立即停止插件实例并隐藏对应能力。 +它同时是 AGC 的**内置插件**:随客户端分发、不能卸载。插件列表、启动、面板、RPC 和 +Agent 工具暴露不受当前工程类型限制;无当前项目也沿用相同的开关、原生适配器、平台和 +feature 规则。切换工程类型不会停止插件或隐藏能力,仍会更新受控项目并失效旧连接。 用户在运行时设置里只能切换“是否可用”,禁用后插件不能启动,`agc_cocos_execute` 与全部 -`cocos_*` Agent 工具也会从 Agent 工具列表、工具策略快照和上下文里消失;重新启用后仍需 -满足当前项目是 Cocos 的门禁。 +`cocos_*` Agent 工具也会从 Agent 工具列表、工具策略快照和后续上下文里消失。 +实际编辑器操作仍需当前受控项目对应的真实 Creator 目标,并通过项目路径、PID、版本和 +握手校验;工具可见不代表任意目录都能作为 Creator 执行目标。 Cocos Creator 项目目录中的 `extensions/`、`package.json` 插件声明或第三方 MCP 包不属于 AGC Cocos 桥接来源。Agent 处理 Cocos 请求时只使用客户端登记的 `agc-cocos-editor` diff --git a/plugins/agc-cocos-editor/src/entry.mjs b/plugins/agc-cocos-editor/src/entry.mjs index 6457863e8..225b3e58a 100644 --- a/plugins/agc-cocos-editor/src/entry.mjs +++ b/plugins/agc-cocos-editor/src/entry.mjs @@ -45,6 +45,7 @@ export function createCocosEditorPlugin({ }) { let nextId = 1; let activeProjectPath = null; + let projectEpoch = 0; let disposed = false; let executionUncertain = false; let executionPending = false; @@ -190,6 +191,7 @@ export function createCocosEditorPlugin({ const projectPath = event.payload?.projectPath; activeProjectPath = typeof projectPath === 'string' && projectPath ? projectPath : null; + projectEpoch += 1; } return; } @@ -239,10 +241,13 @@ export function createCocosEditorPlugin({ const panel = await request('host.registerPanel', { ...COCOS_EDITOR_PANEL, }); + const epoch = projectEpoch; const subscription = await request('host.events.subscribe', { type: PROJECT_CHANGED_EVENT, }); - activeProjectPath = subscription?.projectPath ?? null; + if (epoch === projectEpoch) { + activeProjectPath = subscription?.projectPath ?? null; + } return { command, capability, diff --git a/plugins/agc-cocos-editor/src/entry.test.mjs b/plugins/agc-cocos-editor/src/entry.test.mjs index 00831b843..0025a5595 100644 --- a/plugins/agc-cocos-editor/src/entry.test.mjs +++ b/plugins/agc-cocos-editor/src/entry.test.mjs @@ -215,6 +215,58 @@ test('project.changed event updates the cached project path', async () => { assert.equal(harness.plugin.activeProjectPath, null); }); +for (const [snapshotPath, projectPath] of [ + [null, 'C:/New'], + ['C:/Old', 'C:/New'], + ['C:/Old', null], +]) { + test(`迟到的订阅快照 ${snapshotPath} 不能覆盖新项目事件 ${projectPath}`, async (t) => { + const requests = []; + const plugin = createCocosEditorPlugin({ + send(message) { + requests.push(message); + if (!message.method) return; + queueMicrotask(async () => { + if (message.method === 'host.events.subscribe') { + await plugin.handleMessage({ + jsonrpc: '2.0', + method: 'host.event', + params: { + type: PROJECT_CHANGED_EVENT, + payload: { projectPath }, + }, + }); + } + await plugin.handleMessage({ + jsonrpc: '2.0', + id: message.id, + result: + message.method === 'host.events.subscribe' + ? { subscriptionId: 'sub-1', projectPath: snapshotPath } + : { ok: true }, + }); + }); + }, + }); + t.after(() => plugin.dispose()); + await plugin.start(); + assert.equal(plugin.activeProjectPath, projectPath); + await plugin.handleMessage({ + jsonrpc: '2.0', + id: 500, + method: COCOS_EXECUTE_COMMAND_ID, + params: { code: 'return 2;' }, + }); + const rpc = requests.find((message) => message.method === 'host.rpc'); + if (projectPath) { + assert.equal(rpc.params.params.projectPath, projectPath); + } else { + assert.equal(rpc, undefined); + assert.match(requests.at(-1).error.message, /项目路径/); + } + }); +} + test('execute rejects concurrent requests and blocks later requests after uncertainty', async () => { const harness = createHarness(); await startPlugin(harness); From fc0ce4ee5f1add290ae8952663e6d2d398987366 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:58:10 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=B7=A8=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E6=8F=92=E4=BB=B6=E5=90=AF=E5=8A=A8=E7=9A=84=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E6=B5=8B=E8=AF=95=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为五个项目打开与对话恢复测试提供插件项目绑定及空插件列表响应 保留原有业务状态断言和产品错误提示,修复完整界面门禁暴露的测试桩缺口 --- .../tests/appSurface/project-conversation.suite.ts | 8 ++++++++ .../tests/appSurface/project-development.suite.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index eac40a8c4..2518af5bd 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -516,6 +516,8 @@ export function registerProjectConversationTests() { ); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'set_agc_plugin_project_path') return null; + if (command === 'list_agc_plugins') return []; if (command === 'append_local_permission_log') { return {}; } @@ -580,6 +582,8 @@ export function registerProjectConversationTests() { ); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'set_agc_plugin_project_path') return null; + if (command === 'list_agc_plugins') return []; if (command === 'append_local_permission_log') { return {}; } @@ -630,6 +634,8 @@ export function registerProjectConversationTests() { ); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'set_agc_plugin_project_path') return null; + if (command === 'list_agc_plugins') return []; if (command === 'append_local_permission_log') { return {}; } @@ -3110,6 +3116,8 @@ export function registerProjectConversationTests() { }); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'set_agc_plugin_project_path') return null; + if (command === 'list_agc_plugins') return []; const targetProjectPath = String(args?.projectPath ?? ''); if (command === 'append_local_permission_log') { return {}; diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index af260f08a..f45acbbdb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -10148,6 +10148,8 @@ export function registerProjectWorkbenchNavigationTests() { ); const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'set_agc_plugin_project_path') return null; + if (command === 'list_agc_plugins') return []; if (command === 'append_local_permission_log') { return {}; } From 623e007fae8535e31872a4c06cf2a5842ce84f2f Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:12:49 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=8F=91=E5=B8=83=E6=B8=A0=E9=81=93=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=A8=A1=E6=9D=BF=E5=BA=93=E7=81=B0=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分发布渠道与系统,支持 dev、release 和自定义渠道 允许网站通过服务端配置选择客户端下载检测渠道 接入模板库灰度权限并阻断退出和切号后的异步操作 补齐发布、下载、灰度与会话竞态测试及当前规范 --- .env.example | 4 + .../pages/AdminGrayReleaseConfigPage.test.tsx | 21 ++ .../src/pages/AdminGrayReleaseConfigPage.tsx | 10 +- .../scripts/build-release.mjs | 138 ++++---- .../scripts/build-release.test.mjs | 208 +++++++++--- .../scripts/release-oss.mjs | 20 +- .../scripts/release-oss.test.mjs | 106 ++++-- .../src-tauri/src/main.rs | 1 + .../src-tauri/src/template_library.rs | 224 ++++++++++++- .../features/app-shell/WorkspaceLauncher.tsx | 14 +- .../app-shell/useHomeProjectCreation.ts | 53 +-- .../template-library/useTemplateLibrary.ts | 237 ++++++++++---- .../src/view/home/index.tsx | 66 ++-- .../ai-game-creator-shell/src/view/layout.tsx | 30 +- .../tests/appSurface/home.suite.ts | 21 ++ .../tests/templateLibraryView.test.tsx | 1 + .../tests/useTemplateLibrary.test.tsx | 306 ++++++++++++++++++ .../shared-memory/team-conventions.md | 3 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 42 ++- ...技术方案】AGC模板库与模板建项-2026-09-17.md | 11 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + .../Jenkinsfile.ai-game-creator-shell-build | 8 +- server-rs/crates/api-server/src/app.rs | 45 +++ server-rs/crates/api-server/src/config.rs | 36 +++ .../api-server/src/frontend_runtime_config.rs | 41 ++- .../src/modules/client_downloads.rs | 29 +- server-rs/crates/api-server/src/state.rs | 23 ++ .../crates/module-runtime/src/application.rs | 1 + .../platform-oss/src/client_downloads.rs | 176 +++++++++- src/services/frontendRuntimeConfigService.ts | 1 + 30 files changed, 1554 insertions(+), 323 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx diff --git a/.env.example b/.env.example index f11a17f8f..e7dbc59ac 100644 --- a/.env.example +++ b/.env.example @@ -235,6 +235,10 @@ VITE_DEBUG_MODE="" # This is read by api-server and exposed through /api/runtime/frontend-config. GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false" +# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。 +# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。 +GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev" + # Optional: official VikingDB credentials for regenerating build-tag similarities # with the Python embedding script. The script auto-loads `.env.local` and uses # the fixed `bge-large-zh` embedding model. diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index a48273fae..ac4bb8e80 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -154,6 +154,27 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async () ); }); +test('灰度发布页可选择模板库并默认启用零比例灰度', async () => { + const user = userEvent.setup(); + render( + , + ); + await screen.findByRole('button', { name: 'editor.new-toolbar' }); + await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']); + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'agc:template-library', + ); + expect( + (screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value, + ).toBe('template-library'); + expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( + true, + ); + expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe( + '0', + ); +}); + test('灰度发布页保存时转换数组和百分比', async () => { const user = userEvent.setup(); vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({ diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 00d6d742f..814f2ac34 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -27,9 +27,17 @@ interface GateTargetOption { const GATE_PREFIX_LABELS: Record = { 'image-editor': '画布', + agc: '客户端', }; const FIXED_GATE_TARGETS: GateTargetOption[] = [ + { + prefix: 'agc', + suffix: 'template-library', + key: 'agc:template-library', + label: '模板库', + description: '客户端模板库灰度', + }, { prefix: 'image-editor', suffix: 'agent-sidebar', @@ -180,7 +188,7 @@ export function AdminGrayReleaseConfigPage({ setSelectedGateKey(''); setGatePrefix(option.prefix); setGateKey(option.key); - setEnabled(false); + setEnabled(option.key === 'agc:template-library'); setRolloutPercent('0'); setAllowUserIds(''); setAllowUserTags(''); diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index ecee04d4c..3917f6f9f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -68,7 +68,7 @@ export function resolveReleaseContext(args = [], env = process.env) { ); return Object.freeze({ target, - channel: resolveReleaseChannel(env, target), + channel: resolveReleaseChannel(env), bundleRoot: path.join( appRoot, 'src-tauri', @@ -87,14 +87,14 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -/** - * 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点, - * 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。 - */ -const releaseChannels = { - 'dev-win': 'windows', - 'dev-mac': 'darwin', -}; +const reservedChannelNames = new Set([ + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', +]); /** * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 @@ -162,39 +162,36 @@ export function resolveReleasePlatform(target = defaultTarget()) { throw new Error(`不支持的发布目标:${target}`); } -export function resolveReleaseChannel( - env = process.env, - target = defaultTarget(), -) { - const platform = resolveReleasePlatform(target); - const requested = env.AGC_UPDATE_CHANNEL?.trim(); - if (requested) { - const channelPlatform = releaseChannels[requested]; - if (!channelPlatform) { - throw new Error( - `未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`, - ); - } - if (channelPlatform !== platform) { - throw new Error( - `渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`, - ); - } - return requested; - } - const defaultChannel = Object.entries(releaseChannels).find( - ([, channelPlatform]) => channelPlatform === platform, - )?.[0]; - if (!defaultChannel) { +export function resolveReleaseChannel(env = process.env) { + const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'; + if ( + !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || + channel.endsWith('-') || + reservedChannelNames.has(channel) || + /-(win|mac)$/u.test(channel) + ) { throw new Error( - `目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`, + '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', ); } - return defaultChannel; + return channel; } -export function updateManifestUrl(channel = resolveReleaseChannel()) { - return `${ossBaseUrl()}/${channel}/latest.json`; +/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */ +export function resolveReleasePartition( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { + channel = resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }); + validateReleaseTarget(target); + return `${channel}-${resolveReleasePlatform(target) === 'windows' ? 'win' : 'mac'}`; +} + +export function updateManifestUrl( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { + return `${ossBaseUrl()}/${resolveReleasePartition(channel, target)}/latest.json`; } /** @@ -245,8 +242,8 @@ async function readManifestVersion(manifestUrl, label) { } /** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ -async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { - return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); +async function readRemoteChannelManifest(channel, target) { + return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单'); } /** @@ -258,14 +255,17 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { */ export async function resolvePreviousReleaseCommit( channel = resolveReleaseChannel(), - { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {}, + { + override = process.env.AGC_UPDATE_PREVIOUS_COMMIT, + target = defaultTarget(), + } = {}, ) { const explicit = override?.trim(); if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { return explicit; } try { - const manifest = await readRemoteChannelManifest(channel); + const manifest = await readRemoteChannelManifest(channel, target); const commit = typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; @@ -287,12 +287,14 @@ export async function resolvePreviousReleaseCommit( */ export async function resolveRemoteHighWaterVersion( channel = resolveReleaseChannel(), + target = defaultTarget(), ) { const channelVersion = await readManifestVersion( - updateManifestUrl(channel), + updateManifestUrl(channel, target), 'OSS 渠道清单', ); - if (channel !== 'dev-win') return channelVersion; + if (channel !== 'dev' || resolveReleasePlatform(target) !== 'windows') + return channelVersion; const legacyVersion = await readManifestVersion( legacyBridgeManifestUrl(), 'OSS 迁移指针', @@ -310,9 +312,9 @@ function replaceVersionLine(source, version, pattern, label) { } export async function prepareReleaseVersion(context = resolveReleaseContext()) { - const { channel } = context; + const { channel, target } = context; const localVersion = parseVersion(readPackageJson().version, '本地版本'); - const remoteVersion = await resolveRemoteHighWaterVersion(channel); + const remoteVersion = await resolveRemoteHighWaterVersion(channel, target); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') @@ -401,24 +403,27 @@ export function buildTauriBuildArguments( } /** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ -export function createChannelConfig(channel = resolveReleaseChannel()) { +export function createChannelConfig( + channel = resolveReleaseChannel(), + target = defaultTarget(), +) { return { plugins: { updater: { - endpoints: [updateManifestUrl(channel)], + endpoints: [updateManifestUrl(channel, target)], }, }, }; } -function writeChannelConfigFile(channel) { +function writeChannelConfigFile(channel, target) { const configPath = path.join( os.tmpdir(), - `agc-tauri-channel-${channel}.json`, + `agc-tauri-channel-${channel}-${target}.json`, ); fs.writeFileSync( configPath, - `${JSON.stringify(createChannelConfig(channel), null, 2)}\n`, + `${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`, ); return configPath; } @@ -435,8 +440,8 @@ export function runTauriBuild( throw new Error('构建参数与发布上下文目标不一致'); } const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel } = context; - const configPath = writeChannelConfigFile(channel); + const { channel, target } = context; + const configPath = writeChannelConfigFile(channel, target); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); @@ -552,7 +557,7 @@ export function createUpdateManifest( artifactPath, { target = defaultTarget(), - channel = resolveReleaseChannel(process.env, target), + channel = resolveReleaseChannel(), publishedAt = new Date().toISOString(), notes = readReleaseNotes(), commit = readHeadCommit(), @@ -560,7 +565,7 @@ export function createUpdateManifest( } = {}, ) { validateReleaseTarget(target); - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); + const partition = resolveReleasePartition(channel, target); const signature = readUpdaterSignature(artifactPath); const version = readPackageJson().version; const firstInstallArtifact = selectFirstInstallArtifact( @@ -568,8 +573,8 @@ export function createUpdateManifest( { target, version, artifact: artifactPath }, ); const fileName = path.basename(artifactPath); - const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; - const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`; + const url = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; + const downloadUrl = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`; const platforms = {}; const downloads = {}; for (const key of resolveManifestPlatformKeys(target)) { @@ -702,14 +707,22 @@ export function formatRecentReleaseNotes(commits) { /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, - { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {}, + { + channel = resolveReleaseChannel(), + target = defaultTarget(), + notes = readReleaseNotes(), + } = {}, ) { + const partition = resolveReleasePartition(channel, target); + if (partition !== 'dev-win') { + throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统'); + } const bytes = fs.readFileSync(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); return { version, - downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, + downloadUrl: `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length, ...(notes ? { releaseNotes: notes } : {}), @@ -731,7 +744,9 @@ export async function generateUpdateManifest( artifact, }); const manualNotes = readReleaseNotes(); - const previousCommit = await resolvePreviousReleaseCommit(channel); + const previousCommit = await resolvePreviousReleaseCommit(channel, { + target, + }); const commits = collectReleaseCommits(previousCommit); const recentCommits = previousCommit ? null : collectRecentReleaseCommits(); const notes = @@ -757,8 +772,8 @@ export async function generateUpdateManifest( notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n', ); const legacyManifest = - channel === 'dev-win' - ? createLegacyUpdateManifest(artifact, { channel, notes }) + channel === 'dev' && resolveReleasePlatform(target) === 'windows' + ? createLegacyUpdateManifest(artifact, { channel, target, notes }) : null; const legacyManifestPath = legacyManifest ? path.join(bundleRoot, 'legacy-latest.json') @@ -789,6 +804,7 @@ export async function generateUpdateManifest( } return { channel, + target, artifact, downloadArtifact, manifest, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index cd0001741..83f96a0a0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -30,6 +30,7 @@ import { resolvePreviousReleaseCommit, resolveReleaseChannel, resolveReleaseContext, + resolveReleasePartition, resolveRemoteHighWaterVersion, runTauriBuild, selectFirstInstallArtifact, @@ -126,32 +127,60 @@ test('does not select unsupported files', () => { ); }); -test('resolves the channel from the target platform and rejects mismatches', () => { - assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win'); - assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac'); +test('channels are independent of platform and accept release and custom names', () => { + assert.equal(resolveReleaseChannel({}), 'dev'); + for (const channel of ['dev', 'release', 'beta-2', 'a'.repeat(32)]) { + assert.equal( + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), + channel, + ); + assert.equal( + resolveReleasePartition(channel, windowsTarget), + `${channel}-win`, + ); + assert.equal( + resolveReleasePartition(channel, 'aarch64-apple-darwin'), + `${channel}-mac`, + ); + } assert.equal( - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget), + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: ' release ' }), + 'release', + ); + for (const channel of [ + '', + ' ', + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', + 'dev-win', 'dev-mac', - ); - assert.throws( - () => - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget), - /只能用于 darwin 目标/u, - ); - assert.throws( - () => - resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget), - /未知发布渠道/u, - ); + 'Release', + '../dev', + 'a/b', + 'a_b', + '-beta', + 'beta-', + '1beta', + 'a'.repeat(33), + ]) { + assert.throws( + () => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), + /发布渠道无效/u, + ); + } }); test('channel manifest URL and build-time endpoint follow the channel', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { assert.equal( - updateManifestUrl('dev-win'), + updateManifestUrl('dev', windowsTarget), 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); - assert.deepEqual(createChannelConfig('dev-mac'), { + assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { plugins: { updater: { endpoints: [ @@ -160,6 +189,15 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }, }, }); + assert.equal( + updateManifestUrl('release', windowsTarget), + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/release-win/latest.json', + ); + assert.equal( + createChannelConfig('beta-2', 'x86_64-apple-darwin').plugins.updater + .endpoints[0], + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/beta-2-mac/latest.json', + ); }); }); @@ -185,7 +223,7 @@ test('release context resolves explicit targets before environment/default and f for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) { const context = resolveReleaseContext(args, env); assert.equal(context.target, 'aarch64-apple-darwin'); - assert.equal(context.channel, 'dev-mac'); + assert.equal(context.channel, 'dev'); assert.match( context.bundleRoot.replaceAll('\\', '/'), /target\/aarch64-apple-darwin\/release\/bundle$/, @@ -194,14 +232,14 @@ test('release context resolves explicit targets before environment/default and f } assert.throws( () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }), - /只能用于 windows/, + /发布渠道无效/, ); } assert.equal(resolveReleaseContext([], {}).target, windowsTarget); assert.equal( resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' }) .channel, - 'dev-mac', + 'dev', ); for (const args of [ ['--target'], @@ -231,7 +269,10 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and prepareVersion: async (context) => { seenContexts.push(context); assert.equal( - await resolveRemoteHighWaterVersion(context.channel), + await resolveRemoteHighWaterVersion( + context.channel, + context.target, + ), '0.1.67', ); }, @@ -406,7 +447,7 @@ test('manifest writer refuses to create latest when the current Mac DMG is missi } }); -test('invalid target or mismatched channel fails before any release side effect', async () => { +test('invalid target or platform used as channel fails before any release side effect', async () => { let touched = false; const sideEffects = { prepareVersion: () => { @@ -426,7 +467,7 @@ test('invalid target or mismatched channel fails before any release side effect' await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => assert.rejects( () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects), - /只能用于 windows/, + /发布渠道无效/, ), ); assert.equal(touched, false); @@ -440,7 +481,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme AGC_BUILD_TARGET: 'aarch64-apple-darwin', }), ]) { - assert.equal(context.channel, 'dev-win'); + assert.equal(context.channel, 'dev'); assert.equal( selectReleaseArtifact(files, context.target), '/tmp/windows.exe', @@ -484,14 +525,14 @@ test('no-bundle smoke skips version writes and manifest generation', async () => steps.push('manifest'); }, }); - assert.deepEqual(steps, ['dev-mac']); + assert.deepEqual(steps, ['dev']); }); test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { const manifest = createUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', target: windowsTarget, publishedAt: '2026-09-17T00:00:00.000Z', }); @@ -522,7 +563,7 @@ test('missing signature fails the channel manifest closed', () => { assert.throws( () => createUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', target: windowsTarget, }), /缺少更新包签名/u, @@ -535,7 +576,7 @@ test('missing signature fails the channel manifest closed', () => { test('legacy manifest keeps the sha256 contract of published clients', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { const legacy = createLegacyUpdateManifest(artifact, { - channel: 'dev-win', + channel: 'dev', }); assert.match(legacy.version, /^\d+\.\d+\.\d+$/u); assert.equal(legacy.sha256.length, 64); @@ -551,6 +592,86 @@ test('next release version follows the higher local or channel version', () => { assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); +for (const channel of ['release', 'beta-2']) { + for (const target of [windowsTarget, 'aarch64-apple-darwin']) { + test(`${channel} ${target} freezes its endpoint, version source and published objects`, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-manifest-')); + try { + const windows = target === windowsTarget; + const partition = `${channel}-${windows ? 'win' : 'mac'}`; + const artifact = path.join( + root, + windows ? '陶泥儿_x64-setup.exe' : '陶泥儿.app.tar.gz', + ); + writeFileSync(artifact, 'updater package'); + writeFileSync(`${artifact}.sig`, 'updater signature'); + if (!windows) createDmgFixture(root, target); + const context = { + ...resolveReleaseContext([`--target=${target}`], { + AGC_UPDATE_CHANNEL: channel, + }), + bundleRoot: root, + }; + const requests = []; + const result = await withStubbedFetch( + (url) => { + requests.push(url); + assert.ok(url.endsWith(`/agc/${partition}/latest.json`)); + return jsonResponse({ + version: '2.3.4', + commit: 'abcdef1234567890', + }); + }, + async () => { + assert.equal( + await resolveRemoteHighWaterVersion( + context.channel, + context.target, + ), + '2.3.4', + ); + runTauriBuild([`--target=${target}`], context, { + spawn: (_binary, command) => { + const config = JSON.parse( + readFileSync( + command[command.lastIndexOf('--config') + 1], + 'utf8', + ), + ); + assert.ok( + config.plugins.updater.endpoints[0].endsWith( + `/agc/${partition}/latest.json`, + ), + ); + return { status: 0 }; + }, + }); + return generateUpdateManifest(context); + }, + ); + assert.equal(result.channel, channel); + assert.equal(result.target, target); + assert.equal(result.manifest.version, packageVersion); + assert.equal(result.legacyManifestPath, null); + assert.equal(result.legacyManifest, null); + assert.equal(requests.length, 2); + for (const entry of [ + ...Object.values(result.manifest.platforms), + ...Object.values(result.manifest.downloads), + ]) { + assert.ok(entry.url.includes(`/agc/${partition}/${packageVersion}/`)); + } + assert.throws( + () => createLegacyUpdateManifest(artifact, { channel, target }), + /只属于 dev 渠道/u, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + } +} + test('version high water keeps the legacy pointer during the migration window', async () => { await withStubbedFetch( (url) => @@ -558,7 +679,10 @@ test('version high water keeps the legacy pointer during the migration window', ? jsonResponse({}, 404) : jsonResponse({ version: '0.1.57' }), async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); + assert.equal( + await resolveRemoteHighWaterVersion('dev', windowsTarget), + '0.1.57', + ); // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); }, @@ -572,7 +696,10 @@ test('version high water takes the higher of channel and legacy pointer', async ? jsonResponse({ version: '0.1.60' }) : jsonResponse({ version: '0.1.57' }), async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); + assert.equal( + await resolveRemoteHighWaterVersion('dev', windowsTarget), + '0.1.60', + ); }, ); }); @@ -587,7 +714,16 @@ test('version high water ignores the windows migration pointer for other channel return jsonResponse({ version: '0.1.12' }); }, async () => { - assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); + for (const [channel, target] of [ + ['dev', 'aarch64-apple-darwin'], + ['release', windowsTarget], + ['beta-2', windowsTarget], + ]) { + assert.equal( + await resolveRemoteHighWaterVersion(channel, target), + '0.1.12', + ); + } }, ); }); @@ -597,20 +733,20 @@ test('release notes anchor prefers the explicit commit and falls back to the man () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), async () => { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { + await resolvePreviousReleaseCommit('dev', { override: '6017d46088c04199e99cf89f347b12d67591475e', }), '6017d46088c04199e99cf89f347b12d67591475e', ); // 覆盖值非法时忽略,继续用清单里的 commit。 assert.equal( - await resolvePreviousReleaseCommit('dev-win', { + await resolvePreviousReleaseCommit('dev', { override: 'not-a-sha', }), 'abcdef1234567890', ); assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), + await resolvePreviousReleaseCommit('dev', { override: ' ' }), 'abcdef1234567890', ); }, @@ -620,7 +756,7 @@ test('release notes anchor prefers the explicit commit and falls back to the man () => jsonResponse({ version: '0.1.61' }), async () => { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + await resolvePreviousReleaseCommit('dev', { override: undefined }), null, ); }, @@ -634,7 +770,7 @@ test('release notes anchor degrades to null when the manifest cannot be read', a }; try { assert.equal( - await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + await resolvePreviousReleaseCommit('dev', { override: undefined }), null, ); } finally { diff --git a/apps/ai-game-creator-shell/scripts/release-oss.mjs b/apps/ai-game-creator-shell/scripts/release-oss.mjs index b136145b8..7f0caac10 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.mjs @@ -1,6 +1,8 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; +import { resolveReleasePartition } from './build-release.mjs'; + /** * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式, * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。 @@ -34,16 +36,28 @@ export function createReleaseUploadPlan( artifact, downloadArtifact, channel, + target, manifest, manifestPath, legacyManifestPath, }, bucket, ) { - if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) { - throw new Error('发布结果缺少更新包、首装包或清单'); + if ( + !artifact || + !downloadArtifact || + !manifestPath || + !manifest?.version || + !channel || + !target + ) { + throw new Error('发布结果缺少渠道、构建目标、更新包、首装包或清单'); } - const prefix = `oss://${bucket}/agc/${channel}`; + const partition = resolveReleasePartition(channel, target); + if (legacyManifestPath && partition !== 'dev-win') { + throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统'); + } + const prefix = `oss://${bucket}/agc/${partition}`; const artifacts = [ ...new Set( [artifact, `${artifact}.sig`, downloadArtifact].map((file) => diff --git a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs index 813ff5910..0f6a70f13 100644 --- a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs +++ b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs @@ -40,22 +40,24 @@ test('printed upload command keeps arguments and hides credentials', () => { ); }); -function withReleaseFixture(channel, architecture, run) { +function withReleaseFixture(channel, architecture, run, platform = 'macos') { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-')); try { const artifact = path.join( root, - channel === 'dev-win' + platform === 'windows' ? '陶泥儿_1.2.3_x64-setup.exe' : '陶泥儿.app.tar.gz', ); const downloadArtifact = - channel === 'dev-win' + platform === 'windows' ? artifact : path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`); const manifestPath = path.join(root, 'latest.json'); const legacyManifestPath = - channel === 'dev-win' ? path.join(root, 'legacy-latest.json') : null; + channel === 'dev' && platform === 'windows' + ? path.join(root, 'legacy-latest.json') + : null; for (const file of [ artifact, `${artifact}.sig`, @@ -69,6 +71,10 @@ function withReleaseFixture(channel, architecture, run) { artifact, downloadArtifact, channel, + target: + platform === 'windows' + ? 'x86_64-pc-windows-msvc' + : `${architecture === 'aarch64' ? 'aarch64' : 'x86_64'}-apple-darwin`, manifest: { version: '1.2.3' }, manifestPath, legacyManifestPath, @@ -86,7 +92,7 @@ const uploadOptions = { for (const architecture of ['aarch64', 'x64']) { test(`uploads every ${architecture} Mac object before the channel pointer`, () => { - withReleaseFixture('dev-mac', architecture, (release) => { + withReleaseFixture('dev', architecture, (release) => { const calls = []; uploadReleaseArtifacts(release, { ...uploadOptions, @@ -120,37 +126,42 @@ for (const architecture of ['aarch64', 'x64']) { } test('Windows uploads the shared installer once and publishes migration metadata last', () => { - withReleaseFixture('dev-win', 'x64', (release) => { - const plan = createReleaseUploadPlan(release, 'agc-dev'); - assert.deepEqual( - plan.map(({ source }) => source), - [ - release.artifact, - `${release.artifact}.sig`, - release.manifestPath, - release.legacyManifestPath, - ], - ); - assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json'); - const calls = []; - uploadReleaseArtifacts(release, { - ...uploadOptions, - spawn: (_binary, args) => { - assert.deepEqual(args.slice(0, 2), ['cp', '--force']); - calls.push(args[3]); - return { status: 0 }; - }, - }); - assert.deepEqual( - calls, - plan.map(({ destination }) => destination), - ); - }); + withReleaseFixture( + 'dev', + 'x64', + (release) => { + const plan = createReleaseUploadPlan(release, 'agc-dev'); + assert.deepEqual( + plan.map(({ source }) => source), + [ + release.artifact, + `${release.artifact}.sig`, + release.manifestPath, + release.legacyManifestPath, + ], + ); + assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json'); + const calls = []; + uploadReleaseArtifacts(release, { + ...uploadOptions, + spawn: (_binary, args) => { + assert.deepEqual(args.slice(0, 2), ['cp', '--force']); + calls.push(args[3]); + return { status: 0 }; + }, + }); + assert.deepEqual( + calls, + plan.map(({ destination }) => destination), + ); + }, + 'windows', + ); }); for (const failedArtifactIndex of [0, 1, 2]) { test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => { - withReleaseFixture('dev-mac', 'aarch64', (release) => { + withReleaseFixture('dev', 'aarch64', (release) => { const destinations = []; assert.throws( () => @@ -176,7 +187,7 @@ for (const failedArtifactIndex of [0, 1, 2]) { } test('dry run prints the complete plan without spawning uploads or exposing credentials', () => { - withReleaseFixture('dev-mac', 'aarch64', (release) => { + withReleaseFixture('dev', 'aarch64', (release) => { const output = []; uploadReleaseArtifacts(release, { ...uploadOptions, @@ -195,3 +206,32 @@ test('dry run prints the complete plan without spawning uploads or exposing cred assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u); }); }); + +for (const channel of ['release', 'beta-2']) { + for (const platform of ['windows', 'macos']) { + test(`${channel} ${platform} uploads only its own partition and cannot write the dev bridge`, () => { + withReleaseFixture( + channel, + 'x64', + (release) => { + const plan = createReleaseUploadPlan(release, 'agc-dev'); + const suffix = platform === 'windows' ? 'win' : 'mac'; + const prefix = `oss://agc-dev/agc/${channel}-${suffix}/`; + assert.ok( + plan.every(({ destination }) => destination.startsWith(prefix)), + ); + assert.equal(plan.at(-1).destination, `${prefix}latest.json`); + assert.throws( + () => + createReleaseUploadPlan( + { ...release, legacyManifestPath: release.manifestPath }, + 'agc-dev', + ), + /只属于 dev 渠道/u, + ); + }, + platform, + ); + }); + } +} 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 5b806ed6e..a850b381c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2538,6 +2538,7 @@ fn main() { create_automatic_local_game_project_from_template, init_local_game_project, fetch_game_template_library, + get_game_template_library_access, download_game_template, import_local_godot_project, import_local_cocos_project, diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 6b1e7b8b9..da12b43c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -5,6 +5,10 @@ //! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。 use super::*; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, + with_validated_platform_session_identity, PlatformSessionIdentity, PlatformSessionSnapshot, +}; use serde::{Deserialize, Serialize}; const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1"; @@ -24,6 +28,74 @@ const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096; const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; const TEMPLATE_ID_MAX_CHARS: usize = 64; const TEMPLATE_VERSION_MAX_CHARS: usize = 32; +const TEMPLATE_ACCESS_ERROR: &str = "template-library-unavailable: 模板库暂未向当前账号开放"; + +async fn template_library_access_for_session( + session: &PlatformSessionSnapshot, +) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?; + let response = client + .get(format!( + "{}/api/runtime/frontend-config", + session.api_base_url.trim_end_matches('/') + )) + .bearer_auth(&session.access_token) + .send() + .await + .map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?; + validate_platform_session_identity(&session.identity())?; + if !response.status().is_success() { + return Err(format!( + "template-library-unavailable: 检查模板库权限返回 HTTP {}", + response.status().as_u16() + )); + } + const MAX_BYTES: usize = 64 * 1024; + if response + .content_length() + .is_some_and(|length| length > MAX_BYTES as u64) + { + return Err("template-library-unavailable: 模板库权限响应无效".to_string()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?; + if bytes.len() + chunk.len() > MAX_BYTES { + return Err("template-library-unavailable: 模板库权限响应无效".to_string()); + } + bytes.extend_from_slice(&chunk); + } + validate_platform_session_identity(&session.identity())?; + let payload: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?; + Ok(payload + .get("agcTemplateLibraryEnabled") + .and_then(|value| value.as_bool()) + == Some(true)) +} + +async fn require_template_library_access() -> Result { + let session = current_platform_session().ok_or_else(|| TEMPLATE_ACCESS_ERROR.to_string())?; + if !template_library_access_for_session(&session).await? { + return Err(TEMPLATE_ACCESS_ERROR.to_string()); + } + Ok(session.identity()) +} + +#[tauri::command] +pub(crate) async fn get_game_template_library_access() -> Result { + let Some(session) = current_platform_session() else { + return Ok(false); + }; + template_library_access_for_session(&session).await +} /// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。 #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -719,7 +791,9 @@ async fn ensure_template_installed( cache_root: &Path, template_id: &str, template_version: &str, + identity: &PlatformSessionIdentity, ) -> Result { + validate_platform_session_identity(identity)?; let installed_directory = installed_template_dir(cache_root, template_id, template_version)?; if let Some(record) = read_installed_record(&installed_directory) { return Ok(record); @@ -728,13 +802,16 @@ async fn ensure_template_installed( let client = build_template_library_client(); let url = template_object_url(&summary.zip_key)?; let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?; - install_template_archive(cache_root, &summary, &bytes) + with_validated_platform_session_identity(identity, || { + install_template_archive(cache_root, &summary, &bytes) + }) } #[tauri::command] pub(crate) async fn fetch_game_template_library( app: tauri::AppHandle, ) -> Result { + let identity = require_template_library_access().await?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; let index_url = format!( @@ -749,7 +826,10 @@ pub(crate) async fn fetch_game_template_library( let body = String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; parse_game_template_library_index(&body)?; - write_cached_index(&cache_root, &body); + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; (body, "network") } Err(error) => match read_cached_index(&cache_root) { @@ -760,6 +840,7 @@ pub(crate) async fn fetch_game_template_library( None => return Err(error), }, }; + validate_platform_session_identity(&identity)?; let (header, templates) = parse_game_template_library_index(&body)?; let installed = collect_installed_records(&cache_root); let entries = templates @@ -789,10 +870,17 @@ pub(crate) async fn download_game_template( template_id: String, template_version: String, ) -> Result { + let identity = require_template_library_access().await?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = - ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, + ) + .await?; + validate_platform_session_identity(&identity)?; Ok(InstalledGameTemplate { template_id: record.template_id, template_version: record.template_version, @@ -882,23 +970,137 @@ pub(crate) async fn create_automatic_local_game_project_from_template( planning: Option, projects_root: Option, ) -> Result { + let identity = require_template_library_access().await?; let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = - ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; - create_project_from_installed_template_at( - &projects_root, - Path::new(&record.project_dir), - name.as_deref(), - planning.unwrap_or(false), + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, ) + .await?; + with_validated_platform_session_identity(&identity, || { + create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + name.as_deref(), + planning.unwrap_or(false), + ) + }) } #[cfg(test)] mod tests { use super::*; + fn access_server( + status: u16, + body: &str, + change_identity: bool, + ) -> (String, std::thread::JoinHandle) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let body = body.to_string(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let size = socket.read(&mut buffer).unwrap(); + assert!(size > 0); + request.extend_from_slice(&buffer[..size]); + } + if change_identity { + crate::platform_session::clear_platform_session(2, 2); + } + write!(socket, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + String::from_utf8(request).unwrap() + }); + (url, server) + } + + #[tokio::test] + async fn template_access_requires_current_account_and_explicit_server_grant() { + for (status, body, allowed) in [ + (200, r#"{"agcTemplateLibraryEnabled":true}"#, true), + (200, r#"{"agcTemplateLibraryEnabled":false}"#, false), + (200, r#"{"imageEditorAgentSidebarEnabled":true}"#, false), + (200, r#"{"agcTemplateLibraryEnabled":"true"}"#, false), + (503, r#"{"agcTemplateLibraryEnabled":true}"#, false), + (200, "invalid JSON", false), + ] { + let (origin, server) = access_server(status, body, false); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "template-test-token", + &origin, + ); + assert_eq!(require_template_library_access().await.is_ok(), allowed); + let request = server.join().unwrap().to_lowercase(); + assert!(request.starts_with("get /api/runtime/frontend-config ")); + assert!(request.contains("authorization: bearer template-test-token")); + } + let _session = crate::platform_session::clear_test_platform_session(); + assert!(!get_game_template_library_access().await.unwrap()); + assert!(require_template_library_access().await.is_err()); + } + + #[tokio::test] + async fn template_access_preserves_identity_during_token_rotation() { + let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, false); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "old-token", + &origin, + ); + let frozen = current_platform_session().unwrap(); + crate::platform_session::install_platform_session( + "template-user", + "new-token", + &origin, + 1, + 2, + ) + .unwrap(); + assert!(template_library_access_for_session(&frozen).await.unwrap()); + server.join().unwrap(); + } + + #[tokio::test] + async fn template_access_rejects_old_account_response_and_cached_install() { + let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, true); + let _session = crate::platform_session::install_test_platform_session( + "template-user", + "template-test-token", + &origin, + ); + let identity = current_platform_session().unwrap().identity(); + let error = require_template_library_access().await.unwrap_err(); + assert!(error.contains("authentication-required")); + server.join().unwrap(); + let error = ensure_template_installed( + Path::new("unused-cache"), + "demo-template", + "0.1.0", + &identity, + ) + .await + .unwrap_err(); + assert!(error.contains("authentication-required")); + assert!( + with_validated_platform_session_identity::<()>(&identity, || panic!( + "旧会话不得写入项目" + )) + .is_err() + ); + } + fn sample_index_body() -> String { serde_json::json!({ "schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION, 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 a58eb294e..ed96b3dda 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 @@ -94,10 +94,16 @@ export function WorkspaceLauncherShell({ rememberRecentWorkspace, }); const templateLibrary = useTemplateLibrary({ - onProjectCreated: async (result) => { - await homeProject.enterCreatedTemplateProject(result); + userId: currentUser.id, + onProjectCreated: async (result, isCurrent) => { + await homeProject.enterCreatedTemplateProject(result, isCurrent); }, }); + useEffect(() => { + if (!templateLibrary.enabled && launcherView === 'template-library') { + setLauncherView('home'); + } + }, [templateLibrary.enabled, launcherView]); const { projectPath, setProjectPath, @@ -560,6 +566,7 @@ export function WorkspaceLauncherShell({ > { resetLauncherHomeDraft(); @@ -606,6 +613,7 @@ export function WorkspaceLauncherShell({ }} onProjectPick={() => void homeProject.pickAndOpenProject()} templateRecommendations={templateLibrary.templates} + templateLibraryEnabled={templateLibrary.enabled} templateLibraryLoading={ templateLibrary.status === 'loading' || templateLibrary.status === 'idle' @@ -619,7 +627,7 @@ export function WorkspaceLauncherShell({ homeProject={homeProject} recentProjects={recentProjects} /> - ) : launcherView === 'template-library' ? ( + ) : launcherView === 'template-library' && templateLibrary.enabled ? ( setLauncherView('home')} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 6f501bf1b..6da9f8a7f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -303,7 +303,10 @@ export function useHomeProjectCreation({ } } - async function enterProjectDevelopment(context: LauncherProjectContext) { + async function enterProjectDevelopment( + context: LauncherProjectContext, + isCurrent: () => boolean = () => true, + ) { const entryToken = (projectEntryTokenRef.current += 1); /** * 会话预览只认"内存 registry 里真的还在跑"的那一个(见 @@ -316,7 +319,7 @@ export function useHomeProjectCreation({ projectPath: context.projectPath, recordedPreview: context.manifest.preview ?? null, }); - if (entryToken !== projectEntryTokenRef.current) { + if (entryToken !== projectEntryTokenRef.current || !isCurrent()) { // 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃, // 否则慢请求后到会把新项目覆盖回旧项目。 return; @@ -560,29 +563,37 @@ export function useHomeProjectCreation({ * 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘, * 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。 */ - async function enterCreatedTemplateProject(result: InitLocalProjectResult) { + async function enterCreatedTemplateProject( + result: InitLocalProjectResult, + isCurrent: () => boolean = () => true, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } - await enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - creationType: null, - startMode: null, - initialPrompt: '', - attachments: [], - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); + const projectRevision = await readCurrentProjectRevision( + invoke, + result.projectPath, + ); + if (!isCurrent()) return; + await enterProjectDevelopment( + { + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision, + creationType: null, + startMode: null, + initialPrompt: '', + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }, + isCurrent, + ); } async function openProject(nextProjectPath: string, mode: 'open' | 'create') { diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index bcad3c06b..f2f835f09 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -10,6 +10,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { InitLocalProjectResult } from '../../app/types'; +import { + currentPlatformSessionGeneration, + subscribePlatformSessionGeneration, +} from '../../services/platformSession'; import { readProjectCreationDirectory } from '../app-shell/model'; import { collectGameTemplateRuntimes, @@ -29,8 +33,12 @@ export type TemplateLibraryStatus = 'idle' | 'loading' | 'ready' | 'error'; export type TemplateLibraryBusyKind = 'download' | 'create'; type UseTemplateLibraryOptions = { + userId: string; /** 项目已建好:由调用方负责进入项目工作区(模板库不碰工作区状态)。 */ - onProjectCreated: (result: InitLocalProjectResult) => Promise | void; + onProjectCreated: ( + result: InitLocalProjectResult, + isCurrent: () => boolean, + ) => Promise | void; }; function errorMessage(error: unknown): string { @@ -38,8 +46,25 @@ function errorMessage(error: unknown): string { } export function useTemplateLibrary({ + userId, onProjectCreated, }: UseTemplateLibraryOptions) { + const scopeRef = useRef({ + userId, + generation: 0, + authorityGeneration: currentPlatformSessionGeneration(), + allowed: false, + }); + if (scopeRef.current.userId !== userId) { + scopeRef.current = { + userId, + generation: 0, + authorityGeneration: currentPlatformSessionGeneration(), + allowed: false, + }; + } + const [access, setAccess] = useState({ userId: '', enabled: false }); + const enabled = access.userId === userId && access.enabled; const [snapshot, setSnapshot] = useState( null, ); @@ -53,88 +78,171 @@ export function useTemplateLibrary({ const [busyKind, setBusyKind] = useState( null, ); - const loadingRef = useRef(false); + const refreshSequence = useRef(0); + + const revokeAccess = useCallback(() => { + scopeRef.current.generation += 1; + scopeRef.current.allowed = false; + setAccess({ userId: scopeRef.current.userId, enabled: false }); + setSnapshot(null); + setStatus('idle'); + setError(''); + setNotice(''); + setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS); + setBusyTemplateId(null); + setBusyKind(null); + }, []); + + const handleOperationError = useCallback( + (nextError: unknown) => { + const message = errorMessage(nextError); + if ( + message.includes('template-library-unavailable:') || + message.includes('authentication-required:') + ) { + revokeAccess(); + } else { + setError(message); + } + }, + [revokeAccess], + ); const refresh = useCallback(async () => { - if (loadingRef.current) { + const scope = scopeRef.current; + if (scope.authorityGeneration !== currentPlatformSessionGeneration()) { + revokeAccess(); return; } + const generation = scope.generation; + const sequence = ++refreshSequence.current; + const isCurrent = () => + scopeRef.current === scope && + scope.authorityGeneration === currentPlatformSessionGeneration() && + scope.generation === generation && + refreshSequence.current === sequence; const invoke = resolveTauriInvoke(); if (!invoke) { - setStatus('error'); - setError('需要在陶泥儿客户端内运行'); + revokeAccess(); return; } - loadingRef.current = true; + let allowed: boolean; + try { + allowed = await invoke('get_game_template_library_access'); + } catch { + if (isCurrent()) revokeAccess(); + return; + } + if (!isCurrent()) return; + if (allowed !== true) { + revokeAccess(); + return; + } + scope.allowed = true; + setAccess({ userId: scope.userId, enabled: true }); setStatus('loading'); setError(''); try { const next = await invoke( 'fetch_game_template_library', ); + if (!isCurrent()) return; setSnapshot(next); setStatus('ready'); setNotice( next.source === 'cache' ? '远端清单暂时读不到,当前展示本机缓存' : '', ); } catch (nextError) { + if (!isCurrent()) return; setStatus('error'); - setError(errorMessage(nextError)); - } finally { - loadingRef.current = false; + handleOperationError(nextError); } - }, []); + }, [handleOperationError, revokeAccess]); useEffect(() => { + revokeAccess(); void refresh(); - }, [refresh]); + const onFocus = () => void refresh(); + const unsubscribe = subscribePlatformSessionGeneration((generation) => { + if (scopeRef.current.authorityGeneration !== generation) revokeAccess(); + }); + window.addEventListener('focus', onFocus); + return () => { + scopeRef.current.generation += 1; + scopeRef.current.allowed = false; + window.removeEventListener('focus', onFocus); + unsubscribe(); + }; + }, [userId, refresh, revokeAccess]); - const downloadTemplate = useCallback(async (template: GameTemplateEntry) => { - const invoke = resolveTauriInvoke(); - if (!invoke) { - throw new Error('需要在陶泥儿客户端内运行'); - } - setBusyTemplateId(template.id); - setBusyKind('download'); - setError(''); - try { - const installed = await invoke( - 'download_game_template', - { - templateId: template.id, - templateVersion: template.templateVersion, - }, - ); - setSnapshot((current) => - current - ? { - ...current, - templates: current.templates.map((entry) => - entry.id === template.id - ? { - ...entry, - installed: true, - installedVersion: installed.templateVersion, - installedAtMillis: installed.installedAtMillis, - } - : entry, - ), - } - : current, - ); - setNotice(`已下载模板「${template.title}」`); - return installed; - } catch (nextError) { - setError(errorMessage(nextError)); - throw nextError; - } finally { - setBusyTemplateId(null); - setBusyKind(null); - } - }, []); + const downloadTemplate = useCallback( + async (template: GameTemplateEntry) => { + const scope = scopeRef.current; + const generation = scope.generation; + const isCurrent = () => + scopeRef.current === scope && + scope.authorityGeneration === currentPlatformSessionGeneration() && + scope.generation === generation && + scope.allowed; + if (!isCurrent()) throw new Error('模板库暂未向当前账号开放'); + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + setBusyTemplateId(template.id); + setBusyKind('download'); + setError(''); + try { + const installed = await invoke( + 'download_game_template', + { + templateId: template.id, + templateVersion: template.templateVersion, + }, + ); + if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); + setSnapshot((current) => + current + ? { + ...current, + templates: current.templates.map((entry) => + entry.id === template.id + ? { + ...entry, + installed: true, + installedVersion: installed.templateVersion, + installedAtMillis: installed.installedAtMillis, + } + : entry, + ), + } + : current, + ); + setNotice(`已下载模板「${template.title}」`); + return installed; + } catch (nextError) { + if (isCurrent()) handleOperationError(nextError); + throw nextError; + } finally { + if (isCurrent()) { + setBusyTemplateId(null); + setBusyKind(null); + } + } + }, + [handleOperationError], + ); const createProjectFromTemplate = useCallback( async (template: GameTemplateEntry) => { + const scope = scopeRef.current; + const generation = scope.generation; + const isCurrent = () => + scopeRef.current === scope && + scope.authorityGeneration === currentPlatformSessionGeneration() && + scope.generation === generation && + scope.allowed; + if (!isCurrent()) throw new Error('模板库暂未向当前账号开放'); const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); @@ -143,6 +251,7 @@ export function useTemplateLibrary({ if (needsTemplateDownload(template)) { await downloadTemplate(template); } + if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); setBusyTemplateId(template.id); setBusyKind('create'); setError(''); @@ -158,23 +267,26 @@ export function useTemplateLibrary({ projectsRoot: readProjectCreationDirectory() || null, }, ); - await onProjectCreated(result); - setNotice(`已用模板「${template.title}」创建项目`); + if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); + await onProjectCreated(result, isCurrent); + if (isCurrent()) setNotice(`已用模板「${template.title}」创建项目`); return result; } catch (nextError) { - setError(errorMessage(nextError)); + if (isCurrent()) handleOperationError(nextError); throw nextError; } finally { - setBusyTemplateId(null); - setBusyKind(null); + if (isCurrent()) { + setBusyTemplateId(null); + setBusyKind(null); + } } }, - [downloadTemplate, onProjectCreated], + [downloadTemplate, onProjectCreated, handleOperationError], ); const templates = useMemo( - () => snapshot?.templates ?? [], - [snapshot?.templates], + () => (enabled ? (snapshot?.templates ?? []) : []), + [enabled, snapshot?.templates], ); const visibleTemplates = useMemo( () => filterGameTemplates(templates, filters), @@ -218,6 +330,7 @@ export function useTemplateLibrary({ }, []); return { + enabled, snapshot, status, error, diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index ed160d88f..f81db0cc9 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -116,6 +116,7 @@ type HomeViewProps = { onProjectPick: () => void; /** 模板库推荐位:清单来自 Rust 侧模板库,首页只负责展示与跳转。 */ templateRecommendations: readonly GameTemplateEntry[]; + templateLibraryEnabled: boolean; templateLibraryLoading: boolean; templateLibraryError: string; onTemplateLibraryOpen: () => void; @@ -133,6 +134,7 @@ export default function HomeView({ onProjectOpen, onProjectPick, templateRecommendations, + templateLibraryEnabled, templateLibraryLoading, templateLibraryError, onTemplateLibraryOpen, @@ -429,37 +431,39 @@ export default function HomeView({ )} -
-
- -

- 模板库 -

-
- -
- -
+ {templateLibraryEnabled ? ( +
+
+ +

+ 模板库 +

+
+ +
+ +
+ ) : null} ); } diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 4464661ce..013e12e0c 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -42,6 +42,7 @@ type SidebarUserInfo = { type LauncherSidebarProps = { activeView: LauncherView; + templateLibraryEnabled: boolean; currentUser: SidebarUserInfo; onViewChange: (view: LauncherView) => void; onNoticeRequest: (title: string) => void; @@ -193,6 +194,7 @@ function SidebarAccountMenu({ export function Sidebar({ activeView, + templateLibraryEnabled, currentUser, onViewChange, onNoticeRequest, @@ -286,19 +288,21 @@ export function Sidebar({ >