diff --git a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md index e6ba4d50c..916f60814 100644 --- a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md +++ b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md @@ -41,3 +41,14 @@ ## 注意 不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。 + +## 通用表查询页的枚举展示(2026-09-23 起) + +后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析: + +- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。 +- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。 +- 映射同时应用到 `cells` 与 `raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。 +- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。 + +因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`。 diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 05cfdc10e..7c5464f06 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -8,10 +8,12 @@ import { getAdminUserDetail, importAdminAgcTemplates, listAdminAgcTrackingEvents, + listAdminGameDistributionGames, listAdminGameDistributionReviews, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, + restoreAdminGameDistributionGame, reviewAdminGameDistributionVersion, suspendAdminGameDistributionGame, updateAdminAccount, @@ -501,7 +503,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键' { decision: 'approve', expectedPublicationRevision: 3, - entryUrl: 'https://games.example.test/releases/game_1/index.html', }, ); @@ -521,12 +522,90 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键' body: JSON.stringify({ decision: 'approve', expectedPublicationRevision: 3, - entryUrl: 'https://games.example.test/releases/game_1/index.html', }), }), ); }); +test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, data: { games: [] } }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + data: { + game: { + id: 'game/1', + title: '测试游戏', + status: 'published', + publicationRevision: 10, + }, + replayed: false, + }, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + + await listAdminGameDistributionGames( + 'admin-token', + { limit: 80 }, + controller.signal, + ); + await restoreAdminGameDistributionGame( + 'admin-token', + ' game/1 ', + ' game-restore-key-1 ', + { expectedPublicationRevision: 9 }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games?limit=50', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'GET', + signal: controller.signal, + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + }), + }), + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + '/admin/api/game-distribution/games/game%2F1/restore', + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-restore-key-1', + }), + body: JSON.stringify({ expectedPublicationRevision: 9 }), + }), + ); + + expect(() => + restoreAdminGameDistributionGame('admin-token', ' ', 'key', { + expectedPublicationRevision: 9, + }), + ).toThrow('缺少游戏 ID'); + expect(() => + restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', { + expectedPublicationRevision: 9, + }), + ).toThrow('恢复幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + test('安全下架请求携带公开修订号、原因与幂等键', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index da313c68b..8aae410af 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -32,6 +32,9 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminGameDistributionGameListResponse, + AdminGameDistributionRestoreRequest, + AdminGameDistributionRestoreResponse, AdminGameDistributionReviewListResponse, AdminGameDistributionReviewRequest, AdminGameDistributionReviewResponse, @@ -1240,6 +1243,46 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +export function listAdminGameDistributionGames( + token: string, + options: { limit?: number } = {}, + signal?: AbortSignal, +) { + const requestedLimit = options.limit ?? 50; + const normalizedLimit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50) + : 50; + return request( + `/admin/api/game-distribution/games?limit=${normalizedLimit}`, + { token, signal }, + ); +} + +export function restoreAdminGameDistributionGame( + token: string, + gameId: string, + idempotencyKey: string, + payload: AdminGameDistributionRestoreRequest, +) { + const normalizedGameId = gameId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedGameId) { + throw new Error('缺少游戏 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('恢复幂等键必须是 1 到 128 个字符'); + } + return request( + `/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + /** * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 * 两条审核结论。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index e42df5be4..01e99ed10 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1110,7 +1110,6 @@ export interface AdminGameDistributionReviewRequest { decision: 'approve' | 'reject'; expectedPublicationRevision: number; reviewReason?: string; - entryUrl?: string; } export interface AdminGameDistributionReviewResponse { @@ -1133,6 +1132,57 @@ export interface AdminGameDistributionSuspendResponse { replayed: boolean; } +export interface AdminGameDistributionGameVersionEntry { + versionId: string; + gameId: string; + versionNumber: number; + status: string; + reviewReason: string | null; + packageBytes: number; + packageSha256: string; + createdAt: string; + updatedAt: string; + reviewedAt: string | null; + publishedAt: string | null; + entryUrl: string | null; +} + +export interface AdminGameDistributionGameEntry { + gameId: string; + title: string; + author: { + id: string; + name: string; + avatarUrl: string | null; + }; + status: string; + versionCount: number; + playCount: number; + activeVersionId: string | null; + publicationRevision: number; + createdAt: string; + updatedAt: string; + versions: AdminGameDistributionGameVersionEntry[]; +} + +export interface AdminGameDistributionGameListResponse { + games: AdminGameDistributionGameEntry[]; +} + +export interface AdminGameDistributionRestoreRequest { + expectedPublicationRevision: number; +} + +export interface AdminGameDistributionRestoreResponse { + game: { + id: string; + title: string; + status: string; + publicationRevision: number; + }; + replayed: boolean; +} + export interface AdminAgcTemplatePayload { id: string; title: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 73b48d326..f9294a7a5 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -29,6 +29,7 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; +import { AdminGameManagementPage } from '../pages/AdminGameManagementPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -321,6 +322,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'game-management' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( { expect(routeHash('game-distribution')).toBe('#game-distribution'); }); +test('后台游戏管理路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'game-management', + label: '游戏管理', + hash: '#game-management', + }); + expect(resolveAdminRoute('#game-management')).toBe('game-management'); + expect(routeHash('game-management')).toBe('#game-management'); +}); + test('member 可单独获得游戏审核 Tab 权限', () => { const routes = getAccessibleAdminRoutes({ accountRole: 'member', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 46aa13750..d6c948348 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -17,6 +17,7 @@ export type AdminRouteId = | 'editor-generation-pricing' | 'editor-showcase' | 'game-distribution' + | 'game-management' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -60,6 +61,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-templates', label: '模板管理', hash: '#agc-templates' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' }, + { id: 'game-management', label: '游戏管理', hash: '#game-management' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index ae00212d9..85164f6ad 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -9,10 +9,7 @@ import { suspendAdminGameDistributionGame, } from '../api/adminApiClient'; import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; -import { - AdminGameDistributionReviewPage, - resolveGameReleaseEntryUrlError, -} from './AdminGameDistributionReviewPage'; +import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage'; vi.mock('../api/adminApiClient', () => ({ isAdminApiError: vi.fn( @@ -53,23 +50,7 @@ beforeEach(() => { }); }); -test('发行入口必须是带完整来源的 HTTPS 地址', () => { - expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口'); - expect( - resolveGameReleaseEntryUrlError('http://games.test/a/index.html'), - ).toBe('发行入口必须以 https:// 开头'); - expect( - resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'), - ).toBe('发行入口不能包含 query 或 fragment'); - expect( - resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'), - ).toBe('发行入口不能包含凭据'); - expect( - resolveGameReleaseEntryUrlError('https://games.test/a/index.html'), - ).toBe(''); -}); - -test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => { +test('通过审核只提交当前 publicationRevision 并刷新列表', async () => { vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ version: { ...entry, status: 'published' }, replayed: false, @@ -83,9 +64,8 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 ); await screen.findByText('game_1'); - fireEvent.change(screen.getByLabelText('发行入口'), { - target: { value: 'https://games.test/releases/game_1/index.html' }, - }); + expect(screen.queryByLabelText('发行入口')).toBeNull(); + expect(screen.getByText('通过后由系统分配发行地址')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '通过' })); await waitFor(() => @@ -99,7 +79,6 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 expect(payload).toEqual({ decision: 'approve', expectedPublicationRevision: 4, - entryUrl: 'https://games.test/releases/game_1/index.html', }); await waitFor(() => expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes( diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index b1e993ed7..787bce570 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -47,26 +47,6 @@ function createReviewIdempotencyKey(versionId: string) { return `game-review-${versionId}-${random}`.slice(0, 128); } -export function resolveGameReleaseEntryUrlError(value: string) { - const normalized = value.trim(); - if (!normalized) return '请填写发行入口'; - if (!normalized.startsWith('https://')) { - return '发行入口必须以 https:// 开头'; - } - if (normalized.includes('?') || normalized.includes('#')) { - return '发行入口不能包含 query 或 fragment'; - } - try { - const parsed = new URL(normalized); - if (parsed.username || parsed.password) { - return '发行入口不能包含凭据'; - } - } catch { - return '发行入口不是合法 URL'; - } - return ''; -} - export function AdminGameDistributionReviewPage({ token, onUnauthorized, @@ -78,9 +58,6 @@ export function AdminGameDistributionReviewPage({ const [busyVersionId, setBusyVersionId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); - const [entryUrlByVersion, setEntryUrlByVersion] = useState< - Record - >({}); const [reasonByVersion, setReasonByVersion] = useState< Record >({}); @@ -111,15 +88,8 @@ export function AdminGameDistributionReviewPage({ entry: AdminGameDistributionReviewEntry, decision: 'approve' | 'reject', ) { - const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim(); const reason = (reasonByVersion[entry.versionId] ?? '').trim(); - if (decision === 'approve') { - const invalid = resolveGameReleaseEntryUrlError(entryUrl); - if (invalid) { - setErrorMessage(invalid); - return; - } - } else if (!reason) { + if (decision === 'reject' && !reason) { setErrorMessage('拒绝审核必须填写理由'); return; } @@ -135,7 +105,6 @@ export function AdminGameDistributionReviewPage({ ? { decision, expectedPublicationRevision: entry.publicationRevision, - entryUrl, } : { decision, @@ -269,23 +238,9 @@ export function AdminGameDistributionReviewPage({
- - - setEntryUrlByVersion((current) => ({ - ...current, - [entry.versionId]: event.target.value, - })) - } - disabled={busy} - /> + + 通过后由系统分配发行地址 +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+

游戏列表

