diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index 1e6c9acc4..5f2425dba 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -96,6 +96,10 @@ - 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`。 +- 真实本地栈媒体验证(2026-09-20,`npm run dev:api-server` + `dev:web`,api-server `127.0.0.1:12401`、SpacetimeDB `127.0.0.1:12402`、真实 dev OSS 桶):`E2E_ADMIN_USER=… E2E_ADMIN_PASSWORD=… npm run check:game-distribution-media-e2e`(`scripts/check-game-distribution-media-e2e.mjs`)27 项全部通过——真实直传封面与 2 张截图素材、缺封面 400、截图 7 张 400、不存在素材 400(创建游戏与创建版本两处)、他人素材 403、创建游戏/版本/上传/送审(202 + `pending_review`)、作者回读拿到 `frozenMetadata` 且素材 ID 与顺序一致、待审期间公开目录不含该游戏且封面匿名读返回 404、管理员审核通过后公开投影带封面与截图对象键且不含素材 ID、匿名 `read-url` 对封面与截图都返回签名地址、发行网关 `index.html` 与 `assets/app.js` 均 200 且带 nosniff。 +- 真实浏览器展示验证(同一栈,headless Chromium `390x844` 与 `1280x900`):`/games` 卡片渲染真实封面签名地址(无占位回退),`/games/detail?id=…` hero 显示封面、缩略图条渲染「封面 + 2 张截图」,点击第 3 张后 `aria-pressed` 与 hero 图片同步切换;移动端布局正常。截图存于 `~/data/tmp/gd3/`(不入库)。 +- 创建游戏同步校验素材归属:此前 `POST /api/game-distribution/games` 只校验「封面必填 / 截图 ≤6 / ID 非空」,素材是否存在与是否属于当前作者要等到创建版本才失败,游戏行会先落一个无效素材 ID;现在创建游戏与创建版本都走 `resolve_owned_game_media`,不存在返回 400、他人素材返回 403。 + ## 尚未完成 - 真实独立发行域名、通配 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 dc45816f4..8570dd668 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -663,7 +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` 上获得匿名读授权。 +- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。只有可见性为 `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` 的投影。 diff --git a/package.json b/package.json index 0873d9483..e321b34a5 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "check:repository-ci": "bash scripts/check-repository-ci.sh", "check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml -- --check", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", + "check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs new file mode 100644 index 000000000..90599514b --- /dev/null +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -0,0 +1,508 @@ +// 游戏分发「封面 + 截图」真实链路检查(需要本地 dev 栈 + 真实 OSS 配置)。 +// +// 用法: +// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \ +// npm run check:game-distribution-media-e2e +// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。 +// +// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 → +// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影 +// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。 +import JSZip from 'jszip'; + +const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401'; +const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; +const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; +if (!ADMIN_USER || !ADMIN_PASSWORD) { + console.error( + '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:请用已配置管理员账号的环境变量运行,' + + '本地栈可先以 GENARRATIVE_ADMIN_USERNAME / GENARRATIVE_ADMIN_PASSWORD 启动 api-server。', + ); + process.exit(2); +} +const COVER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +let failures = 0; +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); +} + +async function api(path, options = {}) { + const { method = 'GET', token, body, headers = {}, binary } = options; + const finalHeaders = { ...ENVELOPE, ...headers }; + if (token) finalHeaders.Authorization = `Bearer ${token}`; + let finalBody; + if (binary) { + finalBody = binary; + } else if (body !== undefined) { + finalHeaders['Content-Type'] = 'application/json'; + finalBody = JSON.stringify(body); + } + const response = await fetch(`${API}${path}`, { + method, + headers: finalHeaders, + body: finalBody, + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { + status: response.status, + json, + text, + data: json?.data, + error: json?.error, + }; +} + +function stamp() { + return `${Date.now()}${Math.floor(Math.random() * 1000)}`; +} + +async function uploadImage(token, kind, id) { + const bytes = COVER_PNG; + const fileName = `${kind}-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', kind, id], + fileName, + contentType: 'image/png', + access: 'private', + maxSizeBytes: bytes.length, + metadata: { asset_kind: `game_distribution_${kind}` }, + }, + }); + if (ticket.status !== 200) { + throw new Error( + `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, + ); + } + const upload = ticket.data.upload; + const form = new FormData(); + for (const [key, value] of Object.entries(upload.formFields ?? {})) { + if (value !== null && value !== undefined) form.append(key, String(value)); + } + form.append('file', new Blob([bytes], { type: 'image/png' }), fileName); + const put = await fetch(upload.host, { method: 'POST', body: form }); + if (!put.ok) { + throw new Error(`直传对象存储失败 ${put.status}`); + } + const confirm = await api('/api/assets/objects/confirm', { + method: 'POST', + token, + body: { + bucket: upload.bucket, + objectKey: upload.objectKey, + contentType: 'image/png', + contentLength: bytes.length, + assetKind: `game_distribution_${kind}`, + accessPolicy: 'private', + entityId: `game-distribution-${kind}`, + }, + }); + if (confirm.status !== 200) { + throw new Error( + `confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + ); + } + return { + assetObjectId: confirm.data.assetObject.assetObjectId, + objectKey: confirm.data.assetObject.objectKey, + }; +} + +function gameMetadata(overrides = {}) { + return { + title: `分发媒体验证 ${stamp().slice(-6)}`, + summary: '真实链路验证封面与截图冻结', + description: 'E2E:真实素材直传 + 冻结 + 审核生效', + category: '益智', + tags: ['E2E'], + deviceSupport: { desktop: true, mobile: true, touch: true }, + inputModes: ['keyboard', 'mouse', 'touch'], + orientation: 'responsive', + ...overrides, + }; +} + +async function buildZip() { + const zip = new JSZip(); + zip.file( + 'index.html', + 'E2E 媒体验证

