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` + : '请先导出'} + +
+
+ +