// 游戏分发「校验执行器重启可恢复 + 失败可观测 + 不误删公开版本」检查。
//
// 两段式:prepare 段上传一个大包并在确认(校验)进行中杀掉 api-server;调用方重启
// api-server 后用同一 E2E_STATE_FILE 跑 resume 段,确认仍能续做、失败可见、公开版本仍在。
//
// 需要:本地 dev 栈 + 管理员账号 + playwright 无关。
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... E2E_STAGE=prepare npm run check:game-distribution-validation-restart-e2e
// <重启 api-server>
// E2E_STAGE=resume ... npm run check:game-distribution-validation-restart-e2e
import { execSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
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:8082';
const API_PORT = Number(new URL(API).port || '80');
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 STATE_FILE =
(process.env.E2E_STATE_FILE ?? '').trim() ||
path.join(tmpdir(), 'genarrative-validation-restart.json');
const DEV_PASSWORD = 'GenE2e123!';
const MIB = 1024 * 1024;
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。');
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,
text,
json,
data: json?.data,
error: json?.error,
};
}
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;
}
async function uploadCover(token, id) {
const fileName = `restart-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'restart', 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}`);
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-restart',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
function gameMetadata(title, coverAssetId) {
return {
title,
summary: '校验重启恢复 E2E',
description: '',
category: '休闲',
tags: ['restart'],
coverAssetId,
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function createVersion(
token,
gameId,
metadata,
bytes,
suffix,
fileCount,
) {
return api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': `restart-version-${suffix}` },
body: {
packageSha256: createHash('sha256').update(bytes).digest('hex'),
packageBytes: bytes.length,
packageFileCount: fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
}
function buildPackage(sizeMiB, marker) {
const zip = new JSZip();
zip.file(
'index.html',
`
${marker}
`,
);
// 单文件上限 64 MiB,这里按 48 MiB 切片,确保大包用例卡在"包大小"而不是"单文件"。
let remaining = sizeMiB;
let index = 0;
while (remaining > 0) {
const chunk = Math.min(remaining, 48);
zip.file(`assets/blob-${index}.bin`, randomBytes(chunk * MIB));
remaining -= chunk;
index += 1;
}
return zip
.generateAsync({ type: 'uint8array', compression: 'STORE' })
.then((bytes) => ({ bytes: Buffer.from(bytes), fileCount: index + 1 }));
}
function killApiServer() {
execSync(
`powershell -NoProfile -Command "$c = Get-NetTCPConnection -LocalPort ${API_PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { Stop-Process -Id $c.OwningProcess -Force }"`,
{ stdio: 'ignore', timeout: 30_000 },
);
}
async function runPrepare() {
const admin = await adminToken();
if (!admin) process.exit(1);
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 校验重启',
},
});
const stamp = Date.now();
const phone = `135${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));
if (!author) process.exit(1);
const coverAssetId = await uploadCover(author, stamp);
const title = `校验重启 ${String(stamp).slice(-6)}`;
const metadata = gameMetadata(title, coverAssetId);
// 先发布 v1(公开版本),用来验证后续失败/重试不会误删公开内容。
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-game-${stamp}` },
body: metadata,
});
const gameId = created.data?.id;
check('创建游戏成功', created.status === 200 && Boolean(gameId));
const v1Package = await buildPackage(1, 'V1-OK');
const v1Bytes = v1Package.bytes;
const v1 = await createVersion(
author,
gameId,
metadata,
v1Bytes,
`${stamp}-v1`,
v1Package.fileCount,
);
await api(`/api/game-distribution/versions/${v1.data?.versionId}/package`, {
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `restart-upload-v1-${stamp}`,
'Content-Type': 'application/zip',
},
binary: v1Bytes,
});
await api(`/api/game-distribution/versions/${v1.data?.versionId}/submit`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-submit-v1-${stamp}` },
body: { expectedPublicationRevision: 0 },
});
const approved = await api(
`/admin/api/game-distribution/versions/${v1.data?.versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `restart-approve-v1-${stamp}` },
body: { decision: 'approve', expectedPublicationRevision: 0 },
},
);
check('v1 已公开', approved.status === 200, `status=${approved.status}`);
// v2:上传一个大包,然后在确认(校验)进行中杀掉 api-server。
const v2Package = await buildPackage(196, 'V2-OK');
const v2Bytes = v2Package.bytes;
const v2 = await createVersion(
author,
gameId,
metadata,
v2Bytes,
`${stamp}-v2`,
v2Package.fileCount,
);
const versionId = v2.data?.versionId;
const uploaded = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `restart-upload-v2-${stamp}`,
'Content-Type': 'application/zip',
},
binary: v2Bytes,
},
);
check(
'v2 整包上传完成(等待确认校验)',
uploaded.status === 200,
`status=${uploaded.status} bytes=${v2Bytes.length}`,
);
const confirmPromise = api(
`/api/game-distribution/versions/${versionId}/package/complete`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-confirm-v2-${stamp}` },
},
).catch((error) => ({ error: String(error) }));
// 请求已经发出就立刻杀掉校验进程:确认要回读整包并算清单,进程退出必然让这次
// 请求失败,同时版本状态不应被写成 uploaded / validation_failed。
killApiServer();
const interrupted = await confirmPromise;
check(
'确认校验进行中杀掉 api-server 会让请求失败而不是静默成功',
Boolean(interrupted?.error) || interrupted.status >= 500,
`result=${interrupted?.error ?? interrupted?.status ?? 'n/a'}`,
);
writeFileSync(
STATE_FILE,
JSON.stringify(
{
apiBase: API,
phone,
password: DEV_PASSWORD,
gameId,
title,
coverAssetId,
versionId,
v1VersionId: v1.data?.versionId,
v2Bytes: v2Bytes.length,
stamp,
},
null,
2,
),
);
console.log(
`\nPREPARED gameId=${gameId} v2VersionId=${versionId}(api-server 已杀,重启后用 E2E_STAGE=resume 继续)`,
);
process.exit(failures === 0 ? 0 : 1);
}
async function runResume() {
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
const admin = await adminToken();
const login = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: state.phone, password: state.password },
});
const author = login.data?.token;
check(
'重启后作者仍能登录',
login.status === 200 && Boolean(author),
`status=${login.status}`,
);
if (!author) process.exit(1);
const uploadState = await api(
`/api/game-distribution/versions/${state.versionId}/package/upload-state`,
{ token: author },
);
check(
'重启后已收字节仍在(暂存对象没因进程退出丢失)',
uploadState.status === 200 &&
uploadState.data?.receivedBytes === state.v2Bytes,
`receivedBytes=${uploadState.data?.receivedBytes ?? 'n/a'} expected=${state.v2Bytes}`,
);
const confirm = await api(
`/api/game-distribution/versions/${state.versionId}/package/complete`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-confirm-retry-${state.stamp}` },
},
);
check(
'重启后重新确认能完成校验(可恢复)',
confirm.status === 200 && confirm.data?.status === 'uploaded',
`status=${confirm.status} versionStatus=${confirm.data?.status ?? ''}`,
);
const submitted = await api(
`/api/game-distribution/versions/${state.versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-submit-v2-${state.stamp}` },
body: { expectedPublicationRevision: 1 },
},
);
check(
'v2 送审成功(200/202)',
submitted.status === 202,
`status=${submitted.status}`,
);
const reviews = await api('/admin/api/game-distribution/reviews', {
token: admin,
});
const backlog = (reviews.data?.entries ?? []).filter(
(entry) => entry.status === 'pending_review',
);
check(
'审核积压可观测(待审队列能查到该版本)',
backlog.some((entry) => entry.versionId === state.versionId),
`pending=${backlog.length}`,
);
const adminGames = await api('/admin/api/game-distribution/games', {
token: admin,
});
const adminGame = (adminGames.data?.games ?? []).find(
(game) => game.gameId === state.gameId,
);
check(
'后台游戏列表能看到同一游戏的两个版本状态',
(adminGame?.versions ?? []).length === 2 &&
adminGame.versions.some((version) => version.status === 'pending_review'),
`versions=${adminGame?.versions?.length ?? 'n/a'}`,
);
// 失败可观测:上传一个结构损坏的包,作者当场能拿到失败码与原因,版本落
// upload_failed(原因同时落库 last_error_code/last_error_message),后台能看到状态。
const brokenBytes = Buffer.concat([
Buffer.from('PK\x03\x04broken', 'binary'),
randomBytes(1024),
]);
const metadata = gameMetadata(state.title, state.coverAssetId);
const v3 = await api(
`/api/game-distribution/games/${state.gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `restart-version-v3-${state.stamp}` },
body: {
packageSha256: createHash('sha256').update(brokenBytes).digest('hex'),
packageBytes: brokenBytes.length,
packageFileCount: 1,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
},
);
const v3Id = v3.data?.versionId;
const brokenUpload = await api(
`/api/game-distribution/versions/${v3Id}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `restart-upload-v3-${state.stamp}`,
'Content-Type': 'application/zip',
},
binary: brokenBytes,
},
);
const v3Readback = await api(`/api/game-distribution/versions/${v3Id}`, {
token: author,
});
check(
'坏包校验失败可诊断(422 + 失败码 + 原因)',
brokenUpload.status === 422 &&
brokenUpload.error?.code === 'PACKAGE_VALIDATION_FAILED' &&
Boolean(brokenUpload.error?.details?.reason),
`upload=${brokenUpload.status} code=${brokenUpload.error?.code ?? ''} reason=${brokenUpload.error?.details?.reason ?? ''}`,
);
check(
'失败版本落 upload_failed 且可回读',
v3Readback.data?.version?.status === 'upload_failed',
`versionStatus=${v3Readback.data?.version?.status ?? ''}`,
);
const v3Admin = await api('/admin/api/game-distribution/games', {
token: admin,
});
const v3AdminGame = (v3Admin.data?.games ?? []).find(
(game) => game.gameId === state.gameId,
);
check(
'失败版本在后台列表可见(可观测)',
(v3AdminGame?.versions ?? []).some(
(version) =>
version.versionId === v3Id && version.status === 'upload_failed',
),
`statuses=${(v3AdminGame?.versions ?? []).map((version) => version.status).join(',')}`,
);
// 清理/误删防护:公开版本 v1 的发行内容在 v2 待审、v3 失败之后仍可读。
const release = await fetch(
`${API}/api/game-distribution/releases/${state.gameId}/index.html`,
);
const releaseBody = await release.text();
check(
'公开版本内容在后续待审/失败之后仍然可读(没有被清理误删)',
release.status === 200 && releaseBody.includes('V1-OK'),
`status=${release.status} marker=${releaseBody.includes('V1-OK')}`,
);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
if (STAGE === 'prepare') {
await runPrepare();
} else {
await runResume();
}