// 游戏分发「封面 + 截图」真实链路检查(需要本地 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)。 // E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实 // Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。 // E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。 // // 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 → // 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影 // 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。 import { readFile } from 'node:fs/promises'; 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}` : ''}`, ); } /** * 探测对象是否被匿名直取。 * * 这一条验的是「bucket / 对象 ACL」而不是接口行为:本地环境若还是公共读,默认只报 WARN, * 设 E2E_REQUIRE_PRIVATE_BUCKET=1 时按失败处理,用于上线前的私有化复验。 */ async function probeAnonymousObjectAccess(url, label) { const response = await fetch(url); if (response.status >= 400) { check(`匿名直取${label}被拒绝`, true, `status=${response.status}`); return; } if ((process.env.E2E_REQUIRE_PRIVATE_BUCKET ?? '').trim() === '1') { check(`匿名直取${label}被拒绝`, false, `status=${response.status}`); return; } console.log( `WARN 匿名直取${label}返回 status=${response.status}:当前 bucket/对象 ACL 不是私有(可用 E2E_REQUIRE_PRIVATE_BUCKET=1 复验)。`, ); } 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, }; } const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim(); const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim(); /** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */ async function buildZip() { if (externalPackageZip) { const bytes = await readFile(externalPackageZip); const archive = new JSZip(); const parsed = await archive.loadAsync(bytes); const entryNames = Object.keys(parsed.files).filter( (name) => !parsed.files[name].dir, ); if (!entryNames.includes('index.html')) { throw new Error( `E2E_PACKAGE_ZIP 根目录缺少 index.html:${externalPackageZip}`, ); } // 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。 const assetPath = entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ?? entryNames.find((name) => name.endsWith('.js')); if (!assetPath) { throw new Error( `E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`, ); } return { bytes: Buffer.from(bytes), fileCount: entryNames.length, assetPath, entryMarker: null, }; } 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 { bytes: Buffer.from(bytes), fileCount: 2, assetPath: 'assets/app.js', entryMarker: 'E2E-MEDIA-OK', }; } /** 更新版本用的小包:入口里带标记,用来证明在线旧版没有被待审/被拒的新版本替换。 */ async function buildZipWithMarker(marker) { const zip = new JSZip(); zip.file( 'index.html', `E2E ${marker}

${marker}

