From 328ac318443608ec4555406cf8d0f332f698462d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 20 Sep 2026 20:49:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=B8=B8=E6=88=8F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0=EF=BC=88=E7=89=B9?= =?UTF-8?q?=E6=80=A7=E5=88=86=E6=94=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录 --- apps/admin-web/src/api/adminApiClient.test.ts | 48 + apps/admin-web/src/api/adminApiClient.ts | 30 + apps/admin-web/src/api/adminApiTypes.ts | 15 + .../AdminGameDistributionReviewPage.test.tsx | 186 ++ .../pages/AdminGameDistributionReviewPage.tsx | 365 +++ .../GameDistributionPublishPanel.tsx | 216 ++ .../src/services/gameDistributionPublish.ts | 225 ++ .../assert-project-tools-and-preview.ts | 8 + .../tests/gameDistributionPublish.test.ts | 118 + .../tests/gameDistributionPublishLive.test.ts | 222 ++ .../gameDistributionPublishPanel.test.tsx | 213 ++ deploy/nginx/README.md | 8 + deploy/nginx/genarrative-release-origin.conf | 80 + ...施计划】游戏分发阶段A领域合同-2026-09-19.md | 91 + ...碑】游戏分发目录详情与在线游玩-2026-09-18.md | 135 + .../shared-memory/decision-log.md | 29 + docs/project-memory/shared-memory/pitfalls.md | 14 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 31 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 10 +- package.json | 1 + .../shared/src/contracts/gameDistribution.ts | 149 ++ scripts/check-release-origin-config.mjs | 272 ++ server-rs/crates/api-server/src/app.rs | 85 + .../src/modules/game_distribution.rs | 1736 +++++++++++++ server-rs/crates/api-server/src/state.rs | 19 + .../module-game-distribution/Cargo.toml | 12 + .../src/application.rs | 1113 +++++++++ .../module-game-distribution/src/commands.rs | 52 + .../module-game-distribution/src/domain.rs | 115 + .../module-game-distribution/src/errors.rs | 105 + .../module-game-distribution/src/events.rs | 21 + .../module-game-distribution/src/lib.rs | 27 + .../module-game-distribution/src/package.rs | 225 ++ .../module-game-distribution/src/release.rs | 179 ++ .../crates/module-runtime/src/application.rs | 3 + server-rs/crates/platform-oss/src/lib.rs | 202 +- .../shared-contracts/src/game_distribution.rs | 213 ++ .../crates/spacetime-client/src/active.rs | 13 +- .../src/active/mapper/game_distribution.rs | 187 ++ .../spacetime-client/src/game_distribution.rs | 688 +++++ .../spacetime-client/src/module_bindings.rs | 8 + ...stribution_version_and_return_procedure.rs | 59 + ...stribution_version_and_return_procedure.rs | 62 + ...stribution_package_and_return_procedure.rs | 62 + ..._distribution_game_and_return_procedure.rs | 59 + ...stribution_version_and_return_procedure.rs | 62 + ...istribution_upload_and_return_procedure.rs | 59 + .../game_distribution_approve_input_type.rs | 21 + ..._distribution_cancel_version_input_type.rs | 20 + ...distribution_confirm_package_input_type.rs | 25 + ...ame_distribution_create_game_input_type.rs | 33 + ..._distribution_create_version_input_type.rs | 25 + ...ame_distribution_fail_upload_input_type.rs | 21 + ...ibution_game_list_procedure_result_type.rs | 19 + ...distribution_game_procedure_result_type.rs | 19 + .../game_distribution_game_snapshot_type.rs | 35 + .../game_distribution_game_table.rs | 230 ++ .../game_distribution_game_type.rs | 126 + .../game_distribution_get_game_input_type.rs | 16 + ..._distribution_idempotency_receipt_table.rs | 243 ++ ...e_distribution_idempotency_receipt_type.rs | 101 + ...distribution_owner_game_list_input_type.rs | 16 + ...n_owner_game_list_procedure_result_type.rs | 19 + ...e_distribution_owner_game_snapshot_type.rs | 19 + ...e_distribution_owner_version_input_type.rs | 16 + ...game_distribution_procedure_result_type.rs | 25 + ...ame_distribution_public_game_input_type.rs | 15 + ...istribution_public_game_list_input_type.rs | 17 + ..._distribution_public_game_snapshot_type.rs | 19 + .../game_distribution_reject_input_type.rs | 21 + ...ame_distribution_review_list_input_type.rs | 15 + ...e_distribution_submit_review_input_type.rs | 20 + .../game_distribution_suspend_input_type.rs | 21 + .../game_distribution_unpublish_input_type.rs | 20 + ..._distribution_version_lookup_input_type.rs | 15 + ...tribution_version_procedure_result_type.rs | 19 + ...game_distribution_version_snapshot_type.rs | 28 + .../game_distribution_version_table.rs | 231 ++ .../game_distribution_version_type.rs | 130 + ..._distribution_game_and_return_procedure.rs | 59 + ...stribution_version_and_return_procedure.rs | 59 + ...stribution_version_and_return_procedure.rs | 62 + ..._distribution_game_and_return_procedure.rs | 59 + ...distribution_games_and_return_procedure.rs | 59 + ...stribution_reviews_and_return_procedure.rs | 59 + ...distribution_games_and_return_procedure.rs | 62 + ...distribution_games_and_return_procedure.rs | 62 + ...stribution_version_and_return_procedure.rs | 59 + ...version_for_review_and_return_procedure.rs | 62 + ..._distribution_game_and_return_procedure.rs | 59 + ..._distribution_game_and_return_procedure.rs | 59 + .../spacetime-module/src/game_distribution.rs | 2218 +++++++++++++++++ .../crates/spacetime-module/src/migration.rs | 2 + .../game-distribution/GameDetailPage.tsx | 225 ++ .../GameDistributionPages.test.tsx | 420 ++++ .../game-distribution/GameGalleryPage.tsx | 389 +++ .../game-distribution/GamePlayPage.tsx | 247 ++ .../GamePublishPage.test.tsx | 413 +++ .../game-distribution/GamePublishPage.tsx | 687 +++++ .../game-distribution/MyGamesPage.test.tsx | 252 ++ .../game-distribution/MyGamesPage.tsx | 346 +++ .../game-distribution/gameDistribution.css | 915 +++++++ .../gameDistributionGuards.ts | 152 ++ .../game-distribution/gameGalleryViewState.ts | 183 ++ .../game-distribution/gamePublishDraft.ts | 118 + .../game-distribution/gamePublishMetadata.ts | 24 + .../game-distribution/gameZipPackage.test.ts | 55 + .../game-distribution/gameZipPackage.ts | 92 + .../PlatformEntryActiveFlowShell.test.tsx | 167 +- .../PlatformEntryActiveFlowShell.tsx | 16 +- src/services/gameDistributionClient.test.ts | 251 ++ src/services/gameDistributionClient.ts | 286 +++ 113 files changed, 17590 insertions(+), 22 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx create mode 100644 apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx create mode 100644 apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts create mode 100644 apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts create mode 100644 apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts create mode 100644 apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx create mode 100644 deploy/nginx/genarrative-release-origin.conf create mode 100644 docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md create mode 100644 docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md create mode 100644 packages/shared/src/contracts/gameDistribution.ts create mode 100644 scripts/check-release-origin-config.mjs create mode 100644 server-rs/crates/api-server/src/modules/game_distribution.rs create mode 100644 server-rs/crates/module-game-distribution/Cargo.toml create mode 100644 server-rs/crates/module-game-distribution/src/application.rs create mode 100644 server-rs/crates/module-game-distribution/src/commands.rs create mode 100644 server-rs/crates/module-game-distribution/src/domain.rs create mode 100644 server-rs/crates/module-game-distribution/src/errors.rs create mode 100644 server-rs/crates/module-game-distribution/src/events.rs create mode 100644 server-rs/crates/module-game-distribution/src/lib.rs create mode 100644 server-rs/crates/module-game-distribution/src/package.rs create mode 100644 server-rs/crates/module-game-distribution/src/release.rs create mode 100644 server-rs/crates/shared-contracts/src/game_distribution.rs create mode 100644 server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs create mode 100644 server-rs/crates/spacetime-client/src/game_distribution.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/approve_game_distribution_version_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/cancel_game_distribution_version_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/confirm_game_distribution_package_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_game_distribution_game_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_game_distribution_version_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/fail_game_distribution_upload_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_approve_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_cancel_version_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_confirm_package_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_fail_upload_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_get_game_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_idempotency_receipt_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_idempotency_receipt_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_game_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_game_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_game_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_owner_version_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_reject_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_submit_review_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_suspend_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_unpublish_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_lookup_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_game_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_version_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_owner_game_distribution_version_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_public_game_distribution_game_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_games_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_reviews_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_owner_game_distribution_games_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_public_game_distribution_games_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/reject_game_distribution_version_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/submit_game_distribution_version_for_review_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/suspend_game_distribution_game_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/unpublish_game_distribution_game_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/game_distribution.rs create mode 100644 src/components/game-distribution/GameDetailPage.tsx create mode 100644 src/components/game-distribution/GameDistributionPages.test.tsx create mode 100644 src/components/game-distribution/GameGalleryPage.tsx create mode 100644 src/components/game-distribution/GamePlayPage.tsx create mode 100644 src/components/game-distribution/GamePublishPage.test.tsx create mode 100644 src/components/game-distribution/GamePublishPage.tsx create mode 100644 src/components/game-distribution/MyGamesPage.test.tsx create mode 100644 src/components/game-distribution/MyGamesPage.tsx create mode 100644 src/components/game-distribution/gameDistribution.css create mode 100644 src/components/game-distribution/gameDistributionGuards.ts create mode 100644 src/components/game-distribution/gameGalleryViewState.ts create mode 100644 src/components/game-distribution/gamePublishDraft.ts create mode 100644 src/components/game-distribution/gamePublishMetadata.ts create mode 100644 src/components/game-distribution/gameZipPackage.test.ts create mode 100644 src/components/game-distribution/gameZipPackage.ts create mode 100644 src/services/gameDistributionClient.test.ts create mode 100644 src/services/gameDistributionClient.ts diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index aee62a5ff..e4009c408 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -10,6 +10,7 @@ import { reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, reviewAdminGameDistributionVersion, + suspendAdminGameDistributionGame, updateAdminAccount, uploadAdminEditorShowcaseCampaignImage, upsertAdminFeatureGateConfig, @@ -411,6 +412,53 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键' ); }); +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( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index d9da6b382..fe6da3890 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1194,6 +1194,36 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 * 两条审核结论。 */ +/** + * 安全下架整个游戏。管理员下架同样要求 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, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 48cfc470e..1da9cc709 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1061,3 +1061,18 @@ 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; +} 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/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx new file mode 100644 index 000000000..c23887955 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -0,0 +1,216 @@ +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 { + createGameDistributionPublishKey, + type GameDistributionPublishMetadata, + type GameDistributionPublishResult, + publishLocalProjectGame, +} from '../../services/gameDistributionPublish'; +import { ThemedModal } from '../modal/ThemedModal'; + +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 publishIdempotencyKeyRef = useRef(''); + + 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]); + + async function handleSubmit() { + if (!packageResult || !projectPath.trim()) { + setError('请先导出有效的试玩包'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setError('需要在 Tauri App 内发布'); + return; + } + setBusy(true); + setError(''); + try { + const next = await publishLocalProjectGame({ + invoke, + projectPath, + packageRelativePath: packageResult.packageRelativePath, + manifest, + metadata: { title, summary, category }, + 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` + : '请先导出'} + +
+
+ +