Files
Genarrative/scripts/check-game-distribution-upload-resume.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

456 lines
14 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.
// 游戏分发「分片上传中断 / 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);
}