恢复游戏分发完整实现(特性分支)
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+216
@@ -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,
|
||||
};
|
||||
}
|
||||
+8
@@ -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(
|
||||
'未找到试玩包',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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-contracts(Rust/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 Vitest(149 passed,含新增游戏审核页与 client 用例)、前端游戏定向 Vitest(15 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 个文件同样 422(0.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 404;http 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` 且无白名单即全部关闭)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。
|
||||
|
||||
### 验收标准与证据
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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-origin(opaque 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();
|
||||
@@ -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
Reference in New Issue
Block a user