// 游戏分发「封面 + 截图」真实链路检查(需要本地 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; });