补齐游戏分发分片续传与失败恢复用例并收口阶段 A 第 5 条
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

- 新增 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、并发落败给权威偏移两条错误码口径
This commit is contained in:
kdletters
2026-09-28 18:44:16 +08:00
parent 665f88b85f
commit 23de78b476
5 changed files with 600 additions and 10 deletions
@@ -0,0 +1,455 @@
// 游戏分发「分片上传中断 / 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', '<!doctype html><title>resume</title>');
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);
}
@@ -1,8 +1,9 @@
// 游戏分发「同版本并发上传不混写 + 幂等重放」真实链路检查(需要本地 dev 栈:SpacetimeDB + api-server)。
// 游戏分发「同版本并发上传不混写 + 幂等重放 + 失败后可恢复上传」真实链路检查
// (需要本地 dev 栈:SpacetimeDB + api-server)。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-upload-concurrency
// npm run check:game-distribution-upload-safety
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。
//
// 覆盖:
@@ -12,6 +13,8 @@
// 已收字节等于单份包长,随后的确认校验必须通过(混写或重复追加都会让校验失败)。
// 3. 送审成功后用同一个 Key 重放 → 202 `replayed=true` 且仍是同一版本;
// 不同 Key 重复送审不会产生第二份提交(版本数仍为 1、状态不变)。
// 4. 内容与声明摘要不符的分片:确认时明确失败、版本落到 `upload_failed`、半包对象被清理
// (确定失败),随后同一版本重新上传正确字节仍能确认成功(失败可恢复)。
// 整条链路只在本地 dev 数据库与该 dev bucket 的对象前缀下落行;一个几百字节的发行包对象
// 会在确认后留在 bucket 里(与现役上传链路的产物一致),另有 67 字节封面 PNG。
import { createHash } from 'node:crypto';
@@ -77,11 +80,11 @@ async function api(path, options = {}) {
};
}
// 两份内容不同但长度一致的条目,保证 fixture ZIP 的可复现长度(分片并发用例需要固定声明大小)。
async function buildFixtureZip() {
// 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('${'x'.repeat(384)}');`);
archive.file('assets/app.js', `console.log('${filler.repeat(384)}');`);
const bytes = await archive.generateAsync({ type: 'uint8array' });
return Buffer.from(bytes);
}
@@ -422,6 +425,125 @@ async function main() {
`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(
'发布灰度恢复关闭',