+ 共 {games.length} 条 +
+ + {isLoading ? ( +

正在加载游戏列表…

+ ) : null} + + {!isLoading && games.length === 0 ? ( +

暂无游戏。

+ ) : null} + + {!isLoading && games.length > 0 ? ( +
+ + + + + + + + + + + + + + {games.map((entry) => { + const status = gameStatusMeta(entry.status); + const isSuspended = entry.status === 'suspended'; + const busy = busyGameId === entry.gameId; + return ( + + + + + + + + + + ); + })} + +
标题作者gameId状态版本数游玩数操作
+ {entry.title?.trim() || '—'} + +
+
+ {entry.author?.avatarUrl ? ( + {`${authorName(entry)} + ) : ( + authorInitial(entry) + )} +
+
+ {authorName(entry)} + {entry.author?.id?.trim() || '—'} +
+
+
+ {entry.gameId} + + + {status.label} + + {entry.versionCount}{entry.playCount} +
+ {!isSuspended ? ( + <> +
+ + + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: event.target.value, + })) + } + disabled={busy} + /> +
+ + + ) : ( + + )} + +
+
+
+ ) : null} +
+ + {versionGame ? ( +
{ + if (event.target === event.currentTarget) { + setVersionGame(null); + } + }} + > +
+
+
+

版本历史

+ + {versionGame.title?.trim() || '—'}({versionGame.gameId}) + +
+ +
+

+ 共 {versionGame.versionCount} 个版本,展示最近 20 个 +

+ {versionGame.versions.length === 0 ? ( +

暂无版本记录。

+ ) : ( +
+ + + + + + + + + + + + + + + + {versionGame.versions.map((version) => ( + + ))} + +
版本状态包大小SHA创建时间审核时间公开时间审核原因发行入口
+
+ )} +
+
+ ) : null} + + {writeConfirm.confirmDialog} + + ); +} + +function GameVersionRow({ + version, +}: { + version: AdminGameDistributionGameVersionEntry; +}) { + return ( + + v{version.versionNumber} + {version.status || '—'} + {formatBytes(version.packageBytes)} + + {version.packageSha256?.slice(0, 12) || '—'} + + {formatOptionalTime(version.createdAt)} + {formatOptionalTime(version.reviewedAt)} + {formatOptionalTime(version.publishedAt)} + {version.reviewReason?.trim() || '—'} + + {version.entryUrl ? ( + + {version.entryUrl} + + ) : ( + '—' + )} + + + ); +} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 43b0f07a8..73f45325f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -459,7 +459,11 @@ export interface AgentRuntimeResult { } export type AgentRuntimeResponseStreamStatus = - 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; + | 'streaming' + | 'ready' + | 'committed' + | 'discarded' + | 'failed'; export interface AgentRuntimeResponseStream { schemaVersion: string; @@ -565,13 +569,22 @@ export interface GameCreatorAgentLlmConfigStatus { } export type GameCreatorLlmApiKind = - 'openai_responses' | 'openai_chat' | 'anthropic'; + | 'openai_responses' + | 'openai_chat' + | 'anthropic'; export type GameCreatorAgentMode = - 'codex_app_server' | 'codex_cli' | 'provider'; + | 'codex_app_server' + | 'codex_cli' + | 'provider'; export type RuntimeLlmProviderPresetId = - 'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark'; + | 'custom' + | 'openai' + | 'deepseek' + | 'anthropic' + | 'ark'; export type RuntimeAgentLlmProviderPresetId = - 'inherit' | RuntimeLlmProviderPresetId; + | 'inherit' + | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { customEnabled?: boolean; @@ -931,7 +944,9 @@ export type GameCreatorDirectToolCallKind = | 'other'; export type GameCreatorDirectToolCallStatus = - 'running' | 'completed' | 'failed'; + | 'running' + | 'completed' + | 'failed'; export interface GameCreatorDirectToolCallChange { path: string; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 59faebc5e..880adb5c3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -186,7 +186,23 @@ export function resolveLocalGamePreviewFitLayout( ): LocalGamePreviewFitLayout { const containerWidth = Math.max(1, container.width); const containerHeight = Math.max(1, container.height); - if (!content) { + // 上报的「内容尺寸」等于它被接受时的容器尺寸,说明这个页面没有超出视口的固有内容——自适应的 + // 全屏游戏(Phaser `Scale.RESIZE` 那类)就是这样,桥把视口原样报回来。这份数字不携带固有尺寸, + // 不能当高水位:否则运行视口一放大(例如全屏预览)就把画布钉在那个尺寸上,退出全屏后画布仍按 + // 全屏比例被缩进小容器、两边留出黑边,且再也回不去(iframe 视口不变 ⇒ 桥不会再报新尺寸)。 + // 这种页面继续让画布跟着容器走;真的比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。 + // + // 已知残余(不修,因为它与上面这条在数据上不可区分):某个**固定尺寸**页面恰好等于它被接受时 + // 的容器(1px 内),且缩小容器后上报的内容尺寸再不变,就会一直按容器取画布——页面自身溢出被 + // `overflow: hidden` 裁掉。改成「内容尺寸没变也把这条记录改认新容器」会反过来让上面那种自适应 + // 页面的过渡期上报(内容仍是放大前的旧值、视口已是新容器)被当成固有尺寸,全屏那类问题原样 + // 复现(实测过)。AGC 的桥对溢出文档才报出更大的内容尺寸,实测自适应与固定画布两种页面都报 + // 「内容 = 视口」,所以按自适应优先。 + if ( + !content || + (Math.abs(content.contentWidth - content.viewportWidth) < 1 && + Math.abs(content.contentHeight - content.viewportHeight) < 1) + ) { return { width: containerWidth, height: containerHeight, scale: 1 }; } const width = Math.max(containerWidth, content.contentWidth); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts b/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts new file mode 100644 index 000000000..2235ebb5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts @@ -0,0 +1,75 @@ +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +export type ElementFullscreenController = { + /** 要全屏的那一格;挂在它的 `ref` 上。 */ + ref: RefObject; + isFullscreen: boolean; + isSupported: boolean; + toggleFullscreen: () => void; +}; + +/** + * 运行画面「全屏预览」用的**元素级**全屏。 + * + * 只走标准 Fullscreen API:宿主是浏览器还是客户端 WebView 都是同一份实现。**不接 Tauri 的 + * 窗口级全屏**——那是把整块工作台(连对话栏)放大,语义是「全屏应用」,不是「全屏预览画面」。 + * + * 支持判据是两层:`requestFullscreen` 真的在,且没有被宿主显式关掉 + * (`fullscreenEnabled === false`,例如被权限策略挡住)。任一不成立就不渲染这枚按钮—— + * 一枚点了没反应的全屏按钮比没有按钮更糟。 + * + * 全屏元素被移除时浏览器按规范自己退出全屏(切回资源页签、切项目都走这条),所以这里不为 + * 卸载补退出逻辑;按钮态只认 `fullscreenchange`,Esc 与宿主自己退出都会回落。 + */ +export function useElementFullscreen< + T extends HTMLElement, +>(): ElementFullscreenController { + const elementRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const isSupported = + typeof document !== 'undefined' && + document.fullscreenEnabled !== false && + typeof document.documentElement?.requestFullscreen === 'function'; + + useEffect(() => { + if (typeof document === 'undefined') { + return undefined; + } + // 事件挂在全局 `document`、在回调里读 ref:运行画面是条件挂载的,按 ref 订监听会在 + // 「进运行页签之前」就订不上,退出全屏(Esc、F11、宿主)再也收不回来。 + const sync = () => { + const element = elementRef.current; + setIsFullscreen( + element !== null && document.fullscreenElement === element, + ); + }; + document.addEventListener('fullscreenchange', sync); + sync(); + return () => document.removeEventListener('fullscreenchange', sync); + }, []); + + const toggleFullscreen = useCallback(() => { + const element = elementRef.current; + if (!element) { + return; + } + const ownerDocument = element.ownerDocument; + // 已经有人在全屏(本元素,或页面里别的东西)时这一步只负责退出:退出本身幂等, + // 不需要先判断当前全屏的是不是自己。 + if (ownerDocument.fullscreenElement) { + void ownerDocument.exitFullscreen?.()?.catch(() => {}); + return; + } + // 失败(用户手势丢失、权限策略拒绝)不改按钮状态,界面回到「还是没全屏」的原样; + // 拒绝的 Promise 必须接住,否则会冒成未处理拒绝。 + void element.requestFullscreen?.()?.catch(() => {}); + }, []); + + return { ref: elementRef, isFullscreen, isSupported, toggleFullscreen }; +} diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index 993ccd754..5dca6dc62 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -232,6 +232,9 @@ export async function generateGameDistributionCover(args: { aspectRatio: args.aspectRatio ?? '16:9', imageSize: args.imageSize ?? '2K', assetLabel: args.assetLabel?.trim() || '游戏封面', + // 中文注释:队列结果按客户端来源选择回填契约;否则后端会按 Standard + // consumer 紧凑化并不返回 result,生成完成后客户端自然拿不到平台素材 ID。 + generationInputs: { source: 'ai-game-creator-client' }, }), }, '生成游戏封面失败', diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d567a5a77..a08b2edde 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8731,6 +8731,43 @@ iframe.preview-frame { min-height: 0; } +/* + * 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都 + * 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮 + * 在全屏里照旧可用,用户点它就能退出来。 + */ +.game-run-preview-fullscreen { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid rgb(255 255 255 / 28%); + border-radius: 10px; + background: rgb(12 14 20 / 62%); + color: #fff; + cursor: pointer; + backdrop-filter: blur(6px); +} + +.game-run-preview-fullscreen:hover, +.game-run-preview-fullscreen:focus-visible { + background: rgb(12 14 20 / 84%); + outline: 0; +} + +/* 全屏里这一格就是整块屏幕:舞台自己的虚线边框与圆角让位给画面。 */ +.game-run-preview:fullscreen { + min-height: 0; + border: 0; + border-radius: 0; + background: #05070b; +} + .game-run-preview-empty { display: grid; align-content: center; @@ -8753,13 +8790,61 @@ iframe.preview-frame { overflow-wrap: anywhere; } +/* + * 运行页签的底部信息栏。没有内容时整栏不渲染(判据在 `project-development/index.tsx`), + * 收起态只剩上面那一行开合按钮——条目卡片的 156px 最小高度不会再变成一片空白色块。 + * + * 卡片只在**有内容**时渲染:一栏也铺满整行,不留半张空位。原先「数值微调」那一栏没有登记表 + * (前端没有数据源),按用户口径没有功能就先不渲染,它的字段样式(label / input)随这一栏一起 + * 删掉;登记表接进来时样式与卡片一起回来。 + */ .game-run-panels { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.game-run-panels-controls { + display: flex; + justify-content: flex-end; +} + +.game-run-panels-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 10px; + border: 1px solid #eadbd4; + border-radius: 999px; + background: #fff; + color: #62483d; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.game-run-panels-toggle:hover, +.game-run-panels-toggle:focus-visible { + border-color: #dfb59f; + background: #fdf1ea; + outline: 0; +} + +.game-run-panels-toggle-chevron { + transition: transform 120ms ease; +} + +.game-run-panels-toggle-chevron.is-collapsed { + transform: rotate(-90deg); +} + +.game-run-panels-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 10px; } -.game-run-panels > section { +.game-run-panels-body > section { display: grid; align-content: start; gap: 8px; @@ -8781,7 +8866,6 @@ iframe.preview-frame { } .game-run-panels p, -.game-run-panels label, .game-run-panels dt, .game-run-panels dd { margin: 0; @@ -8809,24 +8893,6 @@ iframe.preview-frame { overflow-wrap: anywhere; } -.game-run-panels label { - display: grid; - grid-template-columns: minmax(0, 1fr) 88px; - align-items: center; - gap: 8px; -} - -.game-run-panels input { - min-width: 0; - height: 30px; - padding: 0 8px; - border: 1px solid #eadbd4; - border-radius: 8px; - background: #faf7f5; - color: #8f7d75; - font-size: 10px; -} - .game-workbench-chat { display: grid; grid-template-rows: auto minmax(0, 1fr); @@ -10173,7 +10239,7 @@ iframe.preview-frame { min-width: 460px; } - .game-run-panels { + .game-run-panels-body { grid-template-columns: 1fr; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 5aea932d5..3b03ac6b0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -20,6 +20,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import { AtSign, Box, + ChevronDown, Crosshair, ExternalLink, Eye, @@ -34,6 +35,7 @@ import { LayoutGrid, ListFilter, Maximize2, + Minimize2, Minus, Music2, PackageOpen, @@ -141,6 +143,7 @@ import { resourceLabelResolver, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; +import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { type ResourceCanvasAssetGenerationPanelDraft, @@ -1818,6 +1821,9 @@ export default function ProjectDevelopmentView({ }: ProjectDevelopmentViewProps) { const [mode, setMode] = useState('resources'); const [runtimeInspectMode, setRuntimeInspectMode] = useState(false); + // 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台, + // 所以按钮与 ref 都归运行表现层自己持有。 + const runPreviewFullscreen = useElementFullscreen(); const [resourceBookState, dispatchResourceBook] = useReducer( resourceBookReducer, initialResourceBookState, @@ -3000,6 +3006,31 @@ export default function ProjectDevelopmentView({ const selectedResource = canvasResources.find((resource) => resource.id === selectedResourceId) ?? null; + /** + * 运行页签底部信息栏(`.game-run-panels`)的开合。 + * + * 只有一条判据:**有没有内容**——当前是「运行页里选中了一张资源」,「信息展示」渲染的是它 + * 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里 + * 手动收 / 展则一直有效,不会被别的渲染重开。 + * + * 手动态不挂 effect、也不按下标存活,而是在渲染期按**资源 id** 判定:手动的收 / 展只对 + * 做出这个动作时的那张资源有效,换资源或清空后回到默认(有内容就展开)。这样「刚有内容」 + * 的那一帧就已经是展开态——挂 effect 回写会先画一帧收起态再展开,画面高度会抖一下。 + * + * 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等 + * 后端编辑态登记表接进来后,它与它的内容一起进这个判据。 + */ + const runPanelsHaveContent = selectedResource !== null; + const [runPanelsManualState, setRunPanelsManualState] = useState<{ + resourceId: string | null; + expanded: boolean; + } | null>(null); + const runPanelsExpanded = + runPanelsManualState !== null && + runPanelsManualState.resourceId === selectedResourceId + ? runPanelsManualState.expanded + : runPanelsHaveContent; + const runPanelsBodyId = useId(); /** * 画布选中工具栏「编辑标签」的入口判定: * - 选中 1 项:既有的单素材标签编辑(增删标签行为不变); @@ -11002,7 +11033,7 @@ export default function ProjectDevelopmentView({ ) : (
-
+
{embeddedPreviewUrl ? ( 点击顶部播放按钮后将在这里直接运行游戏
)} + {/* + 全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与 + 底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染, + 宿主没有 Fullscreen API 时也不渲染(见 `useElementFullscreen`)。 + */} + {embeddedPreviewUrl && runPreviewFullscreen.isSupported ? ( + + ) : null}
-
-
-
-
- {selectedResource ? ( - + {/* + 底部信息栏:有内容才存在,没有内容就自动收起(整栏不渲染)。 + 收起态只留下这一行开合按钮,展开态才渲染条目卡片——卡片的 156px 最小高度 + 因此不会再变成一片空白色块。 + */} + {runPanelsHaveContent ? ( +
+
+ +
+ {runPanelsExpanded ? ( +
+
+
+
+ {selectedResource ? ( + + ) : null} +
+
) : null} -
-
-
-
-
-
+ + ) : null}
)} {/* diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 645a8e957..7e35bc9a0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -198,7 +198,7 @@ export function PlanningChatView({ : undefined; const streaming = Boolean( animation && - (!animation.persisted || animation.visible !== animation.target), + (!animation.persisted || animation.visible !== animation.target), ); return (
) => void) | null = null; + | ((result: Record) => void) + | null = null; const invoke = vi.fn(async (command: string) => { if (command === 'preflight_web_game_creation') return { status: 'ready' }; if (command === 'create_automatic_local_game_project') { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 066d1f28c..c9ece5b3b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3883,6 +3883,11 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(runTab); const runInfoPanel = screen.getByLabelText('资源信息面板'); expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows); + // 有内容就自动展开;手动收起后条目卡片整体让位,只剩那一行开合按钮。 + fireEvent.click(screen.getByRole('button', { name: '收起信息栏' })); + expect(screen.queryByLabelText('资源信息面板')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '展开信息栏' })); + expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); // 画布浮层只属于画布:切到运行视图后不再渲染。 expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); }); @@ -6453,8 +6458,10 @@ export function registerProjectWorkbenchFoundationTests() { 'allow-scripts allow-same-origin allow-forms allow-pointer-lock', ); expect(screen.queryByLabelText('测试切片控件')).toBeNull(); - expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); - expect(screen.getByLabelText('数值微调面板')).not.toBeNull(); + // 底部信息栏没有内容就整栏不渲染:此时没有选中资源,「信息展示」拿不到字段,「数值微调」的 + // 登记表也还没接,于是条目卡片与开合行都不该出现——留着就是验收现场那半条「空信息栏白占一块高度」。 + expect(screen.queryByLabelText('资源信息面板')).toBeNull(); + expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull(); fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); fireEvent.click(screen.getByRole('button', { name: '按类型' })); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index e4aa8e332..72ec1ffd4 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -276,6 +276,7 @@ test('封面生成直接使用返回的平台素材 ID,不再次上传', async aspectRatio: '16:9', imageSize: '2K', assetLabel: '游戏封面', + generationInputs: { source: 'ai-game-creator-client' }, }); }); diff --git a/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts b/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts index c26d643f5..422237838 100644 --- a/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts +++ b/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts @@ -6,77 +6,151 @@ import { describe, expect, it, vi } from 'vitest'; import { LOCAL_GAME_PREVIEW_SIZE_MESSAGE, + type LocalGamePreviewContentSize, LocalGamePreviewFrame, parseLocalGamePreviewContentSize, resolveLocalGamePreviewContentSizeUpdate, resolveLocalGamePreviewFitLayout, } from '../src/features/project-workspace/LocalGamePreviewFrame'; +/** + * 运行画面用 `getBoundingClientRect` 量容器、用 `ResizeObserver` 跟踪;jsdom 两者都不给,这里补一份 + * 最小可驱动的:能改容器矩形、能手放 ResizeObserver 回调、能送跨窗口尺寸上报。 + */ +function renderFittedFrame(initialContainer: { + width: number; + height: number; +}) { + let containerRect = initialContainer; + let resizeCallback: ResizeObserverCallback | null = null; + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation( + () => + ({ + ...containerRect, + x: 0, + y: 0, + top: 0, + right: containerRect.width, + bottom: containerRect.height, + left: 0, + toJSON: () => ({}), + }) as DOMRect, + ); + const previousResizeObserver = window.ResizeObserver; + window.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe() {} + unobserve() {} + disconnect() {} + }; + const view = render( + createElement(LocalGamePreviewFrame, { + preview: { status: 'running', url: 'http://127.0.0.1:1234/' }, + title: 'preview', + }), + ); + const iframe = view.getByTitle('preview') as HTMLIFrameElement; + return { + iframe, + resizeTo(next: { width: number; height: number }) { + containerRect = next; + act(() => { + if (!resizeCallback) { + throw new Error('ResizeObserver was not registered'); + } + resizeCallback([], {} as ResizeObserver); + }); + }, + reportSize(size: LocalGamePreviewContentSize) { + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + origin: 'http://127.0.0.1:1234', + source: iframe.contentWindow, + data: { type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, ...size }, + }), + ); + }); + }, + cleanup() { + view.unmount(); + window.ResizeObserver = previousResizeObserver; + rectSpy.mockRestore(); + }, + }; +} + describe('local game preview viewport fitting', () => { it('does not reset the fitted iframe to native size while its container resizes', () => { - let containerRect = { width: 800, height: 500 }; - let resizeCallback: ResizeObserverCallback | null = null; - const rectSpy = vi - .spyOn(HTMLElement.prototype, 'getBoundingClientRect') - .mockImplementation( - () => - ({ - ...containerRect, - x: 0, - y: 0, - top: 0, - right: containerRect.width, - bottom: containerRect.height, - left: 0, - toJSON: () => ({}), - }) as DOMRect, - ); - const previousResizeObserver = window.ResizeObserver; - window.ResizeObserver = class { - constructor(callback: ResizeObserverCallback) { - resizeCallback = callback; - } - - observe() {} - unobserve() {} - disconnect() {} - }; - - const view = render( - createElement(LocalGamePreviewFrame, { - preview: { status: 'running', url: 'http://127.0.0.1:1234/' }, - title: 'preview', - }), - ); - const iframe = view.getByTitle('preview') as HTMLIFrameElement; - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - origin: 'http://127.0.0.1:1234', - source: iframe.contentWindow, - data: { - type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, - contentWidth: 800, - contentHeight: 835, - viewportWidth: 800, - viewportHeight: 500, - }, - }), - ); + const frame = renderFittedFrame({ width: 800, height: 500 }); + frame.reportSize({ + contentWidth: 800, + contentHeight: 835, + viewportWidth: 800, + viewportHeight: 500, }); - expect(iframe.style.height).toBe('835px'); + expect(frame.iframe.style.height).toBe('835px'); - containerRect = { width: 1000, height: 600 }; - act(() => { - if (!resizeCallback) throw new Error('ResizeObserver was not registered'); - resizeCallback([], {} as ResizeObserver); + frame.resizeTo({ width: 1000, height: 600 }); + expect(frame.iframe.style.width).toBe('1000px'); + expect(frame.iframe.style.height).toBe('835px'); + + frame.cleanup(); + }); + + it('returns the fitted iframe to the container after the host viewport shrinks', () => { + // 全屏预览把运行视口放大到 1416x808,自适应游戏把视口原样报回来;退出全屏后画布必须跟着 + // 缩回容器尺寸,不能把全屏那一帧的尺寸钉在画布上(改前这里会一直是 1416x808 + 缩放到 0.72)。 + const frame = renderFittedFrame({ width: 1416, height: 808 }); + frame.reportSize({ + contentWidth: 1416, + contentHeight: 808, + viewportWidth: 1416, + viewportHeight: 808, }); - expect(iframe.style.width).toBe('1000px'); - expect(iframe.style.height).toBe('835px'); + expect(frame.iframe.style.width).toBe('1416px'); + expect(frame.iframe.style.height).toBe('808px'); - view.unmount(); - window.ResizeObserver = previousResizeObserver; - rectSpy.mockRestore(); + frame.resizeTo({ width: 1015, height: 660 }); + expect(frame.iframe.style.width).toBe('1015px'); + expect(frame.iframe.style.height).toBe('660px'); + + frame.cleanup(); + }); + + it('refits to the reported content size after the container shrinks', () => { + // 容器缩小后先按容器取画布(上一次的内容尺寸已不能代表当前容器),页面在新容器上重新量出 + // 更大的内容(真的溢出)时,画布回到 `max(容器, 内容)` 并把整幅内容等比缩小。 + const frame = renderFittedFrame({ width: 1015, height: 660 }); + frame.reportSize({ + contentWidth: 1015, + contentHeight: 660, + viewportWidth: 1015, + viewportHeight: 660, + }); + expect(frame.iframe.style.width).toBe('1015px'); + + frame.resizeTo({ width: 800, height: 520 }); + expect(frame.iframe.style.width).toBe('800px'); + + frame.reportSize({ + contentWidth: 1200, + contentHeight: 900, + viewportWidth: 800, + viewportHeight: 520, + }); + expect(frame.iframe.style.width).toBe('1200px'); + expect(frame.iframe.style.height).toBe('900px'); + expect( + Number(/scale\(([\d.]+)\)/u.exec(frame.iframe.style.transform)?.[1]), + ).toBeCloseTo(520 / 900, 10); + + frame.cleanup(); }); it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => { diff --git a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx new file mode 100644 index 000000000..2b289d564 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx @@ -0,0 +1,142 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; + +const PREVIEW_URL = 'http://127.0.0.1:4173/'; + +function installInvoke() { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: [], + referenceEdges: [], + taskFlows: [], + categories: [], + diagnostics: [], + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }), + }, + } as unknown as typeof window.__TAURI__; +} + +function renderRunView() { + installInvoke(); + const manifest = createGameCreationAppManifest( + 'run-preview-fullscreen', + '运行页全屏预览', + ); + render( + 项目总控
} + onHomeOpen={vi.fn()} + onProjectsOpen={vi.fn()} + onNotice={vi.fn()} + />, + ); +} + +/** + * jsdom 完全没有 Fullscreen API(`document.fullscreenEnabled` / `requestFullscreen` / + * `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来: + * 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。 + */ +function installFullscreenHost() { + let fullscreenElement: Element | null = null; + const requestFullscreen = vi.fn(function (this: Element) { + // 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。 + // eslint-disable-next-line @typescript-eslint/no-this-alias + fullscreenElement = this; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + const exitFullscreen = vi.fn(() => { + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: exitFullscreen, + }); + Object.defineProperty(Element.prototype, 'requestFullscreen', { + configurable: true, + writable: true, + value: requestFullscreen, + }); + return { requestFullscreen, exitFullscreen }; +} + +afterEach(() => { + document.body.innerHTML = ''; + Reflect.deleteProperty(document, 'fullscreenElement'); + Reflect.deleteProperty(document, 'exitFullscreen'); + Reflect.deleteProperty(Element.prototype, 'requestFullscreen'); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('运行页「全屏预览」', () => { + it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => { + const { requestFullscreen, exitFullscreen } = installFullscreenHost(); + renderRunView(); + + const button = await screen.findByRole('button', { name: '全屏预览' }); + // 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。 + const stage = document.querySelector('.game-run-preview'); + expect(stage).not.toBeNull(); + expect(button.closest('.game-run-preview')).toBe(stage); + expect(button.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(button); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + // 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。 + expect(document.fullscreenElement).toBe(stage); + const exitButton = await screen.findByRole('button', { + name: '退出全屏预览', + }); + expect(exitButton.getAttribute('aria-pressed')).toBe('true'); + + fireEvent.click(exitButton); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + expect( + (await screen.findByRole('button', { name: '全屏预览' })).getAttribute( + 'aria-pressed', + ), + ).toBe('false'); + }); + + it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => { + renderRunView(); + + await screen.findByTitle('运行页全屏预览 游戏运行画面'); + expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull(); + }); +}); diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index e8b929f38..8e3d0ec1f 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -71,3 +71,7 @@ GENARRATIVE_LLM_API_KEY= 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= diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 25ec4bbc4..f73b16233 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -179,6 +179,11 @@ 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= + # SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index 6d2b2dffe..a34464db9 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -105,5 +105,5 @@ curl -sSI -H 'Accept-Encoding: br' \ - `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` 发行网关设置,边缘不覆盖。 -- 审核通过时填写的 `entryUrl` 就是该子域根地址 `https://.games.example.com/`;换版本或下架只改变后端公开投影,边缘不需要改配置。 +- 审核通过时 `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`。 diff --git a/deploy/nginx/genarrative-release-origin.conf b/deploy/nginx/genarrative-release-origin.conf index 5e120726d..223f83dab 100644 --- a/deploy/nginx/genarrative-release-origin.conf +++ b/deploy/nginx/genarrative-release-origin.conf @@ -51,7 +51,8 @@ server { } # 子域根路径直接服务该游戏的 index.html,游戏内其余资源按相对路径原样交给 - # 发行网关;这样审核通过时填写的 entryUrl 就是 https://.games.example.com/。 + # 发行网关;审核通过时 api-server 按发行入口模板派生的 entryUrl 就是 + # https://.games.example.com/。 location = / { proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index cdf6b4e73..14b25c309 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -75,7 +75,8 @@ - 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。 - 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `` / `` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。 -- 运行视窗下方继续保留“信息展示”和“数值微调”区域标题及原有面板高度;没有真实资源信息或已登记微调项时,内容区域保持空白,不显示示例字段、默认数值、未载入控件或功能说明,也不得因内容为空压缩两个面板。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 +- 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。 +- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是**资源选中态**:在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息栏渲染它的只读字段;运行画面上的“点选素材”只往对话插入引用,不改选中):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 - 数值修改立即写入当前项目的编辑态配置。 - 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。 diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index 0620f55c5..84a2fa4da 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -121,6 +121,12 @@ - 发布灰度改为**默认关闭**并修掉客户端“看得到点不动”:`is_game_distribution_publish_enabled_for_user` 现在要求 gate 行存在且 `enabled=true`(未登录、无行、`enabled=false` 一律 false),因此没配灰度时 `gameDistributionPublishEnabled=false`,AGC 不再渲染「发布到游戏广场」按钮、网页入口也不出现;AGC 侧新增 `announcePublishMessage`,把「已构建并打包试玩包」「先打开一个项目再发布」等提示通过 DirectProject 聊天容器的 `announce` 出口回话(普通项目不渲染工作台状态行,之前只写 workspaceStatus 才会表现为点击无反应)。后台「灰度发布配置」新增「可配置开关」列表:预设开关在未创建行时也可见并可一键配置(不再需要先猜 gate key)。 +## 2026-09-23 口径更新:发行入口改为服务端派生 + +- 管理员不再填写 `entryUrl`:审核通过时 `api-server` 读版本取 gameId,按部署模板 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`(生产形如 `https://{gameId}.games.<发行域名>/`,必须含 `{gameId}` 占位符)派生每游戏独立来源地址,再走原有 HTTPS / 无凭据 / 无 query / 无 fragment 校验;后台审核 DTO 与页面已删除该输入框。 +- 上文「已完成证据」中描述「管理员填写 / 要求 HTTPS 发行入口」的条目是当时的交付事实,当前口径以主规范《平台入口与玩法链路》《本地开发验证与生产运维》与 `shared-memory/decision-log.md` 的 2026-09-23 条目为准。 +- 非生产环境未配置模板时仍回落到本地发行网关回环地址(用于免 TLS 验证内嵌游玩);生产未配置模板、模板缺 `{gameId}`、gameId 非主机安全字符或派生结果非法时,审核通过直接失败。 + ## 尚未完成 - 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。 diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md b/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md index b5856a60f..a0817adcc 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md @@ -45,3 +45,7 @@ AGC 发布面板完成三项收敛: - 不做网页发布页的自动生成。 - 不新增发布草稿持久化、生成历史、重试队列或 SpacetimeDB 表。 - 不改变封面上传入口和手动选择封面的能力。 + +## 6. 本次缺陷修复记录 + +- 2026-09-23:AGC 生成游戏封面请求补充 `generationInputs.source = "ai-game-creator-client"`。队列 worker 依据该来源选择 `GameCreatorResourceEditor` 结果契约;未标记来源时会按 `Standard` 紧凑化并省略 `result`,导致生成完成后无法回传 `assetObjectId`。对应前端定向测试已锁定请求字段与平台素材 ID 回填链路。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4dfa2d28e..f9d61a507 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏 + +- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。 +- 决策一:新增 `useElementFullscreen`(`apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**(`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`,Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。 +- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 存在资源选中态——在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息展示渲染它的只读字段;运行画面上的「点选素材」只往对话插入引用,不改选中):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。手动态按资源 id 在渲染期派生(不挂 effect 回写):手动收 / 展只对做出动作时的那张资源有效,换到别的资源回到默认(有内容即展开),同一张资源即使清空选中后再选回也仍记得上一次的手动状态;这样「刚有内容」的那一帧就已经是展开态,不会先画一帧收起态再展开。 +- 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。 +- 决策四(同日收口全屏回归):用户报「退出全屏后画布仍保持全屏比例」。根因不在全屏本身,而在运行画面的尺寸上报回灌——自适应页面把视口原样报回(内容尺寸 = 容器尺寸),宿主把它当成「内容高水位」,`resolveLocalGamePreviewFitLayout` 的 `max(容器, 内容)` 就把画布钉在全屏那一帧的尺寸上;退出后 iframe 视口不再变化,桥也不会再上报,于是永远回不去(实测 1015×660 → 全屏 1416×808 → 退出仍是 1416×808、缩放到 0.72,画面按全屏比例缩成一条带黑边的窄幅)。修法:内容尺寸与它被接受时的容器尺寸在两个轴上都相等(<1px)时不算高水位,直接按容器尺寸给画布;真比容器高的页面(内容 ≠ 视口,桥注入的原始动机)仍按原生尺寸缩放显示。回归用例 `tests/localGamePreviewFrame.test.ts` 的 `returns the fitted iframe to the container after the host viewport shrinks`(改前必红,实测 1416px vs 1015px)。 +- 决策四的残余边界(明确不修):若某个**固定尺寸**页面恰好等于它被接受时的容器尺寸,且缩小容器后它上报的内容尺寸再不变,就会一直按容器取画布(页面自身溢出被裁)。评审提过「内容尺寸没变也把这条记录改认新容器」,我实现后又**实测回退**了:那条过渡期上报(内容还是放大前的旧值、视口已是缩小后的容器)会被当成固有尺寸,全屏那类问题原样复现且同样永久(iframe 回到旧尺寸后桥不再上报)。两者在宿主拿到的数据上不可区分,按 AGC 常态(桥对自适应与「固定画布但自适应文档」两类页面实测都报「内容 = 视口」)选自适应优先;页面报告新内容尺寸时立即回到 `max(容器, 内容)` 等比缩小(用例 `refits to the reported content size after the container shrinks`)。根治方向在桥 / 协议侧:尺寸消息再带一个「本页是否视口耦合」的布尔(桥内部已有逐元素耦合采样与排除耦合后的边界),拟合直接按它判定,不必用两个数字相等去猜——属桥与协议的独立变更,本 PR 不做。 +- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);`tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具并补上面两条用例;AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染(`数值微调面板` 这条已随删除面消失的 label 断言同步删掉,避免恒真)。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1812 项,合并上游退役提交后的口径)、编码检查与 `git diff --check` 通过;并用真实 Chromium(挂同一份组件 + 客户端真实注入的尺寸桥脚本,`fullbleed` 与 `fixed` 两种游戏页)冒烟:右下角按钮只把画面那一格送进全屏且可退出、退出后画布缩回容器尺寸、选中资源后信息栏自动展开(190px)、收起后画面变高(26px→636px)、再展开恢复。 + ## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它 - 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()` 且 `click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay` 用 `inset: 0` 真的把标题栏盖住了。 @@ -8424,6 +8434,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。 - 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。 - 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。 +- 补充(2026-09-23):上面「容器 resize 期间保留内容尺寸」只对**真比容器高 / 宽**的页面成立。上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回。否则运行画面被放大一次(例如「全屏预览」)就会把画布钉在那个尺寸上,退出后仍按全屏比例缩进小容器,且 iframe 视口不变 ⇒ 桥不再上报 ⇒ 永远回不去。判据与回归用例见 2026-09-23 两条条目(`resolveLocalGamePreviewFitLayout` 的回退分支与 `returns the fitted iframe to the container after the host viewport shrinks`)。 ## 2026-08-23 Direct Codex 显式重生成与切片一等资源 @@ -8621,7 +8632,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-31 LLM Router 独立账号与后置扣费修订 -- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `taonier` 分组;发现旧 Token 为其它分组时先更新为 `taonier`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 2026-09-23 调整:Token/API Key 分组由 `default` 改为与 Router 用户同组的 `taonier`;创建后无条件 PUT、登录恢复同样 PUT,只要 Token 分组不是 `taonier` 就纠正回来(常量见 `external_api_keys.rs` 的 `LLM_ROUTER_TOKEN_GROUP`)。该契约要求 Router 侧 `taonier` 分组已挂载所需模型与套餐,分组缺模型时会失败为 `model_not_found`;存量已签发且分组为 `default` 的 Key 会在该账号下次 provisioning / 登录恢复 / 显式准备 Key 时被纠正。 - 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `llm_router_account` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。 - AGC 调用固定为客户端 access token -> api-server -> Router。计费读取账号 `used_quota`,每 50000 quota 扣 1 泥点,美元数值乘 10、不乘汇率。首次模型调用前以当前累计额度完整建立免追扣基线,之后调用前后同步;扣钱包、写 `llm_router_consume` 流水与推进已结算额度同事务完成。小数和余额不足未支付部分继续累计,失败或重复同步不推进已结算额度,不使用本地 WAL 或余数队列。完整合同见 `docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md`。 - AGC 状态面收口:Tauri `check_game_creator_llm_config` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 @@ -9287,3 +9299,21 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`server-rs/crates/api-server/src/{admin.rs,admin_recharge.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/{AdminRechargeOrderPage.tsx,AdminRedeemCodePage.tsx}`、`apps/admin-web/src/components/AdminUserDetailDialog.tsx`、对应三个用例文件与后端架构数据契约文档。 - 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server cumulative_recharge`(2 条新用例)与 `cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(220 passed),其中三个定向文件 33 passed(新增「未支付订单不显示实付金额,发放泥点单独成列」与「累计充值读取不到时展示未知,不用订单列表近似」)。 - 边界(未验证):未连真实生产库核对历史订单的累计充值数值,也未跑真实栈 API smoke。 + +## 2026-09-23 后台表查询枚举列按 schema 展示变体名,不再只看数值 + +- 背景:后台「表查询」页的枚举列直接落回通用解码,只有 `profile_recharge_order` 的 `kind` / `status` 做了硬编码映射,其余 20 多张表的枚举列(如 `profile_wallet_ledger.source_type`、`tracking_event.scope_kind`、`profile_membership.tier`)都显示成 SATS 原始数值,运营在后台看不到枚举值。 +- 决策(按 schema 自动解析):api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 SpacetimeDB schema 的 `typespace.types` 与表的 `product_type_ref`,对每个「`Sum` 且所有变体都是单元变体」的列生成「列名 → 按变体索引排列的展示名」;变体名归一到 snake_case,与后台既有枚举字符串(`points` / `paid` / `asset_operation_consume`)同口径,因此原硬编码映射的展示结果不变,新增表与新增枚举列不再需要改代码。 +- 决策(边界):`Option<枚举>` 列单独标记为可空(`[0, [索引, []]]` 出变体名、`[1, []]` 仍是空值),`Option<普通值>` 与带载荷的 Sum 不参与映射,继续走通用解码(`Some` 解包、`None` 归空、时间戳原样透出);单变体枚举同样要出名字。映射同时作用于 `cells` 与 `raw`,关键词搜索、结构化筛选和稳定排序都按展示名生效。schema 读取失败时表查询以「表不存在」失败,不会退回展示数字。 +- 影响范围:`server-rs/crates/api-server/src/admin.rs`(新增 schema 解析与 `build_admin_database_enum_labels`,删除 `normalize_admin_database_known_enum`,`parse_admin_database_table_rows_sql_response` / 行构建与归一化改为接收枚举映射)、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md`。前端与 DTO 不变。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin::tests`(86 passed,含新增 `admin_database_enum_labels_come_from_schema_variants` 与改写后的充值订单枚举用例);用本地 dev schema 逐表回放同一算法,85 张表里 32 个枚举列全部解析出展示名、0 个残留;`cargo check -p api-server`、`cargo fmt --all --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 +- 边界(未验证):没有对真实 HTTP 表查询响应做端到端比对(本地 dev api-server 仍是改动前二进制,未重启)。 + +## 2026-09-23 游戏发行入口改为服务端按模板派生:管理员不再手填地址 + +- 背景:游戏审核通过要求管理员手填绝对 HTTPS `entryUrl`,现场出现「不知道该填什么、随手填一个外部站点也能过校验」的风险;而每游戏独立来源本身完全能由 gameId 推出,人工输入没有增加任何判断。 +- 决策(唯一口径):部署侧用 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE` 配置带 `{gameId}` 占位符的模板(生产形如 `https://{gameId}.games.<发行域名>/`);审核通过时 `api-server` 读版本取 gameId、替换模板、再走原有绝对 HTTPS / 无凭据 / 无 query / 无 fragment 校验后写入公开投影。后台审核请求 DTO 删除 `entryUrl`,页面不再渲染输入框,也不要求二次确认。 +- 决策(失败关闭):模板缺 `{gameId}`、生产未配置模板、gameId 含非主机安全字符或派生结果非法时,审核通过直接失败,不回落主站、内网或任意外部地址;非生产未配置模板时回落 `http://127.0.0.1:/api/game-distribution/releases/{gameId}/`,保持免 TLS 的本地内嵌游玩验证。 +- 边界:`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 + 审核通过)未在本轮复跑。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ce058c6a1..625d38d09 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -979,7 +979,7 @@ game-project/ - 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按 manifest 资产功能分类 `category`(`UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。 -- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 +- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,画面右下角提供“全屏预览”(元素级 Fullscreen API,只把画面那一格送进全屏;宿主没有该 API 时整枚入口不渲染)。其下的信息栏只在有真实内容(判据是资源画布或浮层资源面板的资源选中态,切到运行页签后仍保留;信息栏渲染该资源的只读信息)时存在:没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;「数值微调」的登记表尚未接入,因此暂时不渲染它的区域标题与卡片,不渲染预设字段、默认数值、未载入控件或自然语言功能占位。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;但上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回,否则全屏预览退出后画布会被钉在全屏那一帧的尺寸上。preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 - 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。 - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 - 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 925580bd1..64030aad6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -471,6 +471,15 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。 - 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction` 由 `api-server` 按 `status` 派生,不落表。 +### 后台游戏管理读模型与恢复动作(2026-09-23) + +- 页面目标:后台新增「游戏管理」页,展示全量游戏(标题 / 作者名 + 头像 / gameId / 状态 / 版本数 / 游玩数),行内提供安全下架、恢复与版本历史;它是运营面的全量视图,不替代 `#game-distribution` 待审队列。 +- 数据来源:新增只读 procedure `list_admin_game_distribution_games_and_return`(输入 `GameDistributionAdminGameListInput { limit }`)。它在同一事务里读 `game_distribution_game`,按 `by_game_distribution_version_game_id` 统计每个游戏的版本数并取最近 20 个版本;作者名与头像按 `user_account.user_id` 读时联 `display_name` / `avatar_url`(行内快照为空时以联表结果为准)。不新增表、不改 schema、不改公开投影。 +- 恢复动作:新增 procedure `restore_game_distribution_game_and_return`(输入 `GameDistributionRestoreInput`)。它只允许管理员解除 `suspended`:重新激活该游戏最近一个由管理员暂停撤回(`status = revoked`、`published_at` 非空且 `reviewed_by_user_id` 非空)的版本,恢复 `visibility = published` 并递增 `publication_revision`;作者自行下架的版本不写审核者,因此不会被恢复动作重新公开。没有可恢复版本、`expected_publication_revision` CAS 不符或游戏不在暂停态时失败关闭。幂等收据复用 `game_distribution_idempotency_receipt`(action = `restore`),恢复动作不受发布灰度开关限制,与安全下架同口径。 +- 后台 HTTP:`GET /admin/api/game-distribution/games?limit=` 返回 `{ games: [{ gameId, title, author{ id, name, avatarUrl }, status, versionCount, playCount, activeVersionId, publicationRevision, createdAt, updatedAt, versions: [...] }] }`;`POST /admin/api/game-distribution/games/{gameId}/restore` 要求 `Idempotency-Key` 与 `expectedPublicationRevision`,返回 `{ game, replayed }`。两者都走 `require_admin_auth`,Tab 权限为 `game-management` 或 `editor-showcase`,不新增公开契约。 +- 前端:`apps/admin-web` 新增 `#game-management` 路由与 `AdminGameManagementPage`,复用现有 `admin-table` 表格与 `useAdminWriteConfirm` 二次确认;版本历史在弹层内展示,长列表保持横向滚动。 +- 验收:`cargo test -p api-server game_distribution`、`cargo test -p spacetime-module game_distribution`、`npm run spacetime:generate` 后 `npm run check:spacetime-schema`、admin-web 定向 Vitest + typecheck、`npm run check:encoding`、`git diff --check`。 + ### `game_distribution_idempotency_receipt` - Rust 结构体:`GameDistributionIdempotencyReceipt` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index fef7460d7..35d114123 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -217,7 +217,7 @@ SpacetimeDB procedure: 本次 AGC Router 需求不改造原有 `external_api_key`。Router 账号、API Key 核心字段、生命周期和加密凭据统一保存在 `llm_router_account`;明文凭据只在 api-server 进程内短暂存在,并按 owner + route 进行 10 分钟内存缓存,轮换或撤销时立即清理。 -普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段只读取 `llm_router_account` 中合法的已完成 provisioning 凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在;缓存命中时不访问数据库,缓存未命中时从 `llm_router_account` 解密并写入 10 分钟进程内缓存;`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: +普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段只读取 `llm_router_account` 中合法的已完成 provisioning 凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `taonier` 分组,与 Router 用户同组;创建后无条件 `PUT` 一次完整固定契约、登录恢复时同样 `PUT`,只要已有固定 Token 分组不是 `taonier` 就纠正回来;该契约的前提是 Router 侧 `taonier` 分组已挂载所需模型与套餐,分组缺少模型时请求会失败为 `model_not_found`,排查该错误应先核对 Token 与用户的实际分组,而不是只看本地 `llm_router_account`),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在;缓存命中时不访问数据库,缓存未命中时从 `llm_router_account` 解密并写入 10 分钟进程内缓存;`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: ```text AGC loopback Provider Proxy(Bearer=平台 access token) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index f38316f86..1d12ec81f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -151,6 +151,8 @@ dev 调度器发现 revision 变化时,把同一个完整 commit 通过 `COMMI `Genarrative-Agc-Windows-Build` 的 `Tauri NSIS toolchain` 阶段必须在 Rust 编译前预置 NSIS 工具链并失败关闭:tauri-bundler 打包时现场从 GitHub 下载 `nsis-3.11.zip` 与 `nsis_tauri_utils.dll` 且不重试,构建机每次检出都会重下,响应一旦被截断就只能抛 `io: unexpected end of file`,让发布在编译数分钟后才失败。该阶段先跑 `node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs`(固定 SHA1 校验、4 次重试、解压到 `target/.tauri/NSIS`),再执行 `makensis.exe -VERSION` 验证可执行性;Checkout 的 `git clean -fdx` 必须带 `-e apps/ai-game-creator-shell/src-tauri/target/.tauri`,只保留这份工具缓存、其余 `target/` 内容照常清空,否则工作区内缓存会被每个构建删掉,退回到「每次从 GitHub 重下」(实测裸 `git clean -fdx` 会输出 `Would remove apps/ai-game-creator-shell/src-tauri/target/`);原始归档缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),因此同一节点只有冷缓存才需要联网,离线补缓存时把这两个文件放进缓存目录即可;构建机确实无法访问 GitHub 时使用 bundler 自带的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR` 指向可达镜像。升级 `@tauri-apps/cli` 时必须同步核对 `nsis-toolset.mjs` 里的归档地址、SHA1 与必需文件清单(与 tauri-bundler 的 `NSIS_REQUIRED_FILES` 逐条对齐),否则预置会被 bundler 判为不完整。 +`Genarrative-Agc-Windows-Build` 另有 `Godot gdextension dependency` 阶段,同样必须在 Rust 编译前完成并失败关闭:`build.rs` 的 Godot 载荷构建会现场下载固定 commit 的 `godot-cpp` 归档(`codeload.github.com`),下载失败只会表现成数分钟后的 cargo 构建失败。该阶段先跑 `python -X utf8 plugins/agc-godot-editor/native/gdextension/tests/test_dependencies.py`,再跑 `python -X utf8 plugins/agc-godot-editor/native/gdextension/prepare_dependencies.py`(瞬时连接失败、5xx、限流与正文截断最多尝试 4 次、含 3 次重试,仍失败则按固定 SHA256 校验失败关闭);依赖缓存由 Job 环境的 `AGC_GODOT_CPP_CACHE_DIR` 指向工作区外的 `%ProgramData%\genarrative\godot-cpp-cache`,因此 Checkout 的 `git clean -fdx` 不会清掉它,同一节点只有冷缓存才需要联网;离线补缓存时把 `.zip` 放进该目录即可。构建机无法访问 `codeload.github.com` 时用环境变量 `AGC_GODOT_CPP_ARCHIVE_URL`(必须 HTTPS,由 Job 参数或节点环境提供)指向可达镜像,接受与否仍由固定 SHA256 决定,镜像必须提供与官方归档逐字节一致的文件。 + 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 @@ -656,7 +658,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - 路由约定:`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` 必须是该游戏的子域根地址 `https://.games.<域名>/`(HTTPS、无凭据、无 query/fragment);非生产环境仍按现有口径允许 http 回环地址用于本地联调。 +- 审核动作:管理员只提交审核结论与公开修订号,`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}/`)用于联调。 - 门禁与本地联调: ```bash @@ -1114,7 +1116,7 @@ SELECT * FROM profile_recharge_order WHERE status = 'expired' AND expiration_che SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC; ``` -后台通用表查询已经处理 SpacetimeDB 无载荷枚举的 SATS 形态。新增后台表展示时,枚举列优先按表名和列名做业务映射,再落回通用解码。 +后台通用表查询按 SpacetimeDB schema 自动处理无载荷枚举的 SATS 形态:api-server 读取 schema 的 typespace 与表的 `product_type_ref`,为每个“`Sum` 且变体全为单元变体(排除 `Option` 的 `some` / `none`)”的列建立按变体索引排列的展示名,变体名归一到 snake_case,与 `points` / `paid` / `asset_operation_consume` 等同口径;`Option<枚举>` 列单独标记为可空,`[0, [索引, []]]` 出变体名、`[1, []]` 仍是空值。该映射同时作用于行的 `cells` 和 `raw`,所以关键词搜索、字段筛选与排序都按展示名生效;`Option<普通值>`、带载荷的 Sum 和非枚举列继续走通用解码(`Some` 解包、`None` 归空、时间戳按原样透出)。新增表或新增枚举列不再需要改代码,前提是模块已发布且 schema 可读;schema 读取失败时表查询本身就以“表不存在”失败,不会退回按索引展示数字。 后台通用表查询的“每页条数”不是筛选前的 SQL 截断量。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选;关键词 / 字段条件过滤、所选列的完整候选集稳定排序和 1-based `page` 分页都基于这一次 SQL 结果,`totalMatched` 不再依赖另一份 `COUNT(*)` 快照。`filters` 支持两种 JSON 形式:object(列名到等值,如 `{"user_id":"u1"}`,兼容旧入口)与条件数组(如 `[{"column":"points","op":"gt","value":"5"}]`,运算符覆盖 `eq`、`ne`、`gt`、`gte`、`lt`、`lte`、`contains`、`notContains`、`startsWith`、`endsWith`、`in`、`notIn`、`isEmpty`、`isNotEmpty`,允许同列多条件,条件间为 AND);两种形式的用户输入都不进入 SQL,只在 API Server 内存中过滤。请求页码超过实际总页数时钳制到末页,零结果固定返回第 1 页。存在第 50,001 条哨兵行时响应必须返回 `scanLimitReached=true`,后台固定分页栏上方明确提示匹配总数和分页结果可能不完整,不得把扫描范围外的数据误报为不存在。候选 SQL 响应体仍受 32 MiB 和 20 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index e30e1e175..daa54b892 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -84,14 +84,13 @@ 1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。 2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。 -3. 现行限额:压缩包 200 MiB、展开总量 500 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。压缩包上限同时决定 `api-server` 的发行包路由请求体上限(200 MiB + 1 KiB)与反代放行量:Nginx 通用 `/api` location 为 `client_max_body_size 210m`,Pingora 网关为 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960`;三者必须同时满足,否则合法包会在反代或路由层被 413。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。超过约 100 MiB 的包在 `api-server` 会带来数百 MB 的瞬时内存占用,发布窗口与实例规格需按容量验证基线预留。 +3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。 4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。 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. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。 -10. 上传有两条等价入口,共用同一版本状态机、摘要口径、幂等键与校验规则:① 整包入口——网页端与旧客户端对 `versionId` 直接 `PUT` 整包字节,原子、不可续传,仍受发行包上限与请求体限制约束;② 分片续传入口——AGC 原生一键发布对同一 `versionId` 顺序上传固定大小的分片,再以单独的完成动作收口。分片大小由服务端下发且固定(现为 8 MiB,随发行包上限 200 MiB 取整到 25 片以内),客户端不得自行改变;分片续传入口只补传缺失字节,任何分片重复或乱序都不得造成重复写入。AGC 侧必须由原生进程直接读取本地试玩包并按分片发送,整包字节不得经过 WebView IPC 往返,也不得整包驻留宿主内存。 +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。 ### 身份、状态、审核与更新 @@ -107,9 +106,7 @@ ### 幂等、并发与恢复 - 所有创建、提交、审核、撤销和下架动作携带 `Idempotency-Key`。服务端以认证主体、动作和 key 保存请求摘要与结果;同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`。至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。 -- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。 -- 分片续传以「服务端已收字节」为唯一权威偏移:客户端带上自己认为的偏移上传分片,与服务端记录不一致时服务端返回 `409` 与权威偏移,客户端按权威偏移继续,不重放也不跳段。传输失败、网络中断、客户端进程退出或应用重启后,同一 `versionId` 重新发布只补传缺失分片;已收字节数由服务端持久化事实决定,不依赖客户端本地记录。 -- 分片会话在全部字节到齐并执行完成动作之前,不进入包校验、不确认版本、不改变任何公开可见性,半包对象也不服务给发行网关。完成动作里校验失败时删除该半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`);作者要重新上传同一版本时必须先显式重置分片会话,重置后已收字节归零,不允许在半包之上续写不同字节。 +- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。 - 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端持久保存操作 ID、目标游戏/版本和 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时允许新版本,不能仅按包摘要吞掉新的发布意图。 - 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。 - 网络中断或响应丢失后先查询原操作/版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。 @@ -132,7 +129,7 @@ | `POST /versions/{versionId}/cancel` | owner | **已实现**:带 `expectedPublicationRevision` CAS 与 `Idempotency-Key`,只能撤回未参与公开投影的版本;同 key 同请求重放返回 `replayed: true`,摘要不同返回 409 | | `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 | | `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 | -| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准需 HTTPS 发行入口并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 | +| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按部署模板与 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;服务端响应不包含存储凭据和本地绝对路径。 diff --git a/package-lock.json b/package-lock.json index a15be25e6..77bd6d2e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23116,15 +23116,15 @@ "@genarrative/agc-plugin-sdk": "0.1.0" } }, - "plugins/agc-unity-editor": { - "name": "@genarrative/agc-plugin-unity-editor", + "plugins/agc-godot-editor": { + "name": "@genarrative/agc-plugin-godot-editor", "version": "0.1.0", "dependencies": { "@genarrative/agc-plugin-sdk": "0.1.0" } }, - "plugins/agc-godot-editor": { - "name": "@genarrative/agc-plugin-godot-editor", + "plugins/agc-unity-editor": { + "name": "@genarrative/agc-plugin-unity-editor", "version": "0.1.0", "dependencies": { "@genarrative/agc-plugin-sdk": "0.1.0" @@ -26558,6 +26558,7 @@ "@types/three": "^0.184.1", "@vitejs/plugin-react": "^5.0.4", "focus-trap-react": "^12.0.3", + "jszip": "^3.10.1", "lexical": "^0.47.0", "lucide-react": "^0.546.0", "phaser": "^4.2.1", diff --git a/packages/shared/src/components/PlatformSegmentedTabs.tsx b/packages/shared/src/components/PlatformSegmentedTabs.tsx index 5413e759f..3959b6d65 100644 --- a/packages/shared/src/components/PlatformSegmentedTabs.tsx +++ b/packages/shared/src/components/PlatformSegmentedTabs.tsx @@ -1,14 +1,26 @@ import type { ReactNode } from 'react'; export type PlatformSegmentedTabsColumns = - 'one' | 'two' | 'three' | 'four' | 'threeToSix'; + | 'one' + | 'two' + | 'three' + | 'four' + | 'threeToSix'; export type PlatformSegmentedTabsGap = 'sm' | 'md'; export type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl'; export type PlatformSegmentedTabsSize = - 'sm' | 'md' | 'compact' | 'choice' | 'tab'; + | 'sm' + | 'md' + | 'compact' + | 'choice' + | 'tab'; export type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent'; export type PlatformSegmentedTabsTone = - 'neutral' | 'warm' | 'rose' | 'accent' | 'underline'; + | 'neutral' + | 'warm' + | 'rose' + | 'accent' + | 'underline'; export type PlatformSegmentedTabsFrame = 'panel' | 'bare'; export type PlatformSegmentedTabsSemantics = 'segment' | 'tabs'; export type PlatformSegmentedTabsLayout = 'grid' | 'scroll'; diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index f4923e6db..22b3e4a31 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -31,7 +31,9 @@ export type GameDistributionDeviceSupport = { export type GameDistributionInputMode = 'keyboard' | 'mouse' | 'touch'; export type GameDistributionOrientation = - 'landscape' | 'portrait' | 'responsive'; + | 'landscape' + | 'portrait' + | 'responsive'; export type GameDistributionAuthor = { id: string; @@ -52,7 +54,9 @@ export type GameDistributionVersionStatus = | 'revoked'; export type GameDistributionGameVisibility = - 'unpublished' | 'published' | 'suspended'; + | 'unpublished' + | 'published' + | 'suspended'; export type GameDistributionVersionSummary = { id: string; diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index fb6913ae7..94b12b058 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -516,7 +516,7 @@ async function main() { `status=${readBefore.status}`, ); - // 7. 管理员审核通过(本地非生产允许回环 http 入口;管理员 token 在步骤 1.1 已取得) + // 7. 管理员审核通过(发行入口由服务端按部署模板与 gameId 派生;管理员 token 在步骤 1.1 已取得) const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { @@ -526,8 +526,6 @@ async function main() { body: { decision: 'approve', expectedPublicationRevision: readback.data.version.publicationRevision, - // 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。 - entryUrl: `${API}/api/game-distribution/releases/${gameId}/`, }, }, ); @@ -536,6 +534,12 @@ 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'); diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 572ed19aa..cb5971076 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -147,11 +147,19 @@ struct SpacetimeDatabaseInfoResponse { #[derive(Debug, Deserialize)] struct SpacetimeSchemaResponse { tables: Option>, + typespace: Option, } #[derive(Debug, Deserialize)] struct SpacetimeSchemaTable { name: Option, + product_type_ref: Option, +} + +// schema 的 algebraic type 是异构联合,保留原始值由解析层按需下钻,避免复制整套 BSATN 类型定义。 +#[derive(Debug, Deserialize)] +struct SpacetimeSchemaTypespace { + types: Option>, } impl AuthenticatedAdmin { @@ -2207,7 +2215,16 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/editor-assets" => AnyTab(&["editor-assets"]), "/admin/api/assets/read-url" => AnyTab(&["editor-assets", "editor-showcase"]), path if path.starts_with("/admin/api/editor-showcase/") => AnyTab(&["editor-showcase"]), - path if path.starts_with("/admin/api/game-distribution/") => AnyTab(&["editor-showcase"]), + path if path.starts_with("/admin/api/game-distribution/reviews") => { + AnyTab(&["editor-showcase"]) + } + path if path.starts_with("/admin/api/game-distribution/versions/") => { + AnyTab(&["editor-showcase"]) + } + // 游戏管理页与审核页共享 games/* 面(列表、恢复、安全下架)。 + path if path.starts_with("/admin/api/game-distribution/games") => { + AnyTab(&["editor-showcase", "game-management"]) + } "/admin/api/profile/redeem-codes" | "/admin/api/profile/redeem-codes/disable" => { AnyTab(&["redeem"]) } @@ -3301,10 +3318,11 @@ async fn fetch_admin_database_table_rows( .with_message("external_api_key 必须使用专用安全查询接口")); } - let (_, tables, _) = fetch_admin_database_schema_tables(state).await; + let (schema, tables, _) = fetch_admin_database_schema_tables(state).await; if !tables.iter().any(|name| name == table_name) { return Err(AppError::from_status(StatusCode::NOT_FOUND).with_message("表不存在")); } + let enum_labels = build_admin_database_enum_labels(schema.as_ref(), table_name); let client = Client::builder() .timeout(ADMIN_DATABASE_TABLE_REQUEST_TIMEOUT) @@ -3334,11 +3352,12 @@ async fn fetch_admin_database_table_rows( normalize_table_count_error(&error) )) })?; - let response = parse_admin_database_table_rows_sql_response(table_name, limit, payload) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message(format!("表数据解析失败:{error}")) - })?; + let response = + parse_admin_database_table_rows_sql_response(table_name, &enum_labels, limit, payload) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("表数据解析失败:{error}")) + })?; finalize_admin_database_table_rows_response(response, &query) } @@ -3380,6 +3399,160 @@ fn extract_schema_table_names(schema: Option<&SpacetimeSchemaResponse>) -> Vec`。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct AdminDatabaseEnumColumn { + optional: bool, + variants: Vec, +} + +impl AdminDatabaseEnumColumn { + /// 解析单元格里的 SATS 枚举值;`Option<枚举>` 要先剥掉外层 `some` / `none`。 + fn resolve(&self, value: &Value) -> Option<&str> { + let index = if self.optional { + let items = value.as_array()?; + if items.len() != 2 { + return None; + } + match items.first().and_then(Value::as_u64) { + Some(0) => extract_sats_enum_variant_index(items.get(1)?)?, + _ => return None, + } + } else { + extract_sats_enum_variant_index(value)? + }; + self.variants.get(index as usize).map(String::as_str) + } +} + +/// 表内 SATS 枚举列的可读变体名:列名 → 按变体索引排列的展示名。 +/// 展示名取 schema 声明的变体名并归一到 snake_case,与后台其它枚举字符串(`points`、`paid`、`asset_operation_consume`)同口径。 +fn build_admin_database_enum_labels( + schema: Option<&SpacetimeSchemaResponse>, + table_name: &str, +) -> BTreeMap { + let mut labels = BTreeMap::new(); + let Some(schema) = schema else { + return labels; + }; + let Some(table) = schema.tables.as_ref().and_then(|tables| { + tables + .iter() + .find(|table| table.name.as_deref() == Some(table_name)) + }) else { + return labels; + }; + let Some(product_type_ref) = table.product_type_ref else { + return labels; + }; + let Some(types) = schema + .typespace + .as_ref() + .and_then(|typespace| typespace.types.as_deref()) + else { + return labels; + }; + let Some(elements) = types + .get(product_type_ref) + .and_then(|value| value.get("Product")) + .and_then(|value| value.get("elements")) + .and_then(Value::as_array) + else { + return labels; + }; + for element in elements { + let Some(column) = element.get("name").and_then(schema_option_string) else { + continue; + }; + let Some(algebraic_type) = element.get("algebraic_type") else { + continue; + }; + if let Some(variant_labels) = admin_database_enum_variant_labels(algebraic_type, types) { + labels.insert( + column.to_string(), + AdminDatabaseEnumColumn { + optional: false, + variants: variant_labels, + }, + ); + } else if let Some(variant_labels) = + admin_database_optional_enum_variant_labels(algebraic_type, types) + { + labels.insert( + column.to_string(), + AdminDatabaseEnumColumn { + optional: true, + variants: variant_labels, + }, + ); + } + } + labels +} + +fn schema_option_string(value: &Value) -> Option<&str> { + value.get("some").and_then(Value::as_str) +} + +/// 只识别“全部是单元变体”的 SATS 枚举;`Option` 的 `some` / `none` 与带载荷的 Sum 交回既有归一化处理。 +fn admin_database_enum_variant_labels( + algebraic_type: &Value, + types: &[Value], +) -> Option> { + let type_value = match algebraic_type.get("Ref").and_then(Value::as_u64) { + Some(index) => types.get(index as usize)?, + None => algebraic_type, + }; + let variants = type_value.get("Sum")?.get("variants")?.as_array()?; + if variants.is_empty() { + return None; + } + let mut labels = Vec::with_capacity(variants.len()); + for variant in variants { + let name = variant.get("name").and_then(schema_option_string)?; + if matches!(name, "some" | "none") { + return None; + } + if !admin_database_sum_variant_is_unit(variant)? { + return None; + } + labels.push(normalize_admin_enum_value_text(name)); + } + Some(labels) +} + +fn admin_database_sum_variant_is_unit(variant: &Value) -> Option { + let elements = variant + .get("algebraic_type")? + .get("Product")? + .get("elements")? + .as_array()?; + Some(elements.is_empty()) +} + +/// `Option<无载荷枚举>` 列:`[0, [索引, []]]` 能取到变体名,`[1, []]` 仍是空值。 +fn admin_database_optional_enum_variant_labels( + algebraic_type: &Value, + types: &[Value], +) -> Option> { + let type_value = match algebraic_type.get("Ref").and_then(Value::as_u64) { + Some(index) => types.get(index as usize)?, + None => algebraic_type, + }; + let variants = type_value.get("Sum")?.get("variants")?.as_array()?; + if variants.len() != 2 { + return None; + } + let some = variants.first()?; + let none = variants.get(1)?; + if some.get("name").and_then(schema_option_string) != Some("some") + || none.get("name").and_then(schema_option_string) != Some("none") + { + return None; + } + admin_database_enum_variant_labels(some.get("algebraic_type")?, types) +} + fn resolve_admin_spacetime_sql_token(state: &AppState) -> Option { state .config @@ -3564,6 +3737,7 @@ fn admin_database_table_row_stable_key(row: &AdminDatabaseTableRowPayload) -> St fn parse_admin_database_table_rows_sql_response( table_name: &str, + enum_labels: &BTreeMap, limit: u32, payload: Value, ) -> Result { @@ -3577,7 +3751,7 @@ fn parse_admin_database_table_rows_sql_response( .ok_or_else(|| "SQL rows 字段格式非法".to_string())?; let rows = row_values .iter() - .map(|row| build_admin_database_table_row_for_table(table_name, row, &columns)) + .map(|row| build_admin_database_table_row_with_enum_labels(enum_labels, row, &columns)) .collect::>(); Ok(AdminDatabaseTableRowsResponse { table_name: table_name.to_string(), @@ -3627,15 +3801,15 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec { #[cfg(test)] fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload { - build_admin_database_table_row_for_table("", row, columns) + build_admin_database_table_row_with_enum_labels(&BTreeMap::new(), row, columns) } -fn build_admin_database_table_row_for_table( - table_name: &str, +fn build_admin_database_table_row_with_enum_labels( + enum_labels: &BTreeMap, row: &Value, columns: &[String], ) -> AdminDatabaseTableRowPayload { - let raw = normalize_admin_database_table_row_raw(table_name, row, columns); + let raw = normalize_admin_database_table_row_raw(enum_labels, row, columns); let mut cells = Map::new(); if let Some(values) = row.as_array() { for (index, value) in values.iter().enumerate() { @@ -3645,14 +3819,14 @@ fn build_admin_database_table_row_for_table( .unwrap_or_else(|| format!("col_{}", index + 1)); cells.insert( key.clone(), - normalize_admin_database_table_cell(table_name, &key, value), + normalize_admin_database_table_cell(&key, enum_labels, value), ); } } else if let Some(object) = row.as_object() { for (key, value) in object { cells.insert( key.clone(), - normalize_admin_database_table_cell(table_name, key, value), + normalize_admin_database_table_cell(key, enum_labels, value), ); } } @@ -3663,7 +3837,7 @@ fn build_admin_database_table_row_for_table( } fn normalize_admin_database_table_row_raw( - table_name: &str, + enum_labels: &BTreeMap, row: &Value, columns: &[String], ) -> Value { @@ -3674,7 +3848,7 @@ fn normalize_admin_database_table_row_raw( .enumerate() .map(|(index, value)| { let key = columns.get(index).map(String::as_str).unwrap_or_default(); - normalize_admin_database_table_cell(table_name, key, value) + normalize_admin_database_table_cell(key, enum_labels, value) }) .collect(), ); @@ -3687,7 +3861,7 @@ fn normalize_admin_database_table_row_raw( .map(|(key, value)| { ( key.clone(), - normalize_admin_database_table_cell(table_name, key, value), + normalize_admin_database_table_cell(key, enum_labels, value), ) }) .collect(), @@ -3698,42 +3872,19 @@ fn normalize_admin_database_table_row_raw( } fn normalize_admin_database_table_cell( - table_name: &str, column_name: &str, + enum_labels: &BTreeMap, value: &Value, ) -> Value { - if let Some(enum_value) = normalize_admin_database_known_enum(table_name, column_name, value) { - return enum_value; + if let Some(label) = enum_labels + .get(column_name) + .and_then(|labels| labels.resolve(value)) + { + return Value::String(label.to_string()); } normalize_admin_database_value(value) } -fn normalize_admin_database_known_enum( - table_name: &str, - column_name: &str, - value: &Value, -) -> Option { - let variant_index = extract_sats_enum_variant_index(value)?; - let label = match (table_name, column_name) { - ("profile_recharge_order", "kind") => match variant_index { - 0 => "points", - 1 => "membership", - _ => return None, - }, - ("profile_recharge_order", "status") => match variant_index { - 0 => "pending", - 1 => "paid", - 2 => "failed", - 3 => "closed", - 4 => "refunded", - 5 => "expired", - _ => return None, - }, - _ => return None, - }; - Some(Value::String(label.to_string())) -} - fn extract_sats_enum_variant_index(value: &Value) -> Option { let items = value.as_array()?; if items.len() != 2 { @@ -4582,13 +4733,13 @@ fn tracking_scope_kind_to_string(value: &Value) -> Option { fn wallet_ledger_source_type_to_string(value: &Value) -> Option { match value { - Value::String(text) => Some(normalize_wallet_ledger_source_type_text(text)), + Value::String(text) => Some(normalize_admin_enum_value_text(text)), Value::Object(object) => object .get("tag") .or_else(|| object.get("variant")) .or_else(|| object.get("name")) .and_then(value_to_string) - .map(|value| normalize_wallet_ledger_source_type_text(&value)), + .map(|value| normalize_admin_enum_value_text(&value)), Value::Array(items) => { let index = items.first().and_then(Value::as_u64)?; Some( @@ -4614,11 +4765,11 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option { .to_string(), ) } - _ => value_to_string(value).map(|value| normalize_wallet_ledger_source_type_text(&value)), + _ => value_to_string(value).map(|value| normalize_admin_enum_value_text(&value)), } } -fn normalize_wallet_ledger_source_type_text(value: &str) -> String { +fn normalize_admin_enum_value_text(value: &str) -> String { let trimmed = value.trim(); let mut normalized = String::new(); for (index, character) in trimmed.chars().enumerate() { @@ -4900,12 +5051,13 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess mod tests { use super::{ AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory, - EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, + EditorShowcaseAssetRecord, SpacetimeSchemaResponse, admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, apply_admin_database_table_filters, build_admin_asset_read_url_audit, build_admin_dashboard_chart, - build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, + build_admin_database_enum_labels, build_admin_database_table_row, + build_admin_editor_showcase_campaign_image_confirm_request, build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, @@ -4923,6 +5075,8 @@ mod tests { validate_admin_editor_asset_cursor, validate_admin_external_api_key_query, verify_admin_password, wallet_ledger_source_type_to_string, }; + use std::collections::BTreeMap; + use axum::{ http::{Method, StatusCode}, response::IntoResponse, @@ -6133,6 +6287,7 @@ mod tests { .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 10, json!([{ "schema": { @@ -6171,6 +6326,7 @@ mod tests { .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [ @@ -6200,6 +6356,7 @@ mod tests { let descending_response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [ @@ -6251,6 +6408,7 @@ mod tests { .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 10, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, @@ -6277,6 +6435,7 @@ mod tests { fn admin_database_table_query_clamps_page_and_keeps_empty_results_on_page_one() { let response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, @@ -6298,6 +6457,7 @@ mod tests { let empty_response = parse_admin_database_table_rows_sql_response( "profile", + &BTreeMap::new(), 2, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, @@ -6333,8 +6493,13 @@ mod tests { } ]); - let response = parse_admin_database_table_rows_sql_response("profile_wallet", 100, payload) - .expect("table rows should parse"); + let response = parse_admin_database_table_rows_sql_response( + "profile_wallet", + &BTreeMap::new(), + 100, + payload, + ) + .expect("table rows should parse"); assert_eq!(response.table_name, "profile_wallet"); assert_eq!(response.columns, vec!["user_id", "points"]); @@ -6345,6 +6510,8 @@ mod tests { #[test] fn parse_admin_database_table_rows_sql_response_maps_recharge_order_enum_cells() { + let schema = admin_database_schema_with_recharge_order_enums(); + let labels = build_admin_database_enum_labels(Some(&schema), "profile_recharge_order"); let payload = json!([ { "schema": { @@ -6364,9 +6531,13 @@ mod tests { } ]); - let response = - parse_admin_database_table_rows_sql_response("profile_recharge_order", 100, payload) - .expect("recharge order rows should parse"); + let response = parse_admin_database_table_rows_sql_response( + "profile_recharge_order", + &labels, + 100, + payload, + ) + .expect("recharge order rows should parse"); let cells = &response.rows[0].cells; assert_eq!(cells["kind"], json!("points")); @@ -6383,6 +6554,99 @@ mod tests { ); } + /// 枚举列的展示名来自 schema 变体:`Option` 与带载荷的 Sum 不参与,未知表不返回标签。 + #[test] + fn admin_database_enum_labels_come_from_schema_variants() { + let schema = admin_database_schema_with_recharge_order_enums(); + + let labels = build_admin_database_enum_labels(Some(&schema), "profile_recharge_order"); + + assert_eq!( + labels.get("kind").map(|column| &column.variants), + Some(&vec!["points".to_string(), "membership".to_string()]) + ); + assert_eq!( + labels.get("status").map(|column| &column.variants), + Some(&vec!["pending".to_string(), "paid".to_string()]) + ); + assert_eq!( + labels.get("source_type").map(|column| &column.variants), + Some(&vec![ + "snapshot_sync".to_string(), + "llm_router_consume".to_string() + ]) + ); + // 单变体枚举同样要出名字,不能退回原始数值。 + assert_eq!( + labels.get("mode").map(|column| &column.variants), + Some(&vec!["public".to_string()]) + ); + // Option<枚举> 列要标出 optional,并支持 [0, [索引, []]] 与 [1, []] 两种形态。 + let optional = labels + .get("task_status") + .expect("optional enum column should resolve"); + assert!(optional.optional); + assert_eq!(optional.resolve(&json!([0, [1, []]])), Some("paid")); + assert_eq!(optional.resolve(&json!([1, []])), None); + assert_eq!(labels.get("paid_at"), None); + assert_eq!(labels.get("order_id"), None); + assert!(build_admin_database_enum_labels(Some(&schema), "missing_table").is_empty()); + assert!(build_admin_database_enum_labels(None, "profile_recharge_order").is_empty()); + } + + /// schema 夹具按真实 HTTP schema 形态构造:列类型用 `Ref` 指向 typespace 的 `Sum`。 + fn admin_database_schema_with_recharge_order_enums() -> SpacetimeSchemaResponse { + serde_json::from_value(json!({ + "typespace": { + "types": [ + {"Sum": {"variants": [ + {"name": {"some": "points"}, "algebraic_type": {"Product": {"elements": []}}}, + {"name": {"some": "membership"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "pending"}, "algebraic_type": {"Product": {"elements": []}}}, + {"name": {"some": "paid"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Product": {"elements": [ + {"name": {"some": "__timestamp_micros_since_unix_epoch__"}, "algebraic_type": {"I64": []}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "snapshotSync"}, "algebraic_type": {"Product": {"elements": []}}}, + {"name": {"some": "llmRouterConsume"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "some"}, "algebraic_type": {"String": []}}, + {"name": {"some": "none"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "public"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Sum": {"variants": [ + {"name": {"some": "some"}, "algebraic_type": {"Ref": 1}}, + {"name": {"some": "none"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}, + {"Product": {"elements": [ + {"name": {"some": "order_id"}, "algebraic_type": {"String": []}}, + {"name": {"some": "kind"}, "algebraic_type": {"Ref": 0}}, + {"name": {"some": "status"}, "algebraic_type": {"Ref": 1}}, + {"name": {"some": "paid_at"}, "algebraic_type": {"Sum": {"variants": [ + {"name": {"some": "some"}, "algebraic_type": {"Ref": 2}}, + {"name": {"some": "none"}, "algebraic_type": {"Product": {"elements": []}}} + ]}}}, + {"name": {"some": "source_type"}, "algebraic_type": {"Ref": 3}}, + {"name": {"some": "provider_transaction_id"}, "algebraic_type": {"Ref": 4}}, + {"name": {"some": "mode"}, "algebraic_type": {"Ref": 5}}, + {"name": {"some": "task_status"}, "algebraic_type": {"Ref": 6}} + ]}} + ] + }, + "tables": [ + {"name": "profile_recharge_order", "product_type_ref": 7} + ] + })) + .expect("schema fixture should deserialize") + } + #[test] fn build_admin_database_table_row_normalizes_optional_sats_values() { let row = build_admin_database_table_row( @@ -7180,6 +7444,11 @@ mod tests { Method::GET, "/admin/api/editor-showcase/assets", ), + ( + "game-management", + Method::GET, + "/admin/api/game-distribution/games", + ), ("editor-assets", Method::GET, "/admin/api/editor-assets"), ]; @@ -7198,6 +7467,41 @@ mod tests { } } + #[test] + fn game_management_tab_is_separate_from_game_review_queue() { + // 游戏管理页可以读全量游戏与恢复;待审队列仍只属于游戏审核页。 + assert!( + enforce_admin_request_permission( + "member", + &["game-management".to_string()], + &[], + &Method::GET, + "/admin/api/game-distribution/games", + ) + .is_ok() + ); + assert!( + enforce_admin_request_permission( + "member", + &["game-management".to_string()], + &[], + &Method::POST, + "/admin/api/game-distribution/games/game_1/restore", + ) + .is_ok() + ); + assert!( + enforce_admin_request_permission( + "member", + &["game-management".to_string()], + &[], + &Method::GET, + "/admin/api/game-distribution/reviews", + ) + .is_err() + ); + } + #[test] fn wallet_consumption_reconcile_requires_its_standalone_action_permission() { assert!( diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 1b71b2cda..f878f111b 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -94,6 +94,9 @@ 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, @@ -398,6 +401,7 @@ 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, @@ -726,6 +730,9 @@ 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/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index dcc7b9ab1..3e70d8e5f 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -47,8 +47,9 @@ const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ /// New API token 标识。它只用于在每个 Router 用户账号内定位同一个 Token, /// 不承载产品展示语义;Router 用户本身通过完整 owner id 的稳定短哈希区分。 const LLM_ROUTER_TOKEN_IDENTIFIER: &str = "agc_auto_generate"; +/// Router 用户(账号)与它名下固定 Token / API Key 都归属同一分组 `taonier`。 const LLM_ROUTER_USER_GROUP: &str = "taonier"; -const LLM_ROUTER_TOKEN_GROUP: &str = "default"; +const LLM_ROUTER_TOKEN_GROUP: &str = "taonier"; const LLM_ROUTER_API_KEY_SCOPES: [&str; 1] = ["llm:responses"]; const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1; const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60; @@ -1194,13 +1195,14 @@ async fn provision_router_account_via_new_api( { // Keep the fixed token contract authoritative even when the Router // returns an incomplete token summary (for example without `group`). - // The user remains in `taonier`; only this API token must be in the - // `default` group with unlimited quota and no expiry. + // The user and its fixed API token both belong to `taonier`, with + // unlimited quota and no expiry. // PUT the complete fixed token contract, not just the group. This - // repairs old tokens that were created with `taonier`, and also - // restores unlimited quota/permanent expiry if an operator changed - // either field. The request is cheap because this path only runs when - // provisioning or recovering an account, not for every LLM call. + // repairs tokens that were created with another group (for example the + // Router's `default`), and also restores unlimited quota/permanent + // expiry if an operator changed either field. The request is cheap + // because this path only runs when provisioning or recovering an + // account, not for every LLM call. ensure_router_token_contract( &client, origin.as_str(), @@ -1258,10 +1260,11 @@ async fn provision_router_account_via_new_api( ) })? }; - // New API may accept the create request while applying the user's - // default group. Normalize the freshly-created token before issuing a - // key; otherwise a successful POST can still produce a token routed - // through `taonier` and later fail with `model_not_found`. + // New API may accept the create request while applying another group + // (for example its own `default`) to the token. Normalize the + // freshly-created token before issuing a key so the Key always runs in + // the same `taonier` group as its Router account; a token stuck in a + // group without the required models fails with `model_not_found`. ensure_router_token_contract( &client, origin.as_str(), @@ -2291,13 +2294,13 @@ mod tests { } #[test] - fn new_api_token_request_is_unlimited_and_bound_to_default_group() { + fn new_api_token_request_is_unlimited_and_bound_to_taonier_group() { let payload = router_token_request(); assert_eq!(payload["name"], LLM_ROUTER_TOKEN_IDENTIFIER); assert_eq!(payload["expired_time"], -1); assert_eq!(payload["unlimited_quota"], true); - assert_eq!(payload["group"], "default"); + assert_eq!(payload["group"], "taonier"); assert!(payload.get("idempotencyKey").is_none()); } @@ -2363,14 +2366,14 @@ mod tests { } #[test] - fn existing_router_token_group_is_normalized_to_default() { + fn existing_router_token_group_is_normalized_to_taonier() { let payload = router_token_update_request(77); assert_eq!(payload["id"], 77); assert_eq!(payload["name"], LLM_ROUTER_TOKEN_IDENTIFIER); assert_eq!(payload["expired_time"], -1); assert_eq!(payload["unlimited_quota"], true); - assert_eq!(payload["group"], "default"); + assert_eq!(payload["group"], "taonier"); } #[test] @@ -2480,14 +2483,14 @@ mod tests { serde_json::from_str(request_body(&requests[8])).expect("token json"); assert_eq!(token_payload["unlimited_quota"], true); assert_eq!(token_payload["expired_time"], -1); - assert_eq!(token_payload["group"], "default"); + assert_eq!(token_payload["group"], "taonier"); let token_update_payload: Value = serde_json::from_str(request_body(&requests[9])).expect("token update json"); assert_eq!(token_update_payload["id"], 77); assert_eq!(token_update_payload["unlimited_quota"], true); assert_eq!(token_update_payload["expired_time"], -1); - assert_eq!(token_update_payload["group"], "default"); + assert_eq!(token_update_payload["group"], "taonier"); assert!( requests[7] @@ -2569,7 +2572,7 @@ mod tests { let token_update_payload: Value = serde_json::from_str(request_body(&requests[5])).expect("token update json"); assert_eq!(token_update_payload["id"], 77); - assert_eq!(token_update_payload["group"], "default"); + assert_eq!(token_update_payload["group"], "taonier"); assert!( !requests .iter() @@ -2583,7 +2586,7 @@ mod tests { } #[tokio::test] - async fn active_router_key_repair_keeps_user_taonier_and_token_default() { + async fn active_router_key_repair_keeps_user_and_token_in_taonier_group() { let owner_user_id = "owner-active-repair"; let username = router_username_for_owner(owner_user_id); let password = @@ -2655,7 +2658,7 @@ mod tests { let token_payload: Value = serde_json::from_str(request_body(&requests[4])).expect("token update json"); - assert_eq!(token_payload["group"], "default"); + assert_eq!(token_payload["group"], "taonier"); assert_eq!(token_payload["unlimited_quota"], true); assert_eq!(token_payload["expired_time"], -1); 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 ac56a40c2..6bc5c39a4 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -31,10 +31,12 @@ use shared_contracts::game_distribution::{ GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, }; use spacetime_client::{ - GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, - GameDistributionGameRecord, GameDistributionGetGameRecordInput, - GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord, - GameDistributionRejectRecordInput, GameDistributionSubmitReviewRecordInput, + GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord, + GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput, + GameDistributionCancelVersionRecordInput, GameDistributionGameRecord, + GameDistributionGetGameRecordInput, GameDistributionPublicGameListRecordInput, + GameDistributionPublicGameRecord, GameDistributionRejectRecordInput, + GameDistributionRestoreRecordInput, GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, GameDistributionVersionRecord, SpacetimeClientError, }; @@ -45,6 +47,7 @@ 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, @@ -60,6 +63,8 @@ pub(crate) const MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES: usize = PACKAGE_UPLOAD_CH /// 分片偏移由客户端显式声明,服务端以对象当前长度为唯一权威。 const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; const MAX_LIST_LIMIT: u32 = 48; +/// 后台游戏管理页全量列表上限,与 spacetime-module 的 admin game list limit 保持同口径。 +const MAX_ADMIN_GAME_LIST_LIMIT: u32 = 200; const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128; const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024; /// 首版截图上限,与主规范冻结口径一致。 @@ -156,8 +161,6 @@ struct AdminReviewRequest { expected_publication_revision: u64, #[serde(default)] review_reason: Option, - #[serde(default)] - entry_url: Option, } #[derive(Debug, Deserialize)] @@ -176,6 +179,17 @@ struct AdminSuspendRequest { reason: Option, } +#[derive(Debug, Deserialize)] +struct AdminGameListQuery { + limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AdminRestoreGameRequest { + expected_publication_revision: u64, +} + pub fn router(state: AppState) -> Router { let protected = Router::new() .route( @@ -243,10 +257,15 @@ pub fn router(state: AppState) -> Router { "/admin/api/game-distribution/versions/{version_id}", get(admin_get_version), ) + .route("/admin/api/game-distribution/games", get(admin_list_games)) .route( "/admin/api/game-distribution/games/{game_id}/suspend", post(admin_suspend_game), ) + .route( + "/admin/api/game-distribution/games/{game_id}/restore", + post(admin_restore_game), + ) .route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, @@ -1395,6 +1414,37 @@ async fn admin_list_reviews( )) } +async fn admin_list_games( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, AppError> { + let limit = query + .limit + .unwrap_or(MAX_ADMIN_GAME_LIST_LIMIT) + .min(MAX_ADMIN_GAME_LIST_LIMIT); + let games = state + .spacetime_client() + .list_admin_game_distribution_games(GameDistributionAdminGameListRecordInput { limit }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "admin_games_listed", + games = games.len(), + limit, + elapsed_ms = ctx.elapsed(), + "后台读取全量发行游戏" + ); + Ok(json_success_body( + Some(&ctx), + json!({ + "games": games.iter().map(admin_game_payload).collect::>(), + }), + )) +} + async fn admin_review_version( State(state): State, Extension(ctx): Extension, @@ -1416,25 +1466,21 @@ async fn admin_review_version( decision.as_str(), payload.expected_publication_revision, payload.review_reason.as_deref(), - payload.entry_url.as_deref(), )) .map_err(|error| internal(error.to_string()))?, ); let (version, replayed) = if decision == "approve" { // 回滚窗口里“关闭新版本激活”,但拒绝审核与安全下架必须始终可用。 ensure_publish_enabled(&state, None).await?; - let entry_url = payload - .entry_url - .as_deref() - .ok_or_else(|| bad_request("审核通过必须提供发行网关 HTTPS 入口"))?; - validate_release_entry_url(entry_url, !state.config.is_production())?; + // 发行入口由部署模板和 gameId 派生,管理员不填地址,也不做二次确认。 + let entry_url = derive_release_entry_url(&state, &version_id).await?; state .spacetime_client() .approve_game_distribution_version(GameDistributionApproveRecordInput { version_id, admin_user_id, expected_publication_revision: payload.expected_publication_revision, - entry_url: entry_url.to_string(), + entry_url, idempotency_key, request_digest, now_micros: now_micros(), @@ -1506,11 +1552,58 @@ async fn admin_get_version( )) } -/// 校验管理员提交的发行入口。 +/// 审核通过时按部署模板与 gameId 派生发行入口。 +async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result { + let version = state + .spacetime_client() + .get_game_distribution_version(version_id.to_string()) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + build_release_entry_url(&state.config, &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} 占位符,避免多个游戏共用同一个来源", + )); + } + if game_id.is_empty() + || !game_id.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '-' || character == '_' + }) + { + 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,也不允许服务端自行拼默认地址。 +/// 任何环境都拒绝凭据、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"))?; @@ -1597,6 +1690,51 @@ async fn admin_suspend_game( )) } +async fn admin_restore_game( + State(state): State, + Extension(ctx): Extension, + Extension(admin): Extension, + headers: HeaderMap, + Path(game_id): Path, + Json(payload): Json, +) -> Result, AppError> { + let idempotency_key = idempotency_key(&headers)?; + let admin_user_id = admin.session().subject.clone(); + let request_digest = compute_request_digest( + &serde_json::to_vec(&(game_id.as_str(), payload.expected_publication_revision)) + .map_err(|error| internal(error.to_string()))?, + ); + let log_game_id = game_id.clone(); + let log_admin_user_id = admin_user_id.clone(); + let game = state + .spacetime_client() + .restore_game_distribution_game(GameDistributionRestoreRecordInput { + game_id, + admin_user_id, + expected_publication_revision: payload.expected_publication_revision, + idempotency_key, + request_digest, + now_micros: now_micros(), + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_restored", + game_id = %log_game_id, + admin_user_id = %log_admin_user_id, + publication_revision = game.0.publication_revision, + visibility = %game.0.visibility, + replayed = game.1, + elapsed_ms = ctx.elapsed(), + "管理员恢复已下架游戏" + ); + Ok(json_success_body( + Some(&ctx), + json!({ "game": game_payload(&game.0), "replayed": game.1 }), + )) +} + async fn record_upload_failure( state: &AppState, owner_user_id: &str, @@ -1766,6 +1904,51 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value { payload } +/// 后台游戏管理页的游戏行:作者名/头像由 spacetime 事务内读时联账号表得到。 +fn admin_game_payload(game: &GameDistributionAdminGameRecord) -> Value { + json!({ + "gameId": game.game_id, + "title": game.title, + "author": { + "id": game.owner_user_id, + "name": game.author_name.as_deref().unwrap_or("未知作者"), + "avatarUrl": game.author_avatar_url, + }, + "status": game.visibility, + "versionCount": game.version_count, + "playCount": game.play_count, + "activeVersionId": game.active_version_id, + "publicationRevision": game.publication_revision, + "createdAt": game.created_at, + "updatedAt": game.updated_at, + "versions": game + .versions + .iter() + .map(|version| admin_game_version_payload(&game.game_id, version)) + .collect::>(), + }) +} + +fn admin_game_version_payload( + game_id: &str, + version: &GameDistributionAdminVersionRecord, +) -> Value { + json!({ + "versionId": version.version_id, + "gameId": game_id, + "versionNumber": version.version_number, + "status": version.status, + "reviewReason": version.review_reason, + "packageBytes": version.package_bytes, + "packageSha256": version.package_sha256, + "entryUrl": version.entry_url, + "createdAt": version.created_at, + "updatedAt": version.updated_at, + "reviewedAt": version.reviewed_at, + "publishedAt": version.published_at, + }) +} + fn game_payload(game: &GameDistributionGameRecord) -> Value { let tags = serde_json::from_str::>(&game.tags_json).unwrap_or_default(); let screenshots = game @@ -2713,6 +2896,51 @@ mod tests { assert_eq!(recovery_action_for_status("unknown_status"), "none"); } + #[tokio::test] + async fn admin_game_management_routes_are_mounted() { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + let app = crate::app::build_router( + crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("测试状态应可构建"), + ); + + // 全量列表与恢复都必须先过管理员鉴权,未带 token 时在进入业务前被拒。 + let unauthenticated_list = app + .clone() + .oneshot( + Request::builder() + .uri("/admin/api/game-distribution/games") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + // 测试态没有启用后台运行时,鉴权中间件会在 503 处失败关闭;关键是不能 404。 + assert!(matches!( + unauthenticated_list.status(), + StatusCode::UNAUTHORIZED | StatusCode::SERVICE_UNAVAILABLE + )); + + let unauthenticated_restore = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/api/game-distribution/games/game_1/restore") + .header("content-type", "application/json") + .header("Idempotency-Key", "restore-1") + .body(Body::from(r#"{"expectedPublicationRevision":1}"#)) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert!(matches!( + unauthenticated_restore.status(), + StatusCode::UNAUTHORIZED | StatusCode::SERVICE_UNAVAILABLE + )); + } + #[tokio::test] async fn version_readback_and_cancel_routes_are_mounted() { use axum::{body::Body, http::Request}; @@ -2803,7 +3031,69 @@ mod tests { } #[test] - fn approve_requires_credential_free_https_entry_url() { + 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() + }; + assert_eq!( + build_release_entry_url(&config, "game_1").expect("派生发行入口"), + "https://game_1.games.example.test/" + ); + } + + #[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() + }; + for invalid in ["", "../escape", "game/1", "game 1"] { + assert!( + build_release_entry_url(&config, 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, diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 19ed5f06b..e8e0c4637 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -10,7 +10,7 @@ use crate::creation_entry_config::{ }; /// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。 -pub const ADMIN_TAB_PERMISSIONS: [&str; 19] = [ +pub const ADMIN_TAB_PERMISSIONS: [&str; 20] = [ "dashboard", "overview", "tables", @@ -26,6 +26,7 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 19] = [ "recharge-orders", "editor-generation-pricing", "editor-showcase", + "game-management", "editor-assets", "agc-templates", "error-reports", diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index f53356bed..9bcd727c7 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -22,11 +22,12 @@ mod error_reports; pub mod external_api_key; pub mod game_distribution; pub use game_distribution::{ - GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, - GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput, - GameDistributionCreateVersionRecordInput, GameDistributionFailUploadRecordInput, - GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, - GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, + GameDistributionAdminGameListRecordInput, GameDistributionApproveRecordInput, + GameDistributionCancelVersionRecordInput, GameDistributionConfirmPackageRecordInput, + GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput, + GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, + GameDistributionOwnerGameListRecordInput, GameDistributionPublicGameListRecordInput, + GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, }; diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 117980ebd..301ed3202 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -92,6 +92,7 @@ pub use self::external_generation::{ ExternalGenerationQueueStatsRecord, }; pub use self::game_distribution::{ + GameDistributionAdminGameRecord, GameDistributionAdminVersionRecord, GameDistributionGameRecord, GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord, GameDistributionVersionRecord, }; @@ -148,9 +149,10 @@ pub(crate) use self::external_generation::{ map_external_generation_queue_stats_result, }; pub(crate) use self::game_distribution::{ - map_game_distribution_game_result, map_game_distribution_owner_game_list_result, - map_game_distribution_public_game_list_result, map_game_distribution_public_game_result, - map_game_distribution_review_list_result, map_game_distribution_version_result, + map_game_distribution_admin_game_list_result, map_game_distribution_game_result, + map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result, + map_game_distribution_public_game_result, map_game_distribution_review_list_result, + map_game_distribution_version_result, }; pub(crate) use self::runtime::{ map_feature_gate_config_procedure_result, map_runtime_setting_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 72d04d537..3814b976b 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -47,6 +47,38 @@ pub struct GameDistributionVersionRecord { pub metadata_json: Option, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionAdminVersionRecord { + pub version_id: String, + pub version_number: u64, + pub status: String, + pub review_reason: Option, + pub package_sha256: String, + pub package_bytes: u64, + pub entry_url: Option, + pub created_at: String, + pub reviewed_at: Option, + pub published_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionAdminGameRecord { + pub game_id: String, + pub owner_user_id: String, + pub title: String, + pub author_name: Option, + pub author_avatar_url: Option, + pub visibility: String, + pub version_count: u64, + pub play_count: u64, + pub active_version_id: Option, + pub publication_revision: u64, + pub created_at: String, + pub updated_at: String, + pub versions: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct GameDistributionOwnerGameRecord { pub game: GameDistributionGameRecord, @@ -111,6 +143,57 @@ fn map_version( } } +fn map_admin_version( + value: crate::module_bindings::GameDistributionAdminVersionSnapshot, +) -> GameDistributionAdminVersionRecord { + GameDistributionAdminVersionRecord { + version_id: value.version_id, + version_number: value.version_number, + status: value.status, + review_reason: value.review_reason, + package_sha256: value.package_sha_256, + package_bytes: value.package_bytes, + entry_url: value.entry_url, + created_at: shared_kernel::format_timestamp_micros(value.created_at_micros), + reviewed_at: value + .reviewed_at_micros + .map(shared_kernel::format_timestamp_micros), + published_at: value + .published_at_micros + .map(shared_kernel::format_timestamp_micros), + updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros), + } +} + +fn map_admin_game( + value: crate::module_bindings::GameDistributionAdminGameSnapshot, +) -> GameDistributionAdminGameRecord { + GameDistributionAdminGameRecord { + game_id: value.game_id, + owner_user_id: value.owner_user_id, + title: value.title, + author_name: value.author_name, + author_avatar_url: value.author_avatar_url, + visibility: value.visibility, + version_count: value.version_count, + play_count: value.play_count, + active_version_id: value.active_version_id, + publication_revision: value.publication_revision, + created_at: shared_kernel::format_timestamp_micros(value.created_at_micros), + updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros), + versions: value.versions.into_iter().map(map_admin_version).collect(), + } +} + +pub(crate) fn map_game_distribution_admin_game_list_result( + result: crate::module_bindings::GameDistributionAdminGameListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result.games.into_iter().map(map_admin_game).collect()) +} + pub(crate) fn map_game_distribution_game_result( result: crate::module_bindings::GameDistributionProcedureResult, ) -> Result< diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index a9feb2ce9..0a20dea7b 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -7,6 +7,11 @@ pub struct GameDistributionPublicGameListRecordInput { pub limit: u32, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionAdminGameListRecordInput { + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct GameDistributionOwnerGameListRecordInput { pub owner_user_id: String, @@ -128,6 +133,16 @@ pub struct GameDistributionRejectRecordInput { pub now_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionRestoreRecordInput { + pub game_id: String, + pub admin_user_id: String, + pub expected_publication_revision: u64, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct GameDistributionUnpublishRecordInput { pub game_id: String, @@ -665,6 +680,69 @@ impl SpacetimeClient { .await } + /// 后台游戏管理页的全量游戏列表:含版本数与最近版本历史,作者名/头像由事务内读时联。 + pub async fn list_admin_game_distribution_games( + &self, + input: GameDistributionAdminGameListRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = + crate::module_bindings::GameDistributionAdminGameListInput { limit: input.limit }; + self.call_after_connect( + "list_admin_game_distribution_games", + move |connection, sender| { + connection + .procedures() + .list_admin_game_distribution_games_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_admin_game_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + /// 管理员解除安全下架:重新激活最近一次曾公开的版本。 + pub async fn restore_game_distribution_game( + &self, + input: GameDistributionRestoreRecordInput, + ) -> Result<(GameDistributionGameRecord, bool), SpacetimeClientError> { + let procedure_input = crate::module_bindings::GameDistributionRestoreInput { + game_id: input.game_id, + admin_user_id: input.admin_user_id, + expected_publication_revision: input.expected_publication_revision, + idempotency_key: input.idempotency_key, + request_digest: input.request_digest, + now_micros: input.now_micros, + }; + self.call_after_connect( + "restore_game_distribution_game", + move |connection, sender| { + connection + .procedures() + .restore_game_distribution_game_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_game_result) + .and_then(|(game, _, replayed)| { + game.map(|game| (game, replayed)).ok_or_else(|| { + SpacetimeClientError::missing_snapshot("游戏恢复结果") + }) + }); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn list_game_distribution_reviews( &self, limit: u32, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 94d10bb47..8c316104e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -398,6 +398,10 @@ pub mod feature_gate_config_snapshot_type; pub mod feature_gate_config_table; pub mod feature_gate_config_type; pub mod find_editor_asset_group_source_and_return_procedure; +pub mod game_distribution_admin_game_list_input_type; +pub mod game_distribution_admin_game_list_result_type; +pub mod game_distribution_admin_game_snapshot_type; +pub mod game_distribution_admin_version_snapshot_type; pub mod game_distribution_approve_input_type; pub mod game_distribution_cancel_version_input_type; pub mod game_distribution_confirm_package_input_type; @@ -421,6 +425,7 @@ pub mod game_distribution_public_game_input_type; pub mod game_distribution_public_game_list_input_type; pub mod game_distribution_public_game_snapshot_type; pub mod game_distribution_reject_input_type; +pub mod game_distribution_restore_input_type; pub mod game_distribution_review_list_input_type; pub mod game_distribution_submit_review_input_type; pub mod game_distribution_suspend_input_type; @@ -468,6 +473,7 @@ pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_file_procedure; pub mod initialize_editor_generation_pricing_config_if_missing_and_return_procedure; pub mod list_admin_accounts_and_return_procedure; +pub mod list_admin_game_distribution_games_and_return_procedure; pub mod list_agc_tracking_events_procedure; pub mod list_asset_history_and_return_procedure; pub mod list_editor_agent_conversations_and_return_procedure; @@ -591,6 +597,7 @@ pub mod repair_editor_canvas_resources_and_return_procedure; pub mod repair_editor_project_resource_media_and_return_procedure; pub mod resolve_editor_reference_and_return_procedure; pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure; +pub mod restore_game_distribution_game_and_return_procedure; pub mod revoke_database_migration_operator_procedure; pub mod revoke_external_api_key_and_return_procedure; pub mod rollback_editor_canvas_layout_and_return_procedure; @@ -1168,6 +1175,10 @@ pub use feature_gate_config_snapshot_type::FeatureGateConfigSnapshot; pub use feature_gate_config_table::*; pub use feature_gate_config_type::FeatureGateConfig; pub use find_editor_asset_group_source_and_return_procedure::find_editor_asset_group_source_and_return; +pub use game_distribution_admin_game_list_input_type::GameDistributionAdminGameListInput; +pub use game_distribution_admin_game_list_result_type::GameDistributionAdminGameListResult; +pub use game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot; +pub use game_distribution_admin_version_snapshot_type::GameDistributionAdminVersionSnapshot; pub use game_distribution_approve_input_type::GameDistributionApproveInput; pub use game_distribution_cancel_version_input_type::GameDistributionCancelVersionInput; pub use game_distribution_confirm_package_input_type::GameDistributionConfirmPackageInput; @@ -1191,6 +1202,7 @@ pub use game_distribution_public_game_input_type::GameDistributionPublicGameInpu pub use game_distribution_public_game_list_input_type::GameDistributionPublicGameListInput; pub use game_distribution_public_game_snapshot_type::GameDistributionPublicGameSnapshot; pub use game_distribution_reject_input_type::GameDistributionRejectInput; +pub use game_distribution_restore_input_type::GameDistributionRestoreInput; pub use game_distribution_review_list_input_type::GameDistributionReviewListInput; pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput; pub use game_distribution_suspend_input_type::GameDistributionSuspendInput; @@ -1238,6 +1250,7 @@ pub use import_database_migration_incremental_from_chunks_procedure::import_data pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file; pub use initialize_editor_generation_pricing_config_if_missing_and_return_procedure::initialize_editor_generation_pricing_config_if_missing_and_return; pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return; +pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return; pub use list_agc_tracking_events_procedure::list_agc_tracking_events; pub use list_asset_history_and_return_procedure::list_asset_history_and_return; pub use list_editor_agent_conversations_and_return_procedure::list_editor_agent_conversations_and_return; @@ -1361,6 +1374,7 @@ pub use repair_editor_canvas_resources_and_return_procedure::repair_editor_canva pub use repair_editor_project_resource_media_and_return_procedure::repair_editor_project_resource_media_and_return; pub use resolve_editor_reference_and_return_procedure::resolve_editor_reference_and_return; pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::resolve_profile_recharge_refund_manual_review_and_return; +pub use restore_game_distribution_game_and_return_procedure::restore_game_distribution_game_and_return; pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator; pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return; pub use rollback_editor_canvas_layout_and_return_procedure::rollback_editor_canvas_layout_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_input_type.rs new file mode 100644 index 000000000..bcd5d17d6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionAdminGameListInput { + pub limit: u32, +} + +impl __sdk::InModule for GameDistributionAdminGameListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_result_type.rs new file mode 100644 index 000000000..0db38de34 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionAdminGameListResult { + pub ok: bool, + pub games: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionAdminGameListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs new file mode 100644 index 000000000..8d79a32a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_admin_version_snapshot_type::GameDistributionAdminVersionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionAdminGameSnapshot { + pub game_id: String, + pub owner_user_id: String, + pub title: String, + pub author_name: Option, + pub author_avatar_url: Option, + pub visibility: String, + pub version_count: u64, + pub play_count: u64, + pub active_version_id: Option, + pub publication_revision: u64, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub versions: Vec, +} + +impl __sdk::InModule for GameDistributionAdminGameSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_version_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_version_snapshot_type.rs new file mode 100644 index 000000000..c32847c6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_version_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionAdminVersionSnapshot { + pub version_id: String, + pub version_number: u64, + pub status: String, + pub review_reason: Option, + pub package_sha_256: String, + pub package_bytes: u64, + pub entry_url: Option, + pub created_at_micros: i64, + pub reviewed_at_micros: Option, + pub published_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for GameDistributionAdminVersionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_restore_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_restore_input_type.rs new file mode 100644 index 000000000..3b23564c7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_restore_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionRestoreInput { + pub game_id: String, + pub admin_user_id: String, + pub expected_publication_revision: u64, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +impl __sdk::InModule for GameDistributionRestoreInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_games_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_games_and_return_procedure.rs new file mode 100644 index 000000000..bb17220f1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_games_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_admin_game_list_input_type::GameDistributionAdminGameListInput; +use super::game_distribution_admin_game_list_result_type::GameDistributionAdminGameListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListAdminGameDistributionGamesAndReturnArgs { + pub input: GameDistributionAdminGameListInput, +} + +impl __sdk::InModule for ListAdminGameDistributionGamesAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_admin_game_distribution_games_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_admin_game_distribution_games_and_return { + fn list_admin_game_distribution_games_and_return( + &self, + input: GameDistributionAdminGameListInput, + ) { + self.list_admin_game_distribution_games_and_return_then(input, |_, _| {}); + } + + fn list_admin_game_distribution_games_and_return_then( + &self, + input: GameDistributionAdminGameListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_admin_game_distribution_games_and_return for super::RemoteProcedures { + fn list_admin_game_distribution_games_and_return_then( + &self, + input: GameDistributionAdminGameListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminGameListResult>( + "list_admin_game_distribution_games_and_return", + ListAdminGameDistributionGamesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/restore_game_distribution_game_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/restore_game_distribution_game_and_return_procedure.rs new file mode 100644 index 000000000..1bf5be722 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/restore_game_distribution_game_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_procedure_result_type::GameDistributionProcedureResult; +use super::game_distribution_restore_input_type::GameDistributionRestoreInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RestoreGameDistributionGameAndReturnArgs { + pub input: GameDistributionRestoreInput, +} + +impl __sdk::InModule for RestoreGameDistributionGameAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `restore_game_distribution_game_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait restore_game_distribution_game_and_return { + fn restore_game_distribution_game_and_return(&self, input: GameDistributionRestoreInput) { + self.restore_game_distribution_game_and_return_then(input, |_, _| {}); + } + + fn restore_game_distribution_game_and_return_then( + &self, + input: GameDistributionRestoreInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl restore_game_distribution_game_and_return for super::RemoteProcedures { + fn restore_game_distribution_game_and_return_then( + &self, + input: GameDistributionRestoreInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionProcedureResult>( + "restore_game_distribution_game_and_return", + RestoreGameDistributionGameAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 53ef0d7d8..7c552a707 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -170,6 +170,11 @@ const GAME_DISTRIBUTION_ACTION_APPROVE: &str = "approve"; const GAME_DISTRIBUTION_ACTION_REJECT: &str = "reject"; const GAME_DISTRIBUTION_ACTION_UNPUBLISH: &str = "unpublish"; const GAME_DISTRIBUTION_ACTION_SUSPEND: &str = "suspend"; +const GAME_DISTRIBUTION_ACTION_RESTORE: &str = "restore"; +/// 后台游戏管理页每个游戏最多回传多少个版本历史;版本数仍按全量统计。 +const GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT: usize = 20; +/// 后台游戏管理页一次最多回传多少个游戏;全量视图按上限截断,不做游标分页。 +const GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT: u32 = 200; const GAME_DISTRIBUTION_RECEIPT_RETENTION_MICROS: i64 = 30 * 24 * 60 * 60 * 1_000_000; const GAME_DISTRIBUTION_MAX_LIST_LIMIT: u32 = 48; const GAME_DISTRIBUTION_MAX_OWNER_VERSIONS_PER_GAME: usize = 10; @@ -309,6 +314,21 @@ pub struct GameDistributionReviewListInput { pub limit: u32, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminGameListInput { + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionRestoreInput { + pub game_id: String, + pub admin_user_id: String, + pub expected_publication_revision: u64, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionOwnerGameListInput { pub owner_user_id: String, @@ -385,6 +405,47 @@ pub struct GameDistributionVersionSnapshot { pub metadata_json: Option, } +/// 后台游戏管理页的版本历史条目:补上审核与公开时间,供运营判断下架与恢复影响。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminVersionSnapshot { + pub version_id: String, + pub version_number: u64, + pub status: String, + pub review_reason: Option, + pub package_sha256: String, + pub package_bytes: u64, + pub entry_url: Option, + pub created_at_micros: i64, + pub reviewed_at_micros: Option, + pub published_at_micros: Option, + pub updated_at_micros: i64, +} + +/// 后台游戏管理页的聚合快照:游戏行 + 全量版本数 + 最近版本历史 + 读时联的作者资料。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminGameSnapshot { + pub game_id: String, + pub owner_user_id: String, + pub title: String, + pub author_name: Option, + pub author_avatar_url: Option, + pub visibility: String, + pub version_count: u64, + pub play_count: u64, + pub active_version_id: Option, + pub publication_revision: u64, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub versions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminGameListResult { + pub ok: bool, + pub games: Vec, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionPublicGameSnapshot { pub game: GameDistributionGameSnapshot, @@ -676,6 +737,46 @@ pub fn list_game_distribution_reviews_and_return( } } +/// 返回后台游戏管理页的全量游戏;版本数与最近版本在同一事务内统计,作者名/头像读时联账号表。 +#[spacetimedb::procedure] +pub fn list_admin_game_distribution_games_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionAdminGameListInput, +) -> GameDistributionAdminGameListResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + list_admin_game_distribution_games_tx(tx, input.clone()) + }) { + Ok(games) => GameDistributionAdminGameListResult { + ok: true, + games, + error_message: None, + }, + Err(error) => GameDistributionAdminGameListResult { + ok: false, + games: Vec::new(), + error_message: Some(error), + }, + } +} + +/// 管理员解除安全下架:重新激活该游戏最近一次曾公开的版本。 +#[spacetimedb::procedure] +pub fn restore_game_distribution_game_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionRestoreInput, +) -> GameDistributionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + restore_game_distribution_game_tx(tx, input.clone()) + }) { + Ok((game, replayed)) => game_distribution_game_result(game, replayed), + Err(error) => game_distribution_result_error(error), + } +} + /// 返回作者名下的游戏与最近版本;owner 来自 api-server 的认证主体,调用方不能指定他人。 #[spacetimedb::procedure] pub fn list_owner_game_distribution_games_and_return( @@ -1850,6 +1951,205 @@ fn suspend_game_distribution_game_tx( Ok((game_distribution_game_snapshot(&game), false)) } +/// 管理员解除暂停:把最近一次曾公开(有 published_at)的已撤回版本重新设为公开版本。 +/// +/// 暂停下架会把当时的公开版本置为 `revoked` 并清空 `active_version_id`,所以恢复不能只 +/// 翻转可见性;找不到可恢复版本时失败关闭,由运营要求作者重新送审。 +fn restore_game_distribution_game_tx( + ctx: &ReducerContext, + input: GameDistributionRestoreInput, +) -> Result<(GameDistributionGameSnapshot, bool), String> { + let admin_user_id = required_game_distribution_text(input.admin_user_id, "admin_user_id")?; + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let idempotency_key = + required_game_distribution_text(input.idempotency_key, "idempotency_key")?; + let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?; + let receipt_id = game_distribution_receipt_id( + admin_user_id.as_str(), + GAME_DISTRIBUTION_ACTION_RESTORE, + idempotency_key.as_str(), + ); + if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { + ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "幂等收据对应的游戏已不存在".to_string())?; + return Ok((game_distribution_game_snapshot(&game), true)); + } + let mut game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "游戏不存在".to_string())?; + ensure_game_distribution_publication_revision(&game, input.expected_publication_revision)?; + if game.visibility != GAME_DISTRIBUTION_VISIBILITY_SUSPENDED { + return Err("只有管理员暂停的游戏才能恢复".to_string()); + } + let mut candidates = ctx + .db + .game_distribution_version() + .by_game_distribution_version_game_id() + .filter(&game.game_id) + .filter(|version| { + // `reviewed_by_user_id` 区分「管理员暂停撤回」与「作者自行下架撤回」: + // 作者下架不写审核者,恢复不能把作者已经下架的游戏重新公开。 + version.status == GAME_DISTRIBUTION_VERSION_REVOKED + && version.published_at.is_some() + && version.reviewed_by_user_id.is_some() + }) + .collect::>(); + candidates.sort_by(|left, right| { + right + .version_number + .cmp(&left.version_number) + .then_with(|| right.version_id.cmp(&left.version_id)) + }); + let mut version = candidates + .into_iter() + .next() + .ok_or_else(|| "没有可恢复的已审核版本,请作者重新送审".to_string())?; + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + if version.entry_url.is_none() { + return Err("可恢复版本缺少发行入口,请作者重新送审".to_string()); + } + version.status = GAME_DISTRIBUTION_VERSION_PUBLISHED.to_string(); + version.revoked_at = None; + version.updated_at = now; + ctx.db + .game_distribution_version() + .version_id() + .update(version.clone()); + game.visibility = GAME_DISTRIBUTION_VISIBILITY_PUBLISHED.to_string(); + game.active_version_id = Some(version.version_id.clone()); + game.publication_revision = game + .publication_revision + .checked_add(1) + .ok_or_else(|| "publication_revision 溢出".to_string())?; + game.updated_at = now; + ctx.db + .game_distribution_game() + .game_id() + .update(game.clone()); + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id: admin_user_id, + action: GAME_DISTRIBUTION_ACTION_RESTORE.to_string(), + idempotency_key, + request_digest, + game_id: Some(game_id), + version_id: Some(version.version_id), + outcome_kind: "game".to_string(), + outcome_game_id: Some(game.game_id.clone()), + outcome_version_id: None, + outcome_json: None, + now_micros: input.now_micros, + }, + )?; + Ok((game_distribution_game_snapshot(&game), false)) +} + +fn list_admin_game_distribution_games_tx( + ctx: &ReducerContext, + input: GameDistributionAdminGameListInput, +) -> Result, String> { + let limit = if input.limit == 0 { + GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT + } else { + input.limit.min(GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT) + } as usize; + let mut games = ctx.db.game_distribution_game().iter().collect::>(); + games.sort_by(|left, right| { + right + .created_at + .to_micros_since_unix_epoch() + .cmp(&left.created_at.to_micros_since_unix_epoch()) + .then_with(|| left.game_id.cmp(&right.game_id)) + }); + games.truncate(limit); + Ok(games + .into_iter() + .map(|game| admin_game_distribution_game_snapshot(ctx, &game)) + .collect()) +} + +fn admin_game_distribution_game_snapshot( + ctx: &ReducerContext, + game: &GameDistributionGame, +) -> GameDistributionAdminGameSnapshot { + let mut versions = ctx + .db + .game_distribution_version() + .by_game_distribution_version_game_id() + .filter(&game.game_id) + .collect::>(); + // 版本数按全量统计,历史只回传最近若干版本,避免后台一次拉取全部历史。 + let version_count = versions.len() as u64; + versions.sort_by(|left, right| { + right + .version_number + .cmp(&left.version_number) + .then_with(|| right.version_id.cmp(&left.version_id)) + }); + versions.truncate(GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT); + // 作者名/头像读时联账号表;创建时写入的是快照,账号改名或换头像后后台立即跟随。 + let account = ctx.db.user_account().user_id().find(&game.owner_user_id); + let author_name = account + .as_ref() + .map(|user| user.display_name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| game.author_name.clone()); + let author_avatar_url = account + .and_then(|user| user.avatar_url) + .or_else(|| game.author_avatar_url.clone()); + GameDistributionAdminGameSnapshot { + game_id: game.game_id.clone(), + owner_user_id: game.owner_user_id.clone(), + title: game.title.clone(), + author_name, + author_avatar_url, + visibility: game.visibility.clone(), + version_count, + play_count: game.play_count, + active_version_id: game.active_version_id.clone(), + publication_revision: game.publication_revision, + created_at_micros: game.created_at.to_micros_since_unix_epoch(), + updated_at_micros: game.updated_at.to_micros_since_unix_epoch(), + versions: versions + .iter() + .map(admin_game_distribution_version_snapshot) + .collect(), + } +} + +fn admin_game_distribution_version_snapshot( + version: &GameDistributionVersion, +) -> GameDistributionAdminVersionSnapshot { + GameDistributionAdminVersionSnapshot { + version_id: version.version_id.clone(), + version_number: version.version_number, + status: version.status.clone(), + review_reason: version.review_reason.clone(), + package_sha256: version.package_sha256.clone(), + package_bytes: version.package_bytes, + entry_url: version.entry_url.clone(), + created_at_micros: version.created_at.to_micros_since_unix_epoch(), + reviewed_at_micros: version + .reviewed_at + .map(|value| value.to_micros_since_unix_epoch()), + published_at_micros: version + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: version.updated_at.to_micros_since_unix_epoch(), + } +} + fn list_game_distribution_reviews_tx( ctx: &ReducerContext, input: GameDistributionReviewListInput,