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()