// 游戏分发「同版本并发上传不混写 + 幂等重放 + 失败后可恢复上传」真实链路检查 // (需要本地 dev 栈:SpacetimeDB + api-server)。 // // 用法: // E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \ // npm run check:game-distribution-upload-safety // E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。 // // 覆盖: // 1. 同一个 Idempotency-Key 重放建游戏 / 建版本 → 返回同一条记录,不产生第二份; // 同一个 Key 换请求体 → 冲突拒绝。 // 2. 同一个版本、同一个偏移量并发写两片相同字节 → 只有一片被接受,另一片是可重试错误; // 已收字节等于单份包长,随后的确认校验必须通过(混写或重复追加都会让校验失败)。 // 3. 送审成功后用同一个 Key 重放 → 202 `replayed=true` 且仍是同一版本; // 不同 Key 重复送审不会产生第二份提交(版本数仍为 1、状态不变)。 // 4. 内容与声明摘要不符的分片:确认时明确失败、版本落到 `upload_failed`、半包对象被清理 // (确定失败),随后同一版本重新上传正确字节仍能确认成功(失败可恢复)。 // 整条链路只在本地 dev 数据库与该 dev bucket 的对象前缀下落行;一个几百字节的发行包对象 // 会在确认后留在 bucket 里(与现役上传链路的产物一致),另有 67 字节封面 PNG。 import { createHash } from 'node:crypto'; 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 DEV_PASSWORD = 'GenE2e123!'; 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); } 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, }; } // filler 只影响条目内容:换 filler 得到「结构同样合法、长度一致、摘要不同」的另一份包。 async function buildFixtureZip(filler = 'x') { const archive = new JSZip(); archive.file('index.html', 'concurrency'); archive.file('assets/app.js', `console.log('${filler.repeat(384)}');`); const bytes = await archive.generateAsync({ type: 'uint8array' }); return Buffer.from(bytes); } 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-safety-${id}.png`; const ticket = await api('/api/assets/direct-upload-tickets', { method: 'POST', token, body: { legacyPrefix: 'generated-character-drafts', pathSegments: ['game-distribution', 'upload-safety', 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-safety', }, }); if (confirm.status !== 200) { throw new Error( `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, ); } return confirm.data.assetObject.assetObjectId; } async function main() { const adminLogin = await api('/admin/api/login', { method: 'POST', body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, }); const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; check( '管理员登录成功', adminLogin.status === 200 && Boolean(admin), `status=${adminLogin.status}`, ); if (!admin) process.exit(1); const stamp = Date.now(); const register = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: `139${String(stamp).slice(-8)}`, password: DEV_PASSWORD, }, }); const author = register.data?.token; check( '作者注册拿到 token', register.status === 200 && Boolean(author), `status=${register.status}`, ); if (!author) process.exit(1); const setGate = (enabled, rolloutPercent) => api('/admin/api/feature-gates', { method: 'PUT', token: admin, body: { gateKey: 'game-distribution:publish', enabled, rolloutPercent, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 并发上传与幂等重放', }, }); const gateOpen = await setGate(true, 100); check( '发布灰度可开启并放量', gateOpen.status === 200, `status=${gateOpen.status}`, ); const zipBytes = await buildFixtureZip(); const zipSha256 = createHash('sha256').update(zipBytes).digest('hex'); const coverAssetId = await uploadCover(author, stamp); const title = `并发上传 ${String(stamp).slice(-6)}`; const metadata = { ...gameMetadata(title), coverAssetId }; // 1. 幂等重放:同 key 同请求返回同一条记录,同 key 换请求体冲突。 const gameKey = `safety-game-${stamp}`; const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': gameKey }, 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 replayedGame = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': gameKey }, body: metadata, }); check( '同 key 同请求重放建游戏返回同一 game', replayedGame.status === 200 && replayedGame.data?.id === gameId, `status=${replayedGame.status} gameId=${replayedGame.data?.id ?? ''}`, ); const conflictingGame = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': gameKey }, body: { ...metadata, title: `${title} 改` }, }); check( '同 key 换请求体建游戏被拒(不产生第二份)', conflictingGame.status === 409, `status=${conflictingGame.status} code=${conflictingGame.error?.code ?? ''}`, ); const versionKey = `safety-version-${stamp}`; const versionBody = { packageSha256: zipSha256, packageBytes: zipBytes.length, packageFileCount: 2, packageEntryPath: 'index.html', gameMetadata: metadata, }; const versionResponse = await api( `/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': versionKey }, body: versionBody, }, ); const versionId = versionResponse.data?.versionId; const publicationRevision = versionResponse.data?.publicationRevision ?? 0; 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 replayedVersion = await api( `/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': versionKey }, body: versionBody, }, ); check( '同 key 同请求重放建版本返回同一 version', replayedVersion.status === 200 && replayedVersion.data?.versionId === versionId, `status=${replayedVersion.status} versionId=${replayedVersion.data?.versionId ?? ''}`, ); // 2. 同版本同偏移并发写两片:只允许一片落地。 const chunk = (idempotencyKey) => api(`/api/game-distribution/versions/${versionId}/package/chunk`, { method: 'PUT', token: author, headers: { 'Content-Type': 'application/octet-stream', 'Idempotency-Key': idempotencyKey, 'x-genarrative-upload-offset': '0', }, binary: zipBytes, }); const raced = await Promise.all([ chunk(`safety-chunk-a-${stamp}`), chunk(`safety-chunk-b-${stamp}`), ]); const winners = raced.filter((item) => item.status === 200); const losers = raced.filter((item) => item.status !== 200); check( '并发同偏移分片只有一片写入成功', winners.length === 1, raced .map( (item) => `${item.status}${item.error?.code ? `/${item.error.code}` : ''}`, ) .join(' '), ); check( '落败请求返回偏移冲突与权威已收字节(可直接续传)', losers.length === 1 && losers[0].status === 409 && losers[0].error?.details?.receivedBytes === zipBytes.length, losers .map( (item) => `${item.status} ${item.error?.code ?? ''} receivedBytes=${item.error?.details?.receivedBytes ?? 'n/a'}`, ) .join(''), ); check( '成功分片回报的已收字节等于单份包长', winners[0]?.data?.receivedBytes === zipBytes.length, `receivedBytes=${winners[0]?.data?.receivedBytes ?? 'n/a'} zip=${zipBytes.length}`, ); const stateAfterRace = await api( `/api/game-distribution/versions/${versionId}/package/upload-state`, { token: author }, ); check( '服务端权威已收字节等于单份包长(没有重复追加)', stateAfterRace.status === 200 && stateAfterRace.data?.receivedBytes === zipBytes.length, `receivedBytes=${stateAfterRace.data?.receivedBytes ?? 'n/a'}`, ); const completed = await api( `/api/game-distribution/versions/${versionId}/package/complete`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `safety-complete-${stamp}` }, }, ); check( '并发落地的对象就是完整一份(确认校验通过并落到 uploaded)', completed.status === 200 && completed.data?.status === 'uploaded', `status=${completed.status} versionStatus=${completed.data?.status ?? ''} msg=${completed.error?.message ?? ''}`, ); // 3. 送审与重放。 const submitKey = `safety-submit-${stamp}`; const submitBody = { expectedPublicationRevision: publicationRevision }; const submitted = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': submitKey }, body: submitBody, }, ); check( '送审成功(202 + pending_review)', submitted.status === 202 && submitted.data?.version?.status === 'pending_review', `status=${submitted.status} versionStatus=${submitted.data?.version?.status ?? ''}`, ); const replayedSubmit = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': submitKey }, body: submitBody, }, ); check( '同 key 重放送审回到同一个版本且 replayed=true', replayedSubmit.status === 202 && replayedSubmit.data?.replayed === true && replayedSubmit.data?.version?.versionId === versionId && replayedSubmit.data?.version?.status === 'pending_review', `status=${replayedSubmit.status} replayed=${replayedSubmit.data?.replayed}`, ); const resubmitted = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `safety-submit-again-${stamp}` }, body: submitBody, }, ); const myGamesAfterResubmit = await api('/api/game-distribution/my-games', { token: author, }); const gameAfterResubmit = Array.isArray(myGamesAfterResubmit.data?.games) ? myGamesAfterResubmit.data.games.find((item) => item.id === gameId) : null; check( '换 key 重复送审按状态冲突拒绝且不产生第二份提交(版本数仍为 1)', resubmitted.status === 409 && gameAfterResubmit?.versions?.length === 1 && gameAfterResubmit.versions[0]?.versionId === versionId && gameAfterResubmit.versions[0]?.status === 'pending_review', `submitStatus=${resubmitted.status} replayed=${resubmitted.data?.replayed} versions=${gameAfterResubmit?.versions?.length ?? 'n/a'}`, ); // 4. 确定失败与失败后恢复:结构合法但摘要与声明不符的包必须明确失败并给出恢复动作, // 同一版本重置后重传正确字节仍能确认成功。 const mismatchedBytes = await buildFixtureZip('y'); check( '对照组包结构合法、长度一致但摘要不同', mismatchedBytes.length === zipBytes.length && createHash('sha256').update(mismatchedBytes).digest('hex') !== zipSha256, `length=${mismatchedBytes.length}/${zipBytes.length}`, ); const recoveryVersion = await api( `/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `safety-version-recovery-${stamp}` }, body: versionBody, }, ); const recoveryVersionId = recoveryVersion.data?.versionId; check( '为失败恢复用例创建第二个版本', recoveryVersion.status === 200 && Boolean(recoveryVersionId), `status=${recoveryVersion.status}`, ); if (recoveryVersionId) { const brokenChunk = await api( `/api/game-distribution/versions/${recoveryVersionId}/package/chunk`, { method: 'PUT', token: author, headers: { 'Content-Type': 'application/octet-stream', 'Idempotency-Key': `safety-mismatch-chunk-${stamp}`, 'x-genarrative-upload-offset': '0', }, binary: mismatchedBytes, }, ); check( '摘要不符的分片仍会写入暂存区', brokenChunk.status === 200 && brokenChunk.data?.receivedBytes === mismatchedBytes.length, `status=${brokenChunk.status} receivedBytes=${brokenChunk.data?.receivedBytes ?? 'n/a'}`, ); const brokenComplete = await api( `/api/game-distribution/versions/${recoveryVersionId}/package/complete`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `safety-mismatch-complete-${stamp}` }, }, ); check( '内容与声明不符时确认明确失败(PACKAGE_MISMATCH,不是静默成功)', brokenComplete.status === 409 && brokenComplete.error?.code === 'PACKAGE_MISMATCH', `status=${brokenComplete.status} code=${brokenComplete.error?.code ?? ''} msg=${brokenComplete.error?.message ?? ''}`, ); const failedVersion = await api( `/api/game-distribution/versions/${recoveryVersionId}`, { token: author }, ); check( '失败后版本落到 upload_failed 且恢复动作是 reupload', failedVersion.data?.version?.status === 'upload_failed' && failedVersion.data?.version?.recoveryAction === 'reupload', `versionStatus=${failedVersion.data?.version?.status ?? ''} recoveryAction=${failedVersion.data?.version?.recoveryAction ?? ''}`, ); const reset = await api( `/api/game-distribution/versions/${recoveryVersionId}/package/reset`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `safety-reset-${stamp}` }, }, ); const resetState = await api( `/api/game-distribution/versions/${recoveryVersionId}/package/upload-state`, { token: author }, ); check( '失败后可显式重置分片会话(权威已收字节归零)', reset.status === 200 && resetState.data?.receivedBytes === 0, `resetStatus=${reset.status} receivedBytes=${resetState.data?.receivedBytes ?? 'n/a'}`, ); const recoveryChunk = await api( `/api/game-distribution/versions/${recoveryVersionId}/package/chunk`, { method: 'PUT', token: author, headers: { 'Content-Type': 'application/octet-stream', 'Idempotency-Key': `safety-recovery-chunk-${stamp}`, 'x-genarrative-upload-offset': '0', }, binary: zipBytes, }, ); const recoveryComplete = await api( `/api/game-distribution/versions/${recoveryVersionId}/package/complete`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `safety-recovery-complete-${stamp}` }, }, ); check( '同一版本重传正确字节后确认成功(校验失败可恢复)', recoveryChunk.status === 200 && recoveryComplete.status === 200 && recoveryComplete.data?.status === 'uploaded', `chunkStatus=${recoveryChunk.status} completeStatus=${recoveryComplete.status} versionStatus=${recoveryComplete.data?.status ?? ''}`, ); } const gateClosed = await setGate(false, 0); check( '发布灰度恢复关闭', gateClosed.status === 200, `status=${gateClosed.status}`, ); console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exit(failures === 0 ? 0 : 1); } main().catch((error) => { console.error( `[check:game-distribution-upload-concurrency] 运行失败:${error}`, ); process.exit(1); });