游戏分发真实链路脚本迁移到 multipart 媒体直传

- 12 个 check-game-distribution-* 脚本改用 multipart:metadata + cover/screenshot 二进制 part
- 删除 assetId/assetObjectId 断言与素材库直传前置,公开读改走 media/read-url 与 read-bytes
- media-e2e 负例重写:缺封面、超 6 张、非本作品 objectKey、槽位与 part 数量不匹配
- ratings-e2e 直连 reducer 字段改 cover_object_key/screenshots_json,test-creator-runtime 同步
- .gitattributes 为 export/generated 生成目录豁免行尾空白检查
This commit is contained in:
2026-10-07 00:50:19 +08:00
parent d202c76099
commit eea37f80f1
14 changed files with 788 additions and 702 deletions
+1
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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,
}),
}),
],
[
@@ -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(
@@ -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`,
{
@@ -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(
@@ -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 },
+52 -52
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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(
+49 -48
View File
@@ -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;
+1 -1
View File
@@ -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"