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
- 新增 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、并发落败给权威偏移两条错误码口径
564 lines
19 KiB
JavaScript
564 lines
19 KiB
JavaScript
// 游戏分发「同版本并发上传不混写 + 幂等重放 + 失败后可恢复上传」真实链路检查
|
||
// (需要本地 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);
|
||
});
|