`, ); zip.file( 'assets/app.js', `document.documentElement.dataset.e2e="${marker}";`, ); const bytes = await zip.generateAsync({ type: 'uint8array' }); return { bytes: Buffer.from(bytes), fileCount: 2, assetPath: 'assets/app.js', entryMarker: marker, }; } 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; // 1.1 管理员登录:发布灰度默认关闭,脚本先验证关闭态再为本轮验证开启。 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 setPublishGate = (enabled, rolloutPercent) => api('/admin/api/feature-gates', { method: 'PUT', token: admin, body: { gateKey: 'game-distribution:publish', enabled, rolloutPercent, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 发布灰度', }, }); const gateClosed = await setPublishGate(false, 0); check( '发布灰度可配置为关闭', gateClosed.status === 200, `status=${gateClosed.status}`, ); const closedAvailability = await api('/api/runtime/frontend-config', { token: author, }); check( '灰度关闭时作者拿不到发布入口', closedAvailability.data?.gameDistributionPublishEnabled === false, `value=${closedAvailability.data?.gameDistributionPublishEnabled}`, ); const closedPublish = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-gate-closed-${Date.now()}` }, body: gameMetadata({ title: `灰度关闭验证 ${Date.now()}` }), }); check( '灰度关闭时写入口 503', closedPublish.status === 503, `status=${closedPublish.status} code=${closedPublish.error?.code ?? ''}`, ); const gateOpen = await setPublishGate(true, 100); check( '发布灰度可开启并放量', gateOpen.status === 200, `status=${gateOpen.status}`, ); const openAvailability = await api('/api/runtime/frontend-config', { token: author, }); check( '灰度开启后作者拿到发布入口', openAvailability.data?.gameDistributionPublishEnabled === true, `value=${openAvailability.data?.gameDistributionPublishEnabled}`, ); // 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: gameTitleOverride || `分发媒体验证 ${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 built = await buildZip(); const zipBytes = built.bytes; 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: built.fileCount, 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. 管理员审核通过(发行入口由服务端按部署模板与 gameId 派生;管理员 token 在步骤 1.1 已取得) 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, }, }, ); 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?.currentVersion?.entryUrl === `/games/${gameId}/`, String(publishedGame?.currentVersion?.entryUrl), ); 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 signedCoverUrl = coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl; if (signedCoverUrl) { const coverHost = new URL(signedCoverUrl).host; await probeAnonymousObjectAccess( `https://${coverHost}/${cover.objectKey}`, '直传上传的私有封面对象', ); } 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(); const entryOk = release.status === 200 && / 0, `status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`, ); // 11. 审核治理:普通作者不能提交审核动作;更新待审 / 更新被拒都不改变在线旧版 const authorReview = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-author-review-${id}` }, body: { decision: 'approve', expectedPublicationRevision: 0 }, }, ); check( '普通作者不能提交审核动作', authorReview.status === 401 || authorReview.status === 403, `status=${authorReview.status} code=${authorReview.error?.code ?? ''}`, ); const builtV2 = await buildZipWithMarker('E2E-V2-OK'); const sha256V2 = crypto .createHash('sha256') .update(builtV2.bytes) .digest('hex'); const versionV2 = await api( `/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v2-version-${id}` }, body: { packageSha256: sha256V2, packageBytes: builtV2.bytes.length, packageFileCount: builtV2.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }, ); const versionIdV2 = versionV2.data?.versionId; check( '已公开游戏可以创建更新版本', versionV2.status === 200 && Boolean(versionIdV2), `status=${versionV2.status}`, ); const uploadV2 = await api( `/api/game-distribution/versions/${versionIdV2}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `e2e-v2-upload-${id}`, 'Content-Type': 'application/zip', }, binary: builtV2.bytes, }, ); const submitV2 = await api( `/api/game-distribution/versions/${versionIdV2}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v2-submit-${id}` }, body: { expectedPublicationRevision: publishedGame?.publicationRevision ?? 0, }, }, ); check( '更新版本上传并送审后进入待审', uploadV2.status === 200 && submitV2.status === 202, `upload=${uploadV2.status} submit=${submitV2.status}`, ); const reviewQueue = await api('/admin/api/game-distribution/reviews', { token: admin, }); const pendingEntry = (reviewQueue.data?.entries ?? []).find( (entry) => entry.versionId === versionIdV2, ); check( '管理员能看到真实待审条目', pendingEntry?.status === 'pending_review', `status=${pendingEntry?.status ?? 'missing'}`, ); const detailWhilePending = await api( `/api/game-distribution/games/${gameId}`, ); check( '更新待审期间公开详情仍指向在线旧版', detailWhilePending.data?.currentVersion?.sha256 === sha256, `sha=${String(detailWhilePending.data?.currentVersion?.sha256).slice(0, 12)} v1=${sha256.slice(0, 12)}`, ); const releaseWhilePending = await fetch( `${API}/api/game-distribution/releases/${gameId}/index.html`, ); const releaseWhilePendingBody = await releaseWhilePending.text(); check( '更新待审期间发行网关仍服务旧版内容', releaseWhilePending.status === 200 && !releaseWhilePendingBody.includes('E2E-V2-OK') && (built.entryMarker === null || releaseWhilePendingBody.includes(built.entryMarker)), `status=${releaseWhilePending.status}`, ); const rejectedV2 = await api( `/admin/api/game-distribution/versions/${versionIdV2}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-v2-reject-${id}` }, body: { decision: 'reject', expectedPublicationRevision: detailWhilePending.data?.publicationRevision ?? 0, reviewReason: 'E2E 拒绝原因', }, }, ); check( '管理员可以拒绝更新版本并留下原因', rejectedV2.status === 200 && rejectedV2.data?.version?.status === 'rejected' && rejectedV2.data?.version?.reviewReason === 'E2E 拒绝原因', `status=${rejectedV2.status} versionStatus=${rejectedV2.data?.version?.status ?? ''} reason=${rejectedV2.data?.version?.reviewReason ?? ''}`, ); const detailAfterReject = await api(`/api/game-distribution/games/${gameId}`); const releaseAfterReject = await fetch( `${API}/api/game-distribution/releases/${gameId}/index.html`, ); const releaseAfterRejectBody = await releaseAfterReject.text(); check( '更新被拒后公开 URL 与可玩内容仍是旧版', detailAfterReject.data?.currentVersion?.sha256 === sha256 && releaseAfterReject.status === 200 && !releaseAfterRejectBody.includes('E2E-V2-OK'), `status=${releaseAfterReject.status} sha=${String(detailAfterReject.data?.currentVersion?.sha256).slice(0, 12)}`, ); const adminGamesAfterReject = await api( '/admin/api/game-distribution/games', { token: admin }, ); const adminGameAfterReject = (adminGamesAfterReject.data?.games ?? []).find( (game) => game.gameId === gameId, ); const adminVersionAfterReject = (adminGameAfterReject?.versions ?? []).find( (version) => version.versionId === versionIdV2, ); check( '审核结论可在后台追溯(status / reviewReason / reviewedAt 都在版本行上)', adminVersionAfterReject?.status === 'rejected' && adminVersionAfterReject?.reviewReason === 'E2E 拒绝原因' && Boolean(adminVersionAfterReject?.reviewedAt), `status=${adminVersionAfterReject?.status ?? 'missing'} reviewedAt=${adminVersionAfterReject?.reviewedAt ?? ''}`, ); // 12. publicationRevision CAS 与下架后的可见性 const revisionOfPublicGame = async () => (await api(`/api/game-distribution/games/${gameId}`)).data ?.publicationRevision; const adminRevisionOfGame = async () => { const list = await api('/admin/api/game-distribution/games', { token: admin, }); return (list.data?.games ?? []).find((game) => game.gameId === gameId) ?.publicationRevision; }; const staleApprove = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-stale-approve-${id}` }, body: { decision: 'approve', expectedPublicationRevision: 0 }, }, ); check( '过期 revision 的审核被 CAS 拒绝', staleApprove.status === 409 && staleApprove.text.includes('PUBLICATION_CONFLICT'), `status=${staleApprove.status} body=${staleApprove.text.slice(0, 160)}`, ); const replayedApprove = 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, }, }, ); check( '同 key 重放批准返回 replayed=true 且不产生第二次激活', replayedApprove.status === 200 && replayedApprove.data?.replayed === true, `status=${replayedApprove.status} replayed=${replayedApprove.data?.replayed}`, ); const builtV3 = await buildZipWithMarker('E2E-V3-OK'); const sha256V3 = crypto .createHash('sha256') .update(builtV3.bytes) .digest('hex'); const versionV3 = await api( `/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v3-version-${id}` }, body: { packageSha256: sha256V3, packageBytes: builtV3.bytes.length, packageFileCount: builtV3.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }, ); const versionIdV3 = versionV3.data?.versionId; const uploadV3 = await api( `/api/game-distribution/versions/${versionIdV3}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `e2e-v3-upload-${id}`, 'Content-Type': 'application/zip', }, binary: builtV3.bytes, }, ); const revisionBeforeActivation = await revisionOfPublicGame(); const submitV3 = await api( `/api/game-distribution/versions/${versionIdV3}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v3-submit-${id}` }, body: { expectedPublicationRevision: revisionBeforeActivation }, }, ); check( '在线游戏的第三个版本上传并送审成功', uploadV3.status === 200 && submitV3.status === 202, `upload=${uploadV3.status} submit=${submitV3.status}`, ); const activationAttempts = await Promise.all([ api(`/admin/api/game-distribution/versions/${versionIdV3}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-v3-approve-a-${id}` }, body: { decision: 'approve', expectedPublicationRevision: revisionBeforeActivation, }, }), api(`/admin/api/game-distribution/versions/${versionIdV3}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-v3-approve-b-${id}` }, body: { decision: 'approve', expectedPublicationRevision: revisionBeforeActivation, }, }), ]); const activationWins = activationAttempts.filter( (item) => item.status === 200, ); const activationConflicts = activationAttempts.filter( (item) => item.status === 409 && item.text.includes('PUBLICATION_CONFLICT'), ); check( '并发激活只有一次成功、另一次被 CAS 拒绝', activationWins.length === 1 && activationConflicts.length === 1, activationAttempts .map( (item) => `${item.status}${item.error?.code ? `/${item.error.code}` : ''}`, ) .join(' '), ); const detailAfterActivation = await api( `/api/game-distribution/games/${gameId}`, ); check( '并发激活后公开详情指向新版本', detailAfterActivation.data?.currentVersion?.sha256 === sha256V3, `sha=${String(detailAfterActivation.data?.currentVersion?.sha256).slice(0, 12)} v3=${sha256V3.slice(0, 12)}`, ); const revisionBeforeUnpublish = detailAfterActivation.data?.publicationRevision ?? 0; const unpublished = await api( `/api/game-distribution/games/${gameId}/unpublish`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-unpublish-${id}` }, body: { expectedPublicationRevision: revisionBeforeUnpublish }, }, ); check( '作者下架成功', unpublished.status === 200 && unpublished.data?.game?.status === 'unpublished', `status=${unpublished.status} gameStatus=${unpublished.data?.game?.status ?? ''}`, ); const catalogAfterUnpublish = await api('/api/game-distribution/games'); check( '下架后公开目录不含该游戏', !(catalogAfterUnpublish.data?.games ?? []).some( (game) => game.id === gameId, ), ); const detailAfterUnpublish = await api( `/api/game-distribution/games/${gameId}`, ); check( '下架后公开详情不可见', detailAfterUnpublish.status === 404, `status=${detailAfterUnpublish.status}`, ); const releaseAfterUnpublish = await fetch( `${API}/api/game-distribution/releases/${gameId}/index.html`, ); check( '下架后发行网关不再服务该游戏', releaseAfterUnpublish.status === 404, `status=${releaseAfterUnpublish.status}`, ); check( '下架后的目录与详情不返回对象存储地址', !/aliyuncs\.com|oss-cn|Signature=/iu.test( catalogAfterUnpublish.text + detailAfterUnpublish.text, ), ); const staleUnpublish = await api( `/api/game-distribution/games/${gameId}/unpublish`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-stale-unpublish-${id}` }, body: { expectedPublicationRevision: revisionBeforeUnpublish - 1, }, }, ); check( '过期 revision 的下架被 CAS 拒绝', staleUnpublish.status === 409 && staleUnpublish.text.includes('PUBLICATION_CONFLICT'), `status=${staleUnpublish.status} body=${staleUnpublish.text.slice(0, 160)}`, ); const builtV4 = await buildZipWithMarker('E2E-V4-OK'); const sha256V4 = crypto .createHash('sha256') .update(builtV4.bytes) .digest('hex'); const versionV4 = await api( `/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v4-version-${id}` }, body: { packageSha256: sha256V4, packageBytes: builtV4.bytes.length, packageFileCount: builtV4.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }, ); const versionIdV4 = versionV4.data?.versionId; const uploadV4 = await api( `/api/game-distribution/versions/${versionIdV4}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `e2e-v4-upload-${id}`, 'Content-Type': 'application/zip', }, binary: builtV4.bytes, }, ); const revisionAfterUnpublish = await adminRevisionOfGame(); const submitV4 = await api( `/api/game-distribution/versions/${versionIdV4}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `e2e-v4-submit-${id}` }, body: { expectedPublicationRevision: revisionAfterUnpublish }, }, ); check( '下架后仍可准备新的待审版本', uploadV4.status === 200 && submitV4.status === 202, `upload=${uploadV4.status} submit=${submitV4.status}`, ); const reviveAttempt = await api( `/admin/api/game-distribution/versions/${versionIdV4}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-revive-${id}` }, body: { decision: 'approve', expectedPublicationRevision: revisionBeforeUnpublish, }, }, ); check( '下架前的 revision 不能批准新版本(不会复活已下架游戏)', reviveAttempt.status === 409 && reviveAttempt.text.includes('PUBLICATION_CONFLICT'), `status=${reviveAttempt.status} body=${reviveAttempt.text.slice(0, 160)}`, ); const catalogAfterReviveAttempt = await api('/api/game-distribution/games'); check( '陈旧审核之后游戏仍未公开', !(catalogAfterReviveAttempt.data?.games ?? []).some( (game) => game.id === gameId, ), ); // 13. 管理员安全下架 / 恢复,以及匿名直取发行包对象 const revisionForV4 = await adminRevisionOfGame(); const approveV4 = await api( `/admin/api/game-distribution/versions/${versionIdV4}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-v4-approve-${id}` }, body: { decision: 'approve', expectedPublicationRevision: revisionForV4, }, }, ); const detailAfterV4 = await api(`/api/game-distribution/games/${gameId}`); check( '用当前 revision 批准 v4 后新版本上线', approveV4.status === 200 && detailAfterV4.data?.currentVersion?.sha256 === sha256V4, `status=${approveV4.status} sha=${String(detailAfterV4.data?.currentVersion?.sha256).slice(0, 12)}`, ); const revisionBeforeSuspend = detailAfterV4.data?.publicationRevision ?? 0; const suspended = await api( `/admin/api/game-distribution/games/${gameId}/suspend`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-suspend-${id}` }, body: { expectedPublicationRevision: revisionBeforeSuspend, reason: 'E2E 安全下架', }, }, ); check( '管理员安全下架成功', suspended.status === 200 && suspended.data?.game?.status !== 'published', `status=${suspended.status} gameStatus=${suspended.data?.game?.status ?? ''}`, ); const catalogAfterSuspend = await api('/api/game-distribution/games'); const detailAfterSuspend = await api( `/api/game-distribution/games/${gameId}`, ); const releaseAfterSuspend = await fetch( `${API}/api/game-distribution/releases/${gameId}/index.html`, ); check( '安全下架后目录 / 详情 / 发行读取全部关闭', !(catalogAfterSuspend.data?.games ?? []).some( (game) => game.id === gameId, ) && detailAfterSuspend.status === 404 && releaseAfterSuspend.status === 404, `detail=${detailAfterSuspend.status} gateway=${releaseAfterSuspend.status}`, ); const staleSuspend = await api( `/admin/api/game-distribution/games/${gameId}/suspend`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-stale-suspend-${id}` }, body: { expectedPublicationRevision: revisionBeforeSuspend - 1, reason: 'E2E 过期安全下架', }, }, ); check( '过期 revision 的安全下架被 CAS 拒绝', staleSuspend.status === 409 && staleSuspend.text.includes('PUBLICATION_CONFLICT'), `status=${staleSuspend.status} body=${staleSuspend.text.slice(0, 160)}`, ); const restoreRevision = await adminRevisionOfGame(); const restored = await api( `/admin/api/game-distribution/games/${gameId}/restore`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `e2e-restore-${id}` }, body: { expectedPublicationRevision: restoreRevision }, }, ); const catalogAfterRestore = await api('/api/game-distribution/games'); const releaseAfterRestore = await fetch( `${API}/api/game-distribution/releases/${gameId}/index.html`, ); check( '管理员恢复后游戏重新公开且可玩', restored.status === 200 && (catalogAfterRestore.data?.games ?? []).some( (game) => game.id === gameId, ) && releaseAfterRestore.status === 200, `status=${restored.status} gateway=${releaseAfterRestore.status}`, ); // 发行包落在 api-server 的快照 bucket(默认 agc-dev,见 config.rs 的默认值), // 公开读取必须走网关;这里直接用对象键构造匿名请求,验证私有 bucket 不给直取。 const ossBucket = ( process.env.GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET ?? 'agc-dev' ).trim(); const ossEndpoint = ( process.env.GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT ?? 'oss-rg-china-mainland.aliyuncs.com' ).trim(); const objectKey = `agc/project-snapshots/v1/game-distribution/${gameId}/${versionIdV4}.zip`; const directObject = await fetch( `https://${ossBucket}.${ossEndpoint}/${objectKey}`, ); if (directObject.status >= 400) { check( '匿名直取私有 bucket 里的发行包对象被拒绝', true, `status=${directObject.status} bucket=${ossBucket}`, ); } else if ((process.env.E2E_REQUIRE_PRIVATE_BUCKET ?? '').trim() === '1') { check( '匿名直取私有 bucket 里的发行包对象被拒绝', false, `status=${directObject.status} bucket=${ossBucket}`, ); } else { console.log( `WARN 匿名直取发行包对象返回 status=${directObject.status}(bucket=${ossBucket}):` + '本地 dev bucket 允许匿名读,生产上线前必须确认 bucket 与对象 ACL 都是私有(可用 E2E_REQUIRE_PRIVATE_BUCKET=1 复验)。', ); } console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exitCode = failures === 0 ? 0 : 1; } main().catch((error) => { console.error('E2E 脚本异常:', error); process.exitCode = 1; });