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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 自己派生,
|
||||
# 不需要部署侧配置发行域名或通配证书。
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+3
-4
@@ -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
|
||||
|
||||
@@ -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`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 定向 Vitest(19 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。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-origin(opaque sandbox 才能加载自有脚本)',
|
||||
],
|
||||
[
|
||||
'header::ACCESS_CONTROL_ALLOW_ORIGIN',
|
||||
'发行网关必须继续设置无凭据 CORS',
|
||||
],
|
||||
['header::CONTENT_SECURITY_POLICY', '发行网关必须继续为 HTML 设置 CSP'],
|
||||
['StatusCode::FORBIDDEN', '发行网关必须继续拒绝携带 Cookie 的请求'],
|
||||
]) {
|
||||
if (!gateway.includes(snippet)) {
|
||||
fail(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateWithNginx(template);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:release-origin-config] FAILED');
|
||||
for (const message of failures) {
|
||||
console.error(`- ${message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
for (const note of notes) {
|
||||
console.log(`[check:release-origin-config] ${note}`);
|
||||
}
|
||||
console.log(
|
||||
'[check:release-origin-config] OK(发行来源模板、网关响应头策略与 nginx 语法一致)',
|
||||
);
|
||||
}
|
||||
|
||||
function binaryExists(binary) {
|
||||
try {
|
||||
execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateWithNginx(template) {
|
||||
if (!binaryExists('nginx')) {
|
||||
notes.push('未找到 nginx,跳过渲染后的 nginx -t');
|
||||
return;
|
||||
}
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'genarrative-release-origin-'));
|
||||
try {
|
||||
const certPath = join(workDir, 'wildcard.crt');
|
||||
const keyPath = join(workDir, 'wildcard.key');
|
||||
if (binaryExists('openssl')) {
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-nodes',
|
||||
'-days',
|
||||
'1',
|
||||
'-subj',
|
||||
'/CN=games.example.com',
|
||||
'-addext',
|
||||
'subjectAltName=DNS:*.games.example.com,DNS:games.example.com',
|
||||
'-keyout',
|
||||
keyPath,
|
||||
'-out',
|
||||
certPath,
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else {
|
||||
notes.push('未找到 openssl,跳过渲染后的 nginx -t');
|
||||
return;
|
||||
}
|
||||
const rendered = template
|
||||
.replace(
|
||||
'/etc/letsencrypt/live/games.example.com/fullchain.pem',
|
||||
certPath,
|
||||
)
|
||||
.replace('/etc/letsencrypt/live/games.example.com/privkey.pem', keyPath)
|
||||
.replace(
|
||||
/\/var\/log\/nginx\/(genarrative-release\.[a-z]+\.log)/gu,
|
||||
join(workDir, '$1'),
|
||||
)
|
||||
// 非 root 环境无法绑定 80/443;语法检查用高位端口,不改生产模板本身。
|
||||
.replace('listen 80;', 'listen 18080;')
|
||||
.replace('listen 443 ssl http2;', 'listen 18443 ssl http2;');
|
||||
const renderedPath = join(workDir, 'release-origin.conf');
|
||||
writeFileSync(renderedPath, rendered);
|
||||
const wrapperPath = join(workDir, 'nginx.conf');
|
||||
writeFileSync(
|
||||
wrapperPath,
|
||||
[
|
||||
`pid ${join(workDir, 'nginx.pid')};`,
|
||||
`error_log ${join(workDir, 'error.log')} warn;`,
|
||||
'events { worker_connections 64; }',
|
||||
'http {',
|
||||
' access_log off;',
|
||||
' client_body_temp_path ' + join(workDir, 'client-body') + ';',
|
||||
' proxy_temp_path ' + join(workDir, 'proxy') + ';',
|
||||
' fastcgi_temp_path ' + join(workDir, 'fastcgi') + ';',
|
||||
' uwsgi_temp_path ' + join(workDir, 'uwsgi') + ';',
|
||||
' scgi_temp_path ' + join(workDir, 'scgi') + ';',
|
||||
` include ${renderedPath};`,
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
try {
|
||||
execFileSync('nginx', ['-t', '-c', wrapperPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
notes.push('渲染后的发行来源配置通过 nginx -t');
|
||||
} catch (error) {
|
||||
const stderr = error.stderr ? String(error.stderr) : '';
|
||||
fail(
|
||||
`渲染后的发行来源配置未通过 nginx -t:${stderr.trim() || error.message}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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 origin;CORP same-origin 会让游戏
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user