// 游戏分发「封面 + 截图」真实链路检查(需要本地 dev 栈 + 真实 OSS 配置)。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-media-e2e
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。
//
// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 →
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。
import JSZip from 'jszip';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:请用已配置管理员账号的环境变量运行,' +
'本地栈可先以 GENARRATIVE_ADMIN_USERNAME / GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(path, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
json,
text,
data: json?.data,
error: json?.error,
};
}
function stamp() {
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
async function uploadImage(token, kind, id) {
const bytes = COVER_PNG;
const fileName = `${kind}-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', kind, id],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: bytes.length,
metadata: { asset_kind: `game_distribution_${kind}` },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([bytes], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) {
throw new Error(`直传对象存储失败 ${put.status}`);
}
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: bytes.length,
assetKind: `game_distribution_${kind}`,
accessPolicy: 'private',
entityId: `game-distribution-${kind}`,
},
});
if (confirm.status !== 200) {
throw new Error(
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return {
assetObjectId: confirm.data.assetObject.assetObjectId,
objectKey: confirm.data.assetObject.objectKey,
};
}
function gameMetadata(overrides = {}) {
return {
title: `分发媒体验证 ${stamp().slice(-6)}`,
summary: '真实链路验证封面与截图冻结',
description: 'E2E:真实素材直传 + 冻结 + 审核生效',
category: '益智',
tags: ['E2E'],
deviceSupport: { desktop: true, mobile: true, touch: true },
inputModes: ['keyboard', 'mouse', 'touch'],
orientation: 'responsive',
...overrides,
};
}
async function buildZip() {
const zip = new JSZip();
zip.file(
'index.html',
'
E2E 媒体验证E2E-MEDIA-OK
',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
const bytes = await zip.generateAsync({ type: 'uint8array' });
return Buffer.from(bytes);
}
async function main() {
// 1. 作者注册
const phone = `137${String(Date.now()).slice(-8)}`;
const entry = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: 'GenE2e123!' },
});
check(
'作者注册拿到 token',
entry.status === 200 && Boolean(entry.data?.token),
`status=${entry.status}`,
);
const author = entry.data.token;
const otherEntry = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `138${String(Date.now() + 7).slice(-8)}`,
password: 'GenE2e123!',
},
});
const another = otherEntry.data.token;
// 2. 真实素材直传
const id = stamp();
const cover = await uploadImage(author, 'cover', id);
const shot1 = await uploadImage(author, 'screenshot', `${id}-1`);
const shot2 = await uploadImage(author, 'screenshot', `${id}-2`);
check(
'封面素材直传并 confirm',
Boolean(cover.assetObjectId) &&
cover.objectKey.includes('game-distribution/cover'),
cover.objectKey,
);
check(
'截图素材直传并 confirm',
Boolean(shot1.assetObjectId) && Boolean(shot2.assetObjectId),
);
// 3. 服务端校验:缺封面 / 超 6 张 / 素材不存在
const noCover = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-nocover-${id}` },
body: gameMetadata({ title: '缺封面验证' }),
});
check(
'缺少封面被拒(400)',
noCover.status === 400,
`status=${noCover.status} msg=${noCover.error?.message ?? ''}`,
);
const tooMany = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-many-${id}` },
body: gameMetadata({
coverAssetId: cover.assetObjectId,
screenshots: Array.from({ length: 7 }, () => shot1.assetObjectId),
}),
});
check(
'截图超过 6 张被拒(400)',
tooMany.status === 400,
`status=${tooMany.status} msg=${tooMany.error?.message ?? ''}`,
);
const ghost = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-${id}` },
body: gameMetadata({ coverAssetId: 'asset_not_exists', screenshots: [] }),
});
check(
'不存在的封面素材被拒',
ghost.status === 400,
`status=${ghost.status} msg=${ghost.error?.message ?? ''}`,
);
// 4. 创建游戏 + 版本(冻结资料)
const metadata = gameMetadata({
title: `分发媒体验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
});
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-game-${id}` },
body: metadata,
});
check(
'创建游戏成功',
created.status === 200 && Boolean(created.data?.id),
`status=${created.status} ${created.text.slice(0, 200)}`,
);
const gameId = created.data.id;
// 版本级校验用例需要真实的游戏行:用合法素材建一个只用于负面校验的游戏。
const ghostGame = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghostgame-${id}` },
body: gameMetadata({
title: `版本级素材校验 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [],
}),
});
const gameIdForGhost = ghostGame.data?.id ?? gameId;
const ghostVersion = await api(
`/api/game-distribution/games/${gameIdForGhost}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-version-${id}` },
body: {
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
packageFileCount: 1,
packageEntryPath: 'index.html',
gameMetadata: gameMetadata({
coverAssetId: 'asset_not_exists',
screenshots: [],
}),
},
},
);
check(
'版本冻结时同样拒绝不存在的素材',
ghostVersion.status >= 400,
`status=${ghostVersion.status}`,
);
const zipBytes = await buildZip();
const crypto = await import('node:crypto');
const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex');
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-version-${id}` },
body: {
packageSha256: sha256,
packageBytes: zipBytes.length,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
check(
'创建版本成功',
version.status === 200 && Boolean(version.data?.versionId),
`status=${version.status} ${version.text.slice(0, 200)}`,
);
const versionId = version.data.versionId;
const uploadPackage = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `e2e-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: zipBytes,
},
);
check(
'上传发行包成功',
uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded',
`status=${uploadPackage.status}`,
);
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-submit-${id}` },
body: {
expectedPublicationRevision: created.data.publicationRevision ?? 0,
},
},
);
check(
'送审成功(202 + pending_review)',
submitted.status === 202 &&
submitted.data?.version?.status === 'pending_review',
`status=${submitted.status} ${submitted.text.slice(0, 200)}`,
);
// 版本冻结前仍要复核素材归属:换成别人的封面素材必须被拒。
const foreign = await api('/api/game-distribution/games', {
method: 'POST',
token: another,
headers: { 'Idempotency-Key': `e2e-foreign-game-${id}` },
body: gameMetadata({
title: `他人素材验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [],
}),
});
check(
'借用他人封面素材创建游戏被拒',
foreign.status === 403 || foreign.status === 400,
`status=${foreign.status} msg=${foreign.error?.message ?? ''}`,
);
// 5. 作者回读版本:冻结资料带回素材 ID
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token: author,
});
const frozen = readback.data?.version?.frozenMetadata;
check(
'作者回读拿到 frozenMetadata',
Boolean(frozen),
`status=${readback.status}`,
);
check(
'冻结资料保留封面素材 ID',
frozen?.coverAssetId === cover.assetObjectId,
String(frozen?.coverAssetId),
);
check(
'冻结资料保留截图素材 ID 顺序',
Array.isArray(frozen?.screenshots) &&
frozen.screenshots[0]?.assetId === shot1.assetObjectId &&
frozen.screenshots[1]?.assetId === shot2.assetObjectId,
);
check(
'冻结资料对象键由服务端派生',
frozen?.coverObjectKey === cover.objectKey,
String(frozen?.coverObjectKey),
);
// 6. 待审期间:公开目录不可见,匿名读封面被拒
const catalogBefore = await api('/api/game-distribution/games');
check(
'待审期间公开目录不含该游戏',
!(catalogBefore.data?.games ?? []).some((game) => game.id === gameId),
);
const readBefore = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
);
check(
'未公开游戏的封面没有匿名读授权',
readBefore.status >= 400,
`status=${readBefore.status}`,
);
// 7. 管理员审核通过(本地非生产允许回环 http 入口)
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
check(
'管理员登录成功',
adminLogin.status === 200 &&
Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken),
`status=${adminLogin.status}`,
);
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-approve-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
entryUrl: `${API}`,
},
},
);
check(
'管理员审核通过',
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 250)}`,
);
// 8. 公开目录:封面/截图对象键生效
const catalogAfter = await api('/api/game-distribution/games');
const publishedGame = (catalogAfter.data?.games ?? []).find(
(game) => game.id === gameId,
);
check('公开目录返回该游戏', Boolean(publishedGame));
check(
'公开投影带封面对象键',
publishedGame?.coverObjectKey === cover.objectKey,
String(publishedGame?.coverObjectKey),
);
check(
'公开投影带截图对象键',
Array.isArray(publishedGame?.screenshots) &&
publishedGame.screenshots[0] === shot1.objectKey,
JSON.stringify(publishedGame?.screenshots ?? []),
);
check(
'公开投影不泄露素材 ID',
!JSON.stringify(publishedGame ?? {}).includes(cover.assetObjectId),
);
// 9. 匿名读授权:封面与截图都能换签名地址
const coverRead = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
);
check(
'匿名可读已公开游戏封面',
coverRead.status === 200 &&
Boolean(coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl),
`status=${coverRead.status}`,
);
const shotRead = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(shot1.objectKey)}`,
);
check(
'匿名可读已公开游戏截图',
shotRead.status === 200 &&
Boolean(shotRead.data?.read?.signedUrl ?? shotRead.data?.signedUrl),
`status=${shotRead.status}`,
);
// 10. 发行网关可直接玩
const release = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseBody = await release.text();
check(
'发行网关返回游戏入口',
release.status === 200 && releaseBody.includes('E2E-MEDIA-OK'),
`status=${release.status}`,
);
check(
'发行入口带 nosniff',
release.headers.get('x-content-type-options') === 'nosniff',
);
const releaseAsset = await fetch(
`${API}/api/game-distribution/releases/${gameId}/assets/app.js`,
);
check(
'发行网关返回包内资源',
releaseAsset.status === 200,
`status=${releaseAsset.status}`,
);
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exitCode = failures === 0 ? 0 : 1;
}
main().catch((error) => {
console.error('E2E 脚本异常:', error);
process.exitCode = 1;
});