From 4a2b2707147d33018ee1569a1d286e111d4a147d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 20 Sep 2026 22:19:50 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B8=B8=E6=88=8F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E5=B0=81=E9=9D=A2=E4=B8=8E=E6=88=AA=E5=9B=BE=E7=9A=84?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E3=80=81=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。 - 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。 - 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。 - 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。 - 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。 - AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。 - AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。 - 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。 --- .../src-tauri/capabilities/main.json | 3 +- .../GameDistributionPublishPanel.tsx | 282 ++++++++++++++++- .../src/services/assetDirectUpload.ts | 155 +++++++++ .../src/services/gameDistributionPublish.ts | 22 ++ apps/ai-game-creator-shell/src/styles.css | 125 ++++++++ .../tests/assetDirectUpload.test.ts | 178 +++++++++++ .../tests/gameDistributionPublish.test.ts | 48 +++ .../tests/gameDistributionPublishLive.test.ts | 24 ++ .../gameDistributionPublishPanel.test.tsx | 110 +++++++ ...施计划】游戏分发阶段A领域合同-2026-09-19.md | 10 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../shared/src/contracts/gameDistribution.ts | 36 +++ .../src/modules/game_distribution.rs | 270 +++++++++++++++- .../shared-contracts/src/game_distribution.rs | 3 + .../src/active/mapper/game_distribution.rs | 7 + .../spacetime-client/src/game_distribution.rs | 3 + ..._distribution_create_version_input_type.rs | 1 + .../game_distribution_game_snapshot_type.rs | 2 + .../game_distribution_game_type.rs | 6 + ...game_distribution_version_snapshot_type.rs | 1 + .../game_distribution_version_type.rs | 3 + .../src/editor_project_storage.rs | 8 +- .../spacetime-module/src/game_distribution.rs | 115 +++++++ .../crates/spacetime-module/src/migration.rs | 2 + .../game-distribution/GameDetailPage.tsx | 75 ++++- .../GameDistributionPages.test.tsx | 130 ++++++++ .../game-distribution/GameGalleryPage.tsx | 36 ++- .../GamePublishPage.test.tsx | 222 ++++++++++++- .../game-distribution/GamePublishPage.tsx | 296 ++++++++++++++++++ .../game-distribution/gameDistribution.css | 98 ++++++ .../gameDistributionShowcase.css | 75 +++++ .../game-distribution/gamePublishAssets.ts | 90 ++++++ .../game-distribution/gamePublishMetadata.ts | 11 + 34 files changed, 2439 insertions(+), 13 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/assetDirectUpload.ts create mode 100644 apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts create mode 100644 src/components/game-distribution/gameDistributionShowcase.css create mode 100644 src/components/game-distribution/gamePublishAssets.ts diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index b62ed9e70..753d89477 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -16,7 +16,8 @@ { "url": "https://www.genarrative.world/api/*" }, { "url": "https://*/api/*" }, { "url": "http://localhost:*/*" }, - { "url": "http://127.0.0.1:*/*" } + { "url": "http://127.0.0.1:*/*" }, + { "url": "https://*.aliyuncs.com/*" } ] }, "opener:default", diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index c23887955..a6885711a 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -3,14 +3,64 @@ import { useEffect, useRef, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectExportPackageResult } from '../../app/types'; +import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; import { createGameDistributionPublishKey, type GameDistributionPublishMetadata, type GameDistributionPublishResult, + MAX_AGC_GAME_SCREENSHOTS, publishLocalProjectGame, } from '../../services/gameDistributionPublish'; import { ThemedModal } from '../modal/ThemedModal'; +/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */ +const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024; +const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; + +type PanelImageKind = 'cover' | 'screenshot'; + +type PanelImageAsset = { + /** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */ + signature: string; + name: string; + assetObjectId: string; + previewUrl: string; +}; + +function resolvePanelImageLabel(kind: PanelImageKind) { + return kind === 'cover' ? '游戏封面' : '游戏截图'; +} + +/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */ +function resolvePanelImageFileError(file: File, kind: PanelImageKind) { + const label = resolvePanelImageLabel(kind); + if (file.size <= 0) return `${label}文件为空,请重新选择`; + if (file.size > PANEL_IMAGE_MAX_BYTES) { + return `${label}过大,请压缩后再上传(最多 6MB)`; + } + const contentType = file.type.trim(); + if (contentType && !contentType.startsWith('image/')) { + return `${label}必须是图片文件`; + } + return ''; +} + +function buildPanelImageSignature(file: File, kind: PanelImageKind) { + return `${kind}:${file.name}:${file.size}:${file.lastModified}`; +} + +/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */ +function buildPanelImagePreviewUrl(file: File) { + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + return ''; + } + try { + return URL.createObjectURL(file); + } catch { + return ''; + } +} + const CATEGORIES = [ '休闲', '益智', @@ -45,7 +95,15 @@ export function GameDistributionPublishPanel({ const [result, setResult] = useState( null, ); + const [cover, setCover] = useState(null); + const [screenshots, setScreenshots] = useState([]); + const [uploadingLabel, setUploadingLabel] = useState(''); const publishIdempotencyKeyRef = useRef(''); + const coverInputRef = useRef(null); + const screenshotInputRef = useRef(null); + // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 + const uploadedAssetCacheRef = useRef(new Map()); + const objectUrlsRef = useRef(new Set()); useEffect(() => { if (!open) return; @@ -65,6 +123,121 @@ export function GameDistributionPublishPanel({ } }, [open, packageResult?.packageRelativePath]); + useEffect(() => { + const objectUrls = objectUrlsRef.current; + return () => { + objectUrls.forEach((url) => { + try { + URL.revokeObjectURL(url); + } catch { + // 预览地址释放失败不影响关闭面板。 + } + }); + objectUrls.clear(); + }; + }, []); + + async function resolveAssetObjectId(file: File, kind: PanelImageKind) { + const signature = buildPanelImageSignature(file, kind); + const cached = uploadedAssetCacheRef.current.get(signature); + if (cached) return cached; + const uploaded = await uploadPlatformMediaAsset({ + file, + assetKind: + kind === 'cover' + ? 'game_distribution_cover' + : 'game_distribution_screenshot', + pathSegments: ['game-distribution', kind, `${Date.now()}`], + entityId: `game-distribution-${kind}`, + metadata: { game_distribution_media: kind }, + }); + uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId); + return uploaded.assetObjectId; + } + + function buildPanelImageAsset( + file: File, + kind: PanelImageKind, + assetObjectId: string, + ) { + const previewUrl = buildPanelImagePreviewUrl(file); + if (previewUrl) objectUrlsRef.current.add(previewUrl); + return { + signature: buildPanelImageSignature(file, kind), + name: file.name.trim() || resolvePanelImageLabel(kind), + assetObjectId, + previewUrl, + }; + } + + async function handleCoverSelected(file: File | null) { + if (!file) return; + setError(''); + const fileError = resolvePanelImageFileError(file, 'cover'); + if (fileError) { + setError(fileError); + return; + } + setUploadingLabel('正在上传封面…'); + try { + const assetObjectId = await resolveAssetObjectId(file, 'cover'); + setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '封面上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (coverInputRef.current) coverInputRef.current.value = ''; + } + } + + async function handleScreenshotsSelected(files: File[]) { + if (files.length === 0) return; + const remaining = MAX_AGC_GAME_SCREENSHOTS - screenshots.length; + if (files.length > remaining) { + setError( + remaining > 0 + ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` + : '游戏截图最多 6 张', + ); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + return; + } + setError(''); + setUploadingLabel('正在上传截图…'); + try { + for (const file of files) { + const fileError = resolvePanelImageFileError(file, 'screenshot'); + if (fileError) { + setError(fileError); + return; + } + const assetObjectId = await resolveAssetObjectId(file, 'screenshot'); + // 逐张入库:中途失败时已传好的截图保留,作者不用重新选择。 + setScreenshots((current) => + current.length >= MAX_AGC_GAME_SCREENSHOTS + ? current + : [ + ...current, + buildPanelImageAsset(file, 'screenshot', assetObjectId), + ], + ); + } + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '截图上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + } + } + async function handleSubmit() { if (!packageResult || !projectPath.trim()) { setError('请先导出有效的试玩包'); @@ -75,6 +248,14 @@ export function GameDistributionPublishPanel({ setError('需要在 Tauri App 内发布'); return; } + if (!cover) { + setError('请先选择游戏封面(JPG/PNG/WebP)'); + return; + } + if (uploadingLabel) { + setError('素材还在上传中,请稍候再发布'); + return; + } setBusy(true); setError(''); try { @@ -83,7 +264,13 @@ export function GameDistributionPublishPanel({ projectPath, packageRelativePath: packageResult.packageRelativePath, manifest, - metadata: { title, summary, category }, + metadata: { + title, + summary, + category, + coverAssetId: cover.assetObjectId, + screenshots: screenshots.map((item) => item.assetObjectId), + }, idempotencyKey: publishIdempotencyKeyRef.current, }); setResult(next); @@ -190,6 +377,97 @@ export function GameDistributionPublishPanel({ +
+
+
+ {cover?.previewUrl ? ( + 游戏封面预览 + ) : ( + {cover ? '封面已上传' : '还没有封面'} + )} +
+ + {cover ? ( + + ) : null} +
+ + {screenshots.length > 0 ? ( +
    + {screenshots.map((item, index) => ( +
  • + {item.previewUrl ? ( + {`截图 + ) : ( + {`截图 ${index + 1}`} + )} + +
  • + ))} +
+ ) : null} + {uploadingLabel ? ( + + {uploadingLabel} + + ) : null} +
{error ? (

{error} @@ -206,7 +484,7 @@ export function GameDistributionPublishPanel({ busy || !title.trim() || !summary.trim() || !packageResult } > - {busy ? '上传中…' : '发布游戏'} + {busy ? '上传中…' : uploadingLabel ? '素材上传中…' : '发布游戏'} diff --git a/apps/ai-game-creator-shell/src/services/assetDirectUpload.ts b/apps/ai-game-creator-shell/src/services/assetDirectUpload.ts new file mode 100644 index 000000000..9ab0e5597 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/assetDirectUpload.ts @@ -0,0 +1,155 @@ +import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; + +import { requestClientApi } from './clientApi'; + +/** 直传凭证里的对象存储目标;字段口径与平台 `/api/assets/direct-upload-tickets` 一致。 */ +type DirectUploadTicketResponse = { + upload: { + bucket: string; + host: string; + objectKey: string; + legacyPublicPath: string; + formFields: Record; + }; +}; + +type ConfirmAssetObjectResponse = { + assetObject: { + assetObjectId: string; + objectKey: string; + assetKind: string; + }; +}; + +export type UploadedPlatformAsset = { + assetObjectId: string; + objectKey: string; +}; + +type FetchLike = (input: string, init?: RequestInit) => Promise; + +/** + * 平台直传凭证支持的 OSS 主机白名单前缀。 + * + * Tauri 的 http 插件只允许访问 `capabilities/main.json` 里声明的地址,这里再做一次校验, + * 保证即使能力配置放宽到 `*.aliyuncs.com`,客户端也只把文件发到平台素材存储,而不是任意主机。 + */ +const PLATFORM_UPLOAD_HOST_SUFFIXES = ['.aliyuncs.com']; + +function isLocalUploadHost(parsed: URL) { + return ( + parsed.protocol === 'http:' && + (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost') + ); +} + +/** 校验直传地址;不是平台素材存储时直接失败关闭,避免把本地文件发给第三方主机。 */ +export function resolvePlatformAssetUploadUrl(host: string) { + const trimmedHost = host.trim(); + if (!trimmedHost) { + throw new Error('素材上传地址为空,请稍后重试'); + } + let parsed: URL; + try { + parsed = new URL(trimmedHost); + } catch { + throw new Error('素材上传地址无效,请稍后重试'); + } + const isOssHost = + parsed.protocol === 'https:' && + PLATFORM_UPLOAD_HOST_SUFFIXES.some((suffix) => + parsed.hostname.endsWith(suffix), + ); + if (!isOssHost && !isLocalUploadHost(parsed)) { + throw new Error('素材上传地址不属于平台素材存储,已终止上传'); + } + return parsed.toString(); +} + +function buildDirectUploadFormData( + upload: DirectUploadTicketResponse['upload'], + file: File, +) { + const formData = new FormData(); + Object.entries(upload.formFields ?? {}).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + }); + // OSS 要求 file 字段位于表单末尾,否则签名校验会失败。 + formData.append('file', file, file.name); + return formData; +} + +/** + * 把本地文件上传成平台素材对象并返回素材标识。 + * + * 三步与网页端一致:申请直传凭证 → 直传对象存储 → confirm 登记素材。AGC 无法在浏览器里 + * 直接跨域直传 OSS,所以直传这一步固定走 Tauri HTTP 插件(Rust 侧发起请求)。 + */ +export async function uploadPlatformMediaAsset(args: { + file: File; + assetKind: string; + pathSegments: string[]; + entityId: string; + metadata?: Record; + /** 单测注入的直传实现;正式运行时固定使用 Tauri HTTP。 */ + fetchImpl?: FetchLike; +}): Promise { + const fileName = args.file.name.trim() || 'cover.png'; + const contentType = args.file.type.trim() || 'application/octet-stream'; + const ticket = await requestClientApi( + '/api/assets/direct-upload-tickets', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + legacyPrefix: 'generated-character-drafts', + pathSegments: args.pathSegments, + fileName, + contentType, + access: 'private', + maxSizeBytes: args.file.size, + metadata: { + asset_kind: args.assetKind, + ...args.metadata, + }, + }), + }, + '创建素材上传凭证失败', + ); + + const uploadHost = resolvePlatformAssetUploadUrl(ticket.upload.host); + const uploadFetch = args.fetchImpl ?? tauriHttpFetch; + const uploadResponse = await uploadFetch(uploadHost, { + method: 'POST', + body: buildDirectUploadFormData(ticket.upload, args.file), + }); + if (!uploadResponse.ok) { + throw new Error( + `上传素材到对象存储失败(HTTP ${uploadResponse.status}),请重试`, + ); + } + + const confirmed = await requestClientApi( + '/api/assets/objects/confirm', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + bucket: ticket.upload.bucket, + objectKey: ticket.upload.objectKey, + contentType, + contentLength: args.file.size, + assetKind: args.assetKind, + accessPolicy: 'private', + entityId: args.entityId, + }), + }, + '确认素材资产失败', + ); + return { + assetObjectId: confirmed.assetObject.assetObjectId, + objectKey: confirmed.assetObject.objectKey, + }; +} diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index ea6ff5887..282d9b549 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -18,6 +18,10 @@ export type GameDistributionPublishMetadata = { description: string; category: GameDistributionCategory; tags: string[]; + /** 平台素材库里的封面素材 ID;服务端要求发布必须带封面。 */ + coverAssetId: string; + /** 平台素材库里的截图素材 ID,最多 6 张。 */ + screenshots?: string[]; deviceSupport: { desktop: boolean; mobile: boolean; @@ -27,6 +31,9 @@ export type GameDistributionPublishMetadata = { orientation: GameDistributionOrientation; }; +/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */ +export const MAX_AGC_GAME_SCREENSHOTS = 6; + export type GameDistributionPublishResult = { gameId: string; versionId: string; @@ -69,12 +76,25 @@ function normalizeMetadata( if (!summary || summary.length > 120) { throw new Error('游戏简介必须为 1 到 120 个字符'); } + const coverAssetId = (metadata?.coverAssetId ?? '').trim(); + if (!coverAssetId) { + // 服务端会拒绝没有封面的发布;在创建游戏前失败关闭,避免留下无资料的半成品。 + throw new Error('请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场'); + } + const screenshots = (metadata?.screenshots ?? []) + .map((screenshot) => screenshot.trim()) + .filter(Boolean); + if (screenshots.length > MAX_AGC_GAME_SCREENSHOTS) { + throw new Error('游戏截图最多 6 张'); + } return { title, summary, description: (metadata?.description ?? summary).trim().slice(0, 2_000), category: metadata?.category ?? '其他', tags: metadata?.tags ?? [], + coverAssetId, + screenshots, deviceSupport: metadata?.deviceSupport ?? { desktop: true, mobile: true, @@ -96,6 +116,8 @@ function toCreateGameRequest( description: metadata.description, category: metadata.category, tags: metadata.tags, + coverAssetId: metadata.coverAssetId, + screenshots: metadata.screenshots ?? [], deviceSupport: metadata.deviceSupport, inputModes: metadata.inputModes, orientation: metadata.orientation, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5edca2ec9..b306ba2d9 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3878,6 +3878,131 @@ textarea { outline-offset: 1px; } +.game-distribution-publish-panel__media { + display: grid; + gap: 14px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px dashed rgb(104 77 57 / 18%); +} + +.game-distribution-publish-panel__media > label, +.game-distribution-publish-panel__cover label { + display: grid; + gap: 6px; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 700; +} + +.game-distribution-publish-panel__media input[type='file'] { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 10px 12px; + border: 1px solid rgb(104 77 57 / 18%); + border-radius: 10px; + background: rgb(255 255 255 / 76%); + color: var(--platform-text-strong); + font: inherit; +} + +.game-distribution-publish-panel__cover { + display: grid; + grid-template-columns: minmax(0, 168px) minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.game-distribution-publish-panel__cover-preview { + display: grid; + aspect-ratio: 16 / 9; + place-items: center; + overflow: hidden; + border: 1px solid rgb(104 77 57 / 18%); + border-radius: 12px; + background: rgb(168 102 61 / 8%); + color: var(--platform-text-muted); + font-size: 12px; +} + +.game-distribution-publish-panel__cover-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.game-distribution-publish-panel__hint { + color: var(--platform-text-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.5; +} + +.game-distribution-publish-panel__cover > button, +.game-distribution-publish-panel__shots button { + justify-self: start; + width: fit-content; + padding: 5px 12px; + border: 1px solid rgb(104 77 57 / 18%); + border-radius: 999px; + background: transparent; + color: var(--platform-text-muted); + font: inherit; + font-size: 11px; + cursor: pointer; +} + +/* 移除封面按钮固定落在文字列,避免占掉预览列的第二行。 */ +.game-distribution-publish-panel__cover > button { + grid-column: 2; +} + +.game-distribution-publish-panel__shots { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.game-distribution-publish-panel__shots li { + display: grid; + gap: 6px; + width: 124px; +} + +.game-distribution-publish-panel__shots img, +.game-distribution-publish-panel__shots li > span { + width: 100%; + aspect-ratio: 16 / 9; + border: 1px solid rgb(104 77 57 / 18%); + border-radius: 10px; +} + +.game-distribution-publish-panel__shots img { + object-fit: cover; +} + +.game-distribution-publish-panel__shots li > span { + display: grid; + place-items: center; + border-style: dashed; + color: var(--platform-text-muted); + font-size: 11px; +} + +@media (max-width: 560px) { + .game-distribution-publish-panel__cover { + grid-template-columns: minmax(0, 1fr); + } + + .game-distribution-publish-panel__cover > button { + grid-column: 1; + } +} + .game-distribution-publish-panel__error { margin: 14px 0 0; color: #b42318; diff --git a/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts b/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts new file mode 100644 index 000000000..d2c2a4946 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment jsdom +/** + * AGC 素材直传(封面 / 截图)的三步链路边界。 + * + * 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、 + * 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。 + */ +import { beforeEach, expect, test, vi } from 'vitest'; + +const requestClientApiMock = vi.hoisted(() => vi.fn()); + +vi.mock('../src/services/clientApi', () => ({ + requestClientApi: (...args: unknown[]) => requestClientApiMock(...args), +})); + +import { + resolvePlatformAssetUploadUrl, + uploadPlatformMediaAsset, +} from '../src/services/assetDirectUpload'; + +const TICKET = { + upload: { + bucket: 'genarrative-assets', + host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/', + objectKey: + 'generated-character-drafts/game-distribution/cover/42/cover.png', + legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42', + formFields: { + key: 'generated-character-drafts/game-distribution/cover/42/cover.png', + policy: 'policy-value', + OSSAccessKeyId: 'ak-value', + signature: 'signature-value', + success_action_status: '204', + 'x-oss-meta-asset_kind': 'game_distribution_cover', + }, + }, +}; + +const CONFIRMED = { + assetObject: { + assetObjectId: 'asset_cover_1', + objectKey: TICKET.upload.objectKey, + assetKind: 'game_distribution_cover', + }, +}; + +function buildCoverFile() { + return new File(['cover-bytes'], 'cover.png', { type: 'image/png' }); +} + +beforeEach(() => { + requestClientApiMock.mockReset(); + requestClientApiMock + .mockResolvedValueOnce(TICKET) + .mockResolvedValueOnce(CONFIRMED); +}); + +test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => { + const uploadFetch = vi.fn(async () => new Response(null, { status: 204 })); + const file = buildCoverFile(); + + const uploaded = await uploadPlatformMediaAsset({ + file, + assetKind: 'game_distribution_cover', + pathSegments: ['game-distribution', 'cover', '42'], + entityId: 'game-distribution-cover', + metadata: { game_distribution_media: 'cover' }, + fetchImpl: uploadFetch, + }); + + expect(uploaded).toEqual({ + assetObjectId: 'asset_cover_1', + objectKey: TICKET.upload.objectKey, + }); + + const ticketCall = requestClientApiMock.mock.calls[0]; + expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets'); + const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body)); + expect(ticketBody).toEqual( + expect.objectContaining({ + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'cover', '42'], + fileName: 'cover.png', + contentType: 'image/png', + access: 'private', + maxSizeBytes: file.size, + metadata: { + asset_kind: 'game_distribution_cover', + game_distribution_media: 'cover', + }, + }), + ); + + const uploadCall = uploadFetch.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(uploadCall[0]).toBe(TICKET.upload.host); + expect(uploadCall[1].method).toBe('POST'); + const formData = uploadCall[1].body as FormData; + expect(formData.get('policy')).toBe('policy-value'); + expect(formData.get('signature')).toBe('signature-value'); + expect(formData.get('success_action_status')).toBe('204'); + const uploadedFile = formData.get('file'); + expect(uploadedFile).toBeInstanceOf(File); + expect((uploadedFile as File).name).toBe('cover.png'); + + const confirmCall = requestClientApiMock.mock.calls[1]; + expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm'); + expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({ + bucket: 'genarrative-assets', + objectKey: TICKET.upload.objectKey, + contentType: 'image/png', + contentLength: file.size, + assetKind: 'game_distribution_cover', + accessPolicy: 'private', + entityId: 'game-distribution-cover', + }); +}); + +test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => { + requestClientApiMock.mockReset(); + requestClientApiMock + .mockResolvedValueOnce({ + upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' }, + }) + .mockResolvedValueOnce(CONFIRMED); + const uploadFetch = vi.fn(); + + await expect( + uploadPlatformMediaAsset({ + file: buildCoverFile(), + assetKind: 'game_distribution_cover', + pathSegments: ['game-distribution', 'cover', '42'], + entityId: 'game-distribution-cover', + fetchImpl: uploadFetch as never, + }), + ).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传'); + + expect(uploadFetch).not.toHaveBeenCalled(); + expect(requestClientApiMock).toHaveBeenCalledTimes(1); +}); + +test('对象存储拒绝上传时给出可重试文案', async () => { + const uploadFetch = vi.fn( + async () => new Response('denied', { status: 403 }), + ); + + await expect( + uploadPlatformMediaAsset({ + file: buildCoverFile(), + assetKind: 'game_distribution_screenshot', + pathSegments: ['game-distribution', 'screenshot', '42'], + entityId: 'game-distribution-screenshot', + fetchImpl: uploadFetch, + }), + ).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试'); + + // 直传失败时不得再登记素材,避免留下没有实体的素材记录。 + expect(requestClientApiMock).toHaveBeenCalledTimes(1); +}); + +test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => { + expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe( + 'http://127.0.0.1:9000/bucket', + ); + expect( + resolvePlatformAssetUploadUrl( + 'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/', + ), + ).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/'); + expect(() => + resolvePlatformAssetUploadUrl('http://oss.example.com/'), + ).toThrow('素材上传地址不属于平台素材存储,已终止上传'); + expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow( + '素材上传地址为空,请稍后重试', + ); +}); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index 93d69d847..c8e00ec87 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -77,6 +77,10 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台 projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, + metadata: { + coverAssetId: 'asset_cover', + screenshots: ['asset_shot_1', 'asset_shot_2'], + }, }); const createGameCall = fetchClientHttp.mock.calls[0]; @@ -85,6 +89,8 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台 String((createGameCall?.[1] as RequestInit).body), ); expect(createGameBody.localProjectId).toBe('local-proj-1'); + expect(createGameBody.coverAssetId).toBe('asset_cover'); + expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']); const createVersionCall = fetchClientHttp.mock.calls[1]; expect(createVersionCall?.[0]).toBe( @@ -112,7 +118,49 @@ test('缺少本地项目标识时在发起请求前失败关闭', async () => { projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest, + metadata: { coverAssetId: 'asset_cover' }, }), ).rejects.toThrow('发布需要本地项目标识'); expect(fetchClientHttp).not.toHaveBeenCalled(); }); + +test('缺少封面时在创建游戏前失败关闭', async () => { + await expect( + publishLocalProjectGame({ + invoke: (async () => ({ + packageRelativePath: 'exports/playtest-package-1.zip', + packageBytes: [1], + packageSha256: 'a'.repeat(64), + packageSizeBytes: 1, + files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], + })) as never, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: ' ' }, + }), + ).rejects.toThrow('请先选择游戏封面'); + expect(fetchClientHttp).not.toHaveBeenCalled(); +}); + +test('截图超过 6 张时在创建游戏前失败关闭', async () => { + await expect( + publishLocalProjectGame({ + invoke: (async () => ({ + packageRelativePath: 'exports/playtest-package-1.zip', + packageBytes: [1], + packageSha256: 'a'.repeat(64), + packageSizeBytes: 1, + files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], + })) as never, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { + coverAssetId: 'asset_cover', + screenshots: Array.from({ length: 7 }, (_, index) => `asset_${index}`), + }, + }), + ).rejects.toThrow('游戏截图最多 6 张'); + expect(fetchClientHttp).not.toHaveBeenCalled(); +}); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index 4b22e58a6..552b6c26c 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -17,9 +17,21 @@ import { expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectExportPackagePayload } from '../src/app/types'; +import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { setStoredAuthAccessToken } from '../src/services/clientAuth'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; +/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */ +const LIVE_COVER_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +function buildLiveCoverFile() { + const bytes = Buffer.from(LIVE_COVER_PNG_BASE64, 'base64'); + return new File([new Uint8Array(bytes)], 'agc-live-cover.png', { + type: 'image/png', + }); +} + const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '') .trim() .replace(/\/+$/u, ''); @@ -154,6 +166,16 @@ liveTest( goal: '验证 AGC 一键发布链路', } as unknown as GameCreationAppManifest; const invoke = vi.fn(async () => payload); + // 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。 + const uploadedCover = await uploadPlatformMediaAsset({ + file: buildLiveCoverFile(), + assetKind: 'game_distribution_cover', + pathSegments: ['game-distribution', 'cover', stamp], + entityId: 'game-distribution-cover', + // jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。 + fetchImpl: (input, init) => realFetch(apiUrl(input), init), + }); + expect(uploadedCover.assetObjectId).toMatch(/\S/u); const metadata = { summary: '由 AGC 发布函数真实提交', description: @@ -163,6 +185,8 @@ liveTest( deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'] as const, orientation: 'landscape' as const, + coverAssetId: uploadedCover.assetObjectId, + screenshots: [] as string[], }; const first = await publishLocalProjectGame({ diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx index fb9f41fe3..3fc008027 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx @@ -17,6 +17,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectExportPackageResult } from '../src/app/types'; import { GameDistributionPublishPanel } from '../src/components/game-distribution/GameDistributionPublishPanel'; +import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { @@ -27,6 +28,11 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { return { ...actual, publishLocalProjectGame: vi.fn() }; }); +// 面板只负责选图与调用上传;这里替换掉真实直传,避免测试触达 Tauri/OSS。 +vi.mock('../src/services/assetDirectUpload', () => ({ + uploadPlatformMediaAsset: vi.fn(), +})); + type TauriInvoke = ( command: string, args?: Record, @@ -57,6 +63,25 @@ const PACKAGE_RESULT = { totalBytes: 3, } as unknown as LocalProjectExportPackageResult; +function buildImageFile(name: string) { + return new File(['cover-bytes'], name, { type: 'image/png' }); +} + +/** 选择封面并等待上传完成;面板只有在素材拿到 ID 之后才允许发布。 */ +async function selectCover( + file: File = buildImageFile('cover.png'), + assetObjectId = 'asset_cover', +) { + vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({ + assetObjectId, + objectKey: `game-distribution/cover/${file.name}`, + }); + fireEvent.change(screen.getByLabelText(/游戏封面/u), { + target: { files: [file] }, + }); + await screen.findByText(new RegExp(`已选择「${file.name}」`, 'u')); +} + function renderPanel( overrides: Partial[0]> = {}, ) { @@ -79,6 +104,7 @@ function renderPanel( afterEach(() => { cleanup(); vi.mocked(publishLocalProjectGame).mockReset(); + vi.mocked(uploadPlatformMediaAsset).mockReset(); delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; window.localStorage.clear(); }); @@ -123,6 +149,15 @@ describe('GameDistributionPublishPanel', () => { fireEvent.change(screen.getByLabelText('分类'), { target: { value: '动作' }, }); + await selectCover(); + vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({ + assetObjectId: 'asset_shot_1', + objectKey: 'game-distribution/screenshot/shot-1.png', + }); + fireEvent.change(screen.getByLabelText(/游戏截图/u), { + target: { files: [buildImageFile('shot-1.png')] }, + }); + await screen.findByText('移除截图 1'); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); await waitFor(() => @@ -137,6 +172,8 @@ describe('GameDistributionPublishPanel', () => { title: '星轨防线二', summary: '守住轨道城', category: '动作', + coverAssetId: 'asset_cover', + screenshots: ['asset_shot_1'], }); expect(String(args?.idempotencyKey)).toMatch(/^agc-publish-/u); expect(invoke).toBeDefined(); @@ -156,6 +193,7 @@ describe('GameDistributionPublishPanel', () => { }) as never, ); renderPanel(); + await selectCover(); const submit = screen.getByRole('button', { name: '发布游戏' }); fireEvent.click(submit); @@ -182,6 +220,7 @@ describe('GameDistributionPublishPanel', () => { new Error('上传游戏发行包失败:游戏分发服务暂不可用(503)'), ); renderPanel(); + await selectCover(); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); @@ -193,6 +232,77 @@ describe('GameDistributionPublishPanel', () => { expect(screen.queryByText('已提交审核')).toBeNull(); }); + test('没有选择封面时不发起发布并给出可操作提示', async () => { + installTauriInvoke(async () => undefined); + renderPanel(); + + fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + + expect( + await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'), + ).not.toBeNull(); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); + }); + + test('封面与截图都先上传成平台素材,重复提交复用同一素材 ID', async () => { + installTauriInvoke(async () => undefined); + vi.mocked(publishLocalProjectGame).mockRejectedValue(new Error('先失败')); + const coverFile = buildImageFile('cover.png'); + renderPanel(); + await selectCover(coverFile, 'asset_cover_cached'); + + expect(vi.mocked(uploadPlatformMediaAsset).mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + assetKind: 'game_distribution_cover', + entityId: 'game-distribution-cover', + }), + ); + + fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + await waitFor(() => + expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), + ); + + // 再次选择同一个文件对象不应重新上传(面板按文件签名复用素材 ID)。 + fireEvent.change(screen.getByLabelText(/游戏封面/u), { + target: { files: [coverFile] }, + }); + await screen.findByText(/已选择「cover.png」/u); + expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(1); + }); + + test('截图超过 6 张时本地拦截且不上传', async () => { + installTauriInvoke(async () => undefined); + renderPanel(); + + fireEvent.change(screen.getByLabelText(/游戏截图/u), { + target: { + files: Array.from({ length: 7 }, (_, index) => + buildImageFile(`shot-${index}.png`), + ), + }, + }); + + expect(await screen.findByText(/游戏截图最多 6 张/u)).not.toBeNull(); + expect(uploadPlatformMediaAsset).not.toHaveBeenCalled(); + }); + + test('素材上传失败时保留面板并展示原因', async () => { + installTauriInvoke(async () => undefined); + vi.mocked(uploadPlatformMediaAsset).mockRejectedValueOnce( + new Error('创建素材上传凭证失败'), + ); + renderPanel(); + + fireEvent.change(screen.getByLabelText(/游戏封面/u), { + target: { files: [buildImageFile('cover.png')] }, + }); + + expect(await screen.findByText('创建素材上传凭证失败')).not.toBeNull(); + expect(screen.getByText('还没有封面')).not.toBeNull(); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); + }); + test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => { renderPanel(); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index 712e9cae8..1e6c9acc4 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -86,6 +86,16 @@ - 移动视口真实游玩:headless Chromium 以 `390x844` 打开已发布游戏 `/games/play?id=`,页面显示「横屏设计,旋转设备」提示与移动端底部导航,点击「开始游戏」后 iframe 以 `sandbox="allow-scripts"` + `allow="fullscreen"` 挂载发行网关地址并 `ready`,`index.html` 与 `assets/app.js` 均返回 200,控制台无 CSP/CORP 报错;截图存于本轮验证记录(不入库)。 本轮同时修掉三个真实缺陷:Vite dev 代理缺少 `/api/game-distribution` 前缀(本地全部 404);详情页在「已发布但没有 controls」时误显示「暂未发布可玩版本」;发行网关的 `Cross-Origin-Resource-Policy: same-origin` 会让 opaque origin 沙箱内的游戏加载不了自己的脚本(改为 `cross-origin` + 无凭据 CORS,详见 `pitfalls.md`)。 +- 资料冻结与展示闭环(封面 + 截图):游戏表末尾新增可空 `cover_object_key` / `screenshots_json`,版本表末尾新增可空 `metadata_json`;创建版本时 api-server 校验「必需封面、≤6 张截图、素材属于当前作者且 `content_type` 为 `image/`」,并从素材记录派生对象键生成冻结快照,审核通过时整体生效到游戏行。 +- 公开素材读授权:只有 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 获得匿名读授权;其余素材仍按 owner 校验。 +- 作者续发复用:版本回读(作者本人)与审核回读(管理员)的版本投影新增 `frozenMetadata`,带回 `coverAssetId` / `screenshots[].assetId`;`/games/publish` 更新模式据此预填封面与截图,不要求作者为沿用封面重新上传;快照缺素材 ID 的旧版本明确要求重新选择封面。公开投影仍只暴露对象键。 +- 网页发布资料入口:`/games/publish` 新增「封面与截图」区(封面必需、截图 ≤6、可逐张移除、上传中禁用提交),复用平台图片直传 + confirm 通道;本地校验与服务端口径对齐(缺封面/超 6 张在发请求前拦截)。 +- 网页展示:游戏广场卡片与详情页 hero 用 `useResolvedAssetReadUrl` 换签展示真实封面,详情页在存在截图时给出可点击缩略图条(封面 + 截图,选中态 + 键盘可达),换签失败或无素材时静默回退原有渐变占位,不出现空框。 +- 本切片验证:`cargo check -p api-server -p spacetime-module -p spacetime-client`;`cargo test -p api-server game_distribution`(17 passed,含新增 `version_detail_payload_exposes_frozen_metadata_to_owner`);`npm run typecheck`;`npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts`(46 passed);改动文件 `eslint --max-warnings 0` 与 `npm run check:encoding`。 + +- AGC 发布面板资料入口:新增 `apps/ai-game-creator-shell/src/services/assetDirectUpload.ts`(凭证 → 直传 → confirm,直传固定走 Tauri HTTP 插件并校验目标主机必须是平台素材存储),面板支持封面必选 + 截图 ≤6、本地预览、逐张移除、上传中禁用发布;缺封面时在创建游戏前失败关闭,服务端「封面」类错误原样展示;同一文件重复提交复用素材 ID 不重复直传。为支持直传,`src-tauri/capabilities/main.json` 的 http 作用域新增 `https://*.aliyuncs.com/*`(配合客户端主机白名单,避免把本地文件发给任意主机)。 +- AGC 测试证据:`npx vitest run apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx apps/ai-game-creator-shell/tests/clientApi.test.ts`(27 passed);`npm --prefix apps/ai-game-creator-shell run typecheck`;改动文件 `eslint --max-warnings 0`。 + ## 尚未完成 - 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 1d6d0c7d5..dc45816f4 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -663,6 +663,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`GameDistributionGame` - 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs` - 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。 +- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组)。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。 - 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。 - 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。 @@ -672,6 +673,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs` - 用途:不可变发行版本与真实包确认事实。创建后冻结 `package_sha256`、字节数、文件数、根入口和版本号;后续只推进上传、校验、审核、公开、撤回状态,并记录私有对象键、文件清单、入口 URL、审核者和阶段时间。 - 索引:`by_game_distribution_version_game_id`、`by_game_distribution_version_owner_user_id`。真实 ZIP 由 `api-server` 校验并写入私有 OSS 后,才通过 facade 确认 `uploaded`;表不保存 ZIP 正文。 +- 冻结资料:版本表末尾追加可空 `metadata_json`,保存创建版本时由 api-server 校验(标题/简介/分类/标签/设备/方向/必需封面/≤6 张截图)并从素材记录派生对象键后的资料快照;`approve_game_distribution_version_and_return` 通过审核时把该快照整体生效到游戏行,因此公开投影展示的始终是“已随版本审核通过”的资料,旧版本(无快照)保持原值。 +- 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。 - 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction` 由 `api-server` 按 `status` 派生,不落表。 ### `game_distribution_idempotency_receipt` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 58d11d20a..ecbdd2fb7 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -86,7 +86,7 @@ 2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。 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 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。 +5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。 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}/{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`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 80ea20340..27225d45f 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -64,6 +64,13 @@ export type GameDistributionGame = { tags: string[]; coverColor: string; icon: string; + /** + * 公开封面的 OSS objectKey;未上传封面时为空,展示层需回退到 coverColor/icon 占位。 + * 通过 `/api/assets/read-url` 换签名 URL 读取,不接受直连外链。 + */ + coverObjectKey?: string | null; + /** 公开截图的 OSS objectKey 列表,最多 6 张,随版本冻结。 */ + screenshots?: string[]; author: GameDistributionAuthor; deviceSupport: GameDistributionDeviceSupport; inputModes?: GameDistributionInputMode[]; @@ -90,6 +97,8 @@ export type GameDistributionCreateGameRequest = { category: GameDistributionCategory; tags?: string[]; coverAssetId?: string; + /** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */ + screenshots?: string[]; deviceSupport: GameDistributionDeviceSupport; inputModes: GameDistributionInputMode[]; orientation: GameDistributionOrientation; @@ -130,10 +139,37 @@ export type GameDistributionRecoveryAction = | 'fix_package' | 'fix_metadata'; +/** 冻结资料里的截图:同时保留素材 ID(作者续发可复用)与对象键(展示换签用)。 */ +export type GameDistributionFrozenScreenshot = { + assetId: string; + objectKey: string; +}; + +/** + * 随版本冻结的游戏资料快照。 + * + * 只有作者本人(版本回读)与管理员(审核回读)会拿到素材 ID;公开投影只给对象键。 + * 历史版本可能没有快照,读取方必须按空值处理。 + */ +export type GameDistributionVersionFrozenMetadata = { + title?: string; + summary?: string; + description?: string; + category?: GameDistributionCategory; + tags?: string[]; + coverAssetId?: string | null; + coverObjectKey?: string | null; + screenshots?: GameDistributionFrozenScreenshot[]; + deviceSupport?: GameDistributionDeviceSupport; + inputModes?: GameDistributionInputMode[]; + orientation?: GameDistributionOrientation; +}; + export type GameDistributionVersionDetail = { game: GameDistributionGame; version: GameDistributionPrivateVersion & { recoveryAction: GameDistributionRecoveryAction; + frozenMetadata?: GameDistributionVersionFrozenMetadata | null; }; }; 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 6843c998f..22526c728 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -50,6 +50,8 @@ pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as us const MAX_LIST_LIMIT: u32 = 48; const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128; const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024; +/// 首版截图上限,与主规范冻结口径一致。 +const MAX_GAME_SCREENSHOTS: usize = 6; const GAME_DISTRIBUTION_OBJECT_PREFIX: &str = "agc/project-snapshots/v1/game-distribution/"; const GAME_DISTRIBUTION_PUBLISHED_STATUS: &str = "published"; /// 发行包 PUT 的尝试次数与退避,口径与 `platform-oss` 的可重试分类一致。 @@ -521,8 +523,11 @@ async fn create_version( validate_version_declaration(&payload)?; let now = now_micros(); let version_id = format!("gamever_{}", Uuid::new_v4().simple()); + let metadata_json = + resolve_version_metadata_json(&state, auth.claims().user_id(), &payload.game_metadata) + .await?; let request_digest = compute_request_digest( - &serde_json::to_vec(&(game_id.as_str(), &payload)) + &serde_json::to_vec(&(game_id.as_str(), &payload, metadata_json.as_str())) .map_err(|error| internal(error.to_string()))?, ); let version = state @@ -532,6 +537,7 @@ async fn create_version( game_id, owner_user_id: auth.claims().user_id().to_string(), version_id, + metadata_json, package_sha256: payload.package_sha256, package_bytes: payload.package_bytes, package_file_count: payload.package_file_count, @@ -1252,6 +1258,25 @@ fn validate_game_metadata(payload: &GameDistributionCreateGameRequest) -> Result if payload.device_support.mobile && !payload.device_support.touch { return Err(bad_request("声明支持移动端时必须支持触控")); } + if payload + .cover_asset_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err(bad_request("发布游戏必须提供封面")); + } + if payload.screenshots.len() > MAX_GAME_SCREENSHOTS { + return Err(bad_request("游戏截图最多 6 张")); + } + if payload + .screenshots + .iter() + .any(|screenshot| screenshot.trim().is_empty()) + { + return Err(bad_request("游戏截图素材 ID 不能为空")); + } Ok(()) } @@ -1331,6 +1356,14 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value { fn game_payload(game: &GameDistributionGameRecord) -> Value { let tags = serde_json::from_str::>(&game.tags_json).unwrap_or_default(); + let screenshots = game + .screenshots_json + .as_deref() + .and_then(|json| serde_json::from_str::>(json).ok()) + .unwrap_or_default() + .into_iter() + .map(|screenshot| screenshot.object_key) + .collect::>(); let input_modes = serde_json::from_str::>(&game.input_modes_json) .unwrap_or_default(); @@ -1343,6 +1376,8 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value { "tags": tags, "coverColor": "#F3E4D0", "icon": "🎮", + "coverObjectKey": game.cover_object_key, + "screenshots": screenshots, "author": { "id": game.owner_user_id, "name": game.author_name.as_deref().unwrap_or("创作者"), "avatarUrl": game.author_avatar_url }, "deviceSupport": { "desktop": game.device_support_desktop, "mobile": game.device_support_mobile, "touch": game.device_support_touch }, "inputModes": input_modes, @@ -1416,6 +1451,94 @@ async fn ensure_publish_enabled(state: &AppState, user_id: Option<&str>) -> Resu } } +/// 冻结资料快照里的截图素材。 +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct FrozenGameScreenshot { + asset_id: String, + object_key: String, +} + +/// 校验封面/截图素材归属并生成版本冻结资料 JSON。 +/// +/// 对象键由服务端从素材记录派生,客户端只能提供素材 ID;素材必须属于当前作者且是图片。 +async fn resolve_version_metadata_json( + state: &AppState, + owner_user_id: &str, + metadata: &GameDistributionCreateGameRequest, +) -> Result { + let cover_asset_id = metadata + .cover_asset_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| bad_request("发布游戏必须提供封面"))? + .to_string(); + let cover_object_key = + resolve_owned_image_object_key(state, owner_user_id, cover_asset_id.as_str()).await?; + let mut screenshots = Vec::with_capacity(metadata.screenshots.len()); + for asset_id in &metadata.screenshots { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err(bad_request("游戏截图素材 ID 不能为空")); + } + let object_key = resolve_owned_image_object_key(state, owner_user_id, asset_id).await?; + screenshots.push(FrozenGameScreenshot { + asset_id: asset_id.to_string(), + object_key, + }); + } + let tags = metadata + .tags + .iter() + .map(|tag| tag.trim()) + .filter(|tag| !tag.is_empty()) + .collect::>(); + let snapshot = json!({ + "title": metadata.title.trim(), + "summary": metadata.summary.trim(), + "description": metadata + .description + .clone() + .unwrap_or_else(|| metadata.summary.trim().to_string()), + "category": metadata.category, + "tags": tags, + "coverAssetId": cover_asset_id, + "coverObjectKey": cover_object_key, + "screenshots": screenshots, + "deviceSupport": { + "desktop": metadata.device_support.desktop, + "mobile": metadata.device_support.mobile, + "touch": metadata.device_support.touch, + }, + "inputModes": metadata.input_modes, + "orientation": metadata.orientation, + }); + serde_json::to_string(&snapshot).map_err(|error| internal(error.to_string())) +} + +async fn resolve_owned_image_object_key( + state: &AppState, + owner_user_id: &str, + asset_object_id: &str, +) -> Result { + let asset = state + .spacetime_client() + .get_asset_object(asset_object_id.to_string()) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| bad_request("封面或截图素材不存在"))?; + if asset.owner_user_id.as_deref() != Some(owner_user_id) { + return Err(AppError::from_status(StatusCode::FORBIDDEN) + .with_message("封面或截图素材不属于当前账号")); + } + let content_type = asset.content_type.as_deref().unwrap_or(""); + if !content_type.starts_with("image/") { + return Err(bad_request("封面和截图必须是图片素材")); + } + Ok(asset.object_key) +} + /// 读取当前主体名下的版本;未知版本和别人的版本都按不可见处理(404)。 async fn load_owner_version_or_404( state: &AppState, @@ -1436,17 +1559,28 @@ async fn load_owner_version_or_404( } } -/// 版本私有投影:在通用私有字段上追加客户端恢复动作。 +/// 版本私有投影:在通用私有字段上追加客户端恢复动作与随版本冻结的资料快照。 +/// +/// 该投影只用于作者本人与管理员回读,因此可以带上冻结资料里的素材 ID:作者更新游戏时 +/// 复用同一批素材,不需要为了沿用封面重新上传一次;快照缺失(历史版本)时按空值返回。 fn version_detail_payload( version: &GameDistributionVersionRecord, game: &GameDistributionGameRecord, ) -> Value { let mut payload = private_version_payload(version); + let frozen_metadata = version + .metadata_json + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| serde_json::from_str::(value).ok()) + .unwrap_or(Value::Null); if let Value::Object(ref mut object) = payload { object.insert( "recoveryAction".to_string(), Value::String(recovery_action_for_status(version.status.as_str()).to_string()), ); + object.insert("frozenMetadata".to_string(), frozen_metadata); } json!({ "game": game_payload(game), "version": payload }) } @@ -1548,6 +1682,7 @@ mod tests { GameDistributionCreateGameRequest { local_project_id: None, title: "测试游戏".to_string(), + screenshots: Vec::new(), summary: "用于验证发行合同".to_string(), description: Some("描述".to_string()), category: "益智".to_string(), @@ -1575,6 +1710,137 @@ mod tests { ); } + #[test] + fn metadata_requires_cover_and_limits_screenshots() { + let mut payload = metadata(); + payload.cover_asset_id = None; + assert_eq!( + validate_game_metadata(&payload) + .expect_err("缺少封面必须被拒") + .status_code(), + StatusCode::BAD_REQUEST + ); + + let mut payload = metadata(); + payload.cover_asset_id = Some("asset_cover".to_string()); + payload.screenshots = (0..7).map(|index| format!("asset_{index}")).collect(); + assert_eq!( + validate_game_metadata(&payload) + .expect_err("超过 6 张截图必须被拒") + .status_code(), + StatusCode::BAD_REQUEST + ); + + let mut payload = metadata(); + payload.cover_asset_id = Some("asset_cover".to_string()); + payload.screenshots = (0..6).map(|index| format!("asset_{index}")).collect(); + validate_game_metadata(&payload).expect("封面 + 6 张截图应通过校验"); + } + + #[test] + fn public_payload_exposes_cover_and_screenshot_object_keys() { + let game = GameDistributionGameRecord { + game_id: "game_1".to_string(), + owner_user_id: "user_1".to_string(), + title: "封面游戏".to_string(), + summary: "摘要".to_string(), + description: "描述".to_string(), + category: "益智".to_string(), + tags_json: "[]".to_string(), + cover_asset_id: Some("asset_cover".to_string()), + author_name: None, + author_avatar_url: None, + device_support_desktop: true, + device_support_mobile: false, + device_support_touch: false, + input_modes_json: "[]".to_string(), + orientation: "responsive".to_string(), + publication_revision: 1, + active_version_id: Some("version_1".to_string()), + visibility: "published".to_string(), + play_count: 0, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + cover_object_key: Some("generated/game-cover.png".to_string()), + screenshots_json: Some( + r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(), + ), + }; + let payload = game_payload(&game); + assert_eq!( + payload["coverObjectKey"], + Value::String("generated/game-cover.png".to_string()) + ); + assert_eq!( + payload["screenshots"][0], + Value::String("generated/shot-1.png".to_string()) + ); + } + + #[test] + fn version_detail_payload_exposes_frozen_metadata_to_owner() { + let game = GameDistributionGameRecord { + game_id: "game_1".to_string(), + owner_user_id: "user_1".to_string(), + title: "封面游戏".to_string(), + summary: "摘要".to_string(), + description: "描述".to_string(), + category: "益智".to_string(), + tags_json: "[]".to_string(), + cover_asset_id: Some("asset_cover".to_string()), + author_name: None, + author_avatar_url: None, + device_support_desktop: true, + device_support_mobile: false, + device_support_touch: false, + input_modes_json: "[]".to_string(), + orientation: "responsive".to_string(), + publication_revision: 1, + active_version_id: Some("version_1".to_string()), + visibility: "published".to_string(), + play_count: 0, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + cover_object_key: Some("generated/game-cover.png".to_string()), + screenshots_json: None, + }; + let mut version = GameDistributionVersionRecord { + version_id: "version_2".to_string(), + game_id: "game_1".to_string(), + owner_user_id: "user_1".to_string(), + version_number: 2, + package_sha256: "a".repeat(64), + package_bytes: 1024, + package_file_count: 1, + package_entry_path: "index.html".to_string(), + status: "pending_review".to_string(), + review_reason: None, + entry_url: None, + publication_revision: 1, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + metadata_json: Some( + r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/game-cover.png","screenshots":[{"assetId":"asset_shot","objectKey":"generated/shot.png"}]}"# + .to_string(), + ), + }; + + let payload = version_detail_payload(&version, &game); + assert_eq!( + payload["version"]["frozenMetadata"]["coverAssetId"], + Value::String("asset_cover".to_string()) + ); + assert_eq!( + payload["version"]["frozenMetadata"]["screenshots"][0]["assetId"], + Value::String("asset_shot".to_string()) + ); + + // 历史版本没有冻结资料时按 null 返回,客户端必须按空值处理。 + version.metadata_json = None; + let legacy_payload = version_detail_payload(&version, &game); + assert!(legacy_payload["version"]["frozenMetadata"].is_null()); + } + #[test] fn package_validation_errors_are_unprocessable() { let error = map_package_error(ReleasePackageError::MissingEntry); diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 1664b68c7..c9ef77987 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -120,6 +120,9 @@ pub struct GameDistributionCreateGameRequest { pub tags: Vec, #[serde(default)] pub cover_asset_id: Option, + /// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 + #[serde(default)] + pub screenshots: Vec, pub device_support: GameDistributionDeviceSupport, pub input_modes: Vec, pub orientation: GameDistributionOrientation, 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 f361b45b6..72d04d537 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 @@ -23,6 +23,8 @@ pub struct GameDistributionGameRecord { pub play_count: u64, pub created_at: String, pub updated_at: String, + pub cover_object_key: Option, + pub screenshots_json: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -41,6 +43,8 @@ pub struct GameDistributionVersionRecord { pub publication_revision: u64, pub created_at: String, pub updated_at: String, + /// 创建版本时冻结的资料快照;历史版本可能为空,读取方必须容忍。 + pub metadata_json: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -80,6 +84,8 @@ fn map_game( play_count: value.play_count, created_at: shared_kernel::format_timestamp_micros(value.created_at_micros), updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros), + cover_object_key: value.cover_object_key, + screenshots_json: value.screenshots_json, } } @@ -101,6 +107,7 @@ fn map_version( 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), + metadata_json: value.metadata_json, } } diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 5ae6e6408..a9feb2ce9 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -48,6 +48,8 @@ pub struct GameDistributionCreateVersionRecordInput { pub game_id: String, pub owner_user_id: String, pub version_id: String, + /// api-server 校验并解析素材后生成的资料快照 JSON。 + pub metadata_json: String, pub package_sha256: String, pub package_bytes: u64, pub package_file_count: u32, @@ -292,6 +294,7 @@ impl SpacetimeClient { game_id: input.game_id, owner_user_id: input.owner_user_id, version_id: input.version_id, + metadata_json: input.metadata_json, package_sha_256: input.package_sha256, package_bytes: input.package_bytes, package_file_count: input.package_file_count, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs index 97b8c027f..86e199c18 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs @@ -10,6 +10,7 @@ pub struct GameDistributionCreateVersionInput { pub game_id: String, pub owner_user_id: String, pub version_id: String, + pub metadata_json: String, pub package_sha_256: String, pub package_bytes: u64, pub package_file_count: u32, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs index 5d0fe15e1..555f390bf 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs @@ -28,6 +28,8 @@ pub struct GameDistributionGameSnapshot { pub play_count: u64, pub created_at_micros: i64, pub updated_at_micros: i64, + pub cover_object_key: Option, + pub screenshots_json: Option, } impl __sdk::InModule for GameDistributionGameSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs index 9ef982178..7236ad64b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs @@ -29,6 +29,8 @@ pub struct GameDistributionGame { pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, pub local_project_id: Option, + pub cover_object_key: Option, + pub screenshots_json: Option, } impl __sdk::InModule for GameDistributionGame { @@ -61,6 +63,8 @@ pub struct GameDistributionGameCols { pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, pub local_project_id: __sdk::__query_builder::Col>, + pub cover_object_key: __sdk::__query_builder::Col>, + pub screenshots_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for GameDistributionGame { @@ -101,6 +105,8 @@ impl __sdk::__query_builder::HasCols for GameDistributionGame { created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), local_project_id: __sdk::__query_builder::Col::new(table_name, "local_project_id"), + cover_object_key: __sdk::__query_builder::Col::new(table_name, "cover_object_key"), + screenshots_json: __sdk::__query_builder::Col::new(table_name, "screenshots_json"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs index 45699b193..7acae2d7c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs @@ -21,6 +21,7 @@ pub struct GameDistributionVersionSnapshot { pub publication_revision: u64, pub created_at_micros: i64, pub updated_at_micros: i64, + pub metadata_json: Option, } impl __sdk::InModule for GameDistributionVersionSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs index e1a08f8e2..05d3c3cdb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs @@ -31,6 +31,7 @@ pub struct GameDistributionVersion { pub revoked_at: Option<__sdk::Timestamp>, pub last_error_code: Option, pub last_error_message: Option, + pub metadata_json: Option, } impl __sdk::InModule for GameDistributionVersion { @@ -67,6 +68,7 @@ pub struct GameDistributionVersionCols { pub revoked_at: __sdk::__query_builder::Col>, pub last_error_code: __sdk::__query_builder::Col>, pub last_error_message: __sdk::__query_builder::Col>, + pub metadata_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for GameDistributionVersion { @@ -103,6 +105,7 @@ impl __sdk::__query_builder::HasCols for GameDistributionVersion { revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"), last_error_code: __sdk::__query_builder::Col::new(table_name, "last_error_code"), last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"), + metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"), } } } diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 1f506eed4..c2de622d6 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -6329,7 +6329,13 @@ pub(crate) fn asset_location_has_public_showcase_read_grant( asset_object: Option<&module_assets::AssetObjectUpsertSnapshot>, ) -> bool { let asset_object_granted = asset_object - .is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object)); + .is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object)) + || asset_object.is_some_and(|asset_object| { + crate::game_distribution::game_distribution_asset_has_public_read_grant( + ctx, + asset_object.asset_object_id.as_str(), + ) + }); let campaign_config = ctx .db .editor_showcase_campaign_config() diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 5b4019150..53ef0d7d8 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -40,6 +40,12 @@ pub struct GameDistributionGame { /// AGC / 网页发布方的本地项目标识。只用于同一作者复用游戏身份,不构成所有权证明。 #[default(None::)] pub(crate) local_project_id: Option, + /// 审核通过后生效的封面对象键;客户端用它换平台签名读地址。 + #[default(None::)] + pub(crate) cover_object_key: Option, + /// 审核通过后生效的截图对象键 JSON 数组(与 `cover_asset_id` 同源,来自版本冻结资料)。 + #[default(None::)] + pub(crate) screenshots_json: Option, } /// 游戏发行版本。包摘要、确认字节数和送审资料在创建后作为不可变快照保留;状态和 @@ -94,6 +100,9 @@ pub struct GameDistributionVersion { pub(crate) last_error_code: Option, #[default(None::)] pub(crate) last_error_message: Option, + /// 该版本冻结的作者资料快照(JSON,含封面与截图素材键)。审核通过时整体生效。 + #[default(None::)] + pub(crate) metadata_json: Option, } /// 创建、提交、审核、撤回和下架等写操作的幂等收据。 @@ -194,6 +203,8 @@ pub struct GameDistributionCreateVersionInput { pub game_id: String, pub owner_user_id: String, pub version_id: String, + /// 由 api-server 校验并解析素材后生成的资料快照 JSON。 + pub metadata_json: String, pub package_sha256: String, pub package_bytes: u64, pub package_file_count: u32, @@ -350,6 +361,8 @@ pub struct GameDistributionGameSnapshot { pub play_count: u64, pub created_at_micros: i64, pub updated_at_micros: i64, + pub cover_object_key: Option, + pub screenshots_json: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -368,6 +381,8 @@ pub struct GameDistributionVersionSnapshot { pub publication_revision: u64, pub created_at_micros: i64, pub updated_at_micros: i64, + /// 该版本冻结的资料快照 JSON;旧版本为 None。 + pub metadata_json: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -927,6 +942,8 @@ fn create_game_distribution_game_tx( created_at: now, updated_at: now, local_project_id, + cover_object_key: None, + screenshots_json: None, }); insert_game_distribution_receipt( ctx, @@ -963,6 +980,7 @@ fn create_game_distribution_version_tx( let game_id = required_game_distribution_text(input.game_id, "game_id")?; let version_id = required_game_distribution_text(input.version_id, "version_id")?; let package_sha256 = required_game_distribution_text(input.package_sha256, "package_sha256")?; + let metadata_json = required_game_distribution_text(input.metadata_json, "metadata_json")?; let package_entry_path = required_game_distribution_text(input.package_entry_path, "package_entry_path")?; let idempotency_key = @@ -1054,6 +1072,7 @@ fn create_game_distribution_version_tx( revoked_at: None, last_error_code: None, last_error_message: None, + metadata_json: Some(metadata_json), }); insert_game_distribution_receipt( ctx, @@ -1545,6 +1564,11 @@ fn approve_game_distribution_version_tx( .game_distribution_version() .version_id() .update(version.clone()); + // 资料随版本冻结:审核通过时把该版本的资料快照整体生效到公开投影。 + if let Some(metadata_json) = version.metadata_json.as_deref() { + let frozen = parse_game_distribution_frozen_metadata(metadata_json)?; + apply_game_distribution_frozen_metadata(&mut game, &frozen); + } game.active_version_id = Some(version.version_id.clone()); game.visibility = GAME_DISTRIBUTION_VISIBILITY_PUBLISHED.to_string(); game.publication_revision = game @@ -2159,9 +2183,99 @@ fn game_distribution_game_snapshot(game: &GameDistributionGame) -> GameDistribut play_count: game.play_count, created_at_micros: game.created_at.to_micros_since_unix_epoch(), updated_at_micros: game.updated_at.to_micros_since_unix_epoch(), + cover_object_key: game.cover_object_key.clone(), + screenshots_json: game.screenshots_json.clone(), } } +/// 版本冻结资料的 JSON 形状;由 api-server 在创建版本前校验并解析素材后写入。 +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameDistributionFrozenMetadata { + pub title: String, + pub summary: String, + #[serde(default)] + pub description: Option, + pub category: String, + #[serde(default)] + pub tags: Vec, + pub cover_asset_id: String, + pub cover_object_key: String, + #[serde(default)] + pub screenshots: Vec, + pub device_support: GameDistributionFrozenDeviceSupport, + #[serde(default)] + pub input_modes: Vec, + pub orientation: String, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameDistributionFrozenScreenshot { + pub asset_id: String, + pub object_key: String, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub(crate) struct GameDistributionFrozenDeviceSupport { + pub desktop: bool, + pub mobile: bool, + pub touch: bool, +} + +fn parse_game_distribution_frozen_metadata( + metadata_json: &str, +) -> Result { + serde_json::from_str(metadata_json).map_err(|_| "版本冻结资料格式无效".to_string()) +} + +fn apply_game_distribution_frozen_metadata( + game: &mut GameDistributionGame, + frozen: &GameDistributionFrozenMetadata, +) { + game.title = frozen.title.clone(); + game.summary = frozen.summary.clone(); + game.description = frozen.description.clone().unwrap_or_default(); + game.category = frozen.category.clone(); + game.tags_json = serde_json::to_string(&frozen.tags).unwrap_or_else(|_| "[]".to_string()); + game.cover_asset_id = Some(frozen.cover_asset_id.clone()); + game.cover_object_key = Some(frozen.cover_object_key.clone()); + game.screenshots_json = serde_json::to_string(&frozen.screenshots).ok(); + game.device_support_desktop = frozen.device_support.desktop; + game.device_support_mobile = frozen.device_support.mobile; + game.device_support_touch = frozen.device_support.touch; + game.input_modes_json = + serde_json::to_string(&frozen.input_modes).unwrap_or_else(|_| "[]".to_string()); + game.orientation = frozen.orientation.clone(); +} + +/// 已公开游戏的封面/截图素材允许匿名读者换取签名读地址。 +pub(crate) fn game_distribution_asset_has_public_read_grant( + ctx: &ReducerContext, + asset_object_id: &str, +) -> bool { + ctx.db.game_distribution_game().iter().any(|game| { + if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED + || game.active_version_id.is_none() + { + return false; + } + if game.cover_asset_id.as_deref() == Some(asset_object_id) { + return true; + } + game.screenshots_json + .as_deref() + .and_then(|json| { + serde_json::from_str::>(json).ok() + }) + .is_some_and(|screenshots| { + screenshots + .iter() + .any(|screenshot| screenshot.asset_id == asset_object_id) + }) + }) +} + fn game_distribution_version_snapshot( version: &GameDistributionVersion, ) -> GameDistributionVersionSnapshot { @@ -2180,6 +2294,7 @@ fn game_distribution_version_snapshot( publication_revision: 0, created_at_micros: version.created_at.to_micros_since_unix_epoch(), updated_at_micros: version.updated_at.to_micros_since_unix_epoch(), + metadata_json: version.metadata_json.clone(), } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4eddadbe2..f45c5776d 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -254,6 +254,8 @@ macro_rules! migration_tables { external_api_key, // 游戏分发:游戏身份、不可变发行版本和幂等收据都属于业务事实, // 随迁移导出/导入;私有 ZIP 与展开内容保存在对象存储,不进入这两张表。 + // 资料随版本冻结:版本表末尾追加 metadata_json,游戏表末尾追加 + // cover_object_key / screenshots_json,均为可空列,旧行按“未冻结资料”处理。 game_distribution_game, game_distribution_version, game_distribution_idempotency_receipt, diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index fa7252228..243c052e9 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -1,4 +1,5 @@ import './gameDistribution.css'; +import './gameDistributionShowcase.css'; import { ArrowLeft, @@ -9,6 +10,7 @@ import { } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { type GameDistributionGame, type GameDistributionInputMode, @@ -52,6 +54,77 @@ function resolveControlsLabel( return controls || '未标注操作方式'; } +/** 缩略图自己换签,失败时退回空占位,不阻塞 hero 与主行动作。 */ +function GameShowcaseThumb({ + objectKey, + label, + isActive, + onSelect, +}: { + objectKey: string; + label: string; + isActive: boolean; + onSelect: () => void; +}) { + const { resolvedUrl } = useResolvedAssetReadUrl(null, { objectKey }); + return ( + + ); +} + +/** + * 详情页封面区:默认展示封面,存在截图时在下方给出可点击的缩略图条。 + * + * 缩略图第一项固定是封面本身,方便作者/玩家从截图切回封面;只有封面时整条不渲染。 + */ +function GameShowcase({ game }: { game: GameDistributionGame }) { + const coverKey = (game.coverObjectKey ?? '').trim(); + const screenshotKeys = (game.screenshots ?? []) + .map((key) => key.trim()) + .filter(Boolean) + .slice(0, 6); + const items = [ + ...(coverKey ? [{ key: coverKey, label: '封面' }] : []), + ...screenshotKeys.map((key, index) => ({ + key, + label: `截图 ${index + 1}`, + })), + ]; + const [activeIndex, setActiveIndex] = useState(0); + const activeKey = items[activeIndex]?.key ?? coverKey; + + return ( +

+ + {items.length > 1 ? ( +
+ {items.map((item, index) => ( + setActiveIndex(index)} + /> + ))} +
+ ) : null} +
+ ); +} + export function GameDetailPage({ gameId, onBack, @@ -144,7 +217,7 @@ export function GameDetailPage({ 返回游戏广场
- +