E2E-MEDIA-OK

', + ); + zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";'); + const bytes = await zip.generateAsync({ type: 'uint8array' }); + return Buffer.from(bytes); +} + +async function main() { + // 1. 作者注册 + const phone = `137${String(Date.now()).slice(-8)}`; + const entry = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: phone, password: 'GenE2e123!' }, + }); + check( + '作者注册拿到 token', + entry.status === 200 && Boolean(entry.data?.token), + `status=${entry.status}`, + ); + const author = entry.data.token; + const otherEntry = await api('/api/auth/entry', { + method: 'POST', + body: { + purePhoneNumber: `138${String(Date.now() + 7).slice(-8)}`, + password: 'GenE2e123!', + }, + }); + const another = otherEntry.data.token; + + // 2. 真实素材直传 + const id = stamp(); + const cover = await uploadImage(author, 'cover', id); + const shot1 = await uploadImage(author, 'screenshot', `${id}-1`); + const shot2 = await uploadImage(author, 'screenshot', `${id}-2`); + check( + '封面素材直传并 confirm', + Boolean(cover.assetObjectId) && + cover.objectKey.includes('game-distribution/cover'), + cover.objectKey, + ); + check( + '截图素材直传并 confirm', + Boolean(shot1.assetObjectId) && Boolean(shot2.assetObjectId), + ); + + // 3. 服务端校验:缺封面 / 超 6 张 / 素材不存在 + const noCover = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-nocover-${id}` }, + body: gameMetadata({ title: '缺封面验证' }), + }); + check( + '缺少封面被拒(400)', + noCover.status === 400, + `status=${noCover.status} msg=${noCover.error?.message ?? ''}`, + ); + + const tooMany = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-many-${id}` }, + body: gameMetadata({ + coverAssetId: cover.assetObjectId, + screenshots: Array.from({ length: 7 }, () => shot1.assetObjectId), + }), + }); + check( + '截图超过 6 张被拒(400)', + tooMany.status === 400, + `status=${tooMany.status} msg=${tooMany.error?.message ?? ''}`, + ); + + const ghost = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-ghost-${id}` }, + body: gameMetadata({ coverAssetId: 'asset_not_exists', screenshots: [] }), + }); + check( + '不存在的封面素材被拒', + ghost.status === 400, + `status=${ghost.status} msg=${ghost.error?.message ?? ''}`, + ); + + // 4. 创建游戏 + 版本(冻结资料) + const metadata = gameMetadata({ + title: `分发媒体验证 ${id.slice(-6)}`, + coverAssetId: cover.assetObjectId, + screenshots: [shot1.assetObjectId, shot2.assetObjectId], + }); + const created = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-game-${id}` }, + body: metadata, + }); + check( + '创建游戏成功', + created.status === 200 && Boolean(created.data?.id), + `status=${created.status} ${created.text.slice(0, 200)}`, + ); + const gameId = created.data.id; + + // 版本级校验用例需要真实的游戏行:用合法素材建一个只用于负面校验的游戏。 + const ghostGame = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-ghostgame-${id}` }, + body: gameMetadata({ + title: `版本级素材校验 ${id.slice(-6)}`, + coverAssetId: cover.assetObjectId, + screenshots: [], + }), + }); + const gameIdForGhost = ghostGame.data?.id ?? gameId; + + const ghostVersion = await api( + `/api/game-distribution/games/${gameIdForGhost}/versions`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-ghost-version-${id}` }, + body: { + packageSha256: 'a'.repeat(64), + packageBytes: 1024, + packageFileCount: 1, + packageEntryPath: 'index.html', + gameMetadata: gameMetadata({ + coverAssetId: 'asset_not_exists', + screenshots: [], + }), + }, + }, + ); + check( + '版本冻结时同样拒绝不存在的素材', + ghostVersion.status >= 400, + `status=${ghostVersion.status}`, + ); + + const zipBytes = await buildZip(); + const crypto = await import('node:crypto'); + const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex'); + const version = await api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-version-${id}` }, + body: { + packageSha256: sha256, + packageBytes: zipBytes.length, + packageFileCount: 2, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }); + check( + '创建版本成功', + version.status === 200 && Boolean(version.data?.versionId), + `status=${version.status} ${version.text.slice(0, 200)}`, + ); + const versionId = version.data.versionId; + + const uploadPackage = await api( + `/api/game-distribution/versions/${versionId}/package`, + { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `e2e-upload-${id}`, + 'Content-Type': 'application/zip', + }, + binary: zipBytes, + }, + ); + check( + '上传发行包成功', + uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded', + `status=${uploadPackage.status}`, + ); + + const submitted = await api( + `/api/game-distribution/versions/${versionId}/submit`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-submit-${id}` }, + body: { + expectedPublicationRevision: created.data.publicationRevision ?? 0, + }, + }, + ); + check( + '送审成功(202 + pending_review)', + submitted.status === 202 && + submitted.data?.version?.status === 'pending_review', + `status=${submitted.status} ${submitted.text.slice(0, 200)}`, + ); + + // 版本冻结前仍要复核素材归属:换成别人的封面素材必须被拒。 + const foreign = await api('/api/game-distribution/games', { + method: 'POST', + token: another, + headers: { 'Idempotency-Key': `e2e-foreign-game-${id}` }, + body: gameMetadata({ + title: `他人素材验证 ${id.slice(-6)}`, + coverAssetId: cover.assetObjectId, + screenshots: [], + }), + }); + check( + '借用他人封面素材创建游戏被拒', + foreign.status === 403 || foreign.status === 400, + `status=${foreign.status} msg=${foreign.error?.message ?? ''}`, + ); + + // 5. 作者回读版本:冻结资料带回素材 ID + const readback = await api(`/api/game-distribution/versions/${versionId}`, { + token: author, + }); + const frozen = readback.data?.version?.frozenMetadata; + check( + '作者回读拿到 frozenMetadata', + Boolean(frozen), + `status=${readback.status}`, + ); + check( + '冻结资料保留封面素材 ID', + frozen?.coverAssetId === cover.assetObjectId, + String(frozen?.coverAssetId), + ); + check( + '冻结资料保留截图素材 ID 顺序', + Array.isArray(frozen?.screenshots) && + frozen.screenshots[0]?.assetId === shot1.assetObjectId && + frozen.screenshots[1]?.assetId === shot2.assetObjectId, + ); + check( + '冻结资料对象键由服务端派生', + frozen?.coverObjectKey === cover.objectKey, + String(frozen?.coverObjectKey), + ); + + // 6. 待审期间:公开目录不可见,匿名读封面被拒 + const catalogBefore = await api('/api/game-distribution/games'); + check( + '待审期间公开目录不含该游戏', + !(catalogBefore.data?.games ?? []).some((game) => game.id === gameId), + ); + const readBefore = await api( + `/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`, + ); + check( + '未公开游戏的封面没有匿名读授权', + readBefore.status >= 400, + `status=${readBefore.status}`, + ); + + // 7. 管理员审核通过(本地非生产允许回环 http 入口) + const adminLogin = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + check( + '管理员登录成功', + adminLogin.status === 200 && + Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken), + `status=${adminLogin.status}`, + ); + const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; + + const approved = await api( + `/admin/api/game-distribution/versions/${versionId}/review`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `e2e-approve-${id}` }, + body: { + decision: 'approve', + expectedPublicationRevision: readback.data.version.publicationRevision, + entryUrl: `${API}`, + }, + }, + ); + check( + '管理员审核通过', + approved.status === 200, + `status=${approved.status} ${approved.text.slice(0, 250)}`, + ); + + // 8. 公开目录:封面/截图对象键生效 + const catalogAfter = await api('/api/game-distribution/games'); + const publishedGame = (catalogAfter.data?.games ?? []).find( + (game) => game.id === gameId, + ); + check('公开目录返回该游戏', Boolean(publishedGame)); + check( + '公开投影带封面对象键', + publishedGame?.coverObjectKey === cover.objectKey, + String(publishedGame?.coverObjectKey), + ); + check( + '公开投影带截图对象键', + Array.isArray(publishedGame?.screenshots) && + publishedGame.screenshots[0] === shot1.objectKey, + JSON.stringify(publishedGame?.screenshots ?? []), + ); + check( + '公开投影不泄露素材 ID', + !JSON.stringify(publishedGame ?? {}).includes(cover.assetObjectId), + ); + + // 9. 匿名读授权:封面与截图都能换签名地址 + const coverRead = await api( + `/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`, + ); + check( + '匿名可读已公开游戏封面', + coverRead.status === 200 && + Boolean(coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl), + `status=${coverRead.status}`, + ); + const shotRead = await api( + `/api/assets/read-url?objectKey=${encodeURIComponent(shot1.objectKey)}`, + ); + check( + '匿名可读已公开游戏截图', + shotRead.status === 200 && + Boolean(shotRead.data?.read?.signedUrl ?? shotRead.data?.signedUrl), + `status=${shotRead.status}`, + ); + + // 10. 发行网关可直接玩 + const release = await fetch( + `${API}/api/game-distribution/releases/${gameId}/index.html`, + ); + const releaseBody = await release.text(); + check( + '发行网关返回游戏入口', + release.status === 200 && releaseBody.includes('E2E-MEDIA-OK'), + `status=${release.status}`, + ); + check( + '发行入口带 nosniff', + release.headers.get('x-content-type-options') === 'nosniff', + ); + const releaseAsset = await fetch( + `${API}/api/game-distribution/releases/${gameId}/assets/app.js`, + ); + check( + '发行网关返回包内资源', + releaseAsset.status === 200, + `status=${releaseAsset.status}`, + ); + + console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`); + process.exitCode = failures === 0 ? 0 : 1; +} + +main().catch((error) => { + console.error('E2E 脚本异常:', error); + process.exitCode = 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 22526c728..5ed4b8608 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -472,6 +472,9 @@ async fn create_game( ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?; let idempotency_key = idempotency_key(&headers)?; validate_game_metadata(&payload)?; + // 创建游戏时就把封面/截图的归属与类型校验掉:否则游戏行会先落一个不属于当前作者 + // 或根本不存在的素材 ID,直到创建版本才失败,留下无法解释的半成品资料。 + resolve_owned_game_media(&state, auth.claims().user_id(), &payload).await?; let now = now_micros(); let game_id = format!("game_{}", Uuid::new_v4().simple()); let request_digest = compute_request_digest( @@ -1462,11 +1465,11 @@ struct FrozenGameScreenshot { /// 校验封面/截图素材归属并生成版本冻结资料 JSON。 /// /// 对象键由服务端从素材记录派生,客户端只能提供素材 ID;素材必须属于当前作者且是图片。 -async fn resolve_version_metadata_json( +async fn resolve_owned_game_media( state: &AppState, owner_user_id: &str, metadata: &GameDistributionCreateGameRequest, -) -> Result { +) -> Result<(String, String, Vec), AppError> { let cover_asset_id = metadata .cover_asset_id .as_deref() @@ -1488,6 +1491,16 @@ async fn resolve_version_metadata_json( object_key, }); } + Ok((cover_asset_id, cover_object_key, screenshots)) +} + +async fn resolve_version_metadata_json( + state: &AppState, + owner_user_id: &str, + metadata: &GameDistributionCreateGameRequest, +) -> Result { + let (cover_asset_id, cover_object_key, screenshots) = + resolve_owned_game_media(state, owner_user_id, metadata).await?; let tags = metadata .tags .iter()