diff --git a/.gitattributes b/.gitattributes index a63eef88a..309c8cd6b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,3 +31,4 @@ packages/shared/src/contracts/generated/** linguist-generated=true whitespace=-t apps/ai-game-creator-shell/src/features/ui-editor/types/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/view/project-development/chat/generated/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/services/generated/** linguist-generated=true whitespace=-trailing-space +apps/ai-game-creator-shell/src/view/project-development/export/generated/** linguist-generated=true whitespace=-trailing-space diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 619b5d905..89f95f49a 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -8,9 +8,9 @@ // Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。 // E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。 // -// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 → +// 覆盖:本机准备图片 → 创建游戏(multipart 直传媒体)→ 创建版本(资料冻结)→ 送审 → // 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影 -// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。 +// 暴露 objectKey → 匿名走新 media/read-url 换签读封面与截图 → 发行网关可直接游玩。 import { readFile } from 'node:fs/promises'; import JSZip from 'jszip'; @@ -61,11 +61,21 @@ async function probeAnonymousObjectAccess(url, label) { } async function api(path, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -96,61 +106,47 @@ 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)}`, - ); - } +/** + * 发布用图片 part。 + * + * 新合同不再走素材库直传登记:图片字节随 `cover` / `screenshot` part 提交,由服务端直写 + * 项目快照桶并派生 objectKey。这里只在本机准备字节、文件名与 content-type。 + */ +function imagePart(kind, id, bytes = COVER_PNG) { return { - assetObjectId: confirm.data.assetObject.assetObjectId, - objectKey: confirm.data.assetObject.objectKey, + bytes, + fileName: `${kind}-${id}.png`, + contentType: 'image/png', }; } +/** + * 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 按顺序重复的 `screenshot` 二进制 part。 + * + * `metadata.screenshots` 里的 `null` 槽位按出现顺序消费 `screenshots` 数组里的 part;string + * 槽位沿用该 objectKey。调用方负责保证 null 槽位数量与 part 数量一致(不一致服务端会 400)。 + */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { + const form = new FormData(); + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, + ); + } + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; +} + function gameMetadata(overrides = {}) { return { title: `分发媒体验证 ${stamp().slice(-6)}`, @@ -303,7 +299,13 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-gate-closed-${Date.now()}` }, - body: gameMetadata({ title: `灰度关闭验证 ${Date.now()}` }), + formData: publishFormData( + gameMetadata({ + title: `灰度关闭验证 ${Date.now()}`, + coverObjectKey: null, + screenshots: [], + }), + ), }); check( '灰度关闭时写入口 503', @@ -327,32 +329,37 @@ async function main() { `value=${openAvailability.data?.gameDistributionPublishEnabled}`, ); - // 2. 真实素材直传 + // 2. 本机准备发布图片:新合同由服务端把 part 直写项目快照桶,不再走素材库登记。 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`); + const cover = imagePart('cover', id); + const shot1 = imagePart('screenshot', `${id}-1`); + const shot2 = imagePart('screenshot', `${id}-2`); check( - '封面素材直传并 confirm', - Boolean(cover.assetObjectId) && - cover.objectKey.includes('game-distribution/cover'), - cover.objectKey, + '封面图片 part 准备完成', + cover.bytes.length > 0 && cover.contentType === 'image/png', + `${cover.fileName} bytes=${cover.bytes.length}`, ); check( - '截图素材直传并 confirm', - Boolean(shot1.assetObjectId) && Boolean(shot2.assetObjectId), + '两张截图图片 part 准备完成', + shot1.bytes.length > 0 && shot2.bytes.length > 0, ); - // 3. 服务端校验:缺封面 / 超 6 张 / 素材不存在 + // 3. 服务端校验:缺封面 / 超 6 张 / 沿用不属于该作品的 objectKey / 槽位数量不匹配 const noCover = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-nocover-${id}` }, - body: gameMetadata({ title: '缺封面验证' }), + formData: publishFormData( + gameMetadata({ + title: '缺封面验证', + coverObjectKey: null, + screenshots: [], + }), + ), }); check( '缺少封面被拒(400)', - noCover.status === 400, + noCover.status === 400 && (noCover.error?.message ?? '').includes('封面'), `status=${noCover.status} msg=${noCover.error?.message ?? ''}`, ); @@ -360,40 +367,69 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-many-${id}` }, - body: gameMetadata({ - coverAssetId: cover.assetObjectId, - screenshots: Array.from({ length: 7 }, () => shot1.assetObjectId), - }), + formData: publishFormData( + gameMetadata({ + coverObjectKey: null, + screenshots: Array.from({ length: 7 }, () => null), + }), + { cover, screenshots: Array.from({ length: 7 }, () => shot1) }, + ), }); check( '截图超过 6 张被拒(400)', - tooMany.status === 400, + tooMany.status === 400 && + (tooMany.error?.message ?? '').includes('最多 6 张'), `status=${tooMany.status} msg=${tooMany.error?.message ?? ''}`, ); + const foreignObjectKey = `agc/project-snapshots/v1/game-distribution/media/not-this-game/cover-${id}.png`; 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: [] }), + formData: publishFormData( + gameMetadata({ + coverObjectKey: foreignObjectKey, + screenshots: [], + }), + ), }); check( - '不存在的封面素材被拒', - ghost.status === 400, + '沿用不属于该作品的封面 objectKey 被拒(400)', + ghost.status === 400 && (ghost.error?.message ?? '').includes('沿用封面'), `status=${ghost.status} msg=${ghost.error?.message ?? ''}`, ); + const slotMismatch = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-slot-mismatch-${id}` }, + formData: publishFormData( + gameMetadata({ coverObjectKey: null, screenshots: [null, null] }), + { cover, screenshots: [shot1] }, + ), + }); + check( + 'null 截图槽位与 screenshot part 数量不匹配被拒(400)', + slotMismatch.status === 400 && + (slotMismatch.error?.message ?? '').includes('数量与槽位不匹配'), + `status=${slotMismatch.status} msg=${slotMismatch.error?.message ?? ''}`, + ); + // 4. 创建游戏 + 版本(冻结资料) - const metadata = gameMetadata({ + const createMetadata = gameMetadata({ title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`, - coverAssetId: cover.assetObjectId, - screenshots: [shot1.assetObjectId, shot2.assetObjectId], + coverObjectKey: null, + screenshots: [null, null], }); const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-game-${id}` }, - body: metadata, + formData: publishFormData(createMetadata, { + cover, + screenshots: [shot1, shot2], + }), }); check( '创建游戏成功', @@ -401,19 +437,30 @@ async function main() { `status=${created.status} ${created.text.slice(0, 200)}`, ); const gameId = created.data.id; + // 服务端把新图写成 objectKey 并落在游戏行上;后续建版/沿用槽位按 string objectKey 引用。 + const resolvedMetadata = { + ...createMetadata, + coverObjectKey: created.data.coverObjectKey, + screenshots: created.data.screenshots, + }; - // 版本级校验用例需要真实的游戏行:用合法素材建一个只用于负面校验的游戏。 + // 版本级校验用例需要真实的游戏行:用合法封面建一个只用于负面校验的游戏。 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: [], - }), + formData: publishFormData( + gameMetadata({ + title: `版本级素材校验 ${id.slice(-6)}`, + coverObjectKey: null, + screenshots: [], + }), + { cover }, + ), }); const gameIdForGhost = ghostGame.data?.id ?? gameId; + const ghostCoverObjectKey = + ghostGame.data?.coverObjectKey ?? created.data.coverObjectKey; const ghostVersion = await api( `/api/game-distribution/games/${gameIdForGhost}/versions`, @@ -421,22 +468,24 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-ghost-version-${id}` }, - body: { + formData: publishFormData({ packageSha256: 'a'.repeat(64), packageBytes: 1024, packageFileCount: 1, packageEntryPath: 'index.html', + // 有 null 截图槽位却没有对应 screenshot part:按新语义必须被拒。 gameMetadata: gameMetadata({ - coverAssetId: 'asset_not_exists', - screenshots: [], + coverObjectKey: ghostCoverObjectKey, + screenshots: [null], }), - }, + }), }, ); check( - '版本冻结时同样拒绝不存在的素材', - ghostVersion.status >= 400, - `status=${ghostVersion.status}`, + '版本冻结时同样拒绝截图槽位与 part 数量不匹配', + ghostVersion.status === 400 && + (ghostVersion.error?.message ?? '').includes('数量与槽位不匹配'), + `status=${ghostVersion.status} msg=${ghostVersion.error?.message ?? ''}`, ); const built = await buildZip(); @@ -447,13 +496,14 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-version-${id}` }, - body: { + formData: publishFormData({ packageSha256: sha256, packageBytes: zipBytes.length, packageFileCount: built.fileCount, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + // 建版沿用创建时服务端派生的封面/截图 objectKey。 + gameMetadata: resolvedMetadata, + }), }); check( '创建版本成功', @@ -498,24 +548,27 @@ async function main() { `status=${submitted.status} ${submitted.text.slice(0, 200)}`, ); - // 版本冻结前仍要复核素材归属:换成别人的封面素材必须被拒。 + // 版本冻结前仍要复核媒体归属:沿用别人游戏的封面 objectKey 必须被拒。 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: [], - }), + formData: publishFormData( + gameMetadata({ + title: `他人媒体沿用验证 ${id.slice(-6)}`, + coverObjectKey: created.data.coverObjectKey, + screenshots: [], + }), + ), }); check( - '借用他人封面素材创建游戏被拒', - foreign.status === 403 || foreign.status === 400, + '沿用他人作品封面 objectKey 创建游戏被拒(400)', + foreign.status === 400 && + (foreign.error?.message ?? '').includes('沿用封面'), `status=${foreign.status} msg=${foreign.error?.message ?? ''}`, ); - // 5. 作者回读版本:冻结资料带回素材 ID + // 5. 作者回读版本:冻结资料带回 objectKey const readback = await api(`/api/game-distribution/versions/${versionId}`, { token: author, }); @@ -526,21 +579,17 @@ async function main() { `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, + '冻结资料保留封面 objectKey', + frozen?.coverObjectKey === created.data.coverObjectKey, String(frozen?.coverObjectKey), ); + check( + '冻结资料保留截图 objectKey 顺序', + Array.isArray(frozen?.screenshots) && + frozen.screenshots[0] === created.data.screenshots[0] && + frozen.screenshots[1] === created.data.screenshots[1], + JSON.stringify(frozen?.screenshots ?? []), + ); // 6. 待审期间:公开目录不可见,匿名读封面被拒 const catalogBefore = await api('/api/game-distribution/games'); @@ -549,7 +598,7 @@ async function main() { !(catalogBefore.data?.games ?? []).some((game) => game.id === gameId), ); const readBefore = await api( - `/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`, + `/api/game-distribution/media/read-url?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`, ); check( '未公开游戏的封面没有匿名读授权', @@ -589,23 +638,32 @@ async function main() { ); check( '公开投影带封面对象键', - publishedGame?.coverObjectKey === cover.objectKey, + publishedGame?.coverObjectKey === created.data.coverObjectKey, String(publishedGame?.coverObjectKey), ); check( '公开投影带截图对象键', Array.isArray(publishedGame?.screenshots) && - publishedGame.screenshots[0] === shot1.objectKey, + publishedGame.screenshots[0] === created.data.screenshots[0], JSON.stringify(publishedGame?.screenshots ?? []), ); check( - '公开投影不泄露素材 ID', - !JSON.stringify(publishedGame ?? {}).includes(cover.assetObjectId), + '公开投影只暴露服务端派生的媒体 objectKey', + typeof publishedGame?.coverObjectKey === 'string' && + publishedGame.coverObjectKey.startsWith( + 'agc/project-snapshots/v1/game-distribution/media/', + ) && + Array.isArray(publishedGame?.screenshots) && + publishedGame.screenshots.every( + (key) => + typeof key === 'string' && + key.startsWith('agc/project-snapshots/v1/game-distribution/media/'), + ), ); - // 9. 匿名读授权:封面与截图都能换签名地址 + // 9. 匿名读授权:封面与截图都走新的游戏媒体读路由换签名地址 const coverRead = await api( - `/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`, + `/api/game-distribution/media/read-url?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`, ); check( '匿名可读已公开游戏封面', @@ -618,12 +676,12 @@ async function main() { if (signedCoverUrl) { const coverHost = new URL(signedCoverUrl).host; await probeAnonymousObjectAccess( - `https://${coverHost}/${cover.objectKey}`, - '直传上传的私有封面对象', + `https://${coverHost}/${created.data.coverObjectKey}`, + '服务端直写的私有封面对象', ); } const shotRead = await api( - `/api/assets/read-url?objectKey=${encodeURIComponent(shot1.objectKey)}`, + `/api/game-distribution/media/read-url?objectKey=${encodeURIComponent(created.data.screenshots[0])}`, ); check( '匿名可读已公开游戏截图', @@ -632,6 +690,17 @@ async function main() { `status=${shotRead.status}`, ); + // 9b. 匿名同源字节读取:read-bytes 直接回图片字节,不经过素材库换签。 + const coverBytesResponse = await fetch( + `${API}/api/game-distribution/media/read-bytes?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`, + ); + const coverBytesBody = await coverBytesResponse.arrayBuffer(); + check( + '匿名可经 media/read-bytes 取到封面字节', + coverBytesResponse.status === 200 && coverBytesBody.byteLength > 0, + `status=${coverBytesResponse.status} bytes=${coverBytesBody.byteLength}`, + ); + // 10. 发行网关可直接玩 const release = await fetch( `${API}/api/game-distribution/releases/${gameId}/index.html`, @@ -687,13 +756,13 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v2-version-${id}` }, - body: { + formData: publishFormData({ packageSha256: sha256V2, packageBytes: builtV2.bytes.length, packageFileCount: builtV2.fileCount, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + gameMetadata: resolvedMetadata, + }), }, ); const versionIdV2 = versionV2.data?.versionId; @@ -874,13 +943,13 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v3-version-${id}` }, - body: { + formData: publishFormData({ packageSha256: sha256V3, packageBytes: builtV3.bytes.length, packageFileCount: builtV3.fileCount, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + gameMetadata: resolvedMetadata, + }), }, ); const versionIdV3 = versionV3.data?.versionId; @@ -1035,13 +1104,13 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v4-version-${id}` }, - body: { + formData: publishFormData({ packageSha256: sha256V4, packageBytes: builtV4.bytes.length, packageFileCount: builtV4.fileCount, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + gameMetadata: resolvedMetadata, + }), }, ); const versionIdV4 = versionV4.data?.versionId; diff --git a/scripts/check-game-distribution-ops-rollback-e2e.mjs b/scripts/check-game-distribution-ops-rollback-e2e.mjs index 9b89b0408..b516933a3 100644 --- a/scripts/check-game-distribution-ops-rollback-e2e.mjs +++ b/scripts/check-game-distribution-ops-rollback-e2e.mjs @@ -61,11 +61,21 @@ function section(title) { } async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -162,57 +172,46 @@ async function setPublishGate(admin, enabled, rolloutPercent) { return response; } -async function uploadCover(token, id) { - const fileName = `ops-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'ops-rollback', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - throw new Error(`创建直传凭证失败 ${ticket.status}`); - } - 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([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-ops-rollback', - }, - }); - if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); - return confirm.data.assetObject.assetObjectId; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `ops-${id}.png`, + contentType: 'image/png', + }; } -function gameMetadata(title, coverAssetId) { +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { + const form = new FormData(); + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, + ); + } + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; +} + +function gameMetadata(title, coverObjectKey) { return { title, summary: '发布开关回滚窗口 E2E', description: '', category: '休闲', tags: ['ops'], - coverAssetId, + coverObjectKey, + screenshots: [], deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', @@ -242,13 +241,13 @@ async function createVersion(token, gameId, metadata, bytes, fileCount, key) { method: 'POST', token, headers: { 'Idempotency-Key': key }, - body: { + formData: publishFormData({ packageSha256: createHash('sha256').update(bytes).digest('hex'), packageBytes: bytes.length, packageFileCount: fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, - }, + }), }); } @@ -369,25 +368,30 @@ async function main() { .map((pair) => pair.split('=').slice(1).join('=')) .find((value) => value && value.length > 20); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); const title = `发布回滚 ${String(stamp).slice(-6)}`; - const metadata = gameMetadata(title, coverAssetId); + const metadata = gameMetadata(title, null); const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': key('ops-game') }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; check('创建游戏成功', created.status === 200 && Boolean(gameId)); if (!gameId) process.exit(1); + // 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。 + const resolvedMetadata = { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }; section('基线:v1 公开、v2/v3 待审、v4 只有版本记录'); const v1 = await buildPackage('V1-OK'); const v1Version = await createVersion( author, gameId, - metadata, + resolvedMetadata, v1.bytes, v1.fileCount, key('ops-v1'), @@ -476,7 +480,7 @@ async function main() { const version = await createVersion( author, gameId, - metadata, + resolvedMetadata, built.bytes, built.fileCount, key(`ops-${marker}`), @@ -507,7 +511,7 @@ async function main() { const v4Version = await createVersion( author, gameId, - metadata, + resolvedMetadata, v4Built.bytes, v4Built.fileCount, key('ops-v4'), @@ -530,7 +534,7 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': key('ops-blocked-game') }, - body: metadata, + formData: publishFormData(resolvedMetadata, { cover }), }), ], [ @@ -539,15 +543,15 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': key('ops-blocked-version') }, - body: { + formData: publishFormData({ packageSha256: createHash('sha256') .update(v4Built.bytes) .digest('hex'), packageBytes: v4Built.bytes.length, packageFileCount: v4Built.fileCount, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + gameMetadata: resolvedMetadata, + }), }), ], [ diff --git a/scripts/check-game-distribution-owner-isolation.mjs b/scripts/check-game-distribution-owner-isolation.mjs index 9b721d1eb..cf7594c41 100644 --- a/scripts/check-game-distribution-owner-isolation.mjs +++ b/scripts/check-game-distribution-owner-isolation.mjs @@ -9,8 +9,8 @@ // 确认分包、送审、撤回全部按「不可见」处理 → 未认证读私有版本 401 → 越权写没有副作用 // (作者侧已收字节仍为 0)→ 请求体里伪造 owner 不生效(游戏仍归请求者、不出现在被冒名账号 // 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入 -// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(dev bucket,走现役直传 + 确认链路), -// 发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。 +// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(新合同由服务端把 cover part 直写项目 +// 快照桶),发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。 const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', @@ -39,11 +39,21 @@ function check(name, ok, detail = '') { } async function api(path, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -85,55 +95,40 @@ function gameMetadata(title) { }; } -async function uploadCover(token, id) { - const fileName = `owner-isolation-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'owner-isolation', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - throw new Error( - `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, - ); - } - const upload = ticket.data.upload; +/** + * 发布用封面图片 part。 + * + * 新合同不再走素材库直传登记:封面字节随创建请求的 `cover` part 提交,由服务端直写项目 + * 快照桶。这里只在本机准备字节、文件名与 content-type。 + */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `owner-isolation-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-owner-isolation', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, ); } - return confirm.data.assetObject.assetObjectId; + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; } async function register(prefix) { @@ -198,12 +193,15 @@ async function main() { const stamp = Date.now(); const title = `越权隔离 ${String(stamp).slice(-6)}`; - const coverAssetId = await uploadCover(author.token, stamp); + const cover = coverImagePart(stamp); const created = await api('/api/game-distribution/games', { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `iso-game-${stamp}` }, - body: { ...gameMetadata(title), coverAssetId }, + formData: publishFormData( + { ...gameMetadata(title), coverObjectKey: null, screenshots: [] }, + { cover }, + ), }); const gameId = created.data?.id; check( @@ -219,13 +217,18 @@ async function main() { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `iso-version-${stamp}` }, - body: { + formData: publishFormData({ packageSha256: 'a'.repeat(64), packageBytes: 2048, packageFileCount: 2, packageEntryPath: 'index.html', - gameMetadata: { ...gameMetadata(title), coverAssetId }, - }, + // 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。 + gameMetadata: { + ...gameMetadata(title), + coverObjectKey: created.data.coverObjectKey, + screenshots: [], + }, + }), }, ); const versionId = versionResponse.data?.versionId; @@ -391,20 +394,21 @@ async function main() { `status=${authorState.status} receivedBytes=${authorState.data?.receivedBytes}`, ); - const intruderCoverAssetId = await uploadCover( - intruder.token, - `${stamp}-intruder`, - ); + const intruderCover = coverImagePart(`${stamp}-intruder`); const forged = await api('/api/game-distribution/games', { method: 'POST', token: intruder.token, headers: { 'Idempotency-Key': `iso-forged-${stamp}` }, - body: { - ...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`), - coverAssetId: intruderCoverAssetId, - ownerUserId: 'user_forged', - owner_user_id: 'user_forged', - }, + formData: publishFormData( + { + ...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`), + coverObjectKey: null, + screenshots: [], + ownerUserId: 'user_forged', + owner_user_id: 'user_forged', + }, + { cover: intruderCover }, + ), }); const forgedGameId = forged.data?.id; check( diff --git a/scripts/check-game-distribution-package-limits-e2e.mjs b/scripts/check-game-distribution-package-limits-e2e.mjs index 50d6c7678..5c5c7a5f1 100644 --- a/scripts/check-game-distribution-package-limits-e2e.mjs +++ b/scripts/check-game-distribution-package-limits-e2e.mjs @@ -35,11 +35,21 @@ function check(name, ok, detail = '') { } async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -80,51 +90,40 @@ function apiServerWorkingSetMb() { } } -async function uploadCover(token, id) { - const fileName = `limits-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'limits', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) - throw new Error(`创建直传凭证失败 ${ticket.status}`); - const upload = ticket.data.upload; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `limits-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, + ); } - form.append('file', new Blob([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-limits', - }, - }); - if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); - return confirm.data.assetObject.assetObjectId; + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; } async function createGameWithVersion( author, - coverAssetId, + cover, title, suffix, declaration, @@ -135,7 +134,8 @@ async function createGameWithVersion( description: '', category: '休闲', tags: ['limits'], - coverAssetId, + coverObjectKey: null, + screenshots: [], deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', @@ -144,7 +144,7 @@ async function createGameWithVersion( method: 'POST', token: author, headers: { 'Idempotency-Key': `limits-game-${suffix}` }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; if (created.status !== 200 || !gameId) { @@ -156,13 +156,17 @@ async function createGameWithVersion( method: 'POST', token: author, headers: { 'Idempotency-Key': `limits-version-${suffix}` }, - body: { + formData: publishFormData({ packageSha256: declaration.packageSha256 ?? 'a'.repeat(64), packageBytes: declaration.packageBytes, packageFileCount: declaration.packageFileCount, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + // 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。 + gameMetadata: { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }, + }), }); return { gameId, metadata, version }; } @@ -232,12 +236,12 @@ async function main() { `status=${register.status}`, ); if (!author) process.exit(1); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); // A. 声明超过 200 MiB:创建版本阶段就该失败关闭,不必真的传包。 const declaredOver = await createGameWithVersion( author, - coverAssetId, + cover, `边界 声明超限 ${String(stamp).slice(-5)}`, `${stamp}-declared`, { packageBytes: 200 * MIB + 1, packageFileCount: 1 }, @@ -262,7 +266,7 @@ async function main() { ); const manyCase = await createGameWithVersion( author, - coverAssetId, + cover, `边界 文件数 ${String(stamp).slice(-5)}`, `${stamp}-many`, { @@ -301,7 +305,7 @@ async function main() { ); const bigFileCase = await createGameWithVersion( author, - coverAssetId, + cover, `边界 单文件 ${String(stamp).slice(-5)}`, `${stamp}-bigfile`, { @@ -352,7 +356,7 @@ async function main() { ); const expandedCase = await createGameWithVersion( author, - coverAssetId, + cover, `边界 展开量 ${String(stamp).slice(-5)}`, `${stamp}-expanded`, { @@ -389,7 +393,7 @@ async function main() { ); const ratioCase = await createGameWithVersion( author, - coverAssetId, + cover, `边界 压缩比 ${String(stamp).slice(-5)}`, `${stamp}-ratio`, { @@ -442,7 +446,7 @@ async function main() { ); const bigCase = await createGameWithVersion( author, - coverAssetId, + cover, `边界 200MiB 档 ${String(stamp).slice(-5)}`, `${stamp}-big`, { diff --git a/scripts/check-game-distribution-purchase-e2e.mjs b/scripts/check-game-distribution-purchase-e2e.mjs index e6761f21b..20c4ceef3 100644 --- a/scripts/check-game-distribution-purchase-e2e.mjs +++ b/scripts/check-game-distribution-purchase-e2e.mjs @@ -167,11 +167,21 @@ async function resolveWebBase() { } async function api(path, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -213,55 +223,35 @@ async function registerAccount(prefix) { return { status: entry.status, token: entry.data?.token ?? '', phone }; } -async function uploadCover(token, id) { - const fileName = `cover-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'cover', id], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - throw new Error( - `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, - ); - } - const upload = ticket.data.upload; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `cover-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-cover', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, ); } - return confirm.data.assetObject.assetObjectId; + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; } /** @@ -287,7 +277,7 @@ async function buildPackage(marker) { }; } -function gameMetadata(title, coverAssetId) { +function gameMetadata(title, coverObjectKey) { return { title, summary: '买断制泥点付费真实链路验证', @@ -297,7 +287,7 @@ function gameMetadata(title, coverAssetId) { deviceSupport: { desktop: true, mobile: true, touch: true }, inputModes: ['keyboard', 'mouse', 'touch'], orientation: 'responsive', - coverAssetId, + coverObjectKey, screenshots: [], }; } @@ -309,24 +299,24 @@ async function createVersionAndSubmit({ price, marker, title, - coverAssetId, + coverObjectKey, expectedPublicationRevision, }) { const id = stamp(); - const metadata = gameMetadata(title, coverAssetId); + const metadata = gameMetadata(title, coverObjectKey); const pkg = await buildPackage(marker); const version = await api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: authorToken, headers: { 'Idempotency-Key': `e2e-purchase-version-${id}` }, - body: { + formData: publishFormData({ priceMudPoints: price, packageSha256: pkg.sha256, packageBytes: pkg.bytes.length, packageFileCount: pkg.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, - }, + }), }); if (version.status !== 200 || !version.data?.versionId) { throw new Error( @@ -374,18 +364,18 @@ async function createVersionAndSubmit({ async function publishGame({ authorToken, adminToken, - coverAssetId, + cover, price, marker, title, }) { const id = stamp(); - const metadata = gameMetadata(title, coverAssetId); + const metadata = gameMetadata(title, null); const created = await api('/api/game-distribution/games', { method: 'POST', token: authorToken, headers: { 'Idempotency-Key': `e2e-purchase-game-${id}` }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); if (created.status !== 200 || !created.data?.id) { throw new Error( @@ -393,6 +383,8 @@ async function publishGame({ ); } const gameId = created.data.id; + // 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。 + const coverObjectKey = created.data.coverObjectKey; const { versionId, pkg } = await createVersionAndSubmit({ authorToken, @@ -400,7 +392,7 @@ async function publishGame({ price, marker, title, - coverAssetId, + coverObjectKey, expectedPublicationRevision: created.data.publicationRevision ?? 0, }); @@ -425,7 +417,7 @@ async function publishGame({ `审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`, ); } - return { gameId, versionId, pkg }; + return { gameId, versionId, pkg, coverObjectKey }; } async function walletBalance(token) { @@ -750,11 +742,11 @@ async function main() { `status=${gateOpen.status}`, ); - const coverAssetId = await uploadCover(author.token, stamp()); + const cover = coverImagePart(stamp()); check( - '作者封面素材直传并 confirm', - Boolean(coverAssetId), - String(coverAssetId).slice(0, 24), + '作者封面图片 part 准备完成', + cover.bytes.length > 0 && cover.contentType === 'image/png', + `${cover.fileName} bytes=${cover.bytes.length}`, ); // 2. 付费游戏:发布 → 审核通过 @@ -762,7 +754,7 @@ async function main() { const paid = await publishGame({ authorToken: author.token, adminToken, - coverAssetId, + cover, price: PRICE_MUD_POINTS, marker: paidMarker, title: `买断制支付验证 ${stamp().slice(-6)}`, @@ -1091,7 +1083,7 @@ async function main() { const free = await publishGame({ authorToken: author.token, adminToken, - coverAssetId, + cover, price: 0, marker: freeMarker, title: `免费游戏回归 ${stamp().slice(-6)}`, @@ -1151,7 +1143,7 @@ async function main() { const pricey = await publishGame({ authorToken: author.token, adminToken, - coverAssetId, + cover, price: 1_000_000, marker: priceyMarker, title: `余额不足验证 ${stamp().slice(-6)}`, @@ -1413,7 +1405,8 @@ async function main() { price: PRICE_MUD_POINTS, marker: pendingMarker, title: `待审付费版本 ${stamp().slice(-6)}`, - coverAssetId, + // 沿用该付费游戏当前封面 objectKey。 + coverObjectKey: paid.coverObjectKey, expectedPublicationRevision: paidPublicBeforePending.data?.publicationRevision ?? 0, }); @@ -1490,19 +1483,24 @@ async function main() { // 定价边界用例用独立作者账号:作者自有列表按条数上限聚合返回版本,作品多的账号 // 会让目标游戏的版本查不到(既有口径),独立账号才能用公开接口可靠数版本。 const boundsAuthor = await registerAccount('132'); - const boundsCoverAssetId = await uploadCover(boundsAuthor.token, stamp()); + const boundsCover = coverImagePart(stamp()); check( '定价边界用例的准备作者与封面就绪', - boundsAuthor.status === 200 && Boolean(boundsCoverAssetId), - `status=${boundsAuthor.status} cover=${Boolean(boundsCoverAssetId)}`, + boundsAuthor.status === 200 && boundsCover.bytes.length > 0, + `status=${boundsAuthor.status} coverBytes=${boundsCover.bytes.length}`, ); const boundsGameCreated = await api('/api/game-distribution/games', { method: 'POST', token: boundsAuthor.token, headers: { 'Idempotency-Key': `e2e-bounds-game-${stamp()}` }, - body: gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, boundsCoverAssetId), + formData: publishFormData( + gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, null), + { cover: boundsCover }, + ), }); const boundsGameId = boundsGameCreated.data?.id; + // 建版沿用创建时服务端派生的封面 objectKey。 + const boundsCoverObjectKey = boundsGameCreated.data?.coverObjectKey; check( '定价边界用例的准备游戏创建成功', boundsGameCreated.status === 200 && Boolean(boundsGameId), @@ -1522,7 +1520,7 @@ async function main() { method: 'POST', token: boundsAuthor.token, headers: { 'Idempotency-Key': `e2e-bounds-version-${suffix}-${stamp()}` }, - body: { + formData: publishFormData({ priceMudPoints: price, packageSha256: 'a'.repeat(64), packageBytes: 1024, @@ -1530,9 +1528,9 @@ async function main() { packageEntryPath: 'index.html', gameMetadata: gameMetadata( `定价边界验证 ${suffix}`, - boundsCoverAssetId, + boundsCoverObjectKey, ), - }, + }), }); const tooHigh = await declareVersion(1_000_001, 'toohigh'); check( diff --git a/scripts/check-game-distribution-ratings-e2e.mjs b/scripts/check-game-distribution-ratings-e2e.mjs index f9a4b983c..6242c8669 100644 --- a/scripts/check-game-distribution-ratings-e2e.mjs +++ b/scripts/check-game-distribution-ratings-e2e.mjs @@ -120,7 +120,8 @@ async function createGame(owner, title) { description: null, category: '休闲', tags_json: '["e2e"]', - cover_asset_id: null, + cover_object_key: null, + screenshots_json: null, author_name: [0, owner.user.displayName], author_avatar_url: null, device_support_desktop: true, @@ -139,7 +140,6 @@ async function publish(gameId, owner, revision) { title: '网站评价验收', summary: '本地评价 fixture,发行资源不上传', category: '休闲', - coverAssetId: 'reviews-e2e-cover', coverObjectKey: 'reviews-e2e/cover.png', tags: ['e2e'], deviceSupport: { desktop: true, mobile: true, touch: true }, diff --git a/scripts/check-game-distribution-sandbox-e2e.mjs b/scripts/check-game-distribution-sandbox-e2e.mjs index 4da971b3a..f03c1be3b 100644 --- a/scripts/check-game-distribution-sandbox-e2e.mjs +++ b/scripts/check-game-distribution-sandbox-e2e.mjs @@ -41,11 +41,21 @@ function check(name, ok, detail = '') { } async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -85,53 +95,35 @@ function gameMetadata(title) { }; } -async function uploadCover(token, id) { - const fileName = `sandbox-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'sandbox', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - throw new Error( - `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, - ); - } - const upload = ticket.data.upload; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `sandbox-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-sandbox', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, ); } - return confirm.data.assetObject.assetObjectId; + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; } const PROBE_HELPER = "export const marker = 'helper-ok';\n"; @@ -333,15 +325,19 @@ async function main() { .createHash('sha256') .update(packageBytes) .digest('hex'); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); const title = `发行沙箱 ${String(stamp).slice(-6)}`; - const metadata = { ...gameMetadata(title), coverAssetId }; + const metadata = { + ...gameMetadata(title), + coverObjectKey: null, + screenshots: [], + }; const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `sandbox-game-${stamp}` }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; check( @@ -357,13 +353,17 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `sandbox-version-${stamp}` }, - body: { + formData: publishFormData({ packageSha256, packageBytes: packageBytes.length, packageFileCount: 3, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + // 建版沿用创建时服务端派生的封面 objectKey。 + gameMetadata: { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }, + }), }, ); const versionId = versionResponse.data?.versionId; diff --git a/scripts/check-game-distribution-upload-resume.mjs b/scripts/check-game-distribution-upload-resume.mjs index b6d251bd4..a1b0b5365 100644 --- a/scripts/check-game-distribution-upload-resume.mjs +++ b/scripts/check-game-distribution-upload-resume.mjs @@ -55,11 +55,21 @@ function check(name, ok, detail = '') { } async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -138,55 +148,35 @@ function gameMetadata(title) { }; } -async function uploadCover(token, id) { - const fileName = `upload-resume-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'upload-resume', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - throw new Error( - `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, - ); - } - const upload = ticket.data.upload; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `upload-resume-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-upload-resume', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, ); } - return confirm.data.assetObject.assetObjectId; + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; } // 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。 @@ -246,14 +236,18 @@ async function runPrepare(paths) { ); await writeFile(paths.zip, zipBytes); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); const title = `分片续传 ${String(stamp).slice(-6)}`; - const metadata = { ...gameMetadata(title), coverAssetId }; + const metadata = { + ...gameMetadata(title), + coverObjectKey: null, + screenshots: [], + }; const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `resume-game-${stamp}` }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; check( @@ -269,13 +263,17 @@ async function runPrepare(paths) { method: 'POST', token: author, headers: { 'Idempotency-Key': `resume-version-${stamp}` }, - body: { + formData: publishFormData({ packageSha256: zipSha256, packageBytes: zipBytes.length, packageFileCount: built.fileCount, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + // 建版沿用创建时服务端派生的封面 objectKey。 + gameMetadata: { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }, + }), }, ); const versionId = versionResponse.data?.versionId; diff --git a/scripts/check-game-distribution-upload-safety.mjs b/scripts/check-game-distribution-upload-safety.mjs index 4efa5bfb9..afaa08486 100644 --- a/scripts/check-game-distribution-upload-safety.mjs +++ b/scripts/check-game-distribution-upload-safety.mjs @@ -49,11 +49,21 @@ function check(name, ok, detail = '') { } async function api(path, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -102,55 +112,35 @@ function gameMetadata(title) { }; } -async function uploadCover(token, id) { - const fileName = `upload-safety-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'upload-safety', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - throw new Error( - `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, - ); - } - const upload = ticket.data.upload; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `upload-safety-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-upload-safety', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, ); } - return confirm.data.assetObject.assetObjectId; + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; } async function main() { @@ -205,9 +195,13 @@ async function main() { const zipBytes = await buildFixtureZip(); const zipSha256 = createHash('sha256').update(zipBytes).digest('hex'); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); const title = `并发上传 ${String(stamp).slice(-6)}`; - const metadata = { ...gameMetadata(title), coverAssetId }; + const metadata = { + ...gameMetadata(title), + coverObjectKey: null, + screenshots: [], + }; // 1. 幂等重放:同 key 同请求返回同一条记录,同 key 换请求体冲突。 const gameKey = `safety-game-${stamp}`; @@ -215,7 +209,7 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': gameKey }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; check( @@ -229,7 +223,7 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': gameKey }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); check( '同 key 同请求重放建游戏返回同一 game', @@ -241,7 +235,7 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': gameKey }, - body: { ...metadata, title: `${title} 改` }, + formData: publishFormData({ ...metadata, title: `${title} 改` }, { cover }), }); check( '同 key 换请求体建游戏被拒(不产生第二份)', @@ -255,7 +249,11 @@ async function main() { packageBytes: zipBytes.length, packageFileCount: 2, packageEntryPath: 'index.html', - gameMetadata: metadata, + // 建版沿用创建时服务端派生的封面 objectKey。 + gameMetadata: { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }, }; const versionResponse = await api( `/api/game-distribution/games/${gameId}/versions`, @@ -263,7 +261,7 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': versionKey }, - body: versionBody, + formData: publishFormData(versionBody), }, ); const versionId = versionResponse.data?.versionId; @@ -283,7 +281,7 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': versionKey }, - body: versionBody, + formData: publishFormData(versionBody), }, ); check( @@ -440,7 +438,7 @@ async function main() { method: 'POST', token: author, headers: { 'Idempotency-Key': `safety-version-recovery-${stamp}` }, - body: versionBody, + formData: publishFormData(versionBody), }, ); const recoveryVersionId = recoveryVersion.data?.versionId; diff --git a/scripts/check-game-distribution-validation-restart-e2e.mjs b/scripts/check-game-distribution-validation-restart-e2e.mjs index 3316f83b5..598af8f2e 100644 --- a/scripts/check-game-distribution-validation-restart-e2e.mjs +++ b/scripts/check-game-distribution-validation-restart-e2e.mjs @@ -49,11 +49,21 @@ function check(name, ok, detail = '') { } async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -94,56 +104,46 @@ async function adminToken() { return token; } -async function uploadCover(token, id) { - const fileName = `restart-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'restart', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) - throw new Error(`创建直传凭证失败 ${ticket.status}`); - 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([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-restart', - }, - }); - if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); - return confirm.data.assetObject.assetObjectId; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `restart-${id}.png`, + contentType: 'image/png', + }; } -function gameMetadata(title, coverAssetId) { +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { + const form = new FormData(); + form.append('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, + ); + } + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; +} + +function gameMetadata(title, coverObjectKey) { return { title, summary: '校验重启恢复 E2E', description: '', category: '休闲', tags: ['restart'], - coverAssetId, + coverObjectKey, + screenshots: [], deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', @@ -162,13 +162,13 @@ async function createVersion( method: 'POST', token, headers: { 'Idempotency-Key': `restart-version-${suffix}` }, - body: { + formData: publishFormData({ packageSha256: createHash('sha256').update(bytes).digest('hex'), packageBytes: bytes.length, packageFileCount: fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, - }, + }), }); } @@ -224,25 +224,30 @@ async function runPrepare() { const author = register.data?.token; check('作者注册拿到 token', register.status === 200 && Boolean(author)); if (!author) process.exit(1); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); const title = `校验重启 ${String(stamp).slice(-6)}`; - const metadata = gameMetadata(title, coverAssetId); + const metadata = gameMetadata(title, null); // 先发布 v1(公开版本),用来验证后续失败/重试不会误删公开内容。 const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-game-${stamp}` }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; check('创建游戏成功', created.status === 200 && Boolean(gameId)); + // 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。 + const resolvedMetadata = { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }; const v1Package = await buildPackage(1, 'V1-OK'); const v1Bytes = v1Package.bytes; const v1 = await createVersion( author, gameId, - metadata, + resolvedMetadata, v1Bytes, `${stamp}-v1`, v1Package.fileCount, @@ -279,7 +284,7 @@ async function runPrepare() { const v2 = await createVersion( author, gameId, - metadata, + resolvedMetadata, v2Bytes, `${stamp}-v2`, v2Package.fileCount, @@ -330,7 +335,7 @@ async function runPrepare() { password: DEV_PASSWORD, gameId, title, - coverAssetId, + coverObjectKey: resolvedMetadata.coverObjectKey, versionId, v1VersionId: v1.data?.versionId, v2Bytes: v2Bytes.length, @@ -430,20 +435,20 @@ async function runResume() { Buffer.from('PK\x03\x04broken', 'binary'), randomBytes(1024), ]); - const metadata = gameMetadata(state.title, state.coverAssetId); + const metadata = gameMetadata(state.title, state.coverObjectKey); const v3 = await api( `/api/game-distribution/games/${state.gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `restart-version-v3-${state.stamp}` }, - body: { + formData: publishFormData({ packageSha256: createHash('sha256').update(brokenBytes).digest('hex'), packageBytes: brokenBytes.length, packageFileCount: 1, packageEntryPath: 'index.html', gameMetadata: metadata, - }, + }), }, ); const v3Id = v3.data?.versionId; diff --git a/scripts/check-game-distribution-web-a11y-e2e.mjs b/scripts/check-game-distribution-web-a11y-e2e.mjs index a971b8502..4615f67be 100644 --- a/scripts/check-game-distribution-web-a11y-e2e.mjs +++ b/scripts/check-game-distribution-web-a11y-e2e.mjs @@ -35,11 +35,21 @@ function check(name, ok, detail = '') { } async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -67,46 +77,35 @@ async function loadPlaywright() { return requireFromDir('playwright'); } -async function uploadCover(token, id) { - const fileName = `a11y-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'a11y', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) - throw new Error(`创建直传凭证失败 ${ticket.status}`); - const upload = ticket.data.upload; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `a11y-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, + ); } - form.append('file', new Blob([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-a11y', - }, - }); - if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); - return confirm.data.assetObject.assetObjectId; + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); + } + return form; } async function loginThroughWebUi(page, phone) { @@ -192,14 +191,15 @@ async function publishFixtureGame(adminToken, stamp, title) { .createHash('sha256') .update(bytes) .digest('hex'); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); const metadata = { title, summary: '可达性 E2E 临时游戏', description: '', category: '休闲', tags: ['e2e'], - coverAssetId, + coverObjectKey: null, + screenshots: [], deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', @@ -208,20 +208,24 @@ async function publishFixtureGame(adminToken, stamp, title) { method: 'POST', token: author, headers: { 'Idempotency-Key': `a11y-game-${stamp}` }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; const version = await api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `a11y-version-${stamp}` }, - body: { + formData: publishFormData({ packageSha256: sha256, packageBytes: bytes.length, packageFileCount: 2, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + // 建版沿用创建时服务端派生的封面 objectKey。 + gameMetadata: { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }, + }), }); const versionId = version.data?.versionId; const uploaded = await api( diff --git a/scripts/check-game-distribution-web-e2e.mjs b/scripts/check-game-distribution-web-e2e.mjs index d79fe2231..727b33b1c 100644 --- a/scripts/check-game-distribution-web-e2e.mjs +++ b/scripts/check-game-distribution-web-e2e.mjs @@ -69,11 +69,21 @@ async function warmUpWebRoutesInBrowser(browser, urls) { } async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {}, binary } = options; + const { + method = 'GET', + token, + body, + formData, + headers = {}, + binary, + } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; - if (binary) { + if (formData) { + // multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。 + finalBody = formData; + } else if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; @@ -100,49 +110,35 @@ async function api(pathname, options = {}) { }; } -async function uploadCover(token, id) { - const fileName = `web-e2e-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'web-e2e', String(id)], - fileName, - contentType: 'image/png', - access: 'private', - maxSizeBytes: COVER_PNG.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - throw new Error(`创建直传凭证失败 ${ticket.status}`); - } - const upload = ticket.data.upload; +/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */ +function coverImagePart(id) { + return { + bytes: COVER_PNG, + fileName: `web-e2e-${id}.png`, + contentType: 'image/png', + }; +} + +/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */ +function publishFormData(metadata, { cover, screenshots = [] } = {}) { 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('metadata', JSON.stringify(metadata)); + if (cover) { + form.append( + 'cover', + new Blob([cover.bytes], { type: cover.contentType }), + cover.fileName, + ); } - form.append('file', new Blob([COVER_PNG], { 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: COVER_PNG.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-web-e2e', - }, - }); - if (confirm.status !== 200) { - throw new Error(`确认素材失败 ${confirm.status}`); + for (const screenshot of screenshots) { + if (!screenshot) continue; + form.append( + 'screenshot', + new Blob([screenshot.bytes], { type: screenshot.contentType }), + screenshot.fileName, + ); } - return confirm.data.assetObject.assetObjectId; + return form; } async function publishFixtureGame(stamp, title) { @@ -202,14 +198,15 @@ async function publishFixtureGame(stamp, title) { .update(packageBytes) .digest('hex'); - const coverAssetId = await uploadCover(author, stamp); + const cover = coverImagePart(stamp); const metadata = { title, summary: '网页链路 E2E 临时游戏', description: '', category: '休闲', tags: ['e2e'], - coverAssetId, + coverObjectKey: null, + screenshots: [], deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', @@ -219,7 +216,7 @@ async function publishFixtureGame(stamp, title) { method: 'POST', token: author, headers: { 'Idempotency-Key': `web-e2e-game-${stamp}` }, - body: metadata, + formData: publishFormData(metadata, { cover }), }); const gameId = created.data?.id; if (created.status !== 200 || !gameId) { @@ -233,13 +230,17 @@ async function publishFixtureGame(stamp, title) { method: 'POST', token: author, headers: { 'Idempotency-Key': `web-e2e-version-${stamp}` }, - body: { + formData: publishFormData({ packageSha256, packageBytes: packageBytes.length, packageFileCount: 2, packageEntryPath: 'index.html', - gameMetadata: metadata, - }, + // 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。 + gameMetadata: { + ...metadata, + coverObjectKey: created.data.coverObjectKey, + }, + }), }, ); const versionId = versionResponse.data?.versionId; diff --git a/scripts/test-creator-runtime.py b/scripts/test-creator-runtime.py index 40c88d46f..a893e7575 100644 --- a/scripts/test-creator-runtime.py +++ b/scripts/test-creator-runtime.py @@ -191,7 +191,7 @@ def main(): game_id = f"creator-web-fixture-{prefix}-{n:03}" version_id = game_id + "-v1" created = {"__timestamp_micros_since_unix_epoch__": 1800000000000000 + (1000 if prefix == "other" else 0) + n} - game = dict(game_id=game_id, owner_user_id=owner, title=game_id, summary="测试", description="测试", category="adventure", tags_json="[]", cover_asset_id=none, author_name=none, author_avatar_url=none, device_support_desktop=True, device_support_mobile=True, device_support_touch=True, input_modes_json='["touch"]', orientation="any", publication_revision=1, active_version_id=some(version_id), visibility="published", play_count=0, created_at=created, updated_at=created, local_project_id=none, cover_object_key=none, screenshots_json=none, deleted_at=none) + game = dict(game_id=game_id, owner_user_id=owner, title=game_id, summary="测试", description="测试", category="adventure", tags_json="[]", author_name=none, author_avatar_url=none, device_support_desktop=True, device_support_mobile=True, device_support_touch=True, input_modes_json='["touch"]', orientation="any", publication_revision=1, active_version_id=some(version_id), visibility="published", play_count=0, created_at=created, updated_at=created, local_project_id=none, cover_object_key=none, screenshots_json=none, deleted_at=none) version = dict(version_id=version_id, game_id=game_id, owner_user_id=owner, version_number=1, package_sha256="0" * 64, package_bytes=1, package_file_count=1, package_entry_path="index.html", status="published", review_reason=none, created_at=created, updated_at=created, local_project_id=none, package_object_key=none, package_manifest_json=none, entry_url=some("/games/game_" + hashlib.sha256(game_id.encode()).hexdigest()[:32] + "/"), reviewed_by_user_id=none, uploaded_at=none, submitted_at=none, reviewed_at=none, published_at=some(created), revoked_at=none, last_error_code=none, last_error_message=none, metadata_json=none) if prefix == "hidden": if n == 0: game["visibility"] = "unpublished"