Files
Genarrative/scripts/check-game-distribution-media-e2e.mjs
T
kdletters 4a91e5865a 创建游戏同步校验封面与截图素材归属
- 服务端:抽出 `resolve_owned_game_media`,创建游戏与创建版本复用同一素材校验;不存在的素材返回 400,借用他人素材返回 403,避免游戏行先落一个无效素材 ID。
- 脚本:新增 `npm run check:game-distribution-media-e2e`(`scripts/check-game-distribution-media-e2e.mjs`),在本地 dev 栈上跑真实素材直传、冻结、审核生效、匿名换签读与发行网关的可重复检查。
- 文档:后端数据契约补充创建游戏的素材复核口径,实施计划记录真实本地栈 27 项媒体验证与真实浏览器封面/截图展示证据。
2026-09-20 22:34:33 +08:00

509 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发「封面 + 截图」真实链路检查(需要本地 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',
'<!doctype html><html><head><meta charset="utf-8"><title>E2E 媒体验证</title><script src="assets/app.js"></script></head><body><h1>E2E-MEDIA-OK</h1></body></html>',
);
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;
});