Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f0e2a48a3 | |||
| 00b96f244d | |||
| 0dde6ee501 | |||
| 4a91e5865a | |||
| 4a2b270714 | |||
| 789aef4ce5 | |||
| 7ff6e46774 | |||
| 2254831db5 | |||
| 64425e8d1a | |||
| 328ac31844 |
@@ -213,6 +213,8 @@ module.exports = {
|
||||
'!src/services/clipboard.test.ts',
|
||||
'!src/services/frontendRuntimeConfigService.ts',
|
||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||
'!src/services/gameDistributionClient.ts',
|
||||
'!src/services/gameDistributionClient.test.ts',
|
||||
'!src/services/sseStream.ts',
|
||||
'!src/services/sseStream.test.ts',
|
||||
'src/AdventurePanel.tsx',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
{ "url": "https://www.genarrative.world/api/*" },
|
||||
{ "url": "https://*/api/*" },
|
||||
{ "url": "http://localhost:*/*" },
|
||||
{ "url": "http://127.0.0.1:*/*" }
|
||||
{ "url": "http://127.0.0.1:*/*" },
|
||||
{ "url": "https://*.aliyuncs.com/*" }
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
|
||||
@@ -3927,10 +3927,7 @@ fn local_project_export_package_publish_payload_contains_bytes_and_file_digests(
|
||||
payload.package_bytes.len() as u64
|
||||
);
|
||||
assert_eq!(payload.files.len(), 2);
|
||||
assert!(payload
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "index.html"));
|
||||
assert!(payload.files.iter().any(|file| file.path == "index.html"));
|
||||
assert!(payload
|
||||
.files
|
||||
.iter()
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
} from './app/constants';
|
||||
import { useEscapeToClose } from './app/dialogs';
|
||||
import { resolveTauriInvoke } from './app/tauri';
|
||||
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
|
||||
import type {
|
||||
AgentBackgroundSubmitMode,
|
||||
AgentProgressEvent,
|
||||
@@ -98,6 +97,7 @@ import type {
|
||||
TauriInvoke,
|
||||
UploadLocalAssetResult,
|
||||
} from './app/types';
|
||||
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
|
||||
import { useWindowChrome } from './components/windowChromeContext';
|
||||
import {
|
||||
agentConversationId,
|
||||
|
||||
+511
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
import { requestClientApi } from './clientApi';
|
||||
|
||||
/** 直传凭证里的对象存储目标;字段口径与平台 `/api/assets/direct-upload-tickets` 一致。 */
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UploadedPlatformAsset = {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* 平台直传凭证支持的 OSS 主机白名单前缀。
|
||||
*
|
||||
* Tauri 的 http 插件只允许访问 `capabilities/main.json` 里声明的地址,这里再做一次校验,
|
||||
* 保证即使能力配置放宽到 `*.aliyuncs.com`,客户端也只把文件发到平台素材存储,而不是任意主机。
|
||||
*/
|
||||
const PLATFORM_UPLOAD_HOST_SUFFIXES = ['.aliyuncs.com'];
|
||||
|
||||
function isLocalUploadHost(parsed: URL) {
|
||||
return (
|
||||
parsed.protocol === 'http:' &&
|
||||
(parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost')
|
||||
);
|
||||
}
|
||||
|
||||
/** 校验直传地址;不是平台素材存储时直接失败关闭,避免把本地文件发给第三方主机。 */
|
||||
export function resolvePlatformAssetUploadUrl(host: string) {
|
||||
const trimmedHost = host.trim();
|
||||
if (!trimmedHost) {
|
||||
throw new Error('素材上传地址为空,请稍后重试');
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmedHost);
|
||||
} catch {
|
||||
throw new Error('素材上传地址无效,请稍后重试');
|
||||
}
|
||||
const isOssHost =
|
||||
parsed.protocol === 'https:' &&
|
||||
PLATFORM_UPLOAD_HOST_SUFFIXES.some((suffix) =>
|
||||
parsed.hostname.endsWith(suffix),
|
||||
);
|
||||
if (!isOssHost && !isLocalUploadHost(parsed)) {
|
||||
throw new Error('素材上传地址不属于平台素材存储,已终止上传');
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function buildDirectUploadFormData(
|
||||
upload: DirectUploadTicketResponse['upload'],
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields ?? {}).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
// OSS 要求 file 字段位于表单末尾,否则签名校验会失败。
|
||||
formData.append('file', file, file.name);
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本地文件上传成平台素材对象并返回素材标识。
|
||||
*
|
||||
* 三步与网页端一致:申请直传凭证 → 直传对象存储 → confirm 登记素材。AGC 无法在浏览器里
|
||||
* 直接跨域直传 OSS,所以直传这一步固定走 Tauri HTTP 插件(Rust 侧发起请求)。
|
||||
*/
|
||||
export async function uploadPlatformMediaAsset(args: {
|
||||
file: File;
|
||||
assetKind: string;
|
||||
pathSegments: string[];
|
||||
entityId: string;
|
||||
metadata?: Record<string, string>;
|
||||
/** 单测注入的直传实现;正式运行时固定使用 Tauri HTTP。 */
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<UploadedPlatformAsset> {
|
||||
const fileName = args.file.name.trim() || 'cover.png';
|
||||
const contentType = args.file.type.trim() || 'application/octet-stream';
|
||||
const ticket = await requestClientApi<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: args.pathSegments,
|
||||
fileName,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: args.file.size,
|
||||
metadata: {
|
||||
asset_kind: args.assetKind,
|
||||
...args.metadata,
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建素材上传凭证失败',
|
||||
);
|
||||
|
||||
const uploadHost = resolvePlatformAssetUploadUrl(ticket.upload.host);
|
||||
const uploadFetch = args.fetchImpl ?? tauriHttpFetch;
|
||||
let uploadResponse: Response;
|
||||
try {
|
||||
uploadResponse = await uploadFetch(uploadHost, {
|
||||
method: 'POST',
|
||||
body: buildDirectUploadFormData(ticket.upload, args.file),
|
||||
});
|
||||
} catch (error) {
|
||||
// Tauri http 插件在目标不在能力作用域、网络不可达或请求被取消时直接抛错;
|
||||
// 统一转成可操作文案,避免把英文插件错误原样暴露给作者。
|
||||
const detail = error instanceof Error ? error.message.trim() : '';
|
||||
throw new Error(
|
||||
`上传素材失败:无法访问素材存储,请检查网络后重试${
|
||||
detail ? `(${detail.slice(0, 120)})` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`上传素材到对象存储失败(HTTP ${uploadResponse.status}),请重试`,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = await requestClientApi<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: args.file.size,
|
||||
assetKind: args.assetKind,
|
||||
accessPolicy: 'private',
|
||||
entityId: args.entityId,
|
||||
}),
|
||||
},
|
||||
'确认素材资产失败',
|
||||
);
|
||||
return {
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
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[];
|
||||
/** 平台素材库里的封面素材 ID;服务端要求发布必须带封面。 */
|
||||
coverAssetId: string;
|
||||
/** 平台素材库里的截图素材 ID,最多 6 张。 */
|
||||
screenshots?: string[];
|
||||
deviceSupport: {
|
||||
desktop: boolean;
|
||||
mobile: boolean;
|
||||
touch: boolean;
|
||||
};
|
||||
inputModes: GameDistributionInputMode[];
|
||||
orientation: GameDistributionOrientation;
|
||||
};
|
||||
|
||||
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
|
||||
export const MAX_AGC_GAME_SCREENSHOTS = 6;
|
||||
|
||||
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 个字符');
|
||||
}
|
||||
const coverAssetId = (metadata?.coverAssetId ?? '').trim();
|
||||
if (!coverAssetId) {
|
||||
// 服务端会拒绝没有封面的发布;在创建游戏前失败关闭,避免留下无资料的半成品。
|
||||
throw new Error('请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场');
|
||||
}
|
||||
const screenshots = (metadata?.screenshots ?? [])
|
||||
.map((screenshot) => screenshot.trim())
|
||||
.filter(Boolean);
|
||||
if (screenshots.length > MAX_AGC_GAME_SCREENSHOTS) {
|
||||
throw new Error('游戏截图最多 6 张');
|
||||
}
|
||||
return {
|
||||
title,
|
||||
summary,
|
||||
description: (metadata?.description ?? summary).trim().slice(0, 2_000),
|
||||
category: metadata?.category ?? '其他',
|
||||
tags: metadata?.tags ?? [],
|
||||
coverAssetId,
|
||||
screenshots,
|
||||
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,
|
||||
coverAssetId: metadata.coverAssetId,
|
||||
screenshots: metadata.screenshots ?? [],
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -3878,6 +3878,184 @@ textarea {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed rgb(104 77 57 / 18%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media > label,
|
||||
.game-distribution-publish-panel__cover label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media input[type='file'] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
color: var(--platform-text-strong);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__picker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 透明 input 覆盖在触发按钮上:真实点击永远命中原生控件,自定义文案只负责显示。 */
|
||||
.game-distribution-publish-panel__picker input[type='file'] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__pick {
|
||||
display: inline-flex;
|
||||
pointer-events: none;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__picker:hover
|
||||
.game-distribution-publish-panel__pick {
|
||||
border-color: #a8663d;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__picker
|
||||
input[type='file']:focus-visible
|
||||
+ .game-distribution-publish-panel__pick {
|
||||
outline: 2px solid rgb(168 102 61 / 24%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__picker
|
||||
input[type='file']:disabled
|
||||
+ .game-distribution-publish-panel__pick {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 168px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-preview {
|
||||
display: grid;
|
||||
aspect-ratio: 16 / 9;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 12px;
|
||||
background: rgb(168 102 61 / 8%);
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__hint {
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button,
|
||||
.game-distribution-publish-panel__shots button {
|
||||
justify-self: start;
|
||||
width: fit-content;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 移除封面按钮固定落在文字列,避免占掉预览列的第二行。 */
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots li {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img,
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-style: dashed;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.game-distribution-publish-panel__cover {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__error {
|
||||
margin: 14px 0 0;
|
||||
color: #b42318;
|
||||
|
||||
+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,199 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AGC 素材直传(封面 / 截图)的三步链路边界。
|
||||
*
|
||||
* 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、
|
||||
* 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。
|
||||
*/
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestClientApiMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../src/services/clientApi', () => ({
|
||||
requestClientApi: (...args: unknown[]) => requestClientApiMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
resolvePlatformAssetUploadUrl,
|
||||
uploadPlatformMediaAsset,
|
||||
} from '../src/services/assetDirectUpload';
|
||||
|
||||
const TICKET = {
|
||||
upload: {
|
||||
bucket: 'genarrative-assets',
|
||||
host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/',
|
||||
objectKey:
|
||||
'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||||
legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||||
policy: 'policy-value',
|
||||
OSSAccessKeyId: 'ak-value',
|
||||
signature: 'signature-value',
|
||||
success_action_status: '204',
|
||||
'x-oss-meta-asset_kind': 'game_distribution_cover',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CONFIRMED = {
|
||||
assetObject: {
|
||||
assetObjectId: 'asset_cover_1',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
assetKind: 'game_distribution_cover',
|
||||
},
|
||||
};
|
||||
|
||||
function buildCoverFile() {
|
||||
return new File(['cover-bytes'], 'cover.png', { type: 'image/png' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestClientApiMock.mockReset();
|
||||
requestClientApiMock
|
||||
.mockResolvedValueOnce(TICKET)
|
||||
.mockResolvedValueOnce(CONFIRMED);
|
||||
});
|
||||
|
||||
test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => {
|
||||
const uploadFetch = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const file = buildCoverFile();
|
||||
|
||||
const uploaded = await uploadPlatformMediaAsset({
|
||||
file,
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
metadata: { game_distribution_media: 'cover' },
|
||||
fetchImpl: uploadFetch,
|
||||
});
|
||||
|
||||
expect(uploaded).toEqual({
|
||||
assetObjectId: 'asset_cover_1',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
});
|
||||
|
||||
const ticketCall = requestClientApiMock.mock.calls[0];
|
||||
expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets');
|
||||
const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body));
|
||||
expect(ticketBody).toEqual(
|
||||
expect.objectContaining({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
fileName: 'cover.png',
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: file.size,
|
||||
metadata: {
|
||||
asset_kind: 'game_distribution_cover',
|
||||
game_distribution_media: 'cover',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadCall = uploadFetch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(uploadCall[0]).toBe(TICKET.upload.host);
|
||||
expect(uploadCall[1].method).toBe('POST');
|
||||
const formData = uploadCall[1].body as FormData;
|
||||
expect(formData.get('policy')).toBe('policy-value');
|
||||
expect(formData.get('signature')).toBe('signature-value');
|
||||
expect(formData.get('success_action_status')).toBe('204');
|
||||
const uploadedFile = formData.get('file');
|
||||
expect(uploadedFile).toBeInstanceOf(File);
|
||||
expect((uploadedFile as File).name).toBe('cover.png');
|
||||
|
||||
const confirmCall = requestClientApiMock.mock.calls[1];
|
||||
expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm');
|
||||
expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({
|
||||
bucket: 'genarrative-assets',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: file.size,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-cover',
|
||||
});
|
||||
});
|
||||
|
||||
test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => {
|
||||
requestClientApiMock.mockReset();
|
||||
requestClientApiMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' },
|
||||
})
|
||||
.mockResolvedValueOnce(CONFIRMED);
|
||||
const uploadFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
fetchImpl: uploadFetch as never,
|
||||
}),
|
||||
).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||||
|
||||
expect(uploadFetch).not.toHaveBeenCalled();
|
||||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('对象存储拒绝上传时给出可重试文案', async () => {
|
||||
const uploadFetch = vi.fn(
|
||||
async () => new Response('denied', { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_screenshot',
|
||||
pathSegments: ['game-distribution', 'screenshot', '42'],
|
||||
entityId: 'game-distribution-screenshot',
|
||||
fetchImpl: uploadFetch,
|
||||
}),
|
||||
).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试');
|
||||
|
||||
// 直传失败时不得再登记素材,避免留下没有实体的素材记录。
|
||||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('直传网络/作用域错误转成可操作文案', async () => {
|
||||
const uploadFetch = vi.fn(async () => {
|
||||
throw new Error('url not allowed on the configured scope');
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
fetchImpl: uploadFetch,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'上传素材失败:无法访问素材存储,请检查网络后重试(url not allowed on the configured scope)',
|
||||
);
|
||||
|
||||
// 直传抛错时不得继续登记素材。
|
||||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => {
|
||||
expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe(
|
||||
'http://127.0.0.1:9000/bucket',
|
||||
);
|
||||
expect(
|
||||
resolvePlatformAssetUploadUrl(
|
||||
'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/',
|
||||
),
|
||||
).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/');
|
||||
expect(() =>
|
||||
resolvePlatformAssetUploadUrl('http://oss.example.com/'),
|
||||
).toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||||
expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow(
|
||||
'素材上传地址为空,请稍后重试',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// @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,
|
||||
metadata: {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1', 'asset_shot_2'],
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
expect(createGameBody.coverAssetId).toBe('asset_cover');
|
||||
expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']);
|
||||
|
||||
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,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
}),
|
||||
).rejects.toThrow('发布需要本地项目标识');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,
|
||||
metadata: { coverAssetId: ' ' },
|
||||
}),
|
||||
).rejects.toThrow('请先选择游戏封面');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('截图超过 6 张时在创建游戏前失败关闭', 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,
|
||||
metadata: {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: Array.from({ length: 7 }, (_, index) => `asset_${index}`),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('游戏截图最多 6 张');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
// @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 { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
|
||||
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
|
||||
const LIVE_COVER_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
function buildLiveCoverFile() {
|
||||
const bytes = Buffer.from(LIVE_COVER_PNG_BASE64, 'base64');
|
||||
return new File([new Uint8Array(bytes)], 'agc-live-cover.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
|
||||
const uploadedCover = await uploadPlatformMediaAsset({
|
||||
file: buildLiveCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', stamp],
|
||||
entityId: 'game-distribution-cover',
|
||||
// jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。
|
||||
fetchImpl: (input, init) => realFetch(apiUrl(input), init),
|
||||
});
|
||||
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
|
||||
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,
|
||||
coverAssetId: uploadedCover.assetObjectId,
|
||||
screenshots: [] as string[],
|
||||
};
|
||||
|
||||
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,323 @@
|
||||
// @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 { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
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() };
|
||||
});
|
||||
|
||||
// 面板只负责选图与调用上传;这里替换掉真实直传,避免测试触达 Tauri/OSS。
|
||||
vi.mock('../src/services/assetDirectUpload', () => ({
|
||||
uploadPlatformMediaAsset: 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 buildImageFile(name: string) {
|
||||
return new File(['cover-bytes'], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
/** 选择封面并等待上传完成;面板只有在素材拿到 ID 之后才允许发布。 */
|
||||
async function selectCover(
|
||||
file: File = buildImageFile('cover.png'),
|
||||
assetObjectId = 'asset_cover',
|
||||
) {
|
||||
vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({
|
||||
assetObjectId,
|
||||
objectKey: `game-distribution/cover/${file.name}`,
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await screen.findByText(new RegExp(`已选择「${file.name}」`, 'u'));
|
||||
}
|
||||
|
||||
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();
|
||||
vi.mocked(uploadPlatformMediaAsset).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: '动作' },
|
||||
});
|
||||
await selectCover();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({
|
||||
assetObjectId: 'asset_shot_1',
|
||||
objectKey: 'game-distribution/screenshot/shot-1.png',
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: { files: [buildImageFile('shot-1.png')] },
|
||||
});
|
||||
await screen.findByText('移除截图 1');
|
||||
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: '动作',
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1'],
|
||||
});
|
||||
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();
|
||||
await selectCover();
|
||||
|
||||
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();
|
||||
await selectCover();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'上传游戏发行包失败:游戏分发服务暂不可用(503)',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText('已提交审核')).toBeNull();
|
||||
});
|
||||
|
||||
test('没有选择封面时不发起发布并给出可操作提示', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
|
||||
).not.toBeNull();
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('封面与截图都先上传成平台素材,重复提交复用同一素材 ID', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(publishLocalProjectGame).mockRejectedValue(new Error('先失败'));
|
||||
const coverFile = buildImageFile('cover.png');
|
||||
renderPanel();
|
||||
await selectCover(coverFile, 'asset_cover_cached');
|
||||
|
||||
expect(vi.mocked(uploadPlatformMediaAsset).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
assetKind: 'game_distribution_cover',
|
||||
entityId: 'game-distribution-cover',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
await waitFor(() =>
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
// 再次选择同一个文件对象不应重新上传(面板按文件签名复用素材 ID)。
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [coverFile] },
|
||||
});
|
||||
await screen.findByText(/已选择「cover.png」/u);
|
||||
expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('截图超过 6 张时本地拦截且不上传', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: {
|
||||
files: Array.from({ length: 7 }, (_, index) =>
|
||||
buildImageFile(`shot-${index}.png`),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/游戏截图最多 6 张/u)).not.toBeNull();
|
||||
expect(uploadPlatformMediaAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('素材上传失败时保留面板并展示原因', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(uploadPlatformMediaAsset).mockRejectedValueOnce(
|
||||
new Error('创建素材上传凭证失败'),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [buildImageFile('cover.png')] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('创建素材上传凭证失败')).not.toBeNull();
|
||||
expect(screen.getByText('还没有封面')).not.toBeNull();
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,110 @@
|
||||
# 【实施计划】游戏分发阶段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、白名单用户可发布、重新开放后恢复。
|
||||
- 游戏内真实触屏输入(本机 Chromium + CDP `Input.dispatchTouchEvent`,390×844 视口):在 `/games/play` 点「开始游戏」后,向 iframe 可视区域坐标派发 `touchStart`/`touchEnd`;`sandbox="allow-scripts"` 且 `ready` 的沙箱内探测包回传 `{touchstart: 1, touchend: 1, pointerdown: 1, pointerTypes: ["touch"], click: 1, target: "pad", touches: 1}`,证明真实触摸事件经平台进入游戏容器并命中目标元素。注意:触摸模拟必须在 iframe 文档创建之前启用,否则文档不会注册 touch 事件支持(首次实测只收到 pointerdown,重载后 touchstart/touchend 才出现)。
|
||||
- 可观测性(阶段 D「上传失败、校验耗时、审核积压、撤销传播」):`api-server` 的游戏分发模块补齐结构化事件,均带 `request_id`(可 join 访问日志)与 `operation`,不记录 Token、signed URL、完整文件内容或本地路径。事件与字段:`package_confirmed`(game_id/version_id/package_bytes/file_count/sha256_prefix/oss_put_skipped/elapsed_ms)、`package_rejected`(code/reason/uploaded_bytes/elapsed_ms)、`version_submitted`、`version_cancelled`、`review_backlog_listed`(pending_versions/limit)、`review_decided`(decision/admin_user_id/publication_revision)、`game_unpublished`(visibility/publication_revision/active_version_id)、`game_suspended`(admin/reason/publication_revision)、`publish_switch_blocked`/`publish_switch_unavailable`、发行网关 `release_rejected`(debug 级:reason=cookie_present/unsupported_extension/not_public/no_active_version/version_not_published)。真实栈复跑一次完整链路后,8 类事件各出现 1-2 次,字段与耗时均可用(样例:`package_rejected … reason=InvalidArchive uploaded_bytes=9 elapsed_ms=6`、`game_unpublished … visibility=unpublished publication_revision=2 active_version_id=""`)。
|
||||
- 工程门禁:`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`)。
|
||||
|
||||
- 资料冻结与展示闭环(封面 + 截图):游戏表末尾新增可空 `cover_object_key` / `screenshots_json`,版本表末尾新增可空 `metadata_json`;创建版本时 api-server 校验「必需封面、≤6 张截图、素材属于当前作者且 `content_type` 为 `image/`」,并从素材记录派生对象键生成冻结快照,审核通过时整体生效到游戏行。
|
||||
- 公开素材读授权:只有 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 获得匿名读授权;其余素材仍按 owner 校验。
|
||||
- 作者续发复用:版本回读(作者本人)与审核回读(管理员)的版本投影新增 `frozenMetadata`,带回 `coverAssetId` / `screenshots[].assetId`;`/games/publish` 更新模式据此预填封面与截图,不要求作者为沿用封面重新上传;快照缺素材 ID 的旧版本明确要求重新选择封面。公开投影仍只暴露对象键。
|
||||
- 网页发布资料入口:`/games/publish` 新增「封面与截图」区(封面必需、截图 ≤6、可逐张移除、上传中禁用提交),复用平台图片直传 + confirm 通道;本地校验与服务端口径对齐(缺封面/超 6 张在发请求前拦截)。
|
||||
- 网页展示:游戏广场卡片与详情页 hero 用 `useResolvedAssetReadUrl` 换签展示真实封面,详情页在存在截图时给出可点击缩略图条(封面 + 截图,选中态 + 键盘可达),换签失败或无素材时静默回退原有渐变占位,不出现空框。
|
||||
- 本切片验证:`cargo check -p api-server -p spacetime-module -p spacetime-client`;`cargo test -p api-server game_distribution`(17 passed,含新增 `version_detail_payload_exposes_frozen_metadata_to_owner`);`npm run typecheck`;`npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts`(46 passed);改动文件 `eslint --max-warnings 0` 与 `npm run check:encoding`。
|
||||
|
||||
- AGC 发布面板资料入口:新增 `apps/ai-game-creator-shell/src/services/assetDirectUpload.ts`(凭证 → 直传 → confirm,直传固定走 Tauri HTTP 插件并校验目标主机必须是平台素材存储),面板支持封面必选 + 截图 ≤6、本地预览、逐张移除、上传中禁用发布;缺封面时在创建游戏前失败关闭,服务端「封面」类错误原样展示;同一文件重复提交复用素材 ID 不重复直传。为支持直传,`src-tauri/capabilities/main.json` 的 http 作用域新增 `https://*.aliyuncs.com/*`(配合客户端主机白名单,避免把本地文件发给任意主机)。
|
||||
- AGC 测试证据:`npx vitest run apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx apps/ai-game-creator-shell/tests/clientApi.test.ts`(27 passed);`npm --prefix apps/ai-game-creator-shell run typecheck`;改动文件 `eslint --max-warnings 0`。
|
||||
|
||||
- 真实本地栈媒体验证(2026-09-20,`npm run dev:api-server` + `dev:web`,api-server `127.0.0.1:12401`、SpacetimeDB `127.0.0.1:12402`、真实 dev OSS 桶):`E2E_ADMIN_USER=… E2E_ADMIN_PASSWORD=… npm run check:game-distribution-media-e2e`(`scripts/check-game-distribution-media-e2e.mjs`)27 项全部通过——真实直传封面与 2 张截图素材、缺封面 400、截图 7 张 400、不存在素材 400(创建游戏与创建版本两处)、他人素材 403、创建游戏/版本/上传/送审(202 + `pending_review`)、作者回读拿到 `frozenMetadata` 且素材 ID 与顺序一致、待审期间公开目录不含该游戏且封面匿名读返回 404、管理员审核通过后公开投影带封面与截图对象键且不含素材 ID、匿名 `read-url` 对封面与截图都返回签名地址、发行网关 `index.html` 与 `assets/app.js` 均 200 且带 nosniff。
|
||||
- 真实浏览器展示验证(同一栈,headless Chromium `390x844` 与 `1280x900`):`/games` 卡片渲染真实封面签名地址(无占位回退),`/games/detail?id=…` hero 显示封面、缩略图条渲染「封面 + 2 张截图」,点击第 3 张后 `aria-pressed` 与 hero 图片同步切换;移动端布局正常。截图存于 `~/data/tmp/gd3/`(不入库)。
|
||||
- 网页发布页真实上传验证(同一栈 + headless Chromium):在 `/games/publish` 里用作者会话填写资料、选择真实 PNG 封面与 ZIP 并提交,页面返回「已提交审核」;随后 `my-games` 显示该游戏 `pending_review`,版本回读的 `frozenMetadata.coverAssetId` / `coverObjectKey` 正是本次浏览器上传的素材,证明网页端封面直传(凭证 → OSS → confirm)与冻结链路真实可用。
|
||||
- 发布资料入口交互打磨:原生 file 控件在上传后清空 value 时会显示「未选择任何文件」,与「已选择」提示互相矛盾;网页发布页与 AGC 面板都改成透明 input 覆盖自定义胶囊按钮(点击命中原生控件,文案显示「选择/更换封面图片」「添加截图」),并在验证中用 `elementFromPoint` 确认点击命中的是 `input[type=file]`。
|
||||
- AGC 直传能力作用域静态验证:用 `tauri-plugin-http` 同一套 `urlpattern` 解析逻辑验证 `capabilities/main.json` 新增的 `https://*.aliyuncs.com/*` 命中 dev 桶(`xushi-dev.oss-cn-beijing.aliyuncs.com/…`)与生产桶(`genarrative-assets.oss-cn-shanghai.aliyuncs.com/…`)、拒绝 `evil.example.com`;直传失败(含作用域拒绝/网络不可达)统一转成中文可操作文案,并有单测覆盖。桌面端真实执行仍需在装有 AGC 的机器上跑一次。
|
||||
- 创建游戏同步校验素材归属:此前 `POST /api/game-distribution/games` 只校验「封面必填 / 截图 ≤6 / ID 非空」,素材是否存在与是否属于当前作者要等到创建版本才失败,游戏行会先落一个无效素材 ID;现在创建游戏与创建版本都走 `resolve_owned_game_media`,不存在返回 400、他人素材返回 403。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
- 真实独立发行域名、通配 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、会话库或业务真相。
|
||||
|
||||
@@ -5789,3 +5789,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`。
|
||||
|
||||
@@ -663,6 +663,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- Rust 结构体:`GameDistributionGame`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
|
||||
- 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。
|
||||
- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。
|
||||
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。
|
||||
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。
|
||||
|
||||
@@ -672,6 +673,9 @@ 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 正文。
|
||||
- 冻结资料:版本表末尾追加可空 `metadata_json`,保存创建版本时由 api-server 校验(标题/简介/分类/标签/设备/方向/必需封面/≤6 张截图)并从素材记录派生对象键后的资料快照;`approve_game_distribution_version_and_return` 通过审核时把该快照整体生效到游戏行,因此公开投影展示的始终是“已随版本审核通过”的资料,旧版本(无快照)保持原值。
|
||||
- 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。
|
||||
- 撤回与回读:`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 +683,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`
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user