// 游戏分发「分片上传中断 / api-server 进程退出后按权威偏移续传」编排检查。 // // 需要本地 dev 栈(SpacetimeDB standalone + api-server),两个阶段之间由调用方重启 api-server: // E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=prepare \ // npm run check:game-distribution-upload-resume // <重启 api-server> // E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=resume \ // npm run check:game-distribution-upload-resume // // E2E_STATE_FILE 可覆盖状态文件路径(默认在系统临时目录),E2E_API_BASE 默认 http://127.0.0.1:4198。 // // prepare:注册作者 → 建游戏与版本(声明整包摘要/字节数)→ 造一个大于 8 MiB 的真实 ZIP → // 按分片协议只上传第一片(8 MiB)→ 断言权威已收字节就是第一片长度。 // resume :重新登录同一作者 → 断言灰度配置在进程重启后仍然生效 → 读权威已收字节(来自 OSS, // 不是进程内状态)→ 从该偏移续传剩余字节 → 确认整包 → 送审。 // 这条链路只在本机 dev 数据库与该 dev bucket 的对象键下落行。 import { createHash, randomBytes } from 'node:crypto'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import JSZip from 'jszip'; const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198'; const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; const STAGE = (process.env.E2E_STAGE ?? 'prepare').trim(); const DEV_PASSWORD = 'GenE2e123!'; const CHUNK_BYTES = 8 * 1024 * 1024; if (!ADMIN_USER || !ADMIN_PASSWORD) { console.error( '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' + 'game-distribution:publish 写入口;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' + 'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。', ); process.exit(2); } if (STAGE !== 'prepare' && STAGE !== 'resume') { console.error(`E2E_STAGE 只支持 prepare / resume,收到:${STAGE}`); process.exit(2); } let failures = 0; function check(name, ok, detail = '') { if (!ok) failures += 1; console.log( `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, ); } async function api(pathname, 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}${pathname}`, { 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, }; } async function stateFilePaths() { const configured = (process.env.E2E_STATE_FILE ?? '').trim(); if (configured) { return { state: configured, zip: `${configured}.zip` }; } const dir = await mkdtemp(path.join(tmpdir(), 'genarrative-gd-resume-')); const state = path.join(dir, 'state.json'); return { state, zip: `${state}.zip` }; } async function adminToken() { const login = await api('/admin/api/login', { method: 'POST', body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, }); const token = login.data?.token ?? login.data?.accessToken; check( '管理员登录成功', login.status === 200 && Boolean(token), `status=${login.status}`, ); return token; } const setGate = (token, enabled, rolloutPercent) => api('/admin/api/feature-gates', { method: 'PUT', token, body: { gateKey: 'game-distribution:publish', enabled, rolloutPercent, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 分片续传', }, }); function gameMetadata(title) { return { title, summary: '分片续传用例的临时游戏', description: '', category: '休闲', tags: ['e2e'], deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', }; } 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; 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)}`, ); } return confirm.data.assetObject.assetObjectId; } // 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。 async function buildLargeFixtureZip() { const archive = new JSZip(); archive.file('index.html', 'resume'); archive.file('assets/app.js', "console.log('resume');"); archive.file('assets/blob.bin', randomBytes(9 * 1024 * 1024)); const bytes = await archive.generateAsync({ type: 'uint8array' }); return { bytes: Buffer.from(bytes), fileCount: 3 }; } async function uploadChunk(token, versionId, bytes, offset, key) { return api(`/api/game-distribution/versions/${versionId}/package/chunk`, { method: 'PUT', token, headers: { 'Content-Type': 'application/octet-stream', 'Idempotency-Key': key, 'x-genarrative-upload-offset': String(offset), }, binary: bytes, }); } async function runPrepare(paths) { const admin = await adminToken(); if (!admin) process.exit(1); const gateOpen = await setGate(admin, true, 100); check( '发布灰度可开启并放量', gateOpen.status === 200, `status=${gateOpen.status}`, ); const stamp = Date.now(); const phone = `136${String(stamp).slice(-8)}`; const register = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: phone, password: DEV_PASSWORD }, }); const author = register.data?.token; check( '作者注册拿到 token', register.status === 200 && Boolean(author), `status=${register.status}`, ); if (!author) process.exit(1); const built = await buildLargeFixtureZip(); const zipBytes = built.bytes; const zipSha256 = createHash('sha256').update(zipBytes).digest('hex'); check( 'fixture 发行包大于单个分片', zipBytes.length > CHUNK_BYTES, `packageBytes=${zipBytes.length}`, ); await writeFile(paths.zip, zipBytes); const coverAssetId = await uploadCover(author, stamp); const title = `分片续传 ${String(stamp).slice(-6)}`; const metadata = { ...gameMetadata(title), coverAssetId }; const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `resume-game-${stamp}` }, body: metadata, }); const gameId = created.data?.id; check( '创建游戏成功', created.status === 200 && Boolean(gameId), `status=${created.status} msg=${created.error?.message ?? ''}`, ); if (!gameId) process.exit(1); const versionResponse = await api( `/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `resume-version-${stamp}` }, body: { packageSha256: zipSha256, packageBytes: zipBytes.length, packageFileCount: built.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }, ); const versionId = versionResponse.data?.versionId; check( '创建版本成功(awaiting_upload)', versionResponse.status === 200 && Boolean(versionId) && versionResponse.data?.status === 'awaiting_upload', `status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`, ); if (!versionId) process.exit(1); const firstChunk = zipBytes.subarray(0, CHUNK_BYTES); const uploaded = await uploadChunk( author, versionId, firstChunk, 0, `resume-chunk-1-${stamp}`, ); check( '第一片上传成功', uploaded.status === 200 && uploaded.data?.receivedBytes === CHUNK_BYTES, `status=${uploaded.status} receivedBytes=${uploaded.data?.receivedBytes ?? 'n/a'}`, ); const state = await api( `/api/game-distribution/versions/${versionId}/package/upload-state`, { token: author }, ); check( '权威已收字节等于第一片长度', state.status === 200 && state.data?.receivedBytes === CHUNK_BYTES, `status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`, ); await writeFile( paths.state, JSON.stringify( { apiBase: API, stage: 'prepared', phone, password: DEV_PASSWORD, gameId, versionId, packageSha256: zipSha256, packageBytes: zipBytes.length, packageFileCount: built.fileCount, firstChunkBytes: CHUNK_BYTES, zipPath: paths.zip, }, null, 2, ), ); console.log( `\nPREPARED gameId=${gameId} versionId=${versionId} packageBytes=${zipBytes.length} receivedBytes=${CHUNK_BYTES}`, ); console.log( '下一步:重启 api-server,再用 E2E_STAGE=resume 运行本脚本(同一 E2E_STATE_FILE)。', ); process.exit(failures === 0 ? 0 : 1); } async function runResume(paths) { const raw = await readFile(paths.state, 'utf8'); const saved = JSON.parse(raw); const zipBytes = await readFile(saved.zipPath); check( '状态文件与 fixture 齐备', saved.stage === 'prepared' && saved.versionId && zipBytes.length === saved.packageBytes, `versionId=${saved.versionId ?? ''} packageBytes=${zipBytes.length}/${saved.packageBytes}`, ); const admin = await adminToken(); if (!admin) process.exit(1); const login = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: saved.phone, password: saved.password }, }); const author = login.data?.token; check( '重启后同一作者仍能登录', login.status === 200 && Boolean(author), `status=${login.status}`, ); if (!author) process.exit(1); const frontendConfig = await api('/api/runtime/frontend-config', { token: author, }); check( '灰度配置在 api-server 重启后仍然生效', frontendConfig.status === 200 && frontendConfig.data?.gameDistributionPublishEnabled === true, `status=${frontendConfig.status} enabled=${frontendConfig.data?.gameDistributionPublishEnabled}`, ); const state = await api( `/api/game-distribution/versions/${saved.versionId}/package/upload-state`, { token: author }, ); check( '重启后权威已收字节仍等于第一片长度(来自对象存储)', state.status === 200 && state.data?.receivedBytes === saved.firstChunkBytes, `status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`, ); const remaining = zipBytes.subarray(saved.firstChunkBytes); const resumed = await uploadChunk( author, saved.versionId, remaining, saved.firstChunkBytes, `resume-chunk-2-${saved.versionId}`, ); check( '按权威偏移续传剩余字节成功', resumed.status === 200 && resumed.data?.receivedBytes === saved.packageBytes, `status=${resumed.status} receivedBytes=${resumed.data?.receivedBytes ?? 'n/a'} expected=${saved.packageBytes}`, ); const completed = await api( `/api/game-distribution/versions/${saved.versionId}/package/complete`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `resume-complete-${saved.versionId}` }, }, ); check( '整包确认成功(回到可送审状态)', completed.status === 200 && completed.data?.status === 'uploaded', `status=${completed.status} versionStatus=${completed.data?.status ?? ''} msg=${completed.error?.message ?? ''}`, ); const submitted = await api( `/api/game-distribution/versions/${saved.versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `resume-submit-${saved.versionId}` }, body: { expectedPublicationRevision: completed.data?.publicationRevision ?? 0, }, }, ); check( '送审成功(202 + pending_review)', submitted.status === 202 && submitted.data?.version?.status === 'pending_review', `status=${submitted.status} versionStatus=${submitted.data?.version?.status ?? ''}`, ); const gateClosed = await setGate(admin, false, 0); check( '发布灰度恢复关闭', gateClosed.status === 200, `status=${gateClosed.status}`, ); await rm(saved.zipPath, { force: true }); console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exit(failures === 0 ? 0 : 1); } const paths = await stateFilePaths(); if (STAGE === 'prepare') { await runPrepare(paths); } else { await runResume(paths); }