Files
Genarrative/scripts/check-game-distribution-upload-safety.mjs
T
kdletters 23de78b476
Project CI / AI game creator shell Rust crates (push) Successful in 1m41s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m12s
Project CI / Backend tests (push) Successful in 5m14s
Project CI / Native shell tests (push) Successful in 6m19s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m41s
Project CI / Frontend tests (push) Successful in 2m13s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m41s
Project CI / AI game creator shell web tests (push) Successful in 1m38s
Project CI / Repository checks (push) Successful in 2m12s
补齐游戏分发分片续传与失败恢复用例并收口阶段 A 第 5 条
- 新增 scripts/check-game-distribution-upload-resume.mjs:prepare 段造 9,437,658 字节真实 ZIP 只传第一片 8,388,608 字节,杀掉 api-server(PID 53844 -> 7728)后 resume 段从对象存储读回权威偏移继续传完、确认 uploaded、送审 202 pending_review
- 上传安全检查脚本改名并扩展为 check-game-distribution-upload-safety:新增「结构合法但摘要与声明不符 -> 409 PACKAGE_MISMATCH -> upload_failed + recoveryAction=reupload -> reset 归零 -> 重传确认成功」失败恢复用例,共 24 项 PASS
- package.json 注册 check:game-distribution-upload-resume 并改名 check:game-distribution-upload-safety
- 游戏分发里程碑阶段 A 第 5 条改为已勾选,写入三段式取证
- decision-log 记录越权一律 404、并发落败给权威偏移两条错误码口径
2026-09-28 18:44:16 +08:00

564 lines
19 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 栈: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', '<!doctype html><title>concurrency</title>');
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);
});