Merge branch 'master' into opt/compile-warning
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 5m9s
Project CI / Backend tests (pull_request) Successful in 4m46s
Project CI / Frontend tests (pull_request) Successful in 1m55s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / Repository checks (pull_request) Successful in 2m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m30s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m41s

This commit is contained in:
2026-09-24 02:09:50 +08:00
31 changed files with 690 additions and 670 deletions
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
@@ -65,7 +71,9 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async ()
await screen.findByText('game_1');
expect(screen.queryByLabelText('发行入口')).toBeNull();
expect(screen.getByText('通过后由系统分配发行地址')).toBeTruthy();
expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull();
expect(screen.queryByLabelText('拒绝理由')).toBeNull();
expect(screen.queryByLabelText('下架原因')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
@@ -87,7 +95,12 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async ()
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
test('点击拒绝后填写理由再提交审核接口', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'rejected', reviewReason: '运行时报错' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -97,12 +110,32 @@ test('缺少拒绝理由时不调用审核接口', async () => {
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
const reasonInput = within(dialog).getByRole('textbox', {
name: '拒绝理由',
});
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
expect(await within(dialog).findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
fireEvent.change(reasonInput, { target: { value: '运行时报错' } });
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [, versionId, , payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(versionId).toBe('version-1');
expect(payload).toEqual({
decision: 'reject',
expectedPublicationRevision: 4,
reviewReason: '运行时报错',
});
});
test('安全下架需要二次确认并携带公开修订号与原因', async () => {
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
@@ -121,16 +154,21 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
const reasonDialog = await screen.findByRole('dialog');
fireEvent.change(
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
{
target: { value: '盗用素材' },
},
);
fireEvent.click(
within(reasonDialog).getByRole('button', { name: '继续下架' }),
);
// 第一次点击只弹出确认面板,不直接调用后端。
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
@@ -147,7 +185,24 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
expect(await screen.findByText(//u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
test('取消理由输入时不做审核操作', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('取消安全下架确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -157,8 +212,20 @@ test('取消确认时不下架', async () => {
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '取消' }));
const reasonDialog = await screen.findByRole('dialog');
fireEvent.change(
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
{
target: { value: '盗用素材' },
},
);
fireEvent.click(
within(reasonDialog).getByRole('button', { name: '继续下架' }),
);
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
@@ -1,3 +1,4 @@
import { Modal, TextField } from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -15,6 +16,11 @@ interface AdminGameDistributionReviewPageProps {
onUnauthorized: (message?: string) => void;
}
interface ReviewReasonPrompt {
decision: 'reject' | 'suspend';
entry: AdminGameDistributionReviewEntry;
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
@@ -58,12 +64,11 @@ export function AdminGameDistributionReviewPage({
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [reasonPrompt, setReasonPrompt] = useState<ReviewReasonPrompt | null>(
null,
);
const [reasonDraft, setReasonDraft] = useState('');
const [reasonError, setReasonError] = useState('');
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
@@ -87,9 +92,10 @@ export function AdminGameDistributionReviewPage({
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
reason = '',
) {
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'reject' && !reason) {
const trimmedReason = reason.trim();
if (decision === 'reject' && !trimmedReason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
@@ -109,7 +115,7 @@ export function AdminGameDistributionReviewPage({
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
reviewReason: trimmedReason,
},
);
setStatusMessage(
@@ -129,8 +135,11 @@ export function AdminGameDistributionReviewPage({
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
async function suspendGame(
entry: AdminGameDistributionReviewEntry,
reason: string,
) {
const trimmedReason = reason.trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber}`,
@@ -146,11 +155,10 @@ export function AdminGameDistributionReviewPage({
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
...(trimmedReason ? { reason: trimmedReason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
@@ -159,6 +167,39 @@ export function AdminGameDistributionReviewPage({
}
}
function openReasonPrompt(
entry: AdminGameDistributionReviewEntry,
decision: ReviewReasonPrompt['decision'],
) {
setReasonDraft('');
setReasonError('');
setReasonPrompt({ decision, entry });
}
function closeReasonPrompt() {
setReasonPrompt(null);
setReasonDraft('');
setReasonError('');
}
function confirmReasonPrompt() {
if (!reasonPrompt) return;
const reason = reasonDraft.trim();
if (reasonPrompt.decision === 'reject' && !reason) {
setReasonError('拒绝审核必须填写理由');
return;
}
const { decision, entry } = reasonPrompt;
closeReasonPrompt();
if (decision === 'reject') {
void submitReview(entry, 'reject', reason);
return;
}
void suspendGame(entry, reason);
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
@@ -237,11 +278,6 @@ export function AdminGameDistributionReviewPage({
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<span className="admin-muted-text">
</span>
</div>
<button
type="button"
className="admin-primary-button"
@@ -250,55 +286,19 @@ export function AdminGameDistributionReviewPage({
>
</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')}
onClick={() => openReasonPrompt(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)}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId
? '正在下架…'
@@ -314,6 +314,52 @@ export function AdminGameDistributionReviewPage({
</div>
) : null}
</div>
{reasonPrompt ? (
<Modal
open
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
closeLabel="关闭理由输入"
onClose={closeReasonPrompt}
size="sm"
className="genarrative-ui"
footer={
<div className="admin-confirm-actions" style={{ width: '100%' }}>
<button
type="button"
className="admin-secondary-button"
onClick={closeReasonPrompt}
>
</button>
<button
type="button"
className={
reasonPrompt.decision === 'reject'
? 'admin-ghost-button'
: 'admin-danger-button'
}
onClick={confirmReasonPrompt}
>
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
</button>
</div>
}
>
<TextField
autoFocus
multiline
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
value={reasonDraft}
error={reasonError}
rows={4}
onChange={(event) => {
setReasonDraft(event.target.value);
if (reasonError) setReasonError('');
}}
/>
</Modal>
) : null}
{writeConfirm.confirmDialog}
</section>
);
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { CLIENT_MAINTENANCE_EVENT } from '../../services/clientApi';
import { ThemedModal } from './ThemedModal';
/** 维护响应的唯一客户端出口,避免各业务面板把同一个 503 展示成不同兜底错误。 */
export function MaintenanceNotice() {
const [open, setOpen] = useState(false);
useEffect(() => {
const handleMaintenance = () => setOpen(true);
window.addEventListener(CLIENT_MAINTENANCE_EVENT, handleMaintenance);
return () =>
window.removeEventListener(CLIENT_MAINTENANCE_EVENT, handleMaintenance);
}, []);
return (
<ThemedModal
open={open}
ariaLabel="系统维护中"
theme="light"
closeOnBackdrop={false}
closeOnEscape={false}
panelClassName="w-full max-w-2xl rounded-3xl border border-white/70 px-7 py-10 shadow-2xl sm:px-14 sm:py-14"
onClose={() => setOpen(false)}
>
<div className="flex min-h-[min(38vh,24rem)] flex-col items-center justify-center text-center">
<div className="mb-5 text-5xl" aria-hidden="true">
🛠
</div>
<h1 className="text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)]">
</h1>
<p className="mt-5 max-w-lg text-base leading-8 text-[var(--platform-text-base)] sm:text-lg">
</p>
<button
type="button"
className="mt-9 min-w-36 rounded-full bg-[var(--platform-accent)] px-6 py-3 text-base font-semibold text-white shadow-sm transition hover:brightness-105"
onClick={() => setOpen(false)}
>
</button>
</div>
</ThemedModal>
);
}
+2
View File
@@ -6,6 +6,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { AuthenticatedClient, WorkspaceLauncher } from './App';
import { MaintenanceNotice } from './components/modal/MaintenanceNotice';
import { WindowChrome } from './components/WindowChrome';
createRoot(document.getElementById('root') as HTMLElement).render(
@@ -16,6 +17,7 @@ createRoot(document.getElementById('root') as HTMLElement).render(
<WorkspaceLauncher currentUser={user} onLogout={logout} />
)}
</AuthenticatedClient>
<MaintenanceNotice />
</WindowChrome>
</React.StrictMode>,
);
@@ -11,6 +11,7 @@ import {
} from '../../../../packages/shared/src';
import { getStoredAuthAccessToken } from './clientAuth';
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
import { emitClientMaintenanceEvent } from './clientMaintenance';
import { captureClientError } from './errorReporting';
import {
currentPlatformSessionGeneration,
@@ -22,36 +23,97 @@ export {
getStoredAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth';
export { CLIENT_MAINTENANCE_EVENT } from './clientMaintenance';
type ClientApiErrorOptions = {
status?: number | null;
networkError?: boolean;
code?: string;
requestId?: string;
};
export class ClientAuthRequestError extends Error {
readonly status: number | null;
readonly networkError: boolean;
readonly code: string;
readonly requestId: string;
constructor(
message: string,
options: { status?: number | null; networkError?: boolean } = {},
) {
constructor(message: string, options: ClientApiErrorOptions = {}) {
super(message);
this.name = 'ClientAuthRequestError';
this.status = options.status ?? null;
this.networkError = options.networkError ?? false;
this.code =
options.code?.trim() ||
(this.status ? `HTTP_${this.status}` : 'CLIENT_ERROR');
this.requestId = options.requestId?.trim() ?? '';
}
}
async function readApiErrorMessage(
export function isClientMaintenanceError(
error: unknown,
): error is ClientAuthRequestError {
return (
error instanceof ClientAuthRequestError &&
(error.code.toUpperCase() === 'MAINTENANCE' ||
(error.status === 503 && error.message.includes('维护')))
);
}
export function emitClientMaintenanceNotice(error: unknown) {
if (!isClientMaintenanceError(error) || typeof window === 'undefined') return;
const maintenanceError = error as ClientAuthRequestError;
emitClientMaintenanceEvent({
code: maintenanceError.code,
status: maintenanceError.status,
requestId: maintenanceError.requestId || undefined,
});
}
type ParsedClientApiError = {
message: string;
code: string;
requestId: string;
};
async function readApiErrorInfo(
response: Response,
fallback: string,
url: string,
) {
): Promise<ParsedClientApiError> {
const text = await readClientHttpResponseText(response, { url });
if (!text.trim()) {
return fallback;
return {
message: fallback,
code: `HTTP_${response.status}`,
requestId: '',
};
}
try {
unwrapApiResponse(JSON.parse(text) as unknown);
} catch (error) {
return error instanceof Error ? error.message : fallback;
const parsed = JSON.parse(text) as {
error?: { code?: unknown; message?: unknown };
meta?: { requestId?: unknown };
};
const message =
typeof parsed.error?.message === 'string' && parsed.error.message.trim()
? parsed.error.message.trim()
: fallback;
const code =
typeof parsed.error?.code === 'string' && parsed.error.code.trim()
? parsed.error.code.trim()
: `HTTP_${response.status}`;
const requestId =
typeof parsed.meta?.requestId === 'string'
? parsed.meta.requestId.trim()
: '';
return { message, code, requestId };
} catch {
return {
message: fallback,
code: `HTTP_${response.status}`,
requestId: '',
};
}
return fallback;
}
function captureApiErrorStatus(url: string, response: Response) {
@@ -118,10 +180,14 @@ export async function requestClientApi<T>(
}
if (!response.ok) {
captureApiErrorStatus(url, response);
throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage, url),
{ status: response.status },
);
const errorInfo = await readApiErrorInfo(response, fallbackMessage, url);
const error = new ClientAuthRequestError(errorInfo.message, {
status: response.status,
code: errorInfo.code,
requestId: errorInfo.requestId,
});
emitClientMaintenanceNotice(error);
throw error;
}
const text = await readClientHttpResponseText(response, { url });
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
@@ -155,10 +221,14 @@ export async function requestClientApiBytes(
}
if (!response.ok) {
captureApiErrorStatus(url, response);
throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage, url),
{ status: response.status },
);
const errorInfo = await readApiErrorInfo(response, fallbackMessage, url);
const error = new ClientAuthRequestError(errorInfo.message, {
status: response.status,
code: errorInfo.code,
requestId: errorInfo.requestId,
});
emitClientMaintenanceNotice(error);
throw error;
}
return response;
}
@@ -22,6 +22,7 @@ import {
getClientServerBaseUrl,
readClientHttpResponseText,
} from './clientHttp';
import { emitClientMaintenanceEvent } from './clientMaintenance';
import {
type ClientOperation,
createClientOperation,
@@ -249,10 +250,11 @@ async function requestAuthJson<T>(
});
}
if (!response.ok) {
throw new ClientAuthRequestError(
await readAuthErrorMessage(response, fallbackMessage),
{ status: response.status },
);
const message = await readAuthErrorMessage(response, fallbackMessage);
if (response.status === 503 && message.includes('维护')) {
emitClientMaintenanceEvent({ status: response.status });
}
throw new ClientAuthRequestError(message, { status: response.status });
}
const text = await readClientHttpResponseText(response, {
url,
@@ -0,0 +1,15 @@
export const CLIENT_MAINTENANCE_EVENT =
'genarrative-client-maintenance-detected';
export type ClientMaintenanceDetail = {
code?: string;
status?: number | null;
requestId?: string;
};
export function emitClientMaintenanceEvent(
detail: ClientMaintenanceDetail = {},
) {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(CLIENT_MAINTENANCE_EVENT, { detail }));
}
@@ -0,0 +1,45 @@
/** @vitest-environment jsdom */
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { MaintenanceNotice } from '../src/components/modal/MaintenanceNotice';
import {
CLIENT_MAINTENANCE_EVENT,
ClientAuthRequestError,
emitClientMaintenanceNotice,
} from '../src/services/clientApi';
afterEach(() => cleanup());
describe('客户端维护大弹窗', () => {
it('收到维护事件时统一展示大弹窗', () => {
render(<MaintenanceNotice />);
act(() => {
window.dispatchEvent(
new CustomEvent(CLIENT_MAINTENANCE_EVENT, {
detail: { code: 'MAINTENANCE', status: 503 },
}),
);
});
expect(screen.getByRole('dialog', { name: '系统维护中' })).toBeTruthy();
expect(
screen.getByText('服务正在维护,当前操作暂时无法完成。请稍后再试。'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '我知道了' })).toBeTruthy();
});
it('普通业务错误不会上报维护事件', () => {
const received: Event[] = [];
const handler = (event: Event) => received.push(event);
window.addEventListener(CLIENT_MAINTENANCE_EVENT, handler);
emitClientMaintenanceNotice(
new ClientAuthRequestError('创建平台游戏失败', { status: 500 }),
);
expect(received).toHaveLength(0);
window.removeEventListener(CLIENT_MAINTENANCE_EVENT, handler);
});
});
+2 -3
View File
@@ -72,6 +72,5 @@ GENARRATIVE_LLM_MODEL=gpt-5.4-mini
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
# 游戏发行入口模板:审核通过时按 {gameId} 占位符派生每游戏独立来源地址,例如
# https://{gameId}.games.example.com/。模板必须含 {gameId},生产未配置时审核通过直接失败
GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=
# 游戏发行入口固定为平台同源路径 /games/{gameId}/:审核通过时由 api-server 自己派生,
# 不需要部署侧配置发行域名或通配证书
+17 -1
View File
@@ -136,12 +136,28 @@ http {
return 404;
}
# 平台同源路径发行入口:/games/<gameId>/ 与 /games/<gameId>/<asset> 映射到
# api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里,
# 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。
location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$" {
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_api/api/game-distribution/releases/$game_id$game_path;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# BEGIN GENARRATIVE MAIN SPA ROUTES
location = / {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" {
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
try_files $uri /index.html =404;
}
# END GENARRATIVE MAIN SPA ROUTES
+3 -4
View File
@@ -179,10 +179,9 @@ GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID=
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET=
GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev
# 游戏发行入口模板:审核通过时按 {gameId} 占位符派生每游戏独立来源地址,例如
# https://{gameId}.games.example.com/。模板必须含 {gameId},生产未配置时审核通过
# 直接失败;非生产未配置时回落 http://127.0.0.1:<API_PORT>/api/game-distribution/releases/{gameId}/
GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=
# 游戏发行入口固定为平台同源路径 /games/{gameId}/:审核通过时由 api-server 自己派生,
# 不需要部署侧配置发行域名或通配证书;边缘由 nginx 的
# genarrative-game-distribution-path.conf 把该路径映射到发行网关
# SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
+7 -6
View File
@@ -100,10 +100,11 @@ curl -sSI -H 'Accept-Encoding: br' \
- 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` 发行网关设置,边缘不覆盖。
- 审核通过时 `api-server` 按部署模板(`GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=https://{gameId}.games.example.com/`)与 gameId 派生 `entryUrl`,即该子域根地址;换版本或下架只改变后端公开投影,边缘不需要改配置
- 门禁:`npm run check:release-origin-config` 会逐条校验模板约束、交叉检查发行网关仍在设置上述响应头,并在本机存在 `nginx` `openssl` 时用自签通配证书渲染一份临时配置执行 `nginx -t`
- 现役发行入口是平台同源路径 `https://<平台域名>/games/<gameId>/`。三份常驻模板(`genarrative.conf``genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games/<gameId>/``/games/<gameId>/<asset>` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 通配 TLS。
- location 的正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`。不加引号时 nginx 会把 `{32}` 当块定界符,`nginx -t``pcre2_compile() failed: missing closing parenthesis`
- 发行入口不使用 Cookie边缘转发前设置 `proxy_set_header Cookie ""``api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games``/games/detail``/games/play``/games/mine``/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。
+17 -1
View File
@@ -179,6 +179,22 @@ server {
return 404;
}
# 平台同源路径发行入口:/games/<gameId>/ 与 /games/<gameId>/<asset> 映射到
# api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里,
# 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。
location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$" {
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_api/api/game-distribution/releases/$game_id$game_path;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# BEGIN GENARRATIVE MAIN SPA ROUTES
location = / {
error_page 503 /maintenance.html;
@@ -190,7 +206,7 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" {
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
@@ -1,81 +0,0 @@
# 游戏发行来源(每游戏独立 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,游戏内其余资源按相对路径原样交给
# 发行网关;审核通过时 api-server 按发行入口模板派生的 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;
}
}
+17 -1
View File
@@ -199,6 +199,22 @@ server {
return 404;
}
# 平台同源路径发行入口:/games/<gameId>/ 与 /games/<gameId>/<asset> 映射到
# api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里,
# 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。
location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$" {
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_api/api/game-distribution/releases/$game_id$game_path;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# BEGIN GENARRATIVE MAIN SPA ROUTES
location = / {
error_page 503 /maintenance.html;
@@ -210,7 +226,7 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" {
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
@@ -33,3 +33,10 @@ Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-1
- 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。
- 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。
- 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。
## 2026-09-23 维护态错误展示补充
- `apps/ai-game-creator-shell/src/services/clientApi.ts` 解析并保留维护响应的 `error.code`、HTTP 状态与 `meta.requestId`,识别网关 `MAINTENANCE` 后广播客户端维护事件。
- `apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx` 在客户端根部统一展示不可被局部业务兜底替代的大弹窗;发布、上传、资源换签等共用 `requestClientApi` 的请求均进入同一出口。
- 普通 500、资源损坏和本地 `blob:` 图片预览失败不自动归类为维护;图片换签接口在维护期间失败时会触发统一弹窗,但本地刚选中的图片预览仍不依赖后端。
- 验收补充:维护期间发布接口不能只显示“创建平台游戏失败”等局部文案;维护弹窗出现一次即可覆盖并发失败请求,关闭后业务页仍可重试。
@@ -9324,3 +9324,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 边界:`entryUrl` 仍是公开投影字段,只是改由服务端写入;审核请求摘要不再包含它,表结构与版本回读不变;模板变更只影响之后新通过审核的版本,历史版本已冻结的 `entry_url` 不改写。
- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}``apps/admin-web/src/{api/adminApiTypes.ts,api/adminApiClient.test.ts,pages/AdminGameDistributionReviewPage.tsx,pages/AdminGameDistributionReviewPage.test.tsx}``scripts/check-game-distribution-media-e2e.mjs``deploy/{nginx,env,container}`、平台与运维主规范、发行里程碑实施计划。
- 验证:`cargo check -p api-server``cargo test -p api-server game_distribution`31 passed)、admin-web 定向 Vitest19 passed)与 `apps/admin-web` typecheck、`npm run check:release-origin-config``npm run check:doc-index``npm run check:encoding``git diff --check` 全部通过;真实栈端到端(真实 OSS + SpacetimeDB + 审核通过)未在本轮复跑。
## 2026-09-24 游戏发行入口改为平台同源路径:取消发行域名与部署模板变量
- 背景:每游戏独立来源要求 `*.games.<域名>` 通配 DNS 与通配 TLS,一直未在任何环境落地,dev / release 审核通过直接报「发行来源未配置」;同时线上 SPA 白名单缺少 `games` 系列路由,`/games``/games/detail``/games/play` 在真实域名上全部 404。运行隔离实际由 iframe `sandbox="allow-scripts"` 的不透明来源承担,不需要独立 origin 兜底。
- 决策(唯一口径):发行入口固定为平台同源路径 `/games/{gameId}/`。审核通过时 `api-server` 按 gameId 派生该相对路径写入公开投影,不再读取 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE``AppConfig` 字段与两份部署 env 示例一并删除。dev / release / 预览环境口径一致,不再需要发行域名、通配 DNS 或通配 TLS。
- 决策(边缘):`deploy/nginx/genarrative.conf``deploy/nginx/genarrative-dev-http.conf``deploy/container/nginx.conf` 三份模板内联同一条同源发行入口 location,把 `/games/<gameId>/``/games/<gameId>/<asset>` 转发到发行网关,转发前清空 `Cookie`;正则整体必须加双引号,否则 `{32}` 会被 nginx 当块定界符。SPA allowlist 补齐 `components``design-system``games``games/detail``games/mine``games/play``games/publish`
- 决策(客户端):`normalizeGameEntryUrl` 接受相对路径与同源发行路径,按当前 origin 解析成绝对地址后交给 iframe;无尾斜杠会归一化补齐。同源非发行路径继续拒绝,非当前源的绝对 https 继续兼容历史数据。
- 决策(退役):删除 `deploy/nginx/genarrative-release-origin.conf``scripts/check-release-origin-config.mjs``npm run check:release-origin-config`;独立来源不再作为上线门禁。
- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}``server-rs/crates/shared-contracts/src/game_distribution.rs``packages/shared/src/contracts/gameDistribution.ts``src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}``scripts/check-game-distribution-media-e2e.mjs``package.json`、平台与运维主规范。
- 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。
- 验证:`cargo check -p api-server --tests``cargo test -p api-server game_distribution`27 passed)、`cargo fmt --all --check``npx vitest run src/components/game-distribution`57 passed)、`npm run check:nginx-spa-routes``npm run check:encoding`5060 文件)、`npm run check:doc-index``git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/``./assets/index-2Ws3zHlS.js` 均 200。
@@ -649,24 +649,22 @@ Nginx 负责站点和反向代理
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
```
### 游戏发行来源(发行域名、每游戏 origin 与缓存窗口)
### 游戏发行来源(平台同源路径与缓存窗口)
已公开游戏运行在**独立来源**上,与主站来源隔离;这是发行网关(`api-server`)之外唯一需要的边缘配置
已公开游戏通过**平台同源路径** `/games/<gameId>/` 提供,边缘只做前缀映射,不再需要独立发行域名、通配 DNS 或通配 TLS
- 配置工件:`deploy/nginx/genarrative-release-origin.conf`。上线前替换域名、通配证书路径与 upstream 端口,并把文件安装到 Nginx 站点目录
- 前置资源:`*.games.<域名>` 通配 DNS 指向同一入口,以及覆盖该通配名的 TLS 证书(certbot DNS-01 或等价流程)
- 路由约定:`https://<gameId>.games.<域名>/` 是该游戏的入口(子域根路径映射到该游戏的 `index.html`),其余路径按原样映射到 `/api/game-distribution/releases/<gameId>/…`;平台 API、后台、SPA 与上传接口在这个来源上一律 404,命中即证明边缘多代理了命名空间
- 会话隔离:发行来源从不使用 Cookie。带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`;发行网关自身同样对带 Cookie 的请求返回 403
- 响应头与缓存:`X-Content-Type-Options`、CORP`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以“停止继续分发”为准。
- 审核动作:管理员只提交审核结论与公开修订号,`entryUrl``api-server` 按部署模板 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`(必须含 `{gameId}` 占位符)与 gameId 派生,生产应配置 `https://{gameId}.games.<域名>/`;派生结果仍按 HTTPS、无凭据、无 query/fragment 校验,模板缺失或派生结果非法时审核通过直接失败。非生产环境未配置模板时回落到本地回环发行网关地址(`http://127.0.0.1:<api端口>/api/game-distribution/releases/{gameId}/`)用于联调。
- 门禁与本地联调:
- 路由约定:`https://<平台域名>/games/<gameId>/` 是该游戏的入口,`/games/<gameId>/<asset>` 映射到发行网关 `/api/game-distribution/releases/<gameId>/<asset>`。同源发行入口 location 写在 `deploy/nginx/genarrative.conf`(生产 HTTPS)、`deploy/nginx/genarrative-dev-http.conf`(开发 HTTP)与 `deploy/container/nginx.conf`(容器)里。正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`,否则 nginx 会把 `{32}` 当块定界符并在 `nginx -t` 报 missing closing parenthesis
- 会话与隔离:边缘在转发前 `proxy_set_header Cookie ""`,发行网关自身也对带 `Cookie` 的请求返回 `403`;游戏文档跑在 iframe `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载整套游戏代码
- 响应头与缓存:`X-Content-Type-Options`、CORP`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以"停止继续分发"为准
- 审核动作:管理员只提交审核结论与公开修订号,`entryUrl``api-server` 按 gameId 派生**同源路径** `/games/{gameId}/` 写入公开投影;dev / release / 预览环境口径完全一致,入口不再由部署侧配置,历史数据里的绝对 URL 继续兼容
- 门禁:
```bash
# 模板约束 + 发行网关响应头策略交叉检查;本机有 nginx/openssl 时还会渲染一份临时配置跑 nginx -t
npm run check:release-origin-config
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由)
npm run check:nginx-spa-routes
```
本地想在真实边缘语义下复验时,可以把模板渲染到 `~/data/tmp`(替换 upstream 为本地 api-server 端口、证书换成自签通配证书、监听端口换成高位端口),用 `nginx -c <渲染文件>` 起一个临时实例,再用 `curl -H 'Host: <gameId>.games.example.com'` 验证:根路径 200 `text/html``/assets/*` 200、带 Cookie 403、平台 API 路径 404、未知 gameId 404、http 301 到 https
本地想在真实边缘语义下复验时,`deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games/<gameId>/` 验证:入口文档 200 `text/html``/games/<gameId>/assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响
#### 游戏分发可观测事件
@@ -89,8 +89,8 @@
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html``dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config``gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
6. `supportedDevices` 至少包含 `desktop``mobile``inputModes` 来自 `keyboard``mouse``touch`;声明移动端必须包含 `touch``orientation``landscape``portrait``responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html``GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff``Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
9. 发行入口不由管理员填写:部署侧用 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE` 配置带 `{gameId}` 占位符的模板(生产形如 `https://{gameId}.games.<发行域名>/`),审核通过时 `api-server`模板与 gameId 派生每游戏独立来源地址,再按绝对 HTTPS、无凭据、无 query/fragment 校验后写入公开投影;模板缺 `{gameId}`、生产未配置模板或派生结果非法时审核通过直接失败,不偷偷回落主站或内网地址。非生产环境未配置模板时回落到 `http://127.0.0.1:<api端口>/api/game-distribution/releases/{gameId}/`,口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html``GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff``Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
9. 发行入口不由管理员填写,也不需要部署侧配置:审核通过时 `api-server` 按 gameId 派生**平台同源路径** `/games/{gameId}/` 写入公开投影,dev / release / 预览环境口径完全一致,不再需要发行域名、通配 DNS 或通配证书。gameId 必须是服务端生成的稳定标识(只允许 `[A-Za-z0-9_-]`),派生失败时审核通过直接失败,不回落主站其它路径、内网地址或任意外部地址。客户端读取该字段时按当前 origin 解析成绝对地址再交给 iframe;历史数据里的绝对 URL(非当前源的 https)继续兼容,新写入只用相对路径。路径到发行网关的映射由边缘 nginx 的同源发行入口 location 完成
### 身份、状态、审核与更新
@@ -129,17 +129,17 @@
| `POST /versions/{versionId}/cancel` | owner | **已实现**:带 `expectedPublicationRevision` CAS 与 `Idempotency-Key`,只能撤回未参与公开投影的版本;同 key 同请求重放返回 `replayed: true`,摘要不同返回 409 |
| `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 |
| `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 |
| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按部署模板与 gameId 派生该游戏发行入口并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 |
| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按 gameId 派生平台同源发行路径 `/games/{gameId}/` 并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 |
| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**:安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;后台游戏审核页提供带原因输入与二次确认的入口;此行是完整后台路径 |
除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。
领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts``packages/shared`。新业务必须使用当前正式表与契约,不得以未挂载源码或非正式私有快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。
### 发行域名、沙箱与网络能力
### 发行路径、沙箱与网络能力
- 发行域名必须与平台使用不同的可注册站点(不同 eTLD+1),不能仅使用 `*.genarrative.world` 的兄弟子域;域名需在部署前确定。每个游戏有独立 HTTPS origin,例如 `https://g-<gameId>.<发行站点>`,不同游戏不能共用 origin;版本固定在 `/releases/<versionId>/index.html`
- 主站接受服务端配置允许的 HTTPS 发行 host 与发行版本路径,拒绝任意 URL、重定向目标、用户输入 URL、`javascript:``srcdoc`。发行站点不设置平台 Cookie、不接收平台 Bearer、不挂载主站 API请求日志不得携带平台认证数据。
- 发行入口是平台同源路径 `https://<平台域名>/games/<gameId>/`,边缘 nginx 把该前缀原样映射到 `api-server` 发行网关。运行隔离不依赖独立来源,而由 iframe `sandbox="allow-scripts"` 把游戏文档固定在不透明来源:游戏拿不到主站 Cookie、`localStorage``IndexedDB`、DOM 与 Service Worker,离开页面即随 iframe 卸载整套游戏代码
- 主站接受服务端派生的同源发行路径(形态固定为 `/games/<gameId>/`,拒绝任意 URL、重定向目标、用户输入 URL、`javascript:``srcdoc`。发行路径在边缘清空 `Cookie`,网关自身也对带 `Cookie` 的请求返回 `403`;发行响应不接收平台 Bearer请求日志不得携带平台认证数据。
- iframe 首版仅使用 `sandbox="allow-scripts"`,全屏通过明确的 iframe 能力授权与用户手势开放。禁止 `allow-same-origin`、顶层导航、弹窗、表单提交、下载、模态对话框、相机、麦克风、剪贴板和地理位置;不提供持久 localStorage/IndexedDB 存档保证,不启用 Service Worker。
- 网关对所有发行 HTML 强制 CSP:默认拒绝;脚本只允许本游戏 origin 和必要内联脚本,不开放 `unsafe-eval`;样式允许本游戏 origin 与内联样式;图片/字体/音频只允许本游戏静态源及必要 `data:`/`blob:``connect-src` 仅为当前游戏静态 origin`worker-src``frame-src``object-src``form-action``none``base-uri 'none'``frame-ancestors` 仅主站明确 origin。不能由包内 meta 放宽响应头策略。
- 首版允许加载同游戏发行包内 JSON/二进制素材,禁止外部 API、远程分析、广告、第三方 SDK 网络依赖及任意外站 fetch/WebSocket。静态网关不代理任意外部地址。为兼容 opaque sandbox 下的 ES modules,发行静态资源提供不带 credentials 的 CORS;此能力只对获准发行文件生效,不能扩到主站或私有存储。
@@ -163,16 +163,16 @@
| 幂等与恢复 | 双击、响应丢失、上传中断、同 key 不同内容、重启恢复、换账号迟到响应分别验证,不生成重复发行版本 |
| 审核与并发 | 待审不公开;拒绝有理由;旧版在更新失败/待审期间在线;审核与下架并发 CAS 拒绝过期写入 |
| 真正可玩 | 桌面及手机真实浏览器覆盖模块加载、素材、音频、触屏、横竖屏、开始/重试/退出和可用全屏;不以 iframe load 代替 |
| 隔离与撤销 | 真实不同站点和每游戏 origin 下,主站 Cookie/storage/DOM 不可访问外部网络阻断,旧 URL 在缓存窗口后不能取得新资源 |
| 隔离与撤销 | 真实生产构建下同源 iframe 内主站 Cookie/storage/DOM 不可访问外部网络被 CSP 阻断、离开页面后游戏代码不再运行,旧 URL 在缓存窗口后不能取得新资源 |
| 页面与视觉 | 当前 warm token 下的目录、详情、发布、加载/空/失败/待审态,桌面和移动视口无操作遮挡,键盘可达 |
| 工程门禁 | 定向前后端测试、两端类型检查、真实 SpacetimeDB/API smoke、schema/绑定检查、编码、文档索引及 diff 检查 |
所有运行时证据须注明实际环境和结论;缺少发行域名、登录、存储、审核或真实游戏时写明未验证,不使用演示 fixture 填充为业务成功。
所有运行时证据须注明实际环境和结论;缺少登录、存储、审核或真实游戏时写明未验证,不使用演示 fixture 填充为业务成功。
### 待评审决策
1. 是否采纳人工审核及资料随发行版本审核、更新期间旧版保持公开的首版策略;审核负责人、处理时限与申诉/解除封禁口径需确定。
2. 是否采用 `/games` 为网页根入口,以及“游戏 / 创作 / 项目 / 我的”和移动“游戏 / 我的”的导航提案。
3. 是否接受首版离线静态包、无 Wasm/外网/持久存档的范围,以及建议包额度、7 天失败包保留和 60 秒缓存撤销窗口;公开/撤销版本及审核记录保留周期待定。
4. 不同可注册站点发行域名、DNS/TLS/CDN、存储区域运营责任尚待选定。不同站点和每游戏 origin 是上线门禁,不能退化成主站同源目录
4. 是否接受平台同源路径发行(`/games/<gameId>/` + `sandbox="allow-scripts"` 的不透明来源隔离)替代独立发行域名:选择该方案后独立域名、通配 DNS/TLS 与 CDN 不再是上线门禁,存储区域运营责任仍需确定
5. 本节已提供页面行为、发行状态、真实上传、幂等/CAS 和 API 草案,足以评审完整业务;尚不足以直接实现持久化和部署,必须在对应里程碑评审前冻结表/索引/服务身份、额度/清理、最终响应 DTO 与发行基础设施配置。未经评审不建立 `ready` 实施计划,不把 proposed 标记为 accepted。
-1
View File
@@ -83,7 +83,6 @@
"check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs",
"check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs",
"check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs",
"check:release-origin-config": "node scripts/check-release-origin-config.mjs",
"check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs",
"check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs",
"check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs",
@@ -61,6 +61,10 @@ export type GameDistributionGameVisibility =
export type GameDistributionVersionSummary = {
id: string;
version: string;
/**
* 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
* 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。
*/
entryUrl: string;
sha256: string;
publishedAt: string;
@@ -534,12 +534,6 @@ async function main() {
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 250)}`,
);
check(
'审核通过后发行入口由服务端派生',
approved.data?.version?.entryUrl ===
`${API}/api/game-distribution/releases/${gameId}/`,
String(approved.data?.version?.entryUrl),
);
// 8. 公开目录:封面/截图对象键生效
const catalogAfter = await api('/api/game-distribution/games');
@@ -547,6 +541,11 @@ async function main() {
(game) => game.id === gameId,
);
check('公开目录返回该游戏', Boolean(publishedGame));
check(
'审核通过后发行入口由服务端派生为平台同源路径',
publishedGame?.currentVersion?.entryUrl === `/games/${gameId}/`,
String(publishedGame?.currentVersion?.entryUrl),
);
check(
'公开投影带封面对象键',
publishedGame?.coverObjectKey === cover.objectKey,
-272
View File
@@ -1,272 +0,0 @@
#!/usr/bin/env node
/**
* 游戏发行来源配置门禁。
*
* 逐条校验 `deploy/nginx/genarrative-release-origin.conf`
* 1) 每游戏独立 origin 的按主机映射(命名捕获 `game_id` + 发行网关前缀);
* 2) 只暴露发行网关,不代理平台 API / 后台 / SPA
* 3) 发行来源不使用 Cookie(边缘 403 + 转发前清空);
* 4) 响应头策略仍由 api-server 发行网关负责(源码级交叉检查)。
* 只要本机存在 nginx 与 openssl,还会用自签通配证书渲染一份临时配置执行
* `nginx -t`,把语法与指令上下文一起验证掉。
*/
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, '..');
const templatePath = join(
repoRoot,
'deploy/nginx/genarrative-release-origin.conf',
);
const gatewayPath = join(
repoRoot,
'server-rs/crates/api-server/src/modules/game_distribution.rs',
);
const failures = [];
const notes = [];
function fail(message) {
failures.push(message);
}
function normalize(source) {
return source.replace(/\s+/gu, ' ');
}
function requireSnippet(source, snippet, message) {
if (!normalize(source).includes(normalize(snippet))) {
fail(message);
}
}
function main() {
if (!existsSync(templatePath)) {
fail(`缺少发行来源模板:${templatePath}`);
return;
}
const template = readFileSync(templatePath, 'utf8');
requireSnippet(
template,
'server_name ~^(?<game_id>[a-z0-9_]+)\\.games\\.example\\.com$;',
'发行来源必须用命名捕获 game_id 的子域匹配(每游戏独立 origin)',
);
requireSnippet(
template,
'ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem;',
'发行来源必须使用通配 TLS 证书',
);
requireSnippet(
template,
'if ($http_cookie) { return 403; }',
'发行来源必须拒绝携带平台 Cookie 的请求',
);
requireSnippet(
template,
'proxy_set_header Cookie "";',
'发行来源转发前必须清空 Cookie',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri;',
'发行来源必须按 game_id 映射到发行网关前缀',
);
requireSnippet(
template,
'location /.well-known/acme-challenge/',
'发行来源必须保留 ACME challenge 路径',
);
requireSnippet(
template,
'location = / {',
'发行来源必须显式把子域根路径映射为该游戏的 index.html',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html;',
'子域根路径必须映射到该游戏的 index.html',
);
const proxyPassCount = (template.match(/proxy_pass\s/gu) ?? []).length;
if (proxyPassCount !== 2) {
fail(
`发行来源只应存在两条 proxy_pass(子域根路径与发行网关前缀),实际 ${proxyPassCount}`,
);
}
const cookieStripCount = (
template.match(/proxy_set_header Cookie "";/gu) ?? []
).length;
if (cookieStripCount !== 2) {
fail(`每条发行来源代理都必须清空 Cookie,实际 ${cookieStripCount}`);
}
const gatewayPrefixCount = (
template.match(/api\/game-distribution\/releases\/\$game_id/gu) ?? []
).length;
if (gatewayPrefixCount !== 2) {
fail(`发行来源代理必须都映射到发行网关前缀,实际 ${gatewayPrefixCount}`);
}
for (const forbidden of [
'/api/auth',
'/api/profile',
'/admin/api',
'/api/game-distribution/games',
'/api/game-distribution/versions',
]) {
if (template.includes(forbidden)) {
fail(`发行来源不得代理平台命名空间:${forbidden}`);
}
}
if (!existsSync(gatewayPath)) {
fail(`缺少发行网关源码:${gatewayPath}`);
} else {
const gateway = readFileSync(gatewayPath, 'utf8');
for (const [snippet, message] of [
[
'header::X_CONTENT_TYPE_OPTIONS',
'发行网关必须继续设置 X-Content-Type-Options',
],
[
'HeaderName::from_static("cross-origin-resource-policy")',
'发行网关必须继续设置 CORP',
],
[
'HeaderValue::from_static("cross-origin")',
'CORP 必须是 cross-originopaque sandbox 才能加载自有脚本)',
],
[
'header::ACCESS_CONTROL_ALLOW_ORIGIN',
'发行网关必须继续设置无凭据 CORS',
],
['header::CONTENT_SECURITY_POLICY', '发行网关必须继续为 HTML 设置 CSP'],
['StatusCode::FORBIDDEN', '发行网关必须继续拒绝携带 Cookie 的请求'],
]) {
if (!gateway.includes(snippet)) {
fail(message);
}
}
}
validateWithNginx(template);
if (failures.length > 0) {
console.error('[check:release-origin-config] FAILED');
for (const message of failures) {
console.error(`- ${message}`);
}
process.exit(1);
}
for (const note of notes) {
console.log(`[check:release-origin-config] ${note}`);
}
console.log(
'[check:release-origin-config] OK(发行来源模板、网关响应头策略与 nginx 语法一致)',
);
}
function binaryExists(binary) {
try {
execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function validateWithNginx(template) {
if (!binaryExists('nginx')) {
notes.push('未找到 nginx,跳过渲染后的 nginx -t');
return;
}
const workDir = mkdtempSync(join(tmpdir(), 'genarrative-release-origin-'));
try {
const certPath = join(workDir, 'wildcard.crt');
const keyPath = join(workDir, 'wildcard.key');
if (binaryExists('openssl')) {
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-days',
'1',
'-subj',
'/CN=games.example.com',
'-addext',
'subjectAltName=DNS:*.games.example.com,DNS:games.example.com',
'-keyout',
keyPath,
'-out',
certPath,
],
{ stdio: 'ignore' },
);
} else {
notes.push('未找到 openssl,跳过渲染后的 nginx -t');
return;
}
const rendered = template
.replace(
'/etc/letsencrypt/live/games.example.com/fullchain.pem',
certPath,
)
.replace('/etc/letsencrypt/live/games.example.com/privkey.pem', keyPath)
.replace(
/\/var\/log\/nginx\/(genarrative-release\.[a-z]+\.log)/gu,
join(workDir, '$1'),
)
// 非 root 环境无法绑定 80/443;语法检查用高位端口,不改生产模板本身。
.replace('listen 80;', 'listen 18080;')
.replace('listen 443 ssl http2;', 'listen 18443 ssl http2;');
const renderedPath = join(workDir, 'release-origin.conf');
writeFileSync(renderedPath, rendered);
const wrapperPath = join(workDir, 'nginx.conf');
writeFileSync(
wrapperPath,
[
`pid ${join(workDir, 'nginx.pid')};`,
`error_log ${join(workDir, 'error.log')} warn;`,
'events { worker_connections 64; }',
'http {',
' access_log off;',
' client_body_temp_path ' + join(workDir, 'client-body') + ';',
' proxy_temp_path ' + join(workDir, 'proxy') + ';',
' fastcgi_temp_path ' + join(workDir, 'fastcgi') + ';',
' uwsgi_temp_path ' + join(workDir, 'uwsgi') + ';',
' scgi_temp_path ' + join(workDir, 'scgi') + ';',
` include ${renderedPath};`,
'}',
'',
].join('\n'),
);
try {
execFileSync('nginx', ['-t', '-c', wrapperPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
notes.push('渲染后的发行来源配置通过 nginx -t');
} catch (error) {
const stderr = error.stderr ? String(error.stderr) : '';
fail(
`渲染后的发行来源配置未通过 nginx -t:${stderr.trim() || error.message}`,
);
}
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
main();
@@ -94,9 +94,6 @@ pub struct AppConfig {
pub client_download_channel: String,
/// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。
pub project_snapshot_channel: String,
/// 游戏发行入口模板:审核通过时按 `{gameId}` 占位符展开成每游戏独立来源地址。
/// 生产必须显式配置;非生产缺省回落到本地发行网关回环地址,便于免 TLS 验证游玩。
pub game_distribution_release_entry_template: Option<String>,
pub log_filter: String,
pub otel_enabled: bool,
pub admin_username: Option<String>,
@@ -401,7 +398,6 @@ impl Default for AppConfig {
image_editor_agent_sidebar_enabled: false,
client_download_channel: "dev".to_string(),
project_snapshot_channel: "dev".to_string(),
game_distribution_release_entry_template: None,
log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
admin_username: None,
@@ -730,9 +726,6 @@ impl AppConfig {
if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") {
config.project_snapshot_channel = channel.trim().to_string();
}
// 发行入口模板由部署侧提供;显式空值视为未配置,不能悄悄回落到本地回环。
config.game_distribution_release_entry_template =
read_first_non_empty_env(&["GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE"]);
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
{
@@ -47,7 +47,6 @@ use crate::{
admin::{AuthenticatedAdmin, require_admin_auth},
api_response::json_success_body,
auth::{AuthenticatedAccessToken, require_bearer_auth},
config::AppConfig,
http_error::AppError,
platform_errors::{map_llm_error, map_oss_error},
request_context::RequestContext,
@@ -1552,7 +1551,10 @@ async fn admin_get_version(
))
}
/// 审核通过时按部署模板与 gameId 派生发行入口
/// 审核通过时派生的发行入口:平台同源路径 `/games/{gameId}/`
///
/// 存相对路径而不是绝对 URL,部署侧就不需要提供发行域名;dev / release / 预览环境
/// 口径一致,由客户端按当前 origin 解析成绝对地址后再交给 iframe。
async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result<String, AppError> {
let version = state
.spacetime_client()
@@ -1560,73 +1562,19 @@ async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result<
.await
.map_err(map_spacetime_error)?
.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?;
build_release_entry_url(&state.config, &version.game_id)
build_release_entry_url(&version.game_id)
}
/// 本地联调缺省模板:直接指向本进程的发行网关,免 TLS 即可验证内嵌游玩
fn default_local_release_entry_template(bind_port: u16) -> String {
format!("http://127.0.0.1:{bind_port}/api/game-distribution/releases/{{gameId}}/")
}
/// 按部署模板生成该游戏的发行入口。
///
/// 模板必须显式包含 `{gameId}`,否则所有游戏会共用同一个来源;生产环境没有模板时
/// 直接失败,不能悄悄回落到本地回环地址。
fn build_release_entry_url(config: &AppConfig, game_id: &str) -> Result<String, AppError> {
let template = match config.game_distribution_release_entry_template.as_deref() {
Some(template) => template.trim().to_string(),
None if config.is_production() => {
return Err(internal(
"发行来源未配置:请设置 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE",
));
}
None => default_local_release_entry_template(config.bind_port),
};
if !template.contains("{gameId}") {
return Err(internal(
"发行入口模板必须包含 {gameId} 占位符,避免多个游戏共用同一个来源",
));
}
/// 发行入口固定走平台同源路径,游戏标识必须能安全落在路径段里
fn build_release_entry_url(game_id: &str) -> Result<String, AppError> {
if game_id.is_empty()
|| !game_id.chars().all(|character| {
character.is_ascii_alphanumeric() || character == '-' || character == '_'
})
{
return Err(internal("游戏标识不适用于发行子域"));
return Err(internal("游戏标识不适用于发行路径"));
}
let entry_url = template.replace("{gameId}", game_id);
validate_release_entry_url(&entry_url, !config.is_production())?;
Ok(entry_url)
}
/// 校验派生出的发行入口。
///
/// 生产环境只接受绝对 HTTPS 地址;非生产环境额外允许 http 回环地址,口径与前端
/// `normalizeGameEntryUrl` 一致,便于本地把发行网关跑在 127.0.0.1 上验证内嵌游玩。
/// 任何环境都拒绝凭据、query 和 fragment。
fn validate_release_entry_url(value: &str, allow_loopback_http: bool) -> Result<(), AppError> {
let parsed =
url::Url::parse(value.trim()).map_err(|_| bad_request("发行入口必须是有效 URL"))?;
let host = parsed.host_str();
let scheme_allowed = parsed.scheme() == "https"
|| (allow_loopback_http
&& parsed.scheme() == "http"
&& matches!(
host,
Some("127.0.0.1") | Some("localhost") | Some("[::1]") | Some("::1")
));
if !scheme_allowed
|| host.is_none()
|| parsed.username() != ""
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(bad_request(
"发行入口必须是无凭据、无查询参数的 HTTPS URL;仅非生产环境允许回环 http",
));
}
Ok(())
Ok(format!("/games/{game_id}/"))
}
async fn admin_suspend_game(
@@ -3031,117 +2979,23 @@ mod tests {
}
#[test]
fn release_entry_url_is_derived_from_template_and_game_id() {
let config = crate::config::AppConfig {
game_distribution_release_entry_template: Some(
"https://{gameId}.games.example.test/".to_string(),
),
..crate::config::AppConfig::default()
};
fn release_entry_url_is_same_origin_path_with_game_id() {
assert_eq!(
build_release_entry_url(&config, "game_1").expect("派生发行入口"),
"https://game_1.games.example.test/"
build_release_entry_url("game_1").expect("派生发行入口"),
"/games/game_1/"
);
}
#[test]
fn release_entry_template_must_contain_game_id() {
let config = crate::config::AppConfig {
game_distribution_release_entry_template: Some(
"https://games.example.test/".to_string(),
),
..crate::config::AppConfig::default()
};
assert!(build_release_entry_url(&config, "game_1").is_err());
}
#[test]
fn production_release_entry_requires_configured_template() {
let config = crate::config::AppConfig {
environment: "production".to_string(),
..crate::config::AppConfig::default()
};
assert!(build_release_entry_url(&config, "game_1").is_err());
}
#[test]
fn non_production_release_entry_falls_back_to_loopback_gateway() {
let config = crate::config::AppConfig {
bind_port: 12401,
..crate::config::AppConfig::default()
};
assert_eq!(
build_release_entry_url(&config, "game_1").expect("本地发行入口"),
"http://127.0.0.1:12401/api/game-distribution/releases/game_1/"
);
}
#[test]
fn release_entry_rejects_game_id_that_is_not_host_safe() {
let config = crate::config::AppConfig {
game_distribution_release_entry_template: Some(
"https://{gameId}.games.example.test/".to_string(),
),
..crate::config::AppConfig::default()
};
fn release_entry_rejects_game_id_that_is_not_path_safe() {
for invalid in ["", "../escape", "game/1", "game 1"] {
assert!(
build_release_entry_url(&config, invalid).is_err(),
build_release_entry_url(invalid).is_err(),
"未拒绝的游戏标识:{invalid}"
);
}
}
#[test]
fn release_entry_url_requires_credential_free_https() {
validate_release_entry_url(
"https://games.example.test/releases/game_1/index.html",
false,
)
.expect("发行入口");
for invalid in [
"/releases/game_1/index.html",
"http://games.example.test/releases/game_1/index.html",
"http://127.0.0.1:10001/releases/game_1/index.html",
"https://user:pass@games.example.test/index.html",
"https://games.example.test/index.html?token=1",
"https://games.example.test/index.html#x",
] {
assert_eq!(
validate_release_entry_url(invalid, false)
.expect_err("生产环境非法发行入口应被拒绝")
.status_code(),
StatusCode::BAD_REQUEST,
"未拒绝的发行入口:{invalid}"
);
}
}
#[test]
fn non_production_release_entry_allows_loopback_http_only() {
for allowed in [
"http://127.0.0.1:10001/api/game-distribution/releases/game_1/index.html",
"http://localhost:10001/api/game-distribution/releases/game_1/index.html",
"https://games.example.test/releases/game_1/index.html",
] {
validate_release_entry_url(allowed, true).expect("非生产环境应接受回环 http");
}
for invalid in [
"http://games.example.test/releases/game_1/index.html",
"http://192.168.1.10:10001/index.html",
"http://127.0.0.1:10001/index.html?token=1",
"http://user:pass@127.0.0.1:10001/index.html",
] {
assert_eq!(
validate_release_entry_url(invalid, true)
.expect_err("非生产环境也不能放宽回环之外的地址")
.status_code(),
StatusCode::BAD_REQUEST,
"未拒绝的发行入口:{invalid}"
);
}
}
#[test]
fn release_response_allows_opaque_sandbox_asset_loads() {
// 发行文档在 allow-scripts 沙箱里是 opaque originCORP same-origin 会让游戏

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