恢复游戏分发完整实现(特性分支)

- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务
- 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关
- 后台:游戏审核页(待审列表、通过/拒绝、安全下架)
- AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试
- 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试
- 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
This commit is contained in:
2026-09-20 20:49:42 +08:00
parent cde34428f9
commit 328ac31844
113 changed files with 17590 additions and 22 deletions
@@ -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(
+30
View File
@@ -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,
+15
View File
@@ -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;
}
@@ -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(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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();
});
@@ -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<AdminGameDistributionReviewEntry[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
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 (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1></h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadReviews()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2></h2>
<span className="admin-muted-text"> {entries.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">
{entry.reviewReason}
</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
>
{busyGameId === entry.gameId
? '正在下架…'
: '安全下架'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{writeConfirm.confirmDialog}
</section>
);
}
@@ -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<GameDistributionPublishMetadata['category']>('其他');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<GameDistributionPublishResult | null>(
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 (
<ThemedModal
open={open}
ariaLabel="发布到游戏广场"
onClose={busy ? () => undefined : onClose}
panelClassName="game-distribution-publish-panel"
closeOnBackdrop={!busy}
closeOnEscape={!busy}
>
<header className="game-distribution-publish-panel__header">
<div>
<span className="game-distribution-publish-panel__eyebrow">
广
</span>
<h2></h2>
</div>
<button
type="button"
onClick={onClose}
disabled={busy}
aria-label="关闭发布面板"
>
×
</button>
</header>
{result ? (
<div className="game-distribution-publish-panel__success" role="status">
<strong></strong>
<p>广</p>
<p className="game-distribution-publish-panel__mono">
{result.versionNumber} ·{' '}
{result.packageSizeBytes.toLocaleString()} B ·{' '}
{result.packageSha256.slice(0, 16)}
</p>
<div className="game-distribution-publish-panel__actions">
<button type="button" onClick={onClose}>
</button>
</div>
</div>
) : (
<>
<p className="game-distribution-publish-panel__intro">
ZIP
</p>
<div
className="game-distribution-publish-panel__package"
aria-label="发行包摘要"
>
<span>{packageResult?.packageRelativePath ?? '未找到试玩包'}</span>
<span>
{packageResult
? `${packageResult.fileCount} 个文件 · ${packageResult.totalBytes.toLocaleString()} B`
: '请先导出'}
</span>
</div>
<div className="game-distribution-publish-panel__fields">
<label>
<input
value={title}
maxLength={40}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
<textarea
value={summary}
maxLength={120}
rows={2}
onChange={(event) => setSummary(event.target.value)}
/>
</label>
<label>
<select
value={category}
onChange={(event) =>
setCategory(
event.target
.value as GameDistributionPublishMetadata['category'],
)
}
>
{CATEGORIES.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
</div>
{error ? (
<p className="game-distribution-publish-panel__error" role="alert">
{error}
</p>
) : null}
<div className="game-distribution-publish-panel__actions">
<button type="button" onClick={onClose} disabled={busy}>
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={
busy || !title.trim() || !summary.trim() || !packageResult
}
>
{busy ? '上传中…' : '发布游戏'}
</button>
</div>
</>
)}
</ThemedModal>
);
}
@@ -0,0 +1,225 @@
import type { GameCreationAppManifest } from '../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameDistributionCategory,
GameDistributionCreateGameRequest,
GameDistributionCreateVersionRequest,
GameDistributionInputMode,
GameDistributionOrientation,
} from '../../../../packages/shared/src/contracts/gameDistribution';
import type {
LocalProjectExportPackagePayload,
TauriInvoke,
} from '../app/types';
import { requestClientApi } from './clientApi';
export type GameDistributionPublishMetadata = {
title: string;
summary: string;
description: string;
category: GameDistributionCategory;
tags: string[];
deviceSupport: {
desktop: boolean;
mobile: boolean;
touch: boolean;
};
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
};
export type GameDistributionPublishResult = {
gameId: string;
versionId: string;
versionNumber: number;
status: string;
packageSha256: string;
packageSizeBytes: number;
fileCount: number;
};
type CreatedGame = { id: string; publicationRevision?: number };
type CreatedVersion = {
gameId: string;
versionId: string;
versionNumber: number;
status: string;
};
export function createGameDistributionPublishKey() {
const randomUuid =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `agc-publish-${randomUuid}`;
}
function normalizeMetadata(
manifest: GameCreationAppManifest,
metadata?: Partial<GameDistributionPublishMetadata>,
): GameDistributionPublishMetadata {
const title = (metadata?.title ?? manifest.name).trim();
const summary = (
metadata?.summary ??
manifest.goal ??
'由陶泥儿创作的可在线游玩游戏'
).trim();
if (!title || title.length > 40) {
throw new Error('游戏标题必须为 1 到 40 个字符');
}
if (!summary || summary.length > 120) {
throw new Error('游戏简介必须为 1 到 120 个字符');
}
return {
title,
summary,
description: (metadata?.description ?? summary).trim().slice(0, 2_000),
category: metadata?.category ?? '其他',
tags: metadata?.tags ?? [],
deviceSupport: metadata?.deviceSupport ?? {
desktop: true,
mobile: true,
touch: true,
},
inputModes: metadata?.inputModes ?? ['keyboard', 'mouse', 'touch'],
orientation: metadata?.orientation ?? 'responsive',
};
}
function toCreateGameRequest(
metadata: GameDistributionPublishMetadata,
localProjectId: string,
): GameDistributionCreateGameRequest {
return {
localProjectId,
title: metadata.title,
summary: metadata.summary,
description: metadata.description,
category: metadata.category,
tags: metadata.tags,
deviceSupport: metadata.deviceSupport,
inputModes: metadata.inputModes,
orientation: metadata.orientation,
};
}
export async function publishLocalProjectGame(args: {
invoke: TauriInvoke;
projectPath: string;
packageRelativePath: string;
manifest: GameCreationAppManifest;
metadata?: Partial<GameDistributionPublishMetadata>;
idempotencyKey?: string;
}): Promise<GameDistributionPublishResult> {
const projectPath = args.projectPath.trim();
const packageRelativePath = args.packageRelativePath.trim();
if (!projectPath || !packageRelativePath) {
throw new Error('发布需要绑定本地项目和试玩包');
}
const payload = await args.invoke<LocalProjectExportPackagePayload>(
'read_local_project_export_package',
{ projectPath, packageRelativePath },
);
if (
!payload.packageBytes.length ||
payload.packageSizeBytes !== payload.packageBytes.length ||
payload.files.length === 0
) {
throw new Error('本地发行包摘要无效,请重新导出试玩包');
}
if (payload.packageRelativePath !== packageRelativePath) {
throw new Error('本地发行包路径已变化,请重新导出试玩包');
}
const metadata = normalizeMetadata(args.manifest, args.metadata);
const localProjectId = args.manifest.projectId.trim();
if (!localProjectId) {
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
}
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
const rootKey =
args.idempotencyKey?.trim() || createGameDistributionPublishKey();
const game = await requestClientApi<CreatedGame>(
'/api/game-distribution/games',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:game`,
},
body: JSON.stringify(gameMetadata),
},
'创建平台游戏失败',
);
if (!game?.id?.trim()) {
throw new Error('创建平台游戏未返回游戏 ID');
}
const versionRequest: GameDistributionCreateVersionRequest = {
localProjectId,
packageSha256: payload.packageSha256,
packageBytes: payload.packageSizeBytes,
packageFileCount: payload.files.length,
packageEntryPath: 'index.html',
gameMetadata,
};
const version = await requestClientApi<CreatedVersion>(
`/api/game-distribution/games/${encodeURIComponent(game.id)}/versions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:version`,
},
body: JSON.stringify(versionRequest),
},
'创建游戏发行版本失败',
);
if (!version?.versionId?.trim()) {
throw new Error('创建发行版本未返回版本 ID');
}
const packageBody = new Blob([new Uint8Array(payload.packageBytes)], {
type: 'application/zip',
});
const uploaded = await requestClientApi<{
versionId: string;
status: string;
}>(
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/zip',
'Idempotency-Key': `${rootKey}:upload`,
},
body: packageBody,
},
'上传游戏发行包失败',
);
const submitted = await requestClientApi<{
game?: { publicationRevision?: number };
version?: { status?: string };
}>(
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/submit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:submit`,
},
body: JSON.stringify({
expectedPublicationRevision: game.publicationRevision ?? 0,
}),
},
'提交审核失败',
);
return {
gameId: game.id,
versionId: version.versionId,
versionNumber: version.versionNumber,
status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review',
packageSha256: payload.packageSha256,
packageSizeBytes: payload.packageSizeBytes,
fileCount: payload.files.length,
};
}
@@ -849,6 +849,14 @@ export function registerProjectToolsAndPreviewTests() {
/已导出本地试玩包:exports\/playtest-package-unit\.zip/,
),
).not.toBeNull();
// 导出成功后会直接打开发布到游戏广场的面板;它是模态,先确认出现再关闭,
// 然后回到预览快捷操作的会话流。
const publishPanel = await screen.findByRole('dialog', {
name: '发布到游戏广场',
});
expect(publishPanel).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '关闭发布面板' }));
const exportRevealButtons = screen.getAllByRole('button', {
name: '显示目录',
});
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { beforeEach, expect, test, vi } from 'vitest';
const fetchClientHttp = vi.fn();
vi.mock('../src/services/clientHttp', () => ({
AGC_DEVELOPMENT_API_BASE_URL: 'https://dev.genarrative.world',
fetchClientHttp: (...args: unknown[]) => fetchClientHttp(...args),
getClientServerBaseUrl: () => 'https://dev.genarrative.world',
readClientHttpResponseText: (response: Response) => response.text(),
}));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
const MANIFEST = {
projectId: 'local-proj-1',
name: '星轨防线',
goal: '守住轨道城',
} as unknown as GameCreationAppManifest;
function jsonResponse(payload: unknown) {
return new Response(
JSON.stringify({
ok: true,
data: payload,
error: null,
meta: { apiVersion: 'v1' },
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
beforeEach(() => {
fetchClientHttp.mockReset();
window.localStorage.clear();
});
test('发布时携带本地项目标识,让重复发布复用同一个平台游戏', async () => {
fetchClientHttp
.mockResolvedValueOnce(
jsonResponse({ id: 'game_1', publicationRevision: 0 }),
)
.mockResolvedValueOnce(
jsonResponse({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'awaiting_upload',
}),
)
.mockResolvedValueOnce(
jsonResponse({ versionId: 'gamever_1', status: 'uploaded' }),
)
.mockResolvedValueOnce(
jsonResponse({ version: { status: 'pending_review' } }),
);
const result = await publishLocalProjectGame({
invoke: (async (command: string) => {
expect(command).toBe('read_local_project_export_package');
return {
packageRelativePath: 'exports/playtest-package-1.zip',
packageBytes: [1, 2, 3],
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
files: [{ path: 'index.html', sizeBytes: 3, sha256: 'a'.repeat(64) }],
};
}) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
});
const createGameCall = fetchClientHttp.mock.calls[0];
expect(createGameCall?.[0]).toBe('/api/game-distribution/games');
const createGameBody = JSON.parse(
String((createGameCall?.[1] as RequestInit).body),
);
expect(createGameBody.localProjectId).toBe('local-proj-1');
const createVersionCall = fetchClientHttp.mock.calls[1];
expect(createVersionCall?.[0]).toBe(
'/api/game-distribution/games/game_1/versions',
);
expect(
JSON.parse(String((createVersionCall?.[1] as RequestInit).body))
.localProjectId,
).toBe('local-proj-1');
expect(result.gameId).toBe('game_1');
expect(result.versionId).toBe('gamever_1');
});
test('缺少本地项目标识时在发起请求前失败关闭', async () => {
await expect(
publishLocalProjectGame({
invoke: (async () => ({
packageRelativePath: 'exports/playtest-package-1.zip',
packageBytes: [1],
packageSha256: 'a'.repeat(64),
packageSizeBytes: 1,
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
})) as never,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
}),
).rejects.toThrow('发布需要本地项目标识');
expect(fetchClientHttp).not.toHaveBeenCalled();
});
@@ -0,0 +1,222 @@
// @vitest-environment jsdom
/**
* AGC「一键发布」的真实链路验证(默认跳过,按需开启)。
*
* 运行方式:
* GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:10001 \
* npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
*
* 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是
* mock 请求层)调用 AGC 的发布函数,覆盖:本地导出包读取、创建游戏、同
* `localProjectId` 复用游戏身份、真实 ZIP 上传、送审与版本回读。
*/
import { createHash } from 'node:crypto';
import JSZip from 'jszip';
import { expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { LocalProjectExportPackagePayload } from '../src/app/types';
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
.trim()
.replace(/\/+$/u, '');
const liveTest = liveBaseUrl ? test : test.skip;
const realFetch = globalThis.fetch.bind(globalThis);
const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' };
function apiUrl(path: string) {
return new URL(path, `${liveBaseUrl}/`).toString();
}
/** 把 AGC 的相对请求改写到本地栈;其余语义(头部、超时包装)保持真实实现。 */
function installFetchBridge() {
vi.stubGlobal(
'fetch',
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? apiUrl(input)
: input instanceof URL
? input.toString()
: input;
const body = init?.body;
if (typeof Blob !== 'undefined' && body instanceof Blob) {
// jsdom 的 Blob/ArrayBuffer 属于另一个 realm,且旧版 jsdom 没有
// Blob.arrayBuffer;统一读成字节后复制为 Node 侧 Buffer 再转发。
const bytes = await readBlobBytes(body);
return realFetch(url as string, {
...init,
body: Buffer.from(bytes),
});
}
return realFetch(url as string, init);
},
);
}
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
const maybeArrayBuffer = (
blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }
).arrayBuffer;
if (typeof maybeArrayBuffer === 'function') {
return new Uint8Array(await maybeArrayBuffer.call(blob));
}
return await new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
resolve(
result instanceof ArrayBuffer
? new Uint8Array(result)
: new Uint8Array(0),
);
};
reader.onerror = () =>
reject(reader.error ?? new Error('读取发行包字节失败'));
reader.readAsArrayBuffer(blob);
});
}
async function unwrap<T>(response: Response): Promise<T> {
const text = await response.text();
const parsed: unknown = JSON.parse(text);
if (
!parsed ||
typeof parsed !== 'object' ||
(parsed as { ok?: boolean }).ok !== true
) {
throw new Error(`后端返回失败:${text.slice(0, 400)}`);
}
return (parsed as { data: T }).data;
}
async function registerAuthor(): Promise<string> {
const phone = `136${String(Date.now()).slice(-8)}`;
const response = await realFetch(apiUrl('/api/auth/entry'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...ENVELOPE_HEADERS },
body: JSON.stringify({
purePhoneNumber: phone,
password: 'GenTest123!',
}),
});
const data = await unwrap<{ token: string }>(response);
return data.token;
}
async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
const zip = new JSZip();
const indexHtml =
'<!doctype html><html><head><meta charset="utf-8"><title>AGC Live</title>' +
'<script src="assets/app.js"></script></head><body><h1>AGC-LIVE</h1></body></html>';
const appJs =
'window.__agcLive=1;document.documentElement.dataset.booted="agc";';
zip.file('index.html', indexHtml);
zip.file('assets/app.js', appJs);
const bytes = await zip.generateAsync({ type: 'uint8array' });
const sha256 = createHash('sha256').update(bytes).digest('hex');
return {
packageRelativePath: 'dist/game.zip',
packageBytes: Array.from(bytes),
packageSha256: sha256,
packageSizeBytes: bytes.length,
files: [
{
path: 'index.html',
sizeBytes: Buffer.byteLength(indexHtml),
sha256: createHash('sha256').update(indexHtml).digest('hex'),
},
{
path: 'assets/app.js',
sizeBytes: Buffer.byteLength(appJs),
sha256: createHash('sha256').update(appJs).digest('hex'),
},
],
};
}
liveTest(
'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份',
async () => {
installFetchBridge();
const token = await registerAuthor();
setStoredAccessToken(token);
const payload = await buildExportPayload();
const stamp = String(Date.now());
const manifest = {
projectId: `agc-live-${stamp}`,
name: `AGC 真实发布${stamp.slice(-4)}`,
goal: '验证 AGC 一键发布链路',
} as unknown as GameCreationAppManifest;
const invoke = vi.fn(async () => payload);
const metadata = {
summary: '由 AGC 发布函数真实提交',
description:
'集成验证:AGC 发布函数 → 本地 api-server → SpacetimeDB / 私有 OSS',
category: '益智' as const,
tags: ['集成验证'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'] as const,
orientation: 'landscape' as const,
};
const first = await publishLocalProjectGame({
invoke,
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
packageRelativePath: 'dist/game.zip',
manifest,
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
});
expect(first.status).toBe('pending_review');
expect(first.versionNumber).toBe(1);
expect(first.packageSha256).toBe(payload.packageSha256);
const readResult = await unwrap<{
version: { versionId: string; status: string; recoveryAction: string };
game: { id: string };
}>(
await realFetch(
apiUrl(`/api/game-distribution/versions/${first.versionId}`),
{ headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS } },
),
);
expect(readResult.version.status).toBe('pending_review');
expect(readResult.version.recoveryAction).toBe('wait');
expect(readResult.game.id).toBe(first.gameId);
// 第二次发布不带旧幂等键:同 localProjectId 必须复用同一 gameId 并新增版本。
const second = await publishLocalProjectGame({
invoke,
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
packageRelativePath: 'dist/game.zip',
manifest,
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
});
expect(second.gameId).toBe(first.gameId);
expect(second.versionNumber).toBe(first.versionNumber + 1);
const myGames = await unwrap<{
games: Array<{
id: string;
latestVersion: { versionId: string; status: string } | null;
}>;
}>(
await realFetch(apiUrl('/api/game-distribution/my-games'), {
headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS },
}),
);
const published = myGames.games.find((game) => game.id === first.gameId);
expect(published?.latestVersion?.versionId).toBe(second.versionId);
expect(published?.latestVersion?.status).toBe('pending_review');
},
120_000,
);
function setStoredAccessToken(token: string) {
setStoredAuthAccessToken(token);
}
@@ -0,0 +1,213 @@
// @vitest-environment jsdom
/**
* AGC「发布到游戏广场」面板的行为边界。
*
* 这里只覆盖面板自身(预填资料、必填门禁、防重复点击、成功/失败态与无 Tauri 宿主),
* 真实的 HTTP 链路由 `gameDistributionPublishLive.test.ts` 覆盖。
*/
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { LocalProjectExportPackageResult } from '../src/app/types';
import { GameDistributionPublishPanel } from '../src/components/game-distribution/GameDistributionPublishPanel';
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../src/services/gameDistributionPublish')
>();
return { ...actual, publishLocalProjectGame: vi.fn() };
});
type TauriInvoke = (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
function installTauriInvoke(invoke: TauriInvoke) {
const mock = vi.fn(invoke);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof mock } };
}
).__TAURI__ = { core: { invoke: mock } };
return mock;
}
const MANIFEST = {
projectId: 'local-proj-1',
name: '星轨防线',
goal: '守住轨道城',
} as unknown as GameCreationAppManifest;
const PACKAGE_RESULT = {
packageRelativePath: 'exports/playtest-package-unit.zip',
packageBytes: [1, 2, 3],
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
totalBytes: 3,
} as unknown as LocalProjectExportPackageResult;
function renderPanel(
overrides: Partial<Parameters<typeof GameDistributionPublishPanel>[0]> = {},
) {
const onClose = vi.fn();
const onPublished = vi.fn();
render(
<GameDistributionPublishPanel
open
projectPath="/tmp/authorized-game"
manifest={MANIFEST}
packageResult={PACKAGE_RESULT}
onClose={onClose}
onPublished={onPublished}
{...overrides}
/>,
);
return { onClose, onPublished };
}
afterEach(() => {
cleanup();
vi.mocked(publishLocalProjectGame).mockReset();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
window.localStorage.clear();
});
describe('GameDistributionPublishPanel', () => {
test('打开时预填游戏资料并展示发行包摘要', () => {
installTauriInvoke(async () => undefined);
renderPanel();
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
'value',
'星轨防线',
);
expect(screen.getByLabelText('一句话简介')).toHaveProperty(
'value',
'守住轨道城',
);
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
'exports/playtest-package-unit.zip',
);
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
'2 个文件',
);
});
test('提交时带上项目路径、发行包与资料,成功后展示审核状态', async () => {
const invoke = installTauriInvoke(async () => undefined);
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
const { onPublished } = renderPanel();
fireEvent.change(screen.getByLabelText('游戏名称'), {
target: { value: '星轨防线二' },
});
fireEvent.change(screen.getByLabelText('分类'), {
target: { value: '动作' },
});
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
expect(args?.invoke).toBeDefined();
expect(args?.projectPath).toBe('/tmp/authorized-game');
expect(args?.packageRelativePath).toBe('exports/playtest-package-unit.zip');
expect(args?.manifest).toBe(MANIFEST);
expect(args?.metadata).toEqual({
title: '星轨防线二',
summary: '守住轨道城',
category: '动作',
});
expect(String(args?.idempotencyKey)).toMatch(/^agc-publish-/u);
expect(invoke).toBeDefined();
expect(await screen.findByText('已提交审核')).not.toBeNull();
expect(screen.getByText(/ 1/u)).not.toBeNull();
expect(onPublished).toHaveBeenCalledTimes(1);
});
test('连续点击只提交一次,上传中禁用动作按钮', async () => {
installTauriInvoke(async () => undefined);
let resolvePublish: (value: unknown) => void = () => undefined;
vi.mocked(publishLocalProjectGame).mockImplementation(
() =>
new Promise((resolve) => {
resolvePublish = resolve as (value: unknown) => void;
}) as never,
);
renderPanel();
const submit = screen.getByRole('button', { name: '发布游戏' });
fireEvent.click(submit);
fireEvent.click(submit);
expect(await screen.findByText('上传中…')).not.toBeNull();
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1);
resolvePublish({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
expect(await screen.findByText('已提交审核')).not.toBeNull();
});
test('服务端失败时保留面板并展示可读错误', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(publishLocalProjectGame).mockRejectedValue(
new Error('上传游戏发行包失败:游戏分发服务暂不可用(503)'),
);
renderPanel();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
expect(
await screen.findByText(
'上传游戏发行包失败:游戏分发服务暂不可用(503)',
),
).not.toBeNull();
expect(screen.queryByText('已提交审核')).toBeNull();
});
test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => {
renderPanel();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
expect(await screen.findByText('需要在 Tauri App 内发布')).not.toBeNull();
expect(publishLocalProjectGame).not.toHaveBeenCalled();
cleanup();
installTauriInvoke(async () => undefined);
renderPanel({ packageResult: null });
expect(screen.getByRole('button', { name: '发布游戏' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
'未找到试玩包',
);
});
});
+8
View File
@@ -99,3 +99,11 @@ curl -sSI -H 'Accept-Encoding: br' \
- gzip 可用时返回 `Content-Encoding: gzip`
- br 可用时返回 `Content-Encoding: br`
- 响应头应包含 `Vary: Accept-Encoding`
## 游戏发行来源(每游戏独立 origin)
- `deploy/nginx/genarrative-release-origin.conf` 为已公开游戏提供每游戏独立来源:`https://<gameId>.games.example.com/`。部署前替换域名、通配证书路径与 upstream 端口,并为 `*.games.example.com` 配置通配 DNS 与通配 TLS。
- 该来源只把子域根路径映射到 `…/releases/<gameId>/index.html`、其余路径映射到 `…/releases/<gameId>/<原路径>`;平台 API、后台、SPA 与上传接口都不在这个来源上暴露,命中即 404。
- 发行来源不使用 Cookie:带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 审核通过时填写的 `entryUrl` 就是该子域根地址 `https://<gameId>.games.example.com/`;换版本或下架只改变后端公开投影,边缘不需要改配置。
- 门禁:`npm run check:release-origin-config` 会逐条校验模板约束、交叉检查发行网关仍在设置上述响应头,并在本机存在 `nginx``openssl` 时用自签通配证书渲染一份临时配置执行 `nginx -t`
@@ -0,0 +1,80 @@
# 游戏发行来源(每游戏独立 origin)
#
# 部署前替换:
# 1) `games.example.com` 为真实发行域,并为 `*.games.example.com` 配置通配 DNS
# 与通配 TLS 证书;
# 2) `ssl_certificate` / `ssl_certificate_key` 指向该通配证书;
# 3) upstream 端口与 api-server 实际监听一致。
#
# 设计约定:
# - 每个已公开游戏使用自己的子域:`https://<gameId>.games.example.com/`
# - 该来源只把请求映射到发行网关
# `/api/game-distribution/releases/<gameId>/…`,平台 API、后台、SPA 与上传
# 接口都不在这个来源上暴露;
# - 发行来源从不使用 Cookie:带 Cookie 的请求直接 403,转发前也会清空 Cookie;
# - `X-Content-Type-Options` / CORP / 无凭据 CORS / HTML CSP / 内容类型白名单由
# api-server 发行网关设置,这里不覆盖,避免两层策略漂移;
# - 公开版本切换与下架由后端 `publication_revision` CAS 决定,边缘只做按主机映射。
upstream genarrative_release_api {
server 127.0.0.1:8082;
keepalive 32;
}
server {
listen 80;
server_name ~^(?<game_id>[a-z0-9_]+)\.games\.example\.com$;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name ~^(?<game_id>[a-z0-9_]+)\.games\.example\.com$;
ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/games.example.com/privkey.pem;
access_log /var/log/nginx/genarrative-release.access.log;
error_log /var/log/nginx/genarrative-release.error.log warn;
# 发行文件是公开静态资源,从不携带平台 Cookie。带上 Cookie 的请求说明它落在
# 平台会话来源上,直接拒绝,避免发行内容被主站同源脚本读取。
if ($http_cookie) {
return 403;
}
# 子域根路径直接服务该游戏的 index.html,游戏内其余资源按相对路径原样交给
# 发行网关;这样审核通过时填写的 entryUrl 就是 https://<gameId>.games.example.com/。
location = / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-Id $request_id;
proxy_set_header Cookie "";
proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-Id $request_id;
proxy_set_header Cookie "";
proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
@@ -0,0 +1,91 @@
# 【实施计划】游戏分发阶段A领域合同
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md` 阶段 A |
| Status | in_progress |
| Owner | Codex |
## 修改边界
- 允许修改:`module-game-distribution` 新领域 crate、workspace 声明、Rust shared-contracts DTO、领域单测及文档证据。
- 明确不修改:发行域名与 CDN、旧 public work/runtime、AGC native 上传命令、网页发布表单。
## 行为范围
- 定义游戏身份、版本状态、包摘要、owner 权限、幂等 key 冲突和 publication revision CAS。
- 领域服务先以纯内存仓储验证状态合同,再接入已冻结的 SpacetimeDB 游戏/版本/幂等收据表;不把内存仓储作为生产事实源。
- 阶段 A 只提供身份、版本、真实包确认和状态回读基础,不提供“发布成功”或公开可玩状态,不绕过人工审核。
## 验证命令
1. `cargo test -p module-game-distribution --manifest-path server-rs/Cargo.toml`
2. `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`
3. `npm run check:encoding`
4. `git diff --check`
## 风险与回滚点
- API 接入前端前必须完成 SpacetimeDB facade 和 schema 门禁;任何失败都保留旧版本数据,不覆盖已有业务表。
- 内存仓储只用于行为合同测试,不得被前端当作正式数据源。
## 已完成证据(当前切片)
- `module-game-distribution` 已落地游戏/版本状态机、owner 校验、幂等摘要冲突、上传确认、验证失败重试、审核 CAS、撤回和管理员暂停的纯领域服务与内存测试仓储。
- 同一 crate 已加入不执行上传代码的 ZIP 包校验:根 `index.html`、路径穿越/大小写冲突、符号链接、加密文件、敏感文件、嵌套压缩包、单文件/总展开量/压缩比上限和逐文件 SHA-256 清单;并新增按白名单内容类型取单个发行资源的读取层。
- Rust/TypeScript 跨端 DTO、SpacetimeDB 三张持久表、migration 白名单、生成 bindings、typed facade 和游戏分发 HTTP 路由已接入,覆盖创建、上传、送审、审核、下架、公开目录与详情。
- `api-server` 新增发行网关 `GET /api/game-distribution/releases/{gameId}/{assetPath}`:只服务当前已公开版本,未知扩展名 404,附带 nosniff / CORP / HTML CSP,拒绝带 Cookie 请求,并按对象键做有界包缓存。
- 重复发布复用游戏身份:游戏表末尾新增可空 `local_project_id``create_game_distribution_game` 在 owner + local_project_id 命中时复用既有 `gameId` 并只新增版本;`localProjectId` 经 shared-contractsRust/TS)透传,AGC 发布链路写入并在缺失时于发请求前失败关闭,api-server 对短标识做路径分隔符与控制字符校验。
- 后台新增 `#game-distribution` 游戏审核页:待审列表、通过(要求 HTTPS 发行入口)、拒绝(要求理由)、幂等键与列表刷新;对应 `editor-showcase` Tab 权限映射、API client 与页面测试已加入。
- 路由级测试证明发行网关、公开目录与登录发布路由确实挂载:带 Cookie 的发行请求 403、未知扩展名 404 且不触达对象存储、无 Bearer 的发布请求 401、目录在无数据库时返回 502 而不是 404。
- 自动化证据:`cargo test -p module-game-distribution`11 passed)、`cargo test -p api-server` 游戏分发模块(9 passed)与全量 `cargo test -p api-server`1069 passed / 6 ignored)、`cargo test -p shared-contracts`、admin-web Vitest149 passed,含新增游戏审核页与 client 用例)、前端游戏定向 Vitest15 passed)、AGC `typecheck``local_project_export` Rust 测试(7 passed)、Vite build。
- 版本回读、撤回与管理员安全下架闭环:新增 SpacetimeDB `get_game_distribution_version_and_return` / `cancel_game_distribution_version_and_return`(幂等收据 + 公开修订号 CAS)、`spacetime-client` façade、`GET /api/game-distribution/versions/{versionId}`(作者,未知与非 owner 一律 404)、`GET /admin/api/game-distribution/versions/{versionId}`(管理员)与 `POST /api/game-distribution/versions/{versionId}/cancel`(作者,`Idempotency-Key` + `expectedPublicationRevision`);版本私有投影新增服务端派生的 `recoveryAction`upload/submit/wait/none/reupload/fix_package/fix_metadata)。后台游戏审核页补齐「安全下架」入口(原因输入 + 二次确认弹窗 + 幂等键)。
- 幂等响应语义修正:写操作 procedure 现在把「命中既有幂等收据」如实回传为 `replayed: true`(此前所有写操作都固定返回 false),覆盖创建游戏/版本、确认包、上传失败、送审、审核、撤回、下架与暂停。
- 网页端恢复与撤回:`/games/publish` 在创建版本后写入带 owner 的发布草稿,窗口关闭或上传中断后同一账号可见「继续上传/继续送审」并复用原 `versionId`,换账号只忽略不读取;`/games/mine` 对可撤回状态提供二次确认的「撤回审核」,成功后刷新状态。
- AGC 发布真实链路回归测试:新增 `apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts`(默认跳过,设置 `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL` 指向运行中的 api-server 才执行)。它不 mock 请求层,而是走真实的 `clientApi`/`clientHttp` 调用 `publishLocalProjectGame`,覆盖本地导出包读取、创建游戏、同 `localProjectId` 复用游戏身份、真实 ZIP 上传、送审、版本回读与 `my-games` 聚合,把「AGC 一键发布」从请求组装单测提升到真实后端链路证据。
- AGC 客户端回归修复:导出试玩包后自动打开的发布面板是焦点陷阱模态,会让既有 `appSurface` 导出快捷操作用例静默失效;用例改为先断言面板出现、关闭后再继续,`appSurface.test.ts` 552 项(535 passed / 17 skipped)恢复全绿,并顺手补上该面板此前缺失的行为断言。同一轮修掉 `gameDistributionPublish.test.ts` 的模块 mock 缺少 `getClientServerBaseUrl` 导出的既有失败。
- 发行来源(每游戏独立 origin)部署工件:新增 `deploy/nginx/genarrative-release-origin.conf``scripts/check-release-origin-config.mjs``npm run check:release-origin-config`)。模板按命名捕获 `game_id``https://<gameId>.games.<域>/` 映射到该游戏的 `index.html`、其余路径映射到发行网关前缀,边缘拒绝并清空 Cookie,不代理平台 API/后台/SPA;门禁逐条校验模板约束、交叉检查发行网关仍在设置 `nosniff`/CORP/CORS/CSP 与 Cookie 403,并在本机渲染临时配置跑 `nginx -t`
- AGC 发布面板组件测试:新增 `apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx`,覆盖打开时预填资料、发行包摘要、提交入参(项目路径/发行包/资料/幂等键)、连续点击只提交一次、服务端失败保留面板与无 Tauri 宿主/缺包时失败关闭。
- 视觉与交互巡检修复:真实栈 + headless Chromium 逐页巡检 `/games``/games/detail``/games/play``/games/mine``/games/publish`(桌面 1440×900 与移动 390×844)。修掉两处:① 详情页「游玩方式」只读公开投影里恒为空的 `version.controls`,作者声明的 `inputModes` 被忽略,现在优先展示「键盘 · 鼠标 · 触屏」再退回自由文本;② 桌面端 hero 过高导致首屏看不到任何游戏卡片,改为仅桌面压缩 hero 高度,首排卡片进入 900px 视口。
- 容量与限额边界(阶段 D 证据):新增真实栈脚本覆盖 5 类包——① 99.0 MiB(两个 50/49 MiB 文件 + `index.html`,均在单文件/展开量/压缩比限制内)上传 200、耗时 11.8s、api-server RSS 81.6→378.0 MB(峰值 +296 MB)后回落 83.2 MB,随后送审 202 进入 `pending_review`;② 声明的 `packageBytes` 为 101 MiB 时在创建版本即被拒 413 `PAYLOAD_TOO_LARGE`("发行包大小超出限制",不落版本);③ 声明合法但请求体 101 MiB 时被请求体限制拒绝 413(0.03s、内存零增长、版本保持 `awaiting_upload`/`upload`,可原版本重传);④ 60 MiB 全零文件压到 61 KB(压缩比约 1000)拒绝 422 `PACKAGE_VALIDATION_FAILED`0.01s`upload_failed`/`reupload`);⑤ 单文件 65 MiB 与 10,001 个文件同样 4220.05s / 0.02s)。失败包都不出现在公开目录。
- 发行包 PUT 受控重试:`platform-oss` 新增 `put_internal_object_with_retry`(复用既有 `oss_error_is_retryable` 分类:传输/超时/connect、408、429、5xx 与 400+RequestTimeout 可重试,确定性 4xx 不重试;body 只转一次 `Bytes` 供各 attempt 复用),发行包上传接入 3 次尝试 + 250/500ms 退避。触发原因:本机实测 99 MiB 单次 PUT 三次里出现过一次 `UPSTREAM_ERROR``请求 OSS 失败:error sending request`),客户端需要白传整包。
- 网页端「为既有游戏发布新版本」:发布页新增 `updateGameId` 更新模式(按作者中心的最近版本回读冻结资料与公开修订号预填,提交时跳过创建游戏、直接在既有 `gameId` 下创建不可变新版本),作者中心新增「发布新版本」入口,壳层支持 `/games/publish?game=<gameId>`。浏览器真实链路:从 `/games/mine` 点「发布新版本」→ `/games/publish?game=…` 预填并显示「为《…》发布新版本 v2」→ 选包提交 → 该 `gameId` 下出现 `versions [(2, pending_review), (1, published)]`,公开入口仍指向并返回 v1 内容。此前网页端只能新建游戏,更新无法沿用 `gameId`,与里程碑「更新沿用相同 gameId」不符。
- 全生命周期回归(真实栈,11 步):待审公开不可见(目录不返回 + 详情 404)→ 管理员通过 v1 后目录/详情/网关可玩 V1 → v2 待审期间网关仍是 V1 → v2 被拒后仍是 V1 → 重复批准 `rejected` 版本返回 409`rejected` 是终态,必须新建版本,与主规范一致)→ 新建 v3 通过后公开入口切换为 V3 → 作者回读被拒版本为 `rejected`/`fix_metadata` → 作者下架后目录/详情/网关全部关闭。同一轮修掉 5 条既有的壳层导航用例失败(游戏分发新增「游戏」页签与移动底栏后,测试仍断言旧导航集合)。
- 边界、越权与隔离运行时证据(真实栈):① 跨作者越权——作者 B 对作者 A 的版本上传 403、送审 403、撤回 404、回读 404,且 A 的版本状态未被改变;② 过期 CAS——管理员用错误 `publicationRevision` 批准返回 409,正确修订号才通过;③ 包边界——符号链接条目、`.env` 凭据文件、嵌套 ZIP 分别返回 422 `PACKAGE_VALIDATION_FAILED`,版本落到 `upload_failed`/`reupload`;④ 私有对象直取——按对象键直接 GET 私有 OSS 地址返回 **403**,发行文件只能经网关读取。
- 浏览器沙箱隔离实测:发布一个自检探测包(内联脚本主动探测并 `postMessage` 回传结果),在 `/games/play` 里点「开始游戏」后收到 `{ origin: "http://127.0.0.1:10001", cookie: "throw:SecurityError", storage: "throw:SecurityError", parentDom: "throw:SecurityError", externalFetch: "throw:TypeError" }`——主站 Cookie、localStorage、父页面 DOM 全部不可访问,外站 fetch 被 CSP `connect-src 'self'` 阻断。键盘可达抽查:桌面端 Tab 顺序覆盖导航 → 搜索 → 下载客户端 → 账户,游戏分发自己的控件(发布游戏/我的游戏/分类页签/游戏卡片)聚焦时都有可见的浏览器默认焦点环。同一探测包还验证了音频链路:沙箱内 `fetch('assets/beep.wav')` 返回 `status:200 type:audio/wav`WebAudio `decodeAudioData` 成功解出 `0.40s / 48000Hz``AudioContext.state = suspended`,与浏览器“首次播放需帧内用户手势”的策略一致,玩家进入游戏后的第一次交互即提供该手势)。尚未在无头环境验证的是游戏内的真实触屏输入(移动视口渲染与触控声明已验证)。
- 上传中断与进程重启恢复(真实栈,两次独立实验):60 MiB 包 PUT 进行到 ~2.5s 时 `SIGKILL` api-server,客户端拿到 `RemoteDisconnected`(等价于响应丢失);重启后该版本仍是 `awaiting_upload`/`upload`,没有半确认状态。① 用不同字节重传返回 422 `PACKAGE_VALIDATION_FAILED``InvalidArchive`)并转 `upload_failed`/`reupload`;② 用固定时间戳构造的确定性 60 MiB 包重传**相同字节**返回 200(7.64s),版本转 `uploaded`/`submit`,随后送审 202 进入 `pending_review`。两次实验中游戏都未出现在公开目录。
- 发布开关(回滚/事故能力):新增 `game-distribution:publish` 灰度 gate(复用现役灰度配置机制与后台入口)。默认开放;`enabled=true` 时按白名单/用户标签/灰度百分比放行,`rolloutPercent=0` 且无白名单即全部关闭。关闭时作者写入与管理员批准返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`,而目录、详情、版本回读、发行网关、`/my-games`、审核队列读取、拒绝审核与安全下架继续可用;开关读取失败按关闭处理。路由级测试覆盖「默认放行 / 全关拦截 / 读取不受影响 / 白名单放行」,真实栈验证同口径:关闭后作者写入 503、目录/详情/网关/my-games 全 200、管理员批准 503 而拒绝 200、白名单用户可发布、重新开放后恢复。
- 工程门禁:`npm run check:spacetime-schema`147 tables)、`npm run check:server-rs-ddd``cargo fmt --all -- --check``npm run check:encoding``npm run check:doc-index``git diff --check`
## 运行时证据(2026-09-20,真实本地栈)
`npm run dev` 在无冲突端口段启动(spacetime `127.0.0.1:10004`、api-server `127.0.0.1:10001`、真实 OSS bucket `xushi-dev`),用 HTTP 全链路脚本验证:
- 管理员登录 + 用户口令注册/登录均走真实路由。
- 创建游戏后,用相同 `localProjectId` 再次创建返回**同一个 `gameId`**(去重在真实数据库上生效)。
- 真实 ZIP(根 `index.html` + `assets/app.js`)上传私有 OSS,服务端重算 SHA-256 / 字节数 / 文件数并确认 `uploaded`
- 送审返回 `202``pending_review`;此时公开目录、公开详情都不返回该游戏。
- 管理员用 HTTPS `entryUrl` 审核通过后,目录与详情出现该游戏并带当前版本 `entryUrl`
- 发行网关返回 `text/html``text/javascript`;带 `Cookie` 的发行请求 403,未知扩展名 404。
- 作者下架后公开详情回到 404。
- 更新链路:v2 送审期间与 v2 被拒后,公开入口始终停留在 v1 且 v1 内容仍可读取;v3 通过审核后才替换公开版本。
- 真实浏览器(headless Chromium)加载发行网关的 `index.html`:文档渲染、同源 `assets/app.js` 在现役 CSP 下执行成功(`booted=1``window.__gd=1`),控制台只剩 favicon 404。
- 浏览器打开 `http://127.0.0.1:10000/games` 能看到已发布游戏卡片,`/games/detail?id=<id>` 渲染标题与「立即玩」。
- 平台页面内嵌游玩:浏览器打开 `/games/play?id=<id>`,点「开始游戏」后 iframe 以 `sandbox="allow-scripts"` 挂载发行网关地址,`game-player-frame--ready` 置位,控制台无 CSP/CORP 拦截,api-server 访问日志显示该版本 `assets/app.js` 返回 200。
- 非生产环境允许 http 回环 `entryUrl`(与前端 `normalizeGameEntryUrl` 同口径),生产仍只接受 HTTPS;这样本地无需 TLS 即可验证内嵌游玩。
- 作者中心 `/games/mine`SpacetimeDB 新增 `list_owner_game_distribution_games_and_return`(owner 只从认证主体派生,每游戏最多回 10 个最近版本),`api-server` 暴露 `GET /api/game-distribution/my-games`;浏览器里用真实作者 token 打开该页,能看到「已公开」状态与「下架」按钮,点击后状态变为「未公开」、按钮消失,公开目录同步不再返回该游戏。
- 网页端发布入口 `/games/publish`:浏览器里用真实文件选择框上传 ZIP(根 `index.html` + `assets/app.js`),api-server 访问日志记录 `POST /api/game-distribution/games` 200 → `POST /api/game-distribution/games/{gameId}/versions` 200 → `PUT /api/game-distribution/versions/{versionId}/package` 200 → `POST /api/game-distribution/versions/{versionId}/submit` 202;随后同一账号在 `/games/mine` 看到该游戏为「未公开 / 版本 v1 · 审核中」,公开目录不返回它。
- 版本回读/撤回真实链路(`npm run dev`api-server `127.0.0.1:10001`、SpacetimeDB `127.0.0.1:10004`):作者送审后 `GET /versions/{id}` 返回 `pending_review` + `recoveryAction=wait`;其他账号与未知版本都是 404;过期修订号撤回 409;正常撤回 200 且状态变 `cancelled``recoveryAction=none`;同 key 同请求重放返回 `replayed=true`,同 key 不同摘要 409;撤回后公开目录不返回该游戏。
- 主链路回归(同一真实栈):正式发布 `replayed=false`、重复送审 `replayed=true`;待审期间目录不可见;管理员读版本 200、审核通过后状态 `published` 且目录与详情返回 `currentVersion.entryUrl`;发行网关 `index.html` 200、带 Cookie 403、未知扩展名 404。
- AGC 发布真实链路(同一真实栈):`GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:10001 npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts` 通过——AGC 发布函数依次完成创建游戏、创建版本、上传 ZIP、送审,返回 `pending_review`;版本回读为 `pending_review`/`wait`;不带旧幂等键的第二次发布复用同一 `gameId` 并生成 v2`/my/games``latestVersion` 指向 v2。
- 非法包真实链路(同一真实栈):缺根 `index.html` 且含 `../` 条目的 ZIP 上传返回 422(包校验错误),版本进入 `upload_failed``recoveryAction=reupload`,此时送审被拒(409)、公开目录不返回该游戏,作者仍可撤回该版本重新出包。
- 发行来源真实边缘验证(同一真实栈 + 本机 nginx 1.28.3,模板渲染到 `~/data/tmp` 后监听高位端口):`Host: <gameId>.games.example.com` 时根路径 200 `text/html`(游戏 `index.html`)、`/assets/app.js` 200 `text/javascript`;带 `Cookie` 403`/api/auth/me` 404(平台命名空间未暴露);未知 gameId 404http 301 到 https;响应头经边缘透传后仍是 `nosniff` + CORP `cross-origin` + 无凭据 CORS + HTML CSP + `Cache-Control: public, max-age=60, must-revalidate`
- 移动视口真实游玩:headless Chromium 以 `390x844` 打开已发布游戏 `/games/play?id=<id>`,页面显示「横屏设计,旋转设备」提示与移动端底部导航,点击「开始游戏」后 iframe 以 `sandbox="allow-scripts"` + `allow="fullscreen"` 挂载发行网关地址并 `ready``index.html``assets/app.js` 均返回 200,控制台无 CSP/CORP 报错;截图存于本轮验证记录(不入库)。
本轮同时修掉三个真实缺陷:Vite dev 代理缺少 `/api/game-distribution` 前缀(本地全部 404);详情页在「已发布但没有 controls」时误显示「暂未发布可玩版本」;发行网关的 `Cross-Origin-Resource-Policy: same-origin` 会让 opaque origin 沙箱内的游戏加载不了自己的脚本(改为 `cross-origin` + 无凭据 CORS,详见 `pitfalls.md`)。
## 尚未完成
- 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。
- 版本回读、撤回与管理员安全下架已实现;主规范 HTTP 表中不再有待落地路由。容量与限额边界、发行包 PUT 重试已有真实栈证据;仍未做的是 CDN purge 失败行为、回滚演练与清理策略(不删除仍被公开版本引用的对象)的上线验收。
- 生产调用依赖已初始化的 editor generation runtime service identity;未初始化时 procedure 拒绝写入,不会退回 API 进程内存状态。
@@ -0,0 +1,135 @@
# 【里程碑】游戏分发、发布与在线游玩
| 字段 | 值 |
| --- | --- |
| Version | 0.2 |
| Status | accepted(用户“继续”确认按既定假设进入阶段 A;A 未验收) |
| Date | 2026-09-18 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` |
## 交付与评审边界
本文件拆分主规范“AGC 游戏分发与在线游玩合同”的完整业务:真实上传、持久化发行、审核、隔离托管、AGC 发布、网页上传与游玩、上线运维。当前只授权阶段 A 实现,阶段 B/C/D 仍为 `proposed`,没有已上线/已验收结论。
先完成主规范与本文件评审,再为一个获准阶段创建单独实施计划;未经该阶段验收,不进入依赖它的阶段。里程碑只规定行为与证据,不预先写代码步骤。
| 阶段 | 行为切片 | 依赖 | 状态 |
| --- | --- | --- | --- |
| A | 真实包、游戏身份与可恢复发行 | 主规范评审及持久化合同冻结 | accepted(实现中) |
| B | 人工审核、公开投影与隔离托管 | A 验收;发行站点/私有存储/审核策略确认 | proposed |
| C | AGC 与网页发布、现代游戏目录和跨端游玩 | B 验收;导航与交互稿评审 | proposed |
| D | 端到端验收、容量与撤销、上线准备 | C 验收;实际部署资源可用 | proposed |
## 实现进度(2026-09-20,未验收)
以下是已落地的实现与本地运行时证据,**不等于阶段验收**:B/C 的隔离托管、域名与生产验收仍缺少真实发行域名、TLS/CDN 与生产账号。
- 阶段 A:真实 ZIP 上传、游戏身份、owner/幂等/CAS、状态机、DTO 与 schema 门禁已完成;真实 SpacetimeDB + 私有 OSS 的创建/上传/确认/重启恢复已有证据。
- 阶段 B:人工审核(后台列表、通过需 HTTPS 入口、拒绝需理由)、公开投影、发行网关(按公开版本服务、扩展名白名单、`nosniff`/CORP/CSP、带 Cookie 403)与作者下架已实现;每游戏独立来源已有可执行工件 `deploy/nginx/genarrative-release-origin.conf` 与门禁 `npm run check:release-origin-config`,并已在本机用真实 nginx + 真实网关验证按主机映射、Cookie 403 与平台命名空间 404;生产域名、通配证书与 CDN TTL 仍需上线环境确认。
- 阶段 C:AGC 客户端「发布到平台」面板与发布链路(dist 归一化根 `index.html`、摘要/字节数声明、幂等键、`localProjectId` 复用)已实现并有请求组装与 Rust 导出测试;网页 `/games/publish` 走同一服务端管道,本地已用真实文件选择验证;AGC GUI 自身的端到端发布仍待客户端环境验收。目录(关键词/分类/设备筛选、滚动与筛选恢复)、详情、游玩页(主动作后加载、超时重试、旋转提示、全屏、移动端门槛)与作者中心(状态、驳回理由、撤回、下架)已实现;本地已在桌面与 `390x844` 移动视口真实游玩。
- 阶段 D:容量/额度、重启恢复、CDN 撤销与回滚演练尚未开始,依赖生产资源。
细节与命令级证据见[实施计划【游戏分发阶段A领域合同】](【实施计划】游戏分发阶段A领域合同-2026-09-19.md)的「已完成证据」「运行时证据」「尚未完成」。
## 共通范围与不做项
- 正式状态来自后端,素材仍复用平台上传与归属能力;前端和 AGC 不另建公开游戏状态、owner 事实或审核结果。
- 真实发行包具有不可变版本和 SHA-256,AGC dist 归一化为发行根 `index.html`;网页 ZIP 与 AGC 共用一条服务管道。
- 不恢复退役玩法 API、公开作品表或专属 runtime;不把私有项目源码镜像公开。
- 首版不包含原生/Wasm 游戏、任意外网依赖、服务端进程、多人联机、云存档、评论/评分/关注、排行榜、推荐算法和收益结算。
- 本文件只协调本业务;不顺带改造图片编辑器、Agent Runtime 执行模型或无关项目数据。
## 阶段 A:真实包、身份与可恢复发行
### 前置条件
- 主规范的包类型、限额、身份/幂等/CAS、状态机和内部 API 已评审。
- 游戏、版本、审核与操作账本的完整字段、索引、唯一约束、受信服务身份及清理策略已经冻结;若涉及已有表破坏性变更,另有已确认迁移计划。
### 行为与验收
- [ ] 登录用户创建服务端分配的游戏,owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销。
- [ ] 服务端接收真实 ZIP 字节,重算摘要/字节数并建立展开清单;只有 metadata 的请求不能获得已上传或已发布状态。
- [ ] 缺入口、越界/重复/大小写冲突路径、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致。
- [ ] 一份版本只接受一份已确认内容;同 key 同请求重放无重复游戏/版本,不同请求冲突;同版本并发上传不混写。
- [ ] 校验可异步恢复,响应丢失、服务进程退出和客户端重试均回到原版本;确定失败和未知结果在响应中可区分。
- [ ] 正常及失败状态、私有查询和错误 envelope 在 Rust 与 TypeScript DTO 中一致;新增 schema、迁移、表目录与绑定一致。
### 证据要求
- 自动化:领域状态机、owner、幂等/CAS、包读取与错误路径、DTO 和 schema 定向测试。
- 运行时:真实 SpacetimeDB 与私有对象存储完成上传/回读/重启恢复;`/healthz` 正常。
- 边界:服务端记录的 ZIP 摘要与测试上传字节一致;未审核目录不能匿名读取;无凭据或本地路径泄漏。
## 阶段 B:审核、公开投影与隔离托管
### 前置条件
- A 已验收;人工审核角色、资料检查标准与拒绝/封禁行为已确认。
- 已确定不同可注册站点的发行域名、每游戏独立 origin、私有存储和网关能力;同源临时路径不能作为验收替代。
### 行为与验收
- [ ] 自动校验通过只进入待审,管理员可查看真实待审游戏并批准/拒绝;审核记录可追溯且普通作者不能提交审核动作。
- [ ] 新游戏审核通过并核验发行文件可读后才公开;更新待审或失败不改变旧版资料、URL 与可玩性。
- [ ] 审核激活与下架使用 `publicationRevision` CAS;过期审核、重复批准、并发更新和下架不会恢复本应关闭的游戏。
- [ ] 游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见,不返回私有快照地址。
- [ ] 每游戏在独立 HTTPS origin 上,iframe sandbox、网关 CSP/CORS/MIME/禁止 Worker 等策略与主规范一致。
- [ ] 实际 npm/Vite 模块和同包资源在 opaque sandbox 下可载入;外站 fetch/WebSocket、平台 Cookie/storage/DOM、顶层跳转、弹窗和敏感权限被阻断。
- [ ] 作者下架及管理员安全下架会关闭新启动和发行读取;不能绕过网关直取公开 OSS 对象;撤销传播符合最大缓存窗口。
### 证据要求
- 自动化:审核权限、版本切换事务、公开投影与入口 allowlist、响应头和缓存策略测试。
- 运行时:真实独立域名内运行至少一个代表性游戏;真实审核、更新失败、并发下架和旧 URL 回读证据。
- 边界:外部请求阻断、匿名私有对象拒绝、跨游戏 origin 隔离及 60 秒以内缓存撤销(以获批值为准)。
## 阶段 C:双端发布与现代游戏体验
### 前置条件
- B 已验收;网页根入口、桌面/移动导航、暖色主题交互稿及首版设备范围已评审。
- AGC 现有 npm 构建/导出能提供完整 dist,不需要把源码同步管道改作发行管道。
### 行为与验收
- [ ] AGC 从已构建 dist 生成根入口为 `index.html` 的真实包,一次提交动作完成检查、资料确认、上传和送审;状态及失败原因与服务端回读一致。
- [ ] 网页可选 ZIP、提交封面和必需资料,进入相同上传/校验/审核流程;任一客户端可以查看同账号游戏状态,更新沿用相同 `gameId`
- [ ] 上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布。
- [ ] 目录支持真实数据、关键词/分类/设备筛选、空/错/加载态;详情提供明确主动作,搜索与返回恢复上下文。
- [ ] 游客从目录/分享链接进入详情再启动真实已发布游戏;开始操作后才加载 iframe,加载失败可重试,退出不会自动重启游戏。
- [ ] 桌面与移动导航、详情、上传/发布面板、旋转提示、全屏、安全区和焦点可达符合主规范;不适配移动端的游戏有明确门槛。
- [ ] 视觉沿用现有 warm token,通用交互复用共享组件;没有固定假统计、本地演示兜底或同源 iframe 放宽。
### 证据要求
- 自动化:AGC 打包与操作恢复定向 Rust 测试、两端客户端/组件/路由/状态测试及类型检查。
- 运行时:AGC 一次真实发布、网页一次真实 ZIP 上传,分别审核后从桌面和手机游玩;浏览器覆盖游戏模块、素材、音频、触屏及横竖屏。
- 边界:未构建/失效 dist、资料缺失、换账号、迟到响应、审核拒绝、非移动游戏及真实空态。
## 阶段 D:端到端、运维与上线准备
### 前置条件
- C 已验收;生产域名/TLS、CDN、私有存储、审核账号及保留/清理周期可用且已确认。
### 行为与验收
- [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。
- [ ] 100 MiB 包与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。
- [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。
- [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。
- [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。
- [ ] 主规范逐条证据矩阵齐全,未验证项明确列出;有任何核心路径未验证时不标记上线完成。
### 证据要求
- 自动化:范围匹配前后端、schema/契约、编码与文档索引门禁,部署配置检查。
- 运行时:真实发行域名、真实存储、真实账号和两类客户端的完整链路证据,桌面及移动视口记录。
- 边界:压力/额度、重启恢复、缓存撤销、误删防护和回滚演练。
## 当前待审项与下一门禁
主规范仍待确认人工审核策略、导航、首版能力/额度、对象保留、发行域名及运营责任;当前没有主规范与里程碑通过评审的记录,也没有任何阶段验收证据。先评审这些决策,再冻结 A 的持久化与 DTO 明细并创建仅覆盖 A 的实施计划。不得把该文档状态改为 accepted 以代替评审。
所有阶段完成并验收后,稳定事实回写主规范,删除本临时里程碑与各阶段实施计划;过程记录不进入产品代码、用户文案或长期共享记忆。
@@ -55,6 +55,35 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
- 验证方式:`provider_transient_retry_` 7 项中重写后的档位用例与 upstream-400 用例通过(断言 `maxRetries` 直取设置值、400 与其它瞬态共用同一预算),`provider_retry_` 其余 26/28 通过;该组 2 项(`provider_transient_retry_transport_failure_closes_then_stable_retry_succeeds``provider_transient_retry_backoff_is_exponential_and_capped_at_thirty_seconds`)与 `provider_retry_waiting_final_reply_*` 2 项在本机改动前后同为失败(`stash` 基线复跑确认,现象是等待自动重试唤醒超时)。本机串行全量套件另有既有环境失败(`tempfile::tempdir()` 归属校验、缺少 npm 构建产物、Windows 启动失败 MessageBox 阻塞 `startup_log_slot_fail_without_path...`);抽查其中 5 项在 `stash` 基线上同样失败,与本次改动无关。仓库 `cargo fmt --check``npm run check:encoding``git diff --check` 通过。
- 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。
## 2026-09-20 游戏分发用灰度开关承载「关闭投稿、保在线」的回滚口径
- 背景:主规范要求回滚部署时“关闭新提交和新版本激活,保留当前可玩版本与状态读取”。此前只能靠改配置或停服实现。
- 决策:复用现役灰度配置(`game-distribution:publish`,后台「灰度发布配置」可改),没有 gate 行或 `enabled=false` 时默认开放;`enabled=true` 时只有白名单/标签/灰度命中的作者能发布,`rolloutPercent=0` 且无白名单等于紧急关闭投稿。拦截范围是作者写入(创建游戏/版本、上传、送审、撤回、下架)与管理员批准;读取、发行网关、审核队列读取、拒绝审核与安全下架始终可用,避免把“关投稿”变成“停服务”或“无法处理事故”。
- 失败姿态:开关读取失败按关闭处理(写入口 503),读取路径不受影响。
- 关联:`server-rs/crates/module-runtime/src/application.rs``server-rs/crates/api-server/src/state.rs``server-rs/crates/api-server/src/modules/game_distribution.rs`、[开发运维文档](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。
## 2026-09-20 游戏发行包 PUT 采用受控重试与容量边界口径
- 背景:阶段 D 容量验证时实测 99.0 MiB 发行包单次 PUT 成功耗时 11.8s、api-server 峰值内存 378 MB(基线 82 MB),但三次尝试里出现过一次 `请求 OSS 失败:error sending request`。当时版本停在 `awaiting_upload`(可原版本重传),代价是作者白传一次整包。
- 决策:`platform-oss` 新增 `put_internal_object_with_retry`,复用既有 `oss_error_is_retryable` 分类(传输/超时/connect、408、429、5xx、400+RequestTimeout 可重试;确定性 4xx 不重试),body 只转一次引用计数的 `Bytes`,各 attempt 复用同一份字节;发行包上传配置为 3 次尝试、250/500ms 退避,参数不合法(次数为 0 或缺退避)时按配置错误失败关闭。
- 容量口径(真实栈实测,作为阶段 D 证据基线):99.0 MiB 包 11.8s / 峰值 +296 MB;声明 101 MiB 在创建版本即 413;请求体 101 MiB 被请求体限制 413 且版本保持可重传;压缩比 1000 与单文件 65 MiB、10,001 文件都是 422 `PACKAGE_VALIDATION_FAILED` 并落到 `upload_failed`/`reupload`;失败包不进公开目录。
- 关联:`server-rs/crates/platform-oss/src/lib.rs``server-rs/crates/api-server/src/modules/game_distribution.rs`、[实施计划](../plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md)。
## 2026-09-20 游戏详情的“游玩方式”以版本声明的 inputModes 为准
- 背景:公开投影里 `currentVersion.controls` 一直是空数组(首版没有自由文本操作说明的录入),详情页却只读它,于是所有已发布游戏都显示「未标注操作方式」,而作者其实在发布时声明过 `inputModes`
- 决策:展示层优先用公开投影里已有的结构化 `inputModes`(键盘 / 鼠标 / 触屏,去重后按声明顺序拼接),再退回 `controls` 自由文本,两者都为空才显示「未标注操作方式」。不改 HTTP 契约、不新增后端字段。
- 关联:`src/components/game-distribution/GameDetailPage.tsx``src/components/game-distribution/GameDistributionPages.test.tsx`、[主规范](../../【玩法创作】平台入口与玩法链路-2026-05-15.md)。
## 2026-09-20 游戏分发的版本回读、撤回与安全下架以服务端 recoveryAction 为准
- 决策:游戏发行版本的「下一步做什么」不从客户端状态推断。`GET /api/game-distribution/versions/{versionId}`(作者)与 `/admin/api/game-distribution/versions/{versionId}`(管理员)返回版本私有投影 + 服务端派生的 `recoveryAction``upload` / `submit` / `wait` / `none` / `reupload` / `fix_package` / `fix_metadata`),网页发布页与作者中心只按它渲染主行动作。
- 撤回语义:`POST /api/game-distribution/versions/{versionId}/cancel` 只能撤回未参与当前公开投影的版本,要求 `Idempotency-Key``expectedPublicationRevision` CAS;已公开版本必须走作者下架或管理员 `suspend`,不能借撤回关闭线上入口。
- 可见性:未知版本与非 owner 的版本一律 404,不用 403 区分「别人的版本」和「不存在的版本」。
- 幂等响应:命中既有幂等收据的写操作统一回传 `replayed: true`(此前所有写操作固定 false),客户端据此区分「本次生效」与「复用既有结果」。
- 网页恢复:`/games/publish` 只把 `{ownerUserId, gameId, versionId, versionNumber, title}` 写入 localStorage 作为恢复标识;换账号只忽略草稿、不回读也不清除,禁止展示上一账号的私有状态。
- 关联文档:[游戏分发实现计划](../plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md)、[主规范](../../【玩法创作】平台入口与玩法链路-2026-05-15.md)。
## 2026-09-18 AGC backend 采用共享 Runtime、本地宿主与云端控制面分层
- 决策:AGC backend 统一按“`agent-runtime-core`/`agent-runtime-orchestration` 共享内核 + Tauri 本地执行宿主 + `server-rs` 云端控制面 + `module-*`/`platform-*` 领域与外部适配器”整理;先建立 application facade、能力合同和跨边界状态映射,不新建第二套 Agent Runtime、会话库或业务真相。
@@ -5784,3 +5784,17 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:发行网关的公开静态响应使用 `Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`,继续保留 `X-Content-Type-Options: nosniff`、内容类型白名单、HTML 最小权限 CSP 和「带 Cookie 一律 403」。这些都是公开静态文件,放宽 CORP/CORS 不暴露凭据;容器隔离靠沙箱、CSP 与独立来源,不靠 CORP。
- **验证方式**:不要只用 `onLoad` 判断可玩。要在真实浏览器里点「开始游戏」,确认控制台没有 `ERR_BLOCKED_BY_RESPONSE`/CSP 报错,并核对 api-server 访问日志里该版本资源的 `http.response.status_code=200`
- **关联**`server-rs/crates/api-server/src/modules/game_distribution.rs``release_asset_response`)、`src/components/game-distribution/GamePlayPage.tsx`、[`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`](../../【玩法创作】平台入口与玩法链路-2026-05-15.md)。
## 2026-09-20 在 jsdom 里把 AGC 发布接到真实后端:Blob 没有 arrayBuffer,且跨 realm BodyInit 会被 undici 拒绝
- **现象**:给 AGC 的 `publishLocalProjectGame` 写“默认跳过”的真实后端集成测试时,创建游戏、创建版本都成功,只有上传 ZIP 报“无法连接登录服务”(`networkError: true`),服务端访问日志里也没有这次上传。
- **原因**:测试跑在 jsdom 环境,`fetch` 是 Node(undici),但请求体是 jsdom 的 `Blob`:① 该 jsdom 版本的 `Blob` 没有 `arrayBuffer()``typeof blob.arrayBuffer === 'undefined'`),直接调用会抛异常;② 即便拿到字节,jsdom realm 的 `ArrayBuffer`/`Uint8Array` 也不是 undici 认得的 `BodyInit`
- **处理(现行口径)**:桥接层用 `FileReader.readAsArrayBuffer` 读 jsdom Blob(有 `arrayBuffer` 时才走它),再用 `Buffer.from(new Uint8Array(...))` 复制成 Node 侧 Buffer 交给 undici。参考 `apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts`
- **关联**`apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts``apps/ai-game-creator-shell/src/services/clientHttp.ts`
## 2026-09-20 AGC 导出后自动弹发布面板:焦点陷阱会吞掉模态之外的点击,既有导出快捷操作用例变红
- **现象**:给 AGC 加「导出试玩包后自动打开发布到游戏广场面板」后,`appSurface.test.ts` 的「预览快捷操作:导出确认、取消与包列表」变红——期望点消息上的「显示目录」把 `/open-project` 填进输入框,实际输入框仍是空。把发布面板的自动打开去掉,或用例先关掉面板,就恢复绿色。
- **原因**:同目录的 `ThemedModal``createPortal` + `focus-trap-react` 渲染模态。焦点陷阱存在时,模态之外的 `click` 不会触达 React 的处理器(实测:临时把 `FocusTrap` 换成普通 `div`、其余不动,同一个被模态遮住的按钮点击立刻恢复生效),所以自动化里“点模态背后的按钮”不会报错,只是静默无效。
- **处理(现行口径)**:① 产品行为保留“导出成功后自动打开面板”(一键发布入口),但受影响的用例必须先用 `findByRole('dialog', { name: '发布到游戏广场' })` 断言面板出现、点「关闭发布面板」再继续后续会话操作;② 给这类“新增自动弹窗”改流程时,先跑一遍相关 `appSurface` 用例,避免只跑新增用例;③ 排查同类“点了没反应”时,先看当前是否有焦点陷阱模态打开,而不是先怀疑事件绑定或状态。
- **关联**`apps/ai-game-creator-shell/src/App.tsx``setPublishPanelOpen(true)`)、`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx``apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx``apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts`
@@ -672,6 +672,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
- 用途:不可变发行版本与真实包确认事实。创建后冻结 `package_sha256`、字节数、文件数、根入口和版本号;后续只推进上传、校验、审核、公开、撤回状态,并记录私有对象键、文件清单、入口 URL、审核者和阶段时间。
- 索引:`by_game_distribution_version_game_id``by_game_distribution_version_owner_user_id`。真实 ZIP 由 `api-server` 校验并写入私有 OSS 后,才通过 facade 确认 `uploaded`;表不保存 ZIP 正文。
- 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction``api-server``status` 派生,不落表。
### `game_distribution_idempotency_receipt`
@@ -679,6 +680,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
- 用途:创建、上传、提交、审核、撤回和下架操作的幂等收据。`owner_user_id + action + idempotency_key` 组合唯一,保存请求摘要、结果 ID、有限结果 JSON、创建/过期时间和完成时间;同 key 不同摘要必须返回冲突,收据不保存凭据或包正文。
- 索引:owner、game、version 和 `by_game_distribution_receipt_scope` 组合索引;默认保留窗口由服务端清理策略控制。
- 重放语义:写操作命中既有收据时,procedure 回传 `replayed = true` 并复用收据里的结果 ID,不重复创建游戏、版本或审核结论;同 key 不同请求摘要必须返回冲突。
### `llm_router_account`
@@ -606,6 +606,37 @@ Nginx 负责站点和反向代理
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
```
### 游戏发行来源(发行域名、每游戏 origin 与缓存窗口)
已公开游戏运行在**独立来源**上,与主站来源隔离;这是发行网关(`api-server`)之外唯一需要的边缘配置。
- 配置工件:`deploy/nginx/genarrative-release-origin.conf`。上线前替换域名、通配证书路径与 upstream 端口,并把文件安装到 Nginx 站点目录。
- 前置资源:`*.games.<域名>` 通配 DNS 指向同一入口,以及覆盖该通配名的 TLS 证书(certbot DNS-01 或等价流程)。
- 路由约定:`https://<gameId>.games.<域名>/` 是该游戏的入口(子域根路径映射到该游戏的 `index.html`),其余路径按原样映射到 `/api/game-distribution/releases/<gameId>/…`;平台 API、后台、SPA 与上传接口在这个来源上一律 404,命中即证明边缘多代理了命名空间。
- 会话隔离:发行来源从不使用 Cookie。带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`;发行网关自身同样对带 Cookie 的请求返回 403。
- 响应头与缓存:`X-Content-Type-Options`、CORP`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以“停止继续分发”为准。
- 审核动作:管理员审核通过时填写的 `entryUrl` 必须是该游戏的子域根地址 `https://<gameId>.games.<域名>/`HTTPS、无凭据、无 query/fragment);非生产环境仍按现有口径允许 http 回环地址用于本地联调。
- 门禁与本地联调:
```bash
# 模板约束 + 发行网关响应头策略交叉检查;本机有 nginx/openssl 时还会渲染一份临时配置跑 nginx -t
npm run check:release-origin-config
```
本地想在真实边缘语义下复验时,可以把模板渲染到 `~/data/tmp`(替换 upstream 为本地 api-server 端口、证书换成自签通配证书、监听端口换成高位端口),用 `nginx -c <渲染文件>` 起一个临时实例,再用 `curl -H 'Host: <gameId>.games.example.com'` 验证:根路径 200 `text/html``/assets/*` 200、带 Cookie 403、平台 API 路径 404、未知 gameId 404、http 301 到 https。
#### 发布开关(关闭投稿、保在线)
发布事故或回滚窗口里用 `game-distribution:publish` 灰度开关控制写入,不需要改代码或重启:
- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`
- 语义:没有该 gate 行或 `enabled=false` 表示**默认开放**`enabled=true` 时只有 `allowUserIds` / `allowUserTags` / `rolloutPercent` 命中的作者能发布,`rolloutPercent=0` 且无白名单即**全部关闭**(等价紧急关闭投稿)。
- 关闭范围:创建游戏、创建版本、上传包、送审、撤回、作者下架,以及管理员**批准**(新版本激活)都返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`
- 始终可用:目录、详情、版本回读、发行网关(已公开游戏继续游玩)、`/my-games`、审核队列读取、**拒绝审核**与管理员**安全下架**。
- 失败姿态:开关状态读取失败时按关闭处理,避免绕过运营刚下的收紧动作;本地排障时确认 SpacetimeDB 正常后再判断业务是否被误伤。
上线前仍需确认的外部状态:通配 DNS 与证书已生效、upstream 端口与 `genarrative-api.service` 一致、若前面还有 CDN 需把 TTL 收敛到不超过上述 60 秒窗口,并跑一次真实域名下的“审核通过 → 游玩 → 换版 → 下架”链路。
### 生产健康巡检
`Genarrative-Server-Provision` 会安装并启用 `genarrative-health-patrol.timer`,默认每 5 分钟运行一次 `genarrative-health-patrol.service`。巡检脚本随 API release 归档到 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs`,只读检查:
@@ -113,7 +113,7 @@
### HTTP 与持久化边界(拟定)
现役内部命名空间使用 `/api/game-distribution`;除标注为完整后台路径的行外,下表路径都带该前缀。标注「已实现」的路由已在 `api-server` 挂载并有定向测试,标注「待评审」的仍未落地。公开目录只消费后端投影;所有变更经过现有鉴权、限流和埋点中间件。账号内部 API 不扩展 `/api/external/v1`,后续若对外开放再同步 External OpenAPI。
现役内部命名空间使用 `/api/game-distribution`;除标注为完整后台路径的行外,下表路径都带该前缀。标注「已实现」的路由已在 `api-server` 挂载并有定向测试;本表当前没有待落地路由。公开目录只消费后端投影;所有变更经过现有鉴权、限流和埋点中间件。账号内部 API 不扩展 `/api/external/v1`,后续若对外开放再同步 External OpenAPI。
| 方法与路径 | 身份 | 行为 |
| --- | --- | --- |
@@ -124,13 +124,13 @@
| `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` |
| `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 |
| `PUT /versions/{versionId}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 |
| `GET /versions/{versionId}` | owner/管理员 | 回读状态、错误代码、恢复动作及审核结论,普通游客不可读 |
| `GET /versions/{versionId}` | owner/管理员 | **已实现**:回读状态、错误代码、审核结论与服务端 `recoveryAction`;管理员走 `/admin/api/game-distribution/versions/{versionId}`;未知版本与别人的版本都按不可见返回 404 |
| `POST /versions/{versionId}/submit` | owner | **已实现**:只有已确认完整包可提交,返回 202 并进入 `pending_review` |
| `POST /versions/{versionId}/cancel` | owner | 撤回尚未公开版本,保留幂等结果 |
| `POST /versions/{versionId}/cancel` | owner | **已实现**:带 `expectedPublicationRevision` CAS 与 `Idempotency-Key`,只能撤回未参与公开投影的版本;同 key 同请求重放返回 `replayed: true`,摘要不同返回 409 |
| `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 |
| `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 |
| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准需 HTTPS 发行入口并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 |
| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | 安全下架整个游戏并撤销发行访问;此行是完整后台路径 |
| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;后台游戏审核页提供带原因输入与二次确认的入口;此行是完整后台路径 |
除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。
@@ -151,7 +151,7 @@
- 建议 HTML、公开状态与启动 API 使用 `no-store`;发行静态资源的浏览器与 CDN 有效期均不超过 60 秒,禁止 `stale-while-revalidate``stale-if-error` 和发行 Service Worker。下架主动 purge 相关 CDN 键,60 秒作为最大缓存撤销窗口,不把 purge 成功当唯一保障。旧版本被更新替代后,新启动只用当前版;旧游戏已经载入的脚本/资源不承诺远程抹除,用户退出或刷新后按当前授权重新判断。
- 发布所需部署依赖包括独立站点域名及通配 TLS、每游戏 host 路由、私有存储、网关 CSP/CORS/MIME、CDN TTL/purge、管理员审核运营入口和可恢复校验执行器;缺少任一项不能宣布公开上线。
- 观察上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量,日志按游戏/版本/操作 ID 关联,不记录 Token、完整用户文件内容或 signed URL。原始失败/撤回包建议保留 7 天后清理,公开版本和审核记录的保留周期在上线前确定;清理必须先检查引用,不能删除仍在服务的版本。
- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。
- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。现役实现:`game-distribution:publish` 灰度开关(后台「灰度发布配置」)控制作者写入与新版本激活——没有 gate 行或 `enabled=false` 时默认开放;`enabled=true` 时只有白名单/灰度命中的用户能发布(`rolloutPercent=0` 且无白名单即全部关闭)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。
### 验收标准与证据
+1
View File
@@ -82,6 +82,7 @@
"check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs",
"check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs",
"check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs",
"check:release-origin-config": "node scripts/check-release-origin-config.mjs",
"check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs",
"check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs",
"check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs",
@@ -0,0 +1,149 @@
export const GAME_DISTRIBUTION_CATEGORIES = [
'休闲',
'益智',
'动作',
'冒险',
'模拟',
'策略',
'其他',
] as const;
export type GameDistributionCategory =
(typeof GAME_DISTRIBUTION_CATEGORIES)[number];
export type GameDistributionDeviceSupport = {
desktop: boolean;
mobile: boolean;
touch: boolean;
};
export type GameDistributionInputMode = 'keyboard' | 'mouse' | 'touch';
export type GameDistributionOrientation =
| 'landscape'
| 'portrait'
| 'responsive';
export type GameDistributionAuthor = {
id: string;
name: string;
avatarUrl?: string | null;
};
export type GameDistributionVersionStatus =
| 'awaiting_upload'
| 'uploaded'
| 'validating'
| 'pending_review'
| 'published'
| 'upload_failed'
| 'validation_failed'
| 'rejected'
| 'cancelled'
| 'revoked';
export type GameDistributionGameVisibility =
| 'unpublished'
| 'published'
| 'suspended';
export type GameDistributionVersionSummary = {
id: string;
version: string;
entryUrl: string;
sha256: string;
publishedAt: string;
controls: string[];
};
export type GameDistributionGame = {
id: string;
title: string;
summary: string;
description: string;
category: GameDistributionCategory;
tags: string[];
coverColor: string;
icon: string;
author: GameDistributionAuthor;
deviceSupport: GameDistributionDeviceSupport;
inputModes?: GameDistributionInputMode[];
orientation?: GameDistributionOrientation;
status: Extract<GameDistributionGameVisibility, 'published' | 'unpublished'>;
/** 公开切换 CAS 版本号;作者下架与管理员审核都必须回传当前值。 */
publicationRevision: number;
currentVersion: GameDistributionVersionSummary | null;
playCount: number;
createdAt: string;
};
export type GameDistributionListResponse = {
games: GameDistributionGame[];
nextCursor?: string | null;
};
export type GameDistributionCreateGameRequest = {
/** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */
localProjectId?: string | null;
title: string;
summary: string;
description?: string;
category: GameDistributionCategory;
tags?: string[];
coverAssetId?: string;
deviceSupport: GameDistributionDeviceSupport;
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
};
export type GameDistributionCreateVersionRequest = {
localProjectId?: string | null;
packageSha256: string;
packageBytes: number;
packageFileCount: number;
packageEntryPath: 'index.html';
gameMetadata: GameDistributionCreateGameRequest;
};
export type GameDistributionPrivateVersion = {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: GameDistributionVersionStatus;
/** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */
publicationRevision: number;
reviewReason?: string | null;
createdAt: string;
updatedAt: string;
};
/**
* 版本状态的下一步动作,由服务端派生;客户端只按它渲染主行动作,不自行推断状态。
*/
export type GameDistributionRecoveryAction =
| 'upload'
| 'submit'
| 'wait'
| 'none'
| 'reupload'
| 'fix_package'
| 'fix_metadata';
export type GameDistributionVersionDetail = {
game: GameDistributionGame;
version: GameDistributionPrivateVersion & {
recoveryAction: GameDistributionRecoveryAction;
};
};
export type GameDistributionCancelVersionRequest = {
expectedPublicationRevision: number;
reason?: string;
};
export type GameDistributionCancelVersionResponse = {
game: GameDistributionGame;
version: GameDistributionPrivateVersion;
replayed: boolean;
};
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* 游戏发行来源配置门禁。
*
* 逐条校验 `deploy/nginx/genarrative-release-origin.conf`
* 1) 每游戏独立 origin 的按主机映射(命名捕获 `game_id` + 发行网关前缀);
* 2) 只暴露发行网关,不代理平台 API / 后台 / SPA
* 3) 发行来源不使用 Cookie(边缘 403 + 转发前清空);
* 4) 响应头策略仍由 api-server 发行网关负责(源码级交叉检查)。
* 只要本机存在 nginx 与 openssl,还会用自签通配证书渲染一份临时配置执行
* `nginx -t`,把语法与指令上下文一起验证掉。
*/
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, '..');
const templatePath = join(
repoRoot,
'deploy/nginx/genarrative-release-origin.conf',
);
const gatewayPath = join(
repoRoot,
'server-rs/crates/api-server/src/modules/game_distribution.rs',
);
const failures = [];
const notes = [];
function fail(message) {
failures.push(message);
}
function normalize(source) {
return source.replace(/\s+/gu, ' ');
}
function requireSnippet(source, snippet, message) {
if (!normalize(source).includes(normalize(snippet))) {
fail(message);
}
}
function main() {
if (!existsSync(templatePath)) {
fail(`缺少发行来源模板:${templatePath}`);
return;
}
const template = readFileSync(templatePath, 'utf8');
requireSnippet(
template,
'server_name ~^(?<game_id>[a-z0-9_]+)\\.games\\.example\\.com$;',
'发行来源必须用命名捕获 game_id 的子域匹配(每游戏独立 origin)',
);
requireSnippet(
template,
'ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem;',
'发行来源必须使用通配 TLS 证书',
);
requireSnippet(
template,
'if ($http_cookie) { return 403; }',
'发行来源必须拒绝携带平台 Cookie 的请求',
);
requireSnippet(
template,
'proxy_set_header Cookie "";',
'发行来源转发前必须清空 Cookie',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri;',
'发行来源必须按 game_id 映射到发行网关前缀',
);
requireSnippet(
template,
'location /.well-known/acme-challenge/',
'发行来源必须保留 ACME challenge 路径',
);
requireSnippet(
template,
'location = / {',
'发行来源必须显式把子域根路径映射为该游戏的 index.html',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html;',
'子域根路径必须映射到该游戏的 index.html',
);
const proxyPassCount = (template.match(/proxy_pass\s/gu) ?? []).length;
if (proxyPassCount !== 2) {
fail(
`发行来源只应存在两条 proxy_pass(子域根路径与发行网关前缀),实际 ${proxyPassCount}`,
);
}
const cookieStripCount = (
template.match(/proxy_set_header Cookie "";/gu) ?? []
).length;
if (cookieStripCount !== 2) {
fail(`每条发行来源代理都必须清空 Cookie,实际 ${cookieStripCount}`);
}
const gatewayPrefixCount = (
template.match(/api\/game-distribution\/releases\/\$game_id/gu) ?? []
).length;
if (gatewayPrefixCount !== 2) {
fail(`发行来源代理必须都映射到发行网关前缀,实际 ${gatewayPrefixCount}`);
}
for (const forbidden of [
'/api/auth',
'/api/profile',
'/admin/api',
'/api/game-distribution/games',
'/api/game-distribution/versions',
]) {
if (template.includes(forbidden)) {
fail(`发行来源不得代理平台命名空间:${forbidden}`);
}
}
if (!existsSync(gatewayPath)) {
fail(`缺少发行网关源码:${gatewayPath}`);
} else {
const gateway = readFileSync(gatewayPath, 'utf8');
for (const [snippet, message] of [
[
'header::X_CONTENT_TYPE_OPTIONS',
'发行网关必须继续设置 X-Content-Type-Options',
],
[
'HeaderName::from_static("cross-origin-resource-policy")',
'发行网关必须继续设置 CORP',
],
[
'HeaderValue::from_static("cross-origin")',
'CORP 必须是 cross-originopaque sandbox 才能加载自有脚本)',
],
[
'header::ACCESS_CONTROL_ALLOW_ORIGIN',
'发行网关必须继续设置无凭据 CORS',
],
['header::CONTENT_SECURITY_POLICY', '发行网关必须继续为 HTML 设置 CSP'],
['StatusCode::FORBIDDEN', '发行网关必须继续拒绝携带 Cookie 的请求'],
]) {
if (!gateway.includes(snippet)) {
fail(message);
}
}
}
validateWithNginx(template);
if (failures.length > 0) {
console.error('[check:release-origin-config] FAILED');
for (const message of failures) {
console.error(`- ${message}`);
}
process.exit(1);
}
for (const note of notes) {
console.log(`[check:release-origin-config] ${note}`);
}
console.log(
'[check:release-origin-config] OK(发行来源模板、网关响应头策略与 nginx 语法一致)',
);
}
function binaryExists(binary) {
try {
execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function validateWithNginx(template) {
if (!binaryExists('nginx')) {
notes.push('未找到 nginx,跳过渲染后的 nginx -t');
return;
}
const workDir = mkdtempSync(join(tmpdir(), 'genarrative-release-origin-'));
try {
const certPath = join(workDir, 'wildcard.crt');
const keyPath = join(workDir, 'wildcard.key');
if (binaryExists('openssl')) {
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-days',
'1',
'-subj',
'/CN=games.example.com',
'-addext',
'subjectAltName=DNS:*.games.example.com,DNS:games.example.com',
'-keyout',
keyPath,
'-out',
certPath,
],
{ stdio: 'ignore' },
);
} else {
notes.push('未找到 openssl,跳过渲染后的 nginx -t');
return;
}
const rendered = template
.replace(
'/etc/letsencrypt/live/games.example.com/fullchain.pem',
certPath,
)
.replace('/etc/letsencrypt/live/games.example.com/privkey.pem', keyPath)
.replace(
/\/var\/log\/nginx\/(genarrative-release\.[a-z]+\.log)/gu,
join(workDir, '$1'),
)
// 非 root 环境无法绑定 80/443;语法检查用高位端口,不改生产模板本身。
.replace('listen 80;', 'listen 18080;')
.replace('listen 443 ssl http2;', 'listen 18443 ssl http2;');
const renderedPath = join(workDir, 'release-origin.conf');
writeFileSync(renderedPath, rendered);
const wrapperPath = join(workDir, 'nginx.conf');
writeFileSync(
wrapperPath,
[
`pid ${join(workDir, 'nginx.pid')};`,
`error_log ${join(workDir, 'error.log')} warn;`,
'events { worker_connections 64; }',
'http {',
' access_log off;',
' client_body_temp_path ' + join(workDir, 'client-body') + ';',
' proxy_temp_path ' + join(workDir, 'proxy') + ';',
' fastcgi_temp_path ' + join(workDir, 'fastcgi') + ';',
' uwsgi_temp_path ' + join(workDir, 'uwsgi') + ';',
' scgi_temp_path ' + join(workDir, 'scgi') + ';',
` include ${renderedPath};`,
'}',
'',
].join('\n'),
);
try {
execFileSync('nginx', ['-t', '-c', wrapperPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
notes.push('渲染后的发行来源配置通过 nginx -t');
} catch (error) {
const stderr = error.stderr ? String(error.stderr) : '';
fail(
`渲染后的发行来源配置未通过 nginx -t:${stderr.trim() || error.message}`,
);
}
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
main();
+85
View File
@@ -1312,6 +1312,91 @@ mod tests {
);
}
#[tokio::test]
async fn game_distribution_publish_switch_blocks_writes_but_keeps_reads_and_allowlist() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let user = seed_phone_user_with_password(&state, "13800138112", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &user, "sess_game_publish_switch");
let payload = serde_json::json!({
"title": "开关验证",
"summary": "发布开关验证",
"description": "发布开关验证",
"category": "其他",
"tags": ["验证"],
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
"localProjectId": "publish-switch-test"
})
.to_string();
let app = build_router(state.clone());
let publish_request = || {
Request::builder()
.method("POST")
.uri("/api/game-distribution/games")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("idempotency-key", "publish-switch-test-1")
.body(Body::from(payload.clone()))
.expect("request should build")
};
// 默认没有 gate 行:写入进入业务,不能被发布开关拦下。
let open = app
.clone()
.oneshot(publish_request())
.await
.expect("request should succeed");
assert_ne!(
open.status(),
StatusCode::SERVICE_UNAVAILABLE,
"默认状态不应拦截发布"
);
// 运营收紧到 rollout 0 且无白名单:作者写入 503 + 专用错误码。
state.set_test_feature_gate_config(vec![test_feature_gate(
module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY,
)]);
let blocked = app
.clone()
.oneshot(publish_request())
.await
.expect("request should succeed");
assert_eq!(blocked.status(), StatusCode::SERVICE_UNAVAILABLE);
let blocked_payload = read_json_response(blocked).await;
assert_eq!(
blocked_payload["error"]["code"],
"GAME_DISTRIBUTION_PUBLISH_DISABLED"
);
// 读取不受影响:公开目录仍走业务路径,不是发布关闭。
let catalog = app
.clone()
.oneshot(
Request::builder()
.uri("/api/game-distribution/games")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_ne!(catalog.status(), StatusCode::SERVICE_UNAVAILABLE);
// 白名单内用户仍可发布(灰度放行)。
let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
gate.allow_user_ids = vec![user.id.clone()];
state.set_test_feature_gate_config(vec![gate]);
let allowed = app
.oneshot(publish_request())
.await
.expect("request should succeed");
assert_ne!(
allowed.status(),
StatusCode::SERVICE_UNAVAILABLE,
"白名单用户不应被发布开关拦截"
);
}
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
module_runtime::FeatureGateConfigSnapshot {
gate_key: gate_key.to_string(),
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More