diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8a4b2d0c6..6368883c8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -213,6 +213,8 @@ module.exports = { '!src/services/clipboard.test.ts', '!src/services/frontendRuntimeConfigService.ts', '!src/services/frontendRuntimeConfigService.test.ts', + '!src/services/gameDistributionClient.ts', + '!src/services/gameDistributionClient.test.ts', '!src/services/sseStream.ts', '!src/services/sseStream.test.ts', 'src/AdventurePanel.tsx', diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 52394b9a1..b84b9f901 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -82,12 +82,29 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + shell: bash + run: | + set -euo pipefail + node --test scripts/ci-rust-cache.test.mjs + bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell Rust shard 1/4 run: npm run check:native-shells:agc-rust-shard-1 - name: Run AI game creator shell Rust shard 2/4 run: npm run check:native-shells:agc-rust-shard-2 + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + ai-game-creator-shell-rust-lane-2: name: AI game creator shell Rust lane 2/2 runs-on: genarrative-ci @@ -118,12 +135,25 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell Rust shard 3/4 run: npm run check:native-shells:agc-rust-shard-3 - name: Run AI game creator shell Rust shard 4/4 run: npm run check:native-shells:agc-rust-shard-4 + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 ai-game-creator-shell-rust-smoke: @@ -156,9 +186,22 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell agent-run smoke run: npm run check:native-shells:agc-rust-smoke + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + # AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。 ai-game-creator-shell-rust-crates: name: AI game creator shell Rust crates @@ -257,9 +300,22 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell shared crate gates run: npm run check:native-shells:agc-rust-crates + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + backend-tests: name: Backend tests runs-on: genarrative-ci @@ -330,6 +386,9 @@ jobs: - name: Check server-rs boundaries run: npm run check:server-rs-ddd + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run server-rs workspace tests run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml @@ -342,6 +401,16 @@ jobs: - name: Check SpacetimeDB module run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + # 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁, # 以及依赖发布产物的构建 smoke。 native-shell-tests: @@ -384,15 +453,32 @@ jobs: - name: Run native shell contract gates run: npm run check:native-shells:contract + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run native shell gates run: npm run check:native-shells:shells - name: Run native shell release build smoke + # 发布构建有独立 profile/features 和资源 staging,不消费测试对象快照。 + env: + RUSTC_WRAPPER: '' + CARGO_BUILD_RUSTC_WRAPPER: '' run: npm run check:native-shells:release - name: Ensure native lockfiles are unchanged run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + - name: Publish master Rust cache artifact + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} + env: + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: python3 scripts/export-gitea-rust-cache.py + frontend-tests: name: Frontend tests runs-on: genarrative-ci @@ -410,7 +496,7 @@ jobs: run: bash scripts/ci-npm-ci-with-retry.sh - name: Run frontend and script tests - run: npm run test + run: npm run test:ci:frontend - name: Run BgFilter worker smoke harness tests run: npm run bgfilter-worker:smoke-test @@ -474,6 +560,9 @@ jobs: - name: Install npm dependencies run: bash scripts/ci-npm-ci-with-retry.sh + - name: Validate CI cache maintenance behavior + run: python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py' + - name: Run repository checks run: npm run check:repository-ci diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 16b39ca89..a5c27f641 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -7,9 +7,12 @@ import { getAdminFeatureGateConfig, getAdminUserDetail, importAdminAgcTemplates, + listAdminGameDistributionReviews, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, updateAdminAccount, updateAdminAgcTemplate, uploadAdminEditorShowcaseCampaignImage, @@ -454,3 +457,134 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => { }), ); }); + +test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ entries: [], nextCursor: null }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await listAdminGameDistributionReviews('admin-token'); + await reviewAdminGameDistributionVersion( + 'admin-token', + 'gamever/1', + 'game-review-key-1', + { + decision: 'approve', + expectedPublicationRevision: 3, + entryUrl: 'https://games.example.test/releases/game_1/index.html', + }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/reviews?limit=48', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + '/admin/api/game-distribution/versions/gamever%2F1/review', + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-review-key-1', + }), + body: JSON.stringify({ + decision: 'approve', + expectedPublicationRevision: 3, + entryUrl: 'https://games.example.test/releases/game_1/index.html', + }), + }), + ); +}); + +test('安全下架请求携带公开修订号、原因与幂等键', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ game: {}, replayed: false }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await suspendAdminGameDistributionGame( + 'admin-token', + 'game/1', + 'game-suspend-key-1', + { expectedPublicationRevision: 7, reason: '版权投诉' }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games/game%2F1/suspend', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-suspend-key-1', + }), + body: JSON.stringify({ + expectedPublicationRevision: 7, + reason: '版权投诉', + }), + }), + ); + + expect(() => + suspendAdminGameDistributionGame('admin-token', ' ', 'key', { + expectedPublicationRevision: 1, + }), + ).toThrow('缺少游戏 ID'); + expect(() => + suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', { + expectedPublicationRevision: 1, + }), + ).toThrow('下架幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); + +test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ version: {}, replayed: false }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await reviewAdminGameDistributionVersion( + 'admin-token', + 'version-1', + 'game-review-key-2', + { + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: '运行时报错', + }, + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + body: JSON.stringify({ + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: '运行时报错', + }), + }), + ); + + expect(() => + reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', { + decision: 'reject', + expectedPublicationRevision: 0, + reviewReason: 'x', + }), + ).toThrow('审核幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index d85e5a8c8..4688975e0 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -30,6 +30,9 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminGameDistributionReviewListResponse, + AdminGameDistributionReviewRequest, + AdminGameDistributionReviewResponse, AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, @@ -1200,6 +1203,75 @@ export function saveAgcModelCatalog( ); } +export function listAdminGameDistributionReviews(token: string, limit = 48) { + const normalizedLimit = Number.isFinite(limit) + ? Math.min(Math.max(Math.trunc(limit), 1), 48) + : 48; + return request( + `/admin/api/game-distribution/reviews?limit=${normalizedLimit}`, + { token }, + ); +} + +/** + * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 + * 两条审核结论。 + */ +/** + * 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。 + */ +export function suspendAdminGameDistributionGame( + token: string, + gameId: string, + idempotencyKey: string, + payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest, +) { + const normalizedGameId = gameId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedGameId) { + throw new Error('缺少游戏 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('下架幂等键必须是 1 到 128 个字符'); + } + return request< + import('./adminApiTypes').AdminGameDistributionSuspendResponse + >( + `/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + +export function reviewAdminGameDistributionVersion( + token: string, + versionId: string, + idempotencyKey: string, + payload: AdminGameDistributionReviewRequest, +) { + const normalizedVersionId = versionId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedVersionId) { + throw new Error('缺少发行版本 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('审核幂等键必须是 1 到 128 个字符'); + } + return request( + `/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + export function getAdminAgcTemplates(token: string, signal?: AbortSignal) { return request('/admin/api/agc-templates', { token, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 44b833586..161ef7320 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1043,6 +1043,51 @@ export interface AdminAgcModelCatalog { models: AdminAgcModel[]; } +export interface AdminGameDistributionReviewEntry { + versionId: string; + gameId: string; + versionNumber: number; + packageSha256: string; + packageBytes: number; + status: string; + publicationRevision: number; + reviewReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AdminGameDistributionReviewListResponse { + entries: AdminGameDistributionReviewEntry[]; + nextCursor: string | null; +} + +export interface AdminGameDistributionReviewRequest { + decision: 'approve' | 'reject'; + expectedPublicationRevision: number; + reviewReason?: string; + entryUrl?: string; +} + +export interface AdminGameDistributionReviewResponse { + version: AdminGameDistributionReviewEntry; + replayed: boolean; +} + +export interface AdminGameDistributionSuspendRequest { + expectedPublicationRevision: number; + reason?: string; +} + +export interface AdminGameDistributionSuspendResponse { + game: { + id: string; + title: string; + status: string; + publicationRevision: number; + }; + replayed: boolean; +} + export interface AdminAgcTemplatePayload { id: string; title: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index f09af530e..e91c6ca8a 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -27,6 +27,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage'; import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage'; import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; +import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -307,6 +308,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'game-distribution' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( { + expect(adminRoutes).toContainEqual({ + id: 'game-distribution', + label: '游戏审核', + hash: '#game-distribution', + }); + expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution'); + expect(routeHash('game-distribution')).toBe('#game-distribution'); +}); + +test('member 可单独获得游戏审核 Tab 权限', () => { + const routes = getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['game-distribution'], + }); + expect(routes.map((route) => route.id)).toEqual(['game-distribution']); + expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe( + 'game-distribution', + ); +}); + test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => { expect(adminRoutes).toContainEqual({ id: 'agc-templates', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 138ac070c..11764bbe9 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -15,6 +15,7 @@ export type AdminRouteId = | 'recharge-orders' | 'editor-generation-pricing' | 'editor-showcase' + | 'game-distribution' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -56,6 +57,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'agc-templates', label: '模板管理', hash: '#agc-templates' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, + { id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' }, { 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/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx new file mode 100644 index 000000000..ae00212d9 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -0,0 +1,186 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { + listAdminGameDistributionReviews, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import { + AdminGameDistributionReviewPage, + resolveGameReleaseEntryUrlError, +} from './AdminGameDistributionReviewPage'; + +vi.mock('../api/adminApiClient', () => ({ + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + listAdminGameDistributionReviews: vi.fn(), + reviewAdminGameDistributionVersion: vi.fn(), + suspendAdminGameDistributionGame: vi.fn(), +})); + +const entry: AdminGameDistributionReviewEntry = { + versionId: 'version-1', + gameId: 'game_1', + versionNumber: 2, + packageSha256: 'a'.repeat(64), + packageBytes: 2048, + status: 'pending_review', + publicationRevision: 4, + reviewReason: null, + createdAt: '2026-09-20T08:00:00Z', + updatedAt: '2026-09-20T08:00:00Z', +}; + +beforeEach(() => { + vi.mocked(listAdminGameDistributionReviews).mockReset(); + vi.mocked(reviewAdminGameDistributionVersion).mockReset(); + vi.mocked(suspendAdminGameDistributionGame).mockReset(); + vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ + entries: [entry], + nextCursor: null, + }); +}); + +test('发行入口必须是带完整来源的 HTTPS 地址', () => { + expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口'); + expect( + resolveGameReleaseEntryUrlError('http://games.test/a/index.html'), + ).toBe('发行入口必须以 https:// 开头'); + expect( + resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'), + ).toBe('发行入口不能包含 query 或 fragment'); + expect( + resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'), + ).toBe('发行入口不能包含凭据'); + expect( + resolveGameReleaseEntryUrlError('https://games.test/a/index.html'), + ).toBe(''); +}); + +test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => { + vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ + version: { ...entry, status: 'published' }, + replayed: false, + }); + + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.change(screen.getByLabelText('发行入口'), { + target: { value: 'https://games.test/releases/game_1/index.html' }, + }); + fireEvent.click(screen.getByRole('button', { name: '通过' })); + + await waitFor(() => + expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1), + ); + const [token, versionId, idempotencyKey, payload] = + vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(versionId).toBe('version-1'); + expect(String(idempotencyKey)).toContain('version-1'); + expect(payload).toEqual({ + decision: 'approve', + expectedPublicationRevision: 4, + entryUrl: 'https://games.test/releases/game_1/index.html', + }); + await waitFor(() => + expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes( + 2, + ), + ); +}); + +test('缺少拒绝理由时不调用审核接口', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + + expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy(); + expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); +}); + +test('安全下架需要二次确认,并携带公开修订号与原因', async () => { + vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ + game: { + id: 'game_1', + title: '测试游戏', + status: 'suspended', + publicationRevision: 5, + }, + replayed: false, + }); + + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.change(screen.getByLabelText('下架原因'), { + target: { value: '盗用素材' }, + }); + fireEvent.click(screen.getByRole('button', { name: '安全下架' })); + + // 第一次点击只弹出确认面板,不直接调用后端。 + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); + expect(await screen.findByRole('dialog')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), + ); + const [token, gameId, idempotencyKey, payload] = + vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(gameId).toBe('game_1'); + expect(String(idempotencyKey)).toContain('game_1'); + expect(payload).toEqual({ + expectedPublicationRevision: 4, + reason: '盗用素材', + }); + expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); +}); + +test('取消确认时不下架', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '安全下架' })); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx new file mode 100644 index 000000000..b1e993ed7 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -0,0 +1,365 @@ +import { RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { + listAdminGameDistributionReviews, + reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface AdminGameDistributionReviewPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +function formatBytes(value: number) { + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)} MiB`; + } + if (value >= 1024) { + return `${(value / 1024).toFixed(1)} KiB`; + } + return `${value} B`; +} + +function formatTime(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function createSuspendIdempotencyKey(gameId: string) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `game-suspend-${gameId}-${random}`.slice(0, 128); +} + +function createReviewIdempotencyKey(versionId: string) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `game-review-${versionId}-${random}`.slice(0, 128); +} + +export function resolveGameReleaseEntryUrlError(value: string) { + const normalized = value.trim(); + if (!normalized) return '请填写发行入口'; + if (!normalized.startsWith('https://')) { + return '发行入口必须以 https:// 开头'; + } + if (normalized.includes('?') || normalized.includes('#')) { + return '发行入口不能包含 query 或 fragment'; + } + try { + const parsed = new URL(normalized); + if (parsed.username || parsed.password) { + return '发行入口不能包含凭据'; + } + } catch { + return '发行入口不是合法 URL'; + } + return ''; +} + +export function AdminGameDistributionReviewPage({ + token, + onUnauthorized, +}: AdminGameDistributionReviewPageProps) { + const [entries, setEntries] = useState( + [], + ); + const [isLoading, setIsLoading] = useState(false); + const [busyVersionId, setBusyVersionId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [entryUrlByVersion, setEntryUrlByVersion] = useState< + Record + >({}); + const [reasonByVersion, setReasonByVersion] = useState< + Record + >({}); + const [suspendReasonByGame, setSuspendReasonByGame] = useState< + Record + >({}); + const [busyGameId, setBusyGameId] = useState(''); + const writeConfirm = useAdminWriteConfirm(); + + const loadReviews = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminGameDistributionReviews(token); + setEntries(response.entries); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, [token, onUnauthorized]); + + useEffect(() => { + void loadReviews(); + }, [loadReviews]); + + async function submitReview( + entry: AdminGameDistributionReviewEntry, + decision: 'approve' | 'reject', + ) { + const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim(); + const reason = (reasonByVersion[entry.versionId] ?? '').trim(); + if (decision === 'approve') { + const invalid = resolveGameReleaseEntryUrlError(entryUrl); + if (invalid) { + setErrorMessage(invalid); + return; + } + } else if (!reason) { + setErrorMessage('拒绝审核必须填写理由'); + return; + } + setBusyVersionId(entry.versionId); + setErrorMessage(''); + setStatusMessage(''); + try { + await reviewAdminGameDistributionVersion( + token, + entry.versionId, + createReviewIdempotencyKey(entry.versionId), + decision === 'approve' + ? { + decision, + expectedPublicationRevision: entry.publicationRevision, + entryUrl, + } + : { + decision, + expectedPublicationRevision: entry.publicationRevision, + reviewReason: reason, + }, + ); + setStatusMessage( + decision === 'approve' + ? `版本 v${entry.versionNumber} 已通过审核` + : `版本 v${entry.versionNumber} 已拒绝`, + ); + await loadReviews(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyVersionId(''); + } + } + + /** + * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 + * 由服务端返回冲突,前端只提示刷新,不静默重试。 + */ + async function suspendGame(entry: AdminGameDistributionReviewEntry) { + const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + const confirmed = await writeConfirm.confirmWrite({ + action: '安全下架游戏', + target: `${entry.gameId}(版本 v${entry.versionNumber})`, + }); + if (!confirmed) return; + setBusyGameId(entry.gameId); + setErrorMessage(''); + setStatusMessage(''); + try { + await suspendAdminGameDistributionGame( + token, + entry.gameId, + createSuspendIdempotencyKey(entry.gameId), + { + expectedPublicationRevision: entry.publicationRevision, + ...(reason ? { reason } : {}), + }, + ); + setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); + setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' })); + await loadReviews(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyGameId(''); + } + } + + return ( +
+
+

游戏审核

+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+

待审版本

+ 共 {entries.length} 条 +
+ + {isLoading ? ( +

正在加载待审版本…

+ ) : null} + + {!isLoading && entries.length === 0 ? ( +

当前没有待审核的游戏版本。

+ ) : null} + + {!isLoading && entries.length > 0 ? ( +
+ + + + + + + + + + + + {entries.map((entry) => { + const busy = busyVersionId === entry.versionId; + return ( + + + + + + + + ); + })} + +
游戏版本发行包提交时间审核
+ {entry.gameId} + + v{entry.versionNumber} +
{entry.status}
+ {entry.reviewReason ? ( +
+ {entry.reviewReason} +
+ ) : null} +
+ {formatBytes(entry.packageBytes)} +
+ {entry.packageSha256.slice(0, 12)} +
+
{formatTime(entry.createdAt)} +
+
+ + + setEntryUrlByVersion((current) => ({ + ...current, + [entry.versionId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+ + + setReasonByVersion((current) => ({ + ...current, + [entry.versionId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+ + + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: event.target.value, + })) + } + disabled={busy} + /> +
+ +
+
+
+ ) : null} +
+ {writeConfirm.confirmDialog} +
+ ); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index eaba4e582..fb1a1d91b 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -200,15 +200,16 @@ const allowedUncalledTauriCommands = [ 'read_agc_plugin_panel', 'set_agc_plugin_enabled', // 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口; - // 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 / + // 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / // 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现, // `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按 // native-only 登记,避免孤儿检查一直报错。 // 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。 + // 导出试玩包同样不在本清单:发布链路(`requestGamePublish`)已把它接回 + // DirectProject 聊天头的「发布」入口。 'build_local_project_index', 'control_agent_run', 'create_local_project_checkpoint', - 'export_local_project_package', 'generate_local_game_draft', 'generate_platform_art_asset', 'import_canvas_asset', diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs index d79c9f8a7..f52c82836 100644 --- a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs @@ -88,15 +88,21 @@ export function resolveNsisCacheDir( env = process.env, platform = process.platform, ) { + const pathImpl = platform === 'win32' ? path.win32 : path.posix; const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim(); - if (explicit) return path.resolve(explicit); + if (explicit) return pathImpl.resolve(explicit); // Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响; // 缓存里只有待解压的原始归档,不会从该目录执行任何程序。 if (platform === 'win32') { const programData = env.ProgramData?.trim() || 'C:\\ProgramData'; - return path.join(programData, 'genarrative', 'tauri-nsis-cache'); + return pathImpl.join(programData, 'genarrative', 'tauri-nsis-cache'); } - return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache'); + return pathImpl.join( + os.homedir(), + '.cache', + 'genarrative', + 'tauri-nsis-cache', + ); } /** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */ diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs index 26faca27e..d5ce2fbf0 100644 --- a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs @@ -7,6 +7,7 @@ import { test } from 'node:test'; import JSZip from 'jszip'; import { + defaultAppRoot, ensureNsisToolset, extractNsisArchive, NSIS_ARCHIVE_ASSET_NAME, @@ -23,10 +24,7 @@ import { verifyNsisToolset, } from './nsis-toolset.mjs'; -const appRoot = path.resolve( - path.dirname(new URL(import.meta.url).pathname), - '..', -); +const appRoot = defaultAppRoot(); const silentLogger = { log() {}, warn() {} }; function createSandbox() { @@ -98,6 +96,14 @@ test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => }); test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => { + assert.equal( + resolveNsisCacheDir({ AGC_TAURI_NSIS_CACHE_DIR: 'D:\\agc-cache' }, 'win32'), + 'D:\\agc-cache', + ); + assert.equal( + resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), + path.win32.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), + ); assert.equal( resolveNsisCacheDir( { AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' }, @@ -105,13 +111,9 @@ test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => ), '/tmp/agc-cache', ); - assert.equal( - resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), - path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), - ); assert.ok( resolveNsisCacheDir({}, 'linux').endsWith( - path.join('.cache', 'genarrative', 'tauri-nsis-cache'), + path.posix.join('.cache', 'genarrative', 'tauri-nsis-cache'), ), ); }); diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs index 86c70601a..ecd2a50b3 100644 --- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -150,6 +150,7 @@ function formatDuration(milliseconds) { // 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo, // 免得 N 个 cargo 去争 package cache 与 target 目录锁。 function resolveTestExecutable() { + const startedAt = Date.now(); return new Promise((resolve, reject) => { const cargoArguments = buildCargoArguments({ kind: options.targetKind, @@ -194,6 +195,9 @@ function resolveTestExecutable() { reject(new Error(`unable to start cargo: ${error.message}`)); }); child.on('close', (code) => { + console.log( + `[rust-shards] compile duration=${formatDuration(Date.now() - startedAt)} exit=${code}`, + ); if (code !== 0) { reject( new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index b62ed9e70..753d89477 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -16,7 +16,8 @@ { "url": "https://www.genarrative.world/api/*" }, { "url": "https://*/api/*" }, { "url": "http://localhost:*/*" }, - { "url": "http://127.0.0.1:*/*" } + { "url": "http://127.0.0.1:*/*" }, + { "url": "https://*.aliyuncs.com/*" } ] }, "opener:default", diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fa4163a4d..96468794f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5985,6 +5985,16 @@ pub(crate) fn export_local_project_package( export_local_project_package_at(root) } +#[tauri::command] +pub(crate) fn read_local_project_export_package( + project_path: String, + package_relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + read_local_project_export_package_at(root, package_relative_path.trim()) +} + #[tauri::command] pub(crate) fn list_local_project_export_packages( project_path: String, 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 151a18cfe..5f3cfe2e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2717,6 +2717,7 @@ fn main() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, + read_local_project_export_package, list_local_project_export_packages, diff_local_project_checkpoint, restore_local_project_checkpoint, diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 3a890ead3..d963ea811 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -2,6 +2,11 @@ use super::*; #[cfg(target_os = "linux")] use std::process::Stdio; +#[cfg(target_os = "linux")] +mod owner_fixture_cleanup; +#[cfg(target_os = "linux")] +use owner_fixture_cleanup::{project_processes, OwnerFixtureCleanup}; + static PROCESS_SESSION_TEST_LOCK: OnceLock> = OnceLock::new(); fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> { @@ -1743,20 +1748,6 @@ fn process_session_runner_owner_fixture() { #[cfg(target_os = "linux")] #[test] fn process_session_owner_sigkill_leaves_no_child_process() { - fn project_processes(root: &Path) -> Vec { - let canonical_root = fs::canonicalize(root).expect("canonical test project"); - fs::read_dir("/proc") - .into_iter() - .flatten() - .flatten() - .filter_map(|entry| { - let process_id = entry.file_name().to_string_lossy().parse::().ok()?; - let cwd = fs::read_link(entry.path().join("cwd")).ok()?; - (cwd == canonical_root).then_some(process_id) - }) - .collect() - } - let directory = tempfile::tempdir().expect("temp project"); let root = directory.path(); init_local_game_project_at(root, "owner-process-project", "Owner Process Project") @@ -1787,6 +1778,10 @@ setInterval(() => {}, 1000); .stderr(Stdio::null()) .spawn() .expect("spawn owner fixture test process"); + let cleanup = OwnerFixtureCleanup { + owner: &mut owner, + root, + }; let deadline = std::time::Instant::now() + Duration::from_secs(10); while (!root.join("owner-ready").is_file() || project_processes(root).is_empty()) && std::time::Instant::now() < deadline @@ -1799,9 +1794,9 @@ setInterval(() => {}, 1000); "sandbox child should be visible from host /proc" ); - let owner_pid = i32::try_from(owner.id()).expect("owner pid"); - assert_eq!(unsafe { libc::kill(owner_pid, libc::SIGKILL) }, 0); - owner.wait().expect("reap owner fixture"); + // Linux Child::kill 发送 SIGKILL;先检查真实子树回收,再由 guard 兜底。 + cleanup.owner.kill().expect("SIGKILL owner fixture"); + cleanup.owner.wait().expect("reap owner fixture"); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { let remaining = project_processes(root); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs new file mode 100644 index 000000000..af07502cc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs @@ -0,0 +1,127 @@ +use std::fs; +use std::path::Path; +use std::process::Child; +use std::thread; +use std::time::{Duration, Instant}; + +pub(super) fn project_processes(root: &Path) -> Vec { + let Ok(canonical_root) = fs::canonicalize(root) else { + return Vec::new(); + }; + fs::read_dir("/proc") + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let process_id = entry.file_name().to_string_lossy().parse::().ok()?; + if process_id <= 1 || process_id == std::process::id() as i32 { + return None; + } + let cwd = fs::read_link(entry.path().join("cwd")).ok()?; + (cwd == canonical_root).then_some(process_id) + }) + .collect() +} + +pub(super) struct OwnerFixtureCleanup<'a> { + pub(super) owner: &'a mut Child, + pub(super) root: &'a Path, +} + +impl Drop for OwnerFixtureCleanup<'_> { + fn drop(&mut self) { + let _ = self.owner.kill(); + let _ = self.owner.wait(); + + // 正常路径先验证子进程自行退出;这里只兜底作用域退出(包括 panic)后的残留。 + // 项目目录由每条用例独占,不能按进程名清理其他用例或开发进程。 + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let remaining = project_processes(self.root); + if remaining.is_empty() { + return; + } + for process_id in &remaining { + unsafe { + libc::kill(*process_id, libc::SIGKILL); + } + } + if Instant::now() >= deadline { + // Drop 可能在 panic 展开期间执行,不能再次 panic。 + use std::io::Write; + let _ = writeln!( + std::io::stderr(), + "owner fixture cleanup timed out: pids={remaining:?}" + ); + return; + } + thread::sleep(Duration::from_millis(25)); + } + } +} + +#[test] +fn owner_fixture_cleanup_reaps_processes_on_panic_without_touching_other_projects() { + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::process::{Command, Stdio}; + + // 回归夹具自己的回收不能依赖被测 guard,否则 guard 回归时测试也会泄漏。 + struct Sleeper(Child); + impl Drop for Sleeper { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + fn sleeper(root: &Path) -> Sleeper { + Sleeper( + Command::new("sleep") + .arg("60") + .current_dir(root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cleanup fixture"), + ) + } + + // 同时覆盖 owner 刚启动就失败,以及已有残留进程时失败。 + for has_residual in [false, true] { + let project = tempfile::tempdir().expect("cleanup project"); + let other_project = tempfile::tempdir().expect("unrelated project"); + let mut owner = sleeper(project.path()); + let cleanup = OwnerFixtureCleanup { + owner: &mut owner.0, + root: project.path(), + }; + // 故意不依赖 owner 退出监测,验证兜底能清理仍留在项目目录的进程。 + let mut residual = has_residual.then(|| sleeper(project.path())); + let mut other = sleeper(other_project.path()); + let result = catch_unwind(AssertUnwindSafe(move || { + let _cleanup = cleanup; + panic!("simulate an assertion failure before owner shutdown"); + })); + + let owner_status = owner.0.try_wait(); + let residual_status = residual.as_mut().map(|child| child.0.try_wait()); + let other_status = other.0.try_wait(); + // 即使 guard 回归,先收口本测试持有的进程再断言,避免回归用例自身泄漏。 + drop(owner); + drop(residual); + drop(other); + + assert!(result.is_err()); + assert!(matches!(owner_status, Ok(Some(_))), "owner must exit"); + if has_residual { + assert!( + matches!(residual_status, Some(Ok(Some(_)))), + "residual process must exit" + ); + } + assert!( + matches!(other_status, Ok(None)), + "other project must survive" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 6b4f7f43c..cb8fc948c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -1,5 +1,27 @@ use super::*; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::io::{Cursor, Read}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectExportPackageFileDigest { + pub(crate) path: String, + pub(crate) size_bytes: u64, + pub(crate) sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectExportPackagePayload { + pub(crate) package_relative_path: String, + pub(crate) package_bytes: Vec, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + pub(crate) files: Vec, +} + pub(crate) fn export_local_project_package_at( root: &Path, ) -> Result { @@ -110,6 +132,114 @@ pub(crate) fn export_local_project_package_at( }) } +/// Read a previously exported package for the explicit AGC publish flow. +/// +/// The caller receives the package bytes and a deterministic file manifest, but +/// never receives a filesystem path that it could accidentally send to the API. +pub(crate) fn read_local_project_export_package_at( + root: &Path, + package_relative_path: &str, +) -> Result { + validate_project_root(root)?; + let normalized = normalize_export_package_entry_path(package_relative_path)?; + if !normalized.starts_with("exports/playtest-package-") + || !normalized.ends_with(".zip") + || normalized.contains('/') && normalized.split('/').count() != 2 + { + return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string()); + } + let package_path = resolve_local_project_path(root, &normalized)?; + prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?; + let metadata = checked_export_package_metadata(&package_path, &normalized)?; + if !metadata.is_file() { + return Err("发行包必须是普通文件".to_string()); + } + if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES { + return Err("发行包大小超出本地发布上限".to_string()); + } + let package_bytes = + fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?; + if package_bytes.len() as u64 != metadata.len() { + return Err("发行包在读取期间发生变化,请重新导出".to_string()); + } + + let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes)) + .map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?; + let mut entries = Vec::with_capacity(archive.len()); + let mut seen = BTreeSet::new(); + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("读取发行包条目失败:{error}"))?; + if entry.is_dir() { + continue; + } + let source_path = normalize_export_package_entry_path(entry.name())?; + // 本地试玩包以 game/index.html 为入口,而平台发行合同要求根 + // index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改 + // 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。 + let path = source_path + .strip_prefix("game/") + .unwrap_or(source_path.as_str()) + .to_string(); + let path = normalize_export_package_entry_path(&path)?; + if !seen.insert(path.clone()) { + return Err(format!("发行包包含重复条目:{path}")); + } + let expected_size = entry.size(); + let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize); + entry + .read_to_end(&mut content) + .map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?; + if content.len() as u64 != expected_size { + return Err(format!("发行包条目长度不一致:{path}")); + } + entries.push((path, content)); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + if entries.is_empty() { + return Err("发行包没有可上传文件".to_string()); + } + + let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (path, content) in &entries { + normalized_writer + .start_file(path, options) + .map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?; + normalized_writer + .write_all(content) + .map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?; + } + let normalized_cursor = normalized_writer + .finish() + .map_err(|error| format!("完成发行包失败:{error}"))?; + let package_bytes = normalized_cursor.into_inner(); + if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES { + return Err("归一化发行包大小超出本地发布上限".to_string()); + } + let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes)); + let files = entries + .into_iter() + .map(|(path, content)| LocalProjectExportPackageFileDigest { + size_bytes: content.len() as u64, + sha256: format!("{:x}", Sha256::digest(&content)), + path, + }) + .collect::>(); + if !files.iter().any(|file| file.path == "index.html") { + return Err("归一化发行包缺少根 index.html".to_string()); + } + Ok(LocalProjectExportPackagePayload { + package_relative_path: normalized, + package_size_bytes: package_bytes.len() as u64, + package_bytes, + package_sha256, + files, + }) +} + pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result { let seed = unix_millis(); for suffix in 0..1000 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index d81ed7686..dd1c045bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -348,79 +348,113 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool { || should_skip_project_snapshot_path(relative_path) } +/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的 +/// 本机控制面,不参与其中。 pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { + project_snapshot_path_is_excluded(relative_path, false) +} + +/** + * 项目快照同步(上传)的排除口径。 + * + * 与 `should_skip_project_snapshot_path` 是同一份组件与后缀规则,唯一区别是 `.agent`: + * 它承载项目身份与 Agent 状态(`manifest.json`、`agent.db`、会话、运行日志、checkpoint、 + * workbench、`project.lock`),必须整目录随快照同步,因此不再把 `.agent` 组件本身当作 + * 排除项,并放行其中的 Agent 状态数据库(`.db` / `.db-wal` / `.db-shm`)。 + * + * 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、 + * `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。 + */ +pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool { + project_snapshot_path_is_excluded(relative_path, true) +} + +/// 任意层级出现即排除的目录组件。`.agent` 只有项目快照同步会放行。 +const PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS: &[&str] = &[ + ".agent", + ".git", + ".hg", + ".svn", + ".ssh", + ".aws", + ".azure", + ".gnupg", + ".kube", + ".docker", + ".gcloud", + ".terraform", + ".password-store", + ".secrets", + "secrets", + "credentials", + "node_modules", + "target", + "dist", + "build", + ".next", + "coverage", + ".cache", +]; + +/// 凭据、密钥与数据库转储类文件名后缀。 +const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[ + ".pem", + ".key", + ".p12", + ".pfx", + ".ppk", + ".jks", + ".keystore", + ".kdbx", + ".db", + ".db-wal", + ".db-shm", + ".sqlite", + ".sqlite-wal", + ".sqlite-shm", + ".sqlite3", + ".sqlite3-wal", + ".sqlite3-shm", + ".sql", + ".sql.gz", + ".sql.bz2", + ".sql.xz", + ".dump", + ".dump.gz", + ".dmp", + ".bak", + ".mdb", + ".accdb", + ".rdb", + ".bson", + ".pgdump", + ".tfstate", + ".tfstate.backup", +]; + +/// `.agent` 内的 Agent 状态数据库(`agent.db` 及其 WAL / SHM 旁文件)属于项目状态, +/// 随快照同步;其它数据库与转储后缀仍然排除。 +const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"]; + +fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool { let components = relative_path .split('/') .filter(|component| !component.is_empty()) .map(str::to_ascii_lowercase) .collect::>(); if components.iter().any(|component| { - matches!( - component.as_str(), - ".agent" - | ".git" - | ".hg" - | ".svn" - | ".ssh" - | ".aws" - | ".azure" - | ".gnupg" - | ".kube" - | ".docker" - | ".gcloud" - | ".terraform" - | ".password-store" - | ".secrets" - | "secrets" - | "credentials" - | "node_modules" - | "target" - | "dist" - | "build" - | ".next" - | "coverage" - | ".cache" - ) + PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str()) + && !(include_agent_state && component == ".agent") }) { return true; } let Some(file_name) = components.last() else { return true; }; - let sensitive_suffixes = [ - ".pem", - ".key", - ".p12", - ".pfx", - ".ppk", - ".jks", - ".keystore", - ".kdbx", - ".db", - ".db-wal", - ".db-shm", - ".sqlite", - ".sqlite-wal", - ".sqlite-shm", - ".sqlite3", - ".sqlite3-wal", - ".sqlite3-shm", - ".sql", - ".sql.gz", - ".sql.bz2", - ".sql.xz", - ".dump", - ".dump.gz", - ".dmp", - ".bak", - ".mdb", - ".accdb", - ".rdb", - ".bson", - ".pgdump", - ".tfstate", - ".tfstate.backup", - ]; + let agent_state_database = include_agent_state + && components + .first() + .is_some_and(|first| first.as_str() == ".agent"); let structured_secret_suffixes = [".json", ".txt", ".toml", ".yaml", ".yml"]; file_name == ".env" || file_name.starts_with(".env.") @@ -471,9 +505,10 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { || file_name.starts_with("id_ecdsa") || file_name.starts_with("id_ed25519") || file_name.starts_with("id_xmss") - || sensitive_suffixes - .iter() - .any(|suffix| file_name.ends_with(suffix)) + || PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES.iter().any(|suffix| { + file_name.ends_with(suffix) + && !(agent_state_database && PROJECT_AGENT_STATE_DATABASE_SUFFIXES.contains(suffix)) + }) || ((file_name.contains("cookie") || file_name.contains("credential")) && structured_secret_suffixes .iter() 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 964e10a38..a17a89b38 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 @@ -23,9 +23,10 @@ pub(crate) struct ProjectSnapshotScanResult { pub(crate) skipped: Vec, } -/// 扫描项目目录,复用 checkpoint / 项目索引同一份排除口径: -/// `.agent`、版本控制目录、依赖与构建产物目录、凭据目录、符号链接与重解析点 -/// 都不参与同步,超出单文件上限的文件进入跳过清单而不是静默丢弃。 +/// 扫描项目目录,排除口径见 `should_skip_project_snapshot_sync_path`: +/// `.agent` 是项目身份与 Agent 状态的权威位置,整目录参与同步;版本控制目录、 +/// 依赖与构建产物目录、凭据目录、符号链接与重解析点都不参与同步,超出单文件 +/// 上限的文件进入跳过清单而不是静默丢弃。 pub(crate) fn scan_project_snapshot_files( root: &Path, max_file_bytes: u64, @@ -52,7 +53,7 @@ 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) { + if should_skip_project_snapshot_sync_path(&relative_path) { continue; } let metadata = match fs::symlink_metadata(&path) { 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 a39b2b8b1..73a7209c2 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 @@ -95,8 +95,6 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { let root = fixture_root(); write_fixture_file(root.path(), "game/index.html", b""); write_fixture_file(root.path(), "assets/manifest.json", b"{}"); - write_fixture_file(root.path(), ".agent/runtime/state.json", b"{}"); - write_fixture_file(root.path(), ".agent/manifest.json", b"{}"); write_fixture_file(root.path(), "node_modules/pkg/index.js", b"export {};"); write_fixture_file(root.path(), "game/dist/bundle.js", b"bundle"); write_fixture_file(root.path(), "secrets/key.pem", b"private-key"); @@ -111,7 +109,7 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { assert_eq!( scanned, vec!["assets/manifest.json".to_string()], - ".agent、node_modules、dist 与凭据目录里的文件不能进入候选集合" + "node_modules、dist 与凭据目录里的文件不能进入候选集合" ); let skipped = scan .skipped @@ -125,6 +123,111 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { ); } +#[test] +fn project_snapshot_scan_uploads_whole_agent_directory() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/index.html", b""); + write_fixture_file(root.path(), ".agent/manifest.json", b"{}"); + write_fixture_file(root.path(), ".agent/agent.db", b"sqlite"); + write_fixture_file(root.path(), ".agent/agent.db-wal", b"wal"); + write_fixture_file(root.path(), ".agent/project.lock", b"{}"); + write_fixture_file(root.path(), ".agent/.manifest.json.lock", b""); + write_fixture_file(root.path(), ".agent/conversations/project.jsonl", b"{}\n"); + write_fixture_file(root.path(), ".agent/runtime/events/art.jsonl", b"{}\n"); + write_fixture_file( + root.path(), + ".agent/runtime/command-env/home/.config.json", + b"{}", + ); + write_fixture_file(root.path(), ".agent/logs/command.log", b"log"); + write_fixture_file( + root.path(), + ".agent/checkpoints/0001/manifest.json", + b"{\"files\":[]}", + ); + write_fixture_file( + root.path(), + ".agent/workbench/resource-layouts/art.json", + b"{}", + ); + + let scan = scan_fixture(root.path()); + let scanned = scan + .files + .iter() + .map(|file| file.relative_path.clone()) + .collect::>(); + assert_eq!( + scanned, + vec![ + ".agent/.manifest.json.lock".to_string(), + ".agent/agent.db".to_string(), + ".agent/agent.db-wal".to_string(), + ".agent/checkpoints/0001/manifest.json".to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/logs/command.log".to_string(), + ".agent/manifest.json".to_string(), + ".agent/project.lock".to_string(), + ".agent/runtime/command-env/home/.config.json".to_string(), + ".agent/runtime/events/art.jsonl".to_string(), + ".agent/workbench/resource-layouts/art.json".to_string(), + "game/index.html".to_string(), + ], + "`.agent` 是项目身份与 Agent 状态的权威位置,必须整目录参与同步" + ); + assert!( + scan.skipped.is_empty(), + "`.agent` 内的普通文件既不跳过也不延后" + ); +} + +#[test] +fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() { + for relative_path in [ + ".agent/manifest.json", + ".agent/agent.db", + ".agent/agent.db-wal", + ".agent/agent.db-shm", + ".agent/project.lock", + ".agent/runtime/events/art.jsonl", + ".agent/runtime/locks/append/01.lock", + ".agent/checkpoints/0001/manifest.json", + ".agent/conversations/project.jsonl", + ".agent/workbench/resource-layouts/art.json", + ".AGENT/manifest.json", + ] { + assert!( + !should_skip_project_snapshot_sync_path(relative_path), + "`.agent` 状态必须参与同步:{relative_path}" + ); + } + + for relative_path in [ + ".agent/credentials/platform.json", + ".agent/.ssh/id_rsa", + ".agent/certs/server.pem", + ".agent/node_modules/pkg/index.js", + ".agent/runtime/command-env/home/.env", + ".agent/runtime/command-env/home/.npmrc", + ".agent/backup/game.sql", + ".git/config", + "node_modules/pkg/index.js", + "game/dist/bundle.js", + "secrets/key.pem", + "", + ] { + assert!( + should_skip_project_snapshot_sync_path(relative_path), + "凭据、版本库与构建产物仍然排除:{relative_path}" + ); + } + + // 项目索引、checkpoint 与 Agent 上下文继续排除整个 `.agent`,本变更只放开快照同步。 + assert!(should_skip_project_snapshot_path(".agent/manifest.json")); + assert!(should_skip_project_index_path(".agent/manifest.json")); + assert!(should_skip_project_index_path(".agent/agent.db")); +} + #[test] fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() { let root = fixture_root(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 6e9abb9e8..a67c171f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3935,6 +3935,54 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() { + let root = unique_project_path(); + init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init"); + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); + + let exported = export_local_project_package_at(&root).expect("export package"); + let payload = read_local_project_export_package_at(&root, &exported.package_relative_path) + .expect("read publish payload"); + + assert_eq!( + payload.package_relative_path, + exported.package_relative_path + ); + assert_eq!( + payload.package_size_bytes, + payload.package_bytes.len() as u64 + ); + assert_eq!(payload.files.len(), 2); + assert!(payload.files.iter().any(|file| file.path == "index.html")); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes)) + .expect("read normalized package"); + let names = (0..archive.len()) + .map(|index| { + archive + .by_index(index) + .expect("normalized entry") + .name() + .to_string() + }) + .collect::>(); + assert!(names.iter().any(|name| name == "index.html")); + assert!(!names.iter().any(|name| name.starts_with("game/"))); + assert_eq!(payload.package_sha256.len(), 64); + assert!(payload + .package_sha256 + .chars() + .all(|value| value.is_ascii_hexdigit())); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_export_package_list_only_returns_recent_playtest_zips() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f319ee651..0d60d4cec 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -40,12 +40,14 @@ import type { LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, + LocalProjectExportPackageResult, LocalProjectFileResult, LocalProjectKind, PendingUiConfirmation, ProjectPermissionPolicyView, TauriInvoke, } from './app/types'; +import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; import { agentConversationId, agentRuntimeStateFromResult, @@ -91,6 +93,7 @@ import { type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { readGamePublishAvailability } from './services/gameDistributionPublish'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -359,6 +362,12 @@ export function App({ const chatComposerRef = useRef(null); const [chatAgentBusy, setChatAgentBusy] = useState(false); + // 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。 + const [publishPackageResult, setPublishPackageResult] = + useState(null); + const [publishPanelOpen, setPublishPanelOpen] = useState(false); + // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 + const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = useState(''); @@ -511,6 +520,7 @@ export function App({ texts.push(entry.text); reasoningByMessageId.set(entry.messageId, texts); } + // 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。 const messages: ChatMessage[] = view.messages .filter((message) => message.text.trim()) .map((message) => ({ @@ -519,7 +529,6 @@ export function App({ runtimeOwned: true, messageId: message.id, reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), - updatedAt: Date.now(), })); const initialPrompt = initialPlanningPromptLatchRef.current.prompt; if ( @@ -532,7 +541,6 @@ export function App({ role: 'user', text: initialPrompt, runtimeOwned: true, - updatedAt: Date.now(), }); } return messages; @@ -870,7 +878,15 @@ export function App({ if (messageList) { messageList.scrollTop = messageList.scrollHeight; } - }, [messages, projectChatError]); + }, [ + messages, + projectChatError, + designAgentTransientReply, + designAgentReasoning, + designAgentView, + pendingUiConfirmation, + chatFileImportNotice, + ]); useEffect(() => { latestMessagesRef.current = messages; @@ -1170,6 +1186,71 @@ export function App({ } } + useEffect(() => { + let cancelled = false; + void readGamePublishAvailability() + .then((allowed) => { + if (!cancelled) setGamePublishAllowed(allowed); + }) + .catch(() => { + if (!cancelled) setGamePublishAllowed(false); + }); + return () => { + cancelled = true; + }; + }, [localProject?.projectPath]); + + /** + * 导出试玩包并打开发布面板。 + * + * 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出; + * 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。 + */ + async function requestGamePublish() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setWorkspaceStatus('需要在 Tauri App 内发布'); + return; + } + const nextProjectPath = + resolveChatProjectPath(localProject) ?? projectPath.trim(); + if (!nextProjectPath) { + setWorkspaceStatus('先打开一个项目再发布'); + return; + } + const runExport = async () => { + try { + const result = await invoke( + 'export_local_project_package', + { projectPath: nextProjectPath }, + ); + setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`); + setPublishPackageResult(result); + setPublishPanelOpen(true); + appendLocalPermissionLog( + nextProjectPath, + 'command.auto', + 'project.export_package', + ); + } catch (error) { + setWorkspaceStatus( + error instanceof Error ? error.message : String(error), + ); + } + }; + const queued = await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'project.export_package', + nextProjectPath, + '导出试玩包并打开「发布到游戏广场」面板。', + '导出试玩包需要确认,确认后继续。', + () => void runExport(), + ); + if (!queued) { + await runExport(); + } + } + async function confirmUiCommand() { const pending = pendingUiConfirmation; if (!pending) { @@ -2232,13 +2313,25 @@ export function App({ // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 return ( - + <> + + setPublishPanelOpen(false)} + /> + ); } @@ -2382,6 +2475,13 @@ export function App({ onClose={() => setRuntimeConfigOpen(false)} /> ) : null} + setPublishPanelOpen(false)} + /> ); } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 210cbd489..227966041 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -801,6 +801,20 @@ export interface LocalProjectExportPackageResult { totalBytes: number; } +export interface LocalProjectExportPackageFileDigest { + path: string; + sizeBytes: number; + sha256: string; +} + +export interface LocalProjectExportPackagePayload { + packageRelativePath: string; + packageBytes: number[]; + packageSha256: string; + packageSizeBytes: number; + files: LocalProjectExportPackageFileDigest[]; +} + export interface LocalProjectExportPackageSummary { packagePath: string; packageRelativePath: string; diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx new file mode 100644 index 000000000..5f11e3f37 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -0,0 +1,511 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { LocalProjectExportPackageResult } from '../../app/types'; +import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; +import { + createGameDistributionPublishKey, + type GameDistributionPublishMetadata, + type GameDistributionPublishResult, + MAX_AGC_GAME_SCREENSHOTS, + publishLocalProjectGame, +} from '../../services/gameDistributionPublish'; +import { ThemedModal } from '../modal/ThemedModal'; + +/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */ +const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024; +const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; + +type PanelImageKind = 'cover' | 'screenshot'; + +type PanelImageAsset = { + /** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */ + signature: string; + name: string; + assetObjectId: string; + previewUrl: string; +}; + +function resolvePanelImageLabel(kind: PanelImageKind) { + return kind === 'cover' ? '游戏封面' : '游戏截图'; +} + +/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */ +function resolvePanelImageFileError(file: File, kind: PanelImageKind) { + const label = resolvePanelImageLabel(kind); + if (file.size <= 0) return `${label}文件为空,请重新选择`; + if (file.size > PANEL_IMAGE_MAX_BYTES) { + return `${label}过大,请压缩后再上传(最多 6MB)`; + } + const contentType = file.type.trim(); + if (contentType && !contentType.startsWith('image/')) { + return `${label}必须是图片文件`; + } + return ''; +} + +function buildPanelImageSignature(file: File, kind: PanelImageKind) { + return `${kind}:${file.name}:${file.size}:${file.lastModified}`; +} + +/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */ +function buildPanelImagePreviewUrl(file: File) { + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + return ''; + } + try { + return URL.createObjectURL(file); + } catch { + return ''; + } +} + +const CATEGORIES = [ + '休闲', + '益智', + '动作', + '冒险', + '模拟', + '策略', + '其他', +] as const; + +export function GameDistributionPublishPanel({ + open, + projectPath, + manifest, + packageResult, + onClose, + onPublished, +}: { + open: boolean; + projectPath: string; + manifest: GameCreationAppManifest; + packageResult: LocalProjectExportPackageResult | null; + onClose: () => void; + onPublished?: (result: GameDistributionPublishResult) => void; +}) { + const [title, setTitle] = useState(''); + const [summary, setSummary] = useState(''); + const [category, setCategory] = + useState('其他'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState( + null, + ); + const [cover, setCover] = useState(null); + const [screenshots, setScreenshots] = useState([]); + const [uploadingLabel, setUploadingLabel] = useState(''); + const publishIdempotencyKeyRef = useRef(''); + const coverInputRef = useRef(null); + const screenshotInputRef = useRef(null); + // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 + const uploadedAssetCacheRef = useRef(new Map()); + const objectUrlsRef = useRef(new Set()); + + useEffect(() => { + if (!open) return; + setTitle(manifest.name.trim()); + setSummary( + (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏').trim().slice(0, 120), + ); + setCategory('其他'); + setBusy(false); + setError(''); + setResult(null); + }, [manifest.goal, manifest.name, open, packageResult?.packageRelativePath]); + + useEffect(() => { + if (open) { + publishIdempotencyKeyRef.current = createGameDistributionPublishKey(); + } + }, [open, packageResult?.packageRelativePath]); + + useEffect(() => { + const objectUrls = objectUrlsRef.current; + return () => { + objectUrls.forEach((url) => { + try { + URL.revokeObjectURL(url); + } catch { + // 预览地址释放失败不影响关闭面板。 + } + }); + objectUrls.clear(); + }; + }, []); + + async function resolveAssetObjectId(file: File, kind: PanelImageKind) { + const signature = buildPanelImageSignature(file, kind); + const cached = uploadedAssetCacheRef.current.get(signature); + if (cached) return cached; + const uploaded = await uploadPlatformMediaAsset({ + file, + assetKind: + kind === 'cover' + ? 'game_distribution_cover' + : 'game_distribution_screenshot', + pathSegments: ['game-distribution', kind, `${Date.now()}`], + entityId: `game-distribution-${kind}`, + metadata: { game_distribution_media: kind }, + }); + uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId); + return uploaded.assetObjectId; + } + + function buildPanelImageAsset( + file: File, + kind: PanelImageKind, + assetObjectId: string, + ) { + const previewUrl = buildPanelImagePreviewUrl(file); + if (previewUrl) objectUrlsRef.current.add(previewUrl); + return { + signature: buildPanelImageSignature(file, kind), + name: file.name.trim() || resolvePanelImageLabel(kind), + assetObjectId, + previewUrl, + }; + } + + async function handleCoverSelected(file: File | null) { + if (!file) return; + setError(''); + const fileError = resolvePanelImageFileError(file, 'cover'); + if (fileError) { + setError(fileError); + return; + } + setUploadingLabel('正在上传封面…'); + try { + const assetObjectId = await resolveAssetObjectId(file, 'cover'); + setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '封面上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (coverInputRef.current) coverInputRef.current.value = ''; + } + } + + async function handleScreenshotsSelected(files: File[]) { + if (files.length === 0) return; + const remaining = MAX_AGC_GAME_SCREENSHOTS - screenshots.length; + if (files.length > remaining) { + setError( + remaining > 0 + ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` + : '游戏截图最多 6 张', + ); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + return; + } + setError(''); + setUploadingLabel('正在上传截图…'); + try { + for (const file of files) { + const fileError = resolvePanelImageFileError(file, 'screenshot'); + if (fileError) { + setError(fileError); + return; + } + const assetObjectId = await resolveAssetObjectId(file, 'screenshot'); + // 逐张入库:中途失败时已传好的截图保留,作者不用重新选择。 + setScreenshots((current) => + current.length >= MAX_AGC_GAME_SCREENSHOTS + ? current + : [ + ...current, + buildPanelImageAsset(file, 'screenshot', assetObjectId), + ], + ); + } + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '截图上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + } + } + + async function handleSubmit() { + if (!packageResult || !projectPath.trim()) { + setError('请先导出有效的试玩包'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setError('需要在 Tauri App 内发布'); + return; + } + if (!cover) { + setError('请先选择游戏封面(JPG/PNG/WebP)'); + return; + } + if (uploadingLabel) { + setError('素材还在上传中,请稍候再发布'); + return; + } + setBusy(true); + setError(''); + try { + const next = await publishLocalProjectGame({ + invoke, + projectPath, + packageRelativePath: packageResult.packageRelativePath, + manifest, + metadata: { + title, + summary, + category, + coverAssetId: cover.assetObjectId, + screenshots: screenshots.map((item) => item.assetObjectId), + }, + idempotencyKey: publishIdempotencyKeyRef.current, + }); + setResult(next); + onPublished?.(next); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : String(nextError), + ); + } finally { + setBusy(false); + } + } + + return ( + undefined : onClose} + panelClassName="game-distribution-publish-panel" + closeOnBackdrop={!busy} + closeOnEscape={!busy} + > +
+
+ + 发布到游戏广场 + +

让玩家现在就能试玩

+
+ +
+ {result ? ( +
+ 已提交审核 +

版本已进入审核队列,审核通过后才会在游戏广场公开展示。

+

+ 版本 {result.versionNumber} ·{' '} + {result.packageSizeBytes.toLocaleString()} B ·{' '} + {result.packageSha256.slice(0, 16)}… +

+
+ +
+
+ ) : ( + <> +

+ 仅上传已导出的 ZIP 字节和摘要信息;不会上传本地路径或项目源码快照。 +

+
+ {packageResult?.packageRelativePath ?? '未找到试玩包'} + + {packageResult + ? `${packageResult.fileCount} 个文件 · ${packageResult.totalBytes.toLocaleString()} B` + : '请先导出'} + +
+
+ +