diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 85164f6ad..8291e815e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -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( { 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( + , + ); + 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( { 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(); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 787bce570..7a3d8890e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -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 - >({}); - const [suspendReasonByGame, setSuspendReasonByGame] = useState< - Record - >({}); + const [reasonPrompt, setReasonPrompt] = useState( + 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 (
@@ -237,11 +278,6 @@ export function AdminGameDistributionReviewPage({ {formatTime(entry.createdAt)}
-
- - 通过后由系统分配发行地址 - -
-
- - - setReasonByVersion((current) => ({ - ...current, - [entry.versionId]: event.target.value, - })) - } - disabled={busy} - /> -
-
- - - setSuspendReasonByGame((current) => ({ - ...current, - [entry.gameId]: event.target.value, - })) - } - disabled={busy} - /> -
) : null}
+ {reasonPrompt ? ( + + + + + } + > + { + setReasonDraft(event.target.value); + if (reasonError) setReasonError(''); + }} + /> + + ) : null} {writeConfirm.confirmDialog}
); diff --git a/apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx b/apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx new file mode 100644 index 000000000..874c2b87b --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx @@ -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 ( + setOpen(false)} + > +
+ +

+ 系统维护中 +

+

+ 服务正在维护,当前操作暂时无法完成。请稍后再试。 +

+ +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index 2bb5681dc..38aeea0bd 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -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( )} + , ); diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 9c9416b6b..5531f4d17 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -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 { 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( } 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(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; } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 13441c5ef..5d50699fd 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -22,6 +22,7 @@ import { getClientServerBaseUrl, readClientHttpResponseText, } from './clientHttp'; +import { emitClientMaintenanceEvent } from './clientMaintenance'; import { type ClientOperation, createClientOperation, @@ -249,10 +250,11 @@ async function requestAuthJson( }); } 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, diff --git a/apps/ai-game-creator-shell/src/services/clientMaintenance.ts b/apps/ai-game-creator-shell/src/services/clientMaintenance.ts new file mode 100644 index 000000000..91072149c --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientMaintenance.ts @@ -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 })); +} diff --git a/apps/ai-game-creator-shell/tests/maintenanceNotice.test.tsx b/apps/ai-game-creator-shell/tests/maintenanceNotice.test.tsx new file mode 100644 index 000000000..7b9813276 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/maintenanceNotice.test.tsx @@ -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(); + + 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); + }); +}); diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 8e3d0ec1f..2ce6a6ddb 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -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 自己派生, +# 不需要部署侧配置发行域名或通配证书。 diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 6d3284ce0..0b8d4c958 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -136,12 +136,28 @@ http { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + 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 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index f73b16233..02202a8ec 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -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/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 diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index a34464db9..c931fc687 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -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://.games.example.com/`。部署前替换域名、通配证书路径与 upstream 端口,并为 `*.games.example.com` 配置通配 DNS 与通配 TLS。 -- 该来源只把子域根路径映射到 `…/releases//index.html`、其余路径映射到 `…/releases//<原路径>`;平台 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//`。三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games//` 与 `/games//` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 或通配 TLS。 +- 该 location 的正则必须整体加双引号:`location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"`。不加引号时 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//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 +- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 640ef088c..49b76e619 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -179,6 +179,22 @@ server { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + 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) { diff --git a/deploy/nginx/genarrative-release-origin.conf b/deploy/nginx/genarrative-release-origin.conf deleted file mode 100644 index 223f83dab..000000000 --- a/deploy/nginx/genarrative-release-origin.conf +++ /dev/null @@ -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://.games.example.com/`; -# - 该来源只把请求映射到发行网关 -# `/api/game-distribution/releases//…`,平台 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 ~^(?[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 ~^(?[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://.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; - } -} diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index b7d1c433a..981a7c932 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -199,6 +199,22 @@ server { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + 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) { diff --git a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md index 031cd576c..8b103da65 100644 --- a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md @@ -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:` 图片预览失败不自动归类为维护;图片换签接口在维护期间失败时会触发统一弹窗,但本地刚选中的图片预览仍不依赖后端。 +- 验收补充:维护期间发布接口不能只显示“创建平台游戏失败”等局部文案;维护弹窗出现一次即可覆盖并发失败请求,关闭后业务页仍可重试。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e504f0aaa..b7aef73f4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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//` 与 `/games//` 转发到发行网关,转发前清空 `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。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1d12ec81f..f41e09fb2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -649,24 +649,22 @@ Nginx 负责站点和反向代理 Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 ``` -### 游戏发行来源(发行域名、每游戏 origin 与缓存窗口) +### 游戏发行来源(平台同源路径与缓存窗口) -已公开游戏运行在**独立来源**上,与主站来源隔离;这是发行网关(`api-server`)之外唯一需要的边缘配置。 +已公开游戏通过**平台同源路径** `/games//` 提供,边缘只做前缀映射,不再需要独立发行域名、通配 DNS 或通配 TLS。 -- 配置工件:`deploy/nginx/genarrative-release-origin.conf`。上线前替换域名、通配证书路径与 upstream 端口,并把文件安装到 Nginx 站点目录。 -- 前置资源:`*.games.<域名>` 通配 DNS 指向同一入口,以及覆盖该通配名的 TLS 证书(certbot DNS-01 或等价流程)。 -- 路由约定:`https://.games.<域名>/` 是该游戏的入口(子域根路径映射到该游戏的 `index.html`),其余路径按原样映射到 `/api/game-distribution/releases//…`;平台 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/game-distribution/releases/{gameId}/`)用于联调。 -- 门禁与本地联调: +- 路由约定:`https://<平台域名>/games//` 是该游戏的入口,`/games//` 映射到发行网关 `/api/game-distribution/releases//`。同源发行入口 location 写在 `deploy/nginx/genarrative.conf`(生产 HTTPS)、`deploy/nginx/genarrative-dev-http.conf`(开发 HTTP)与 `deploy/container/nginx.conf`(容器)里。正则必须整体加双引号:`location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"`,否则 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: .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//` 验证:入口文档 200 `text/html`、`/games//assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。 #### 游戏分发可观测事件 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index daa54b892..99e4c004a 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -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/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-.<发行站点>`,不同游戏不能共用 origin;版本固定在 `/releases//index.html`。 -- 主站仅接受服务端配置允许的 HTTPS 发行 host 与发行版本路径,拒绝任意 URL、重定向目标、用户输入 URL、`javascript:` 和 `srcdoc`。发行站点不设置平台 Cookie、不接收平台 Bearer、不挂载主站 API;请求和日志也不得携带平台认证数据。 +- 发行入口是平台同源路径 `https://<平台域名>/games//`,边缘 nginx 把该前缀原样映射到 `api-server` 发行网关。运行隔离不依赖独立来源,而由 iframe `sandbox="allow-scripts"` 把游戏文档固定在不透明来源:游戏拿不到主站 Cookie、`localStorage`、`IndexedDB`、DOM 与 Service Worker,离开页面即随 iframe 卸载整套游戏代码。 +- 主站只接受服务端派生的同源发行路径(形态固定为 `/games//`),拒绝任意 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//` + `sandbox="allow-scripts"` 的不透明来源隔离)替代独立发行域名:选择该方案后独立域名、通配 DNS/TLS 与 CDN 不再是上线门禁,存储区域与运营责任仍需确定。 5. 本节已提供页面行为、发行状态、真实上传、幂等/CAS 和 API 草案,足以评审完整业务;尚不足以直接实现持久化和部署,必须在对应里程碑评审前冻结表/索引/服务身份、额度/清理、最终响应 DTO 与发行基础设施配置。未经评审不建立 `ready` 实施计划,不把 proposed 标记为 accepted。 diff --git a/package.json b/package.json index 0098a0e6b..87d45e193 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 22b3e4a31..2d2391b48 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -61,6 +61,10 @@ export type GameDistributionGameVisibility = export type GameDistributionVersionSummary = { id: string; version: string; + /** + * 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 + * 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。 + */ entryUrl: string; sha256: string; publishedAt: string; diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 94b12b058..55a88bf78 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -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, diff --git a/scripts/check-release-origin-config.mjs b/scripts/check-release-origin-config.mjs deleted file mode 100644 index 7a3164ae6..000000000 --- a/scripts/check-release-origin-config.mjs +++ /dev/null @@ -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 ~^(?[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(); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index f878f111b..1b71b2cda 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -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"]) { diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 6bc5c39a4..ee797f8df 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -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 { 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 { - 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 { 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 会让游戏 diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 47fee1ffb..4eb118b9e 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -89,6 +89,7 @@ pub struct GameDistributionAuthor { pub struct GameDistributionVersionSummary { pub id: String, pub version: String, + /// 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 pub entry_url: String, pub sha256: String, pub published_at: String, diff --git a/src/active-main.tsx b/src/active-main.tsx index f71ccee81..2020f055f 100644 --- a/src/active-main.tsx +++ b/src/active-main.tsx @@ -7,6 +7,7 @@ import { StrictMode, Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { FloatingFeedbackEntry } from './components/common/FloatingFeedbackEntry'; +import { MaintenanceNotice } from './components/common/MaintenanceNotice'; import { stabilizeMobileViewportKeyboardFocus } from './mobileViewportKeyboardFocus'; import { lockMobileViewportZoom } from './mobileViewportZoomLock'; import { resolveAppRoute } from './routing/activeAppRoutes'; @@ -48,6 +49,7 @@ void refreshNativeAppHostRuntime(); root.render( {routeElement} + {route.kind === 'platform' ? : null} , ); diff --git a/src/components/common/MaintenanceNotice.tsx b/src/components/common/MaintenanceNotice.tsx new file mode 100644 index 000000000..b5d93385a --- /dev/null +++ b/src/components/common/MaintenanceNotice.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; + +import { MAINTENANCE_EVENT } from '../../services/apiClient'; +import { PlatformActionButton } from './PlatformActionButton'; +import { UnifiedModal } from './UnifiedModal'; + +type MaintenanceEventDetail = { + code?: string; + status?: number; + requestId?: string; +}; + +function isMaintenanceEvent( + event: Event, +): event is CustomEvent { + return event.type === MAINTENANCE_EVENT; +} + +/** + * 维护态的唯一客户端出口。请求层识别到维护响应后广播事件,页面不再各自展示业务兜底错误。 + */ +export function MaintenanceNotice() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handleMaintenance = (event: Event) => { + if (isMaintenanceEvent(event)) { + setOpen(true); + } + }; + window.addEventListener(MAINTENANCE_EVENT, handleMaintenance); + return () => + window.removeEventListener(MAINTENANCE_EVENT, handleMaintenance); + }, []); + + return ( + setOpen(false)} + size="lg" + closeOnBackdrop={false} + showCloseButton={false} + portalTheme="light" + panelClassName="min-h-[min(42vh,28rem)]" + bodyClassName="flex flex-1 items-center justify-center px-6 py-10 sm:px-12 sm:py-16" + footerClassName="justify-center px-6 py-5 sm:px-12" + footer={ + setOpen(false)} + tone="primary" + size="md" + shape="pill" + className="min-w-36" + > + 我知道了 + + } + > +
+ 为避免维护期间出现不一致,相关页面错误会统一收口到此提示。 +
+
+ ); +} diff --git a/src/components/game-distribution/gameDistributionGuards.test.ts b/src/components/game-distribution/gameDistributionGuards.test.ts new file mode 100644 index 000000000..e306937bd --- /dev/null +++ b/src/components/game-distribution/gameDistributionGuards.test.ts @@ -0,0 +1,46 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it } from 'vitest'; + +import { normalizeGameEntryUrl } from './gameDistributionGuards'; + +const GAME_ID = `game_${'a'.repeat(32)}`; +const GAME_ENTRY_PATH = `/games/${GAME_ID}/`; + +describe('normalizeGameEntryUrl', () => { + it('resolves a release gateway relative path against the current origin', () => { + expect(normalizeGameEntryUrl(GAME_ENTRY_PATH)).toBe( + new URL(GAME_ENTRY_PATH, window.location.origin).href, + ); + }); + + it('adds the trailing slash required for relative game assets', () => { + expect(normalizeGameEntryUrl(`/games/${GAME_ID}`)).toBe( + new URL(GAME_ENTRY_PATH, window.location.origin).href, + ); + }); + + it('normalizes a same-origin absolute release gateway URL', () => { + expect( + normalizeGameEntryUrl(`${window.location.origin}/games/${GAME_ID}`), + ).toBe(new URL(GAME_ENTRY_PATH, window.location.origin).href); + }); + + it.each([ + `/games/${GAME_ID}/assets/x.js`, + '/games/detail', + '/creation', + '/', + 'javascript:alert(1)', + 'https://user:pass@x/y', + 'http://evil.test/x', + ])('rejects an invalid or unsafe entry URL: %s', (entryUrl) => { + expect(normalizeGameEntryUrl(entryUrl)).toBeNull(); + }); + + it('passes through an absolute HTTPS URL on another origin', () => { + const entryUrl = 'https://play.example.test/releases/version-1/index.html'; + + expect(normalizeGameEntryUrl(entryUrl)).toBe(entryUrl); + }); +}); diff --git a/src/components/game-distribution/gameDistributionGuards.ts b/src/components/game-distribution/gameDistributionGuards.ts index bb11a1059..656b6baa3 100644 --- a/src/components/game-distribution/gameDistributionGuards.ts +++ b/src/components/game-distribution/gameDistributionGuards.ts @@ -2,6 +2,12 @@ import { useEffect, useState } from 'react'; const MAX_GAME_ID_LENGTH = 128; const MAX_ENTRY_URL_LENGTH = 4096; +const GAME_ENTRY_PATH_PATTERN = /^\/games\/(game_[0-9a-f]{32})\/?$/; + +function normalizeGameEntryPath(pathname: string) { + const match = GAME_ENTRY_PATH_PATTERN.exec(pathname); + return match ? `/games/${match[1]}/` : null; +} function containsControlCharacter(value: string) { for (const character of value) { @@ -45,13 +51,18 @@ export function normalizeGameEntryUrl(value: string | null | undefined) { return null; } + const currentOrigin = + typeof window === 'undefined' ? null : window.location.origin; + if (normalized.startsWith('/')) { + if (!currentOrigin) { + return null; + } + const normalizedPath = normalizeGameEntryPath(normalized); + return normalizedPath ? new URL(normalizedPath, currentOrigin).href : null; + } + try { - const url = new URL( - normalized, - typeof window === 'undefined' - ? 'http://localhost' - : window.location.origin, - ); + const url = new URL(normalized); const isLocalDevelopmentHttp = url.protocol === 'http:' && (url.hostname === 'localhost' || @@ -60,13 +71,16 @@ export function normalizeGameEntryUrl(value: string | null | undefined) { if ( (url.protocol !== 'https:' && !isLocalDevelopmentHttp) || url.username || - url.password || - (typeof window !== 'undefined' && - url.origin === window.location.origin && - !import.meta.env.DEV) + url.password ) { return null; } + if (currentOrigin && url.origin === currentOrigin) { + const normalizedPath = normalizeGameEntryPath(url.pathname); + return normalizedPath + ? new URL(normalizedPath, currentOrigin).href + : null; + } return url.href; } catch { return null; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index f594ca662..2a528d7b6 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -12,6 +12,7 @@ import { getHostRuntime } from './host-bridge/hostBridge'; const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed'; +export const MAINTENANCE_EVENT = 'genarrative-maintenance-detected'; const REQUEST_ID_HEADER = 'x-request-id'; const API_VERSION_HEADER = 'x-api-version'; const ROUTE_VERSION_HEADER = 'x-route-version'; @@ -505,6 +506,38 @@ function shouldRetryResponse( ); } +export function isMaintenanceApiError(error: unknown) { + return ( + error instanceof ApiClientError && + (error.code.trim().toUpperCase() === 'MAINTENANCE' || + (error.status === 503 && error.message.includes('维护'))) + ); +} + +export function emitMaintenanceNotice(error?: unknown) { + if (typeof globalThis.dispatchEvent !== 'function') { + return; + } + + if (error !== undefined && !isMaintenanceApiError(error)) { + return; + } + + const detail = + error instanceof ApiClientError + ? { + code: error.code, + status: error.status, + requestId: error.meta.requestId, + } + : undefined; + const event = + typeof CustomEvent === 'function' + ? new CustomEvent(MAINTENANCE_EVENT, { detail }) + : new Event(MAINTENANCE_EVENT); + globalThis.dispatchEvent(event); +} + export function isAbortError(error: unknown) { return ( error instanceof Error && @@ -1001,7 +1034,7 @@ async function buildApiClientError( undefined; const baseMessage = parseApiErrorMessage(responseText, fallbackMessage); - return new ApiClientError({ + const error = new ApiClientError({ message: requestId ? `${baseMessage}(requestId: ${requestId})` : baseMessage, @@ -1024,6 +1057,10 @@ async function buildApiClientError( }, responseText, }); + if (isMaintenanceApiError(error)) { + emitMaintenanceNotice(error); + } + return error; } export async function requestJson(