// 游戏分发「发布开关回滚窗口 + 发行网关缓存与响应头」真实栈检查。
//
// 对应里程碑阶段 D 的上线准备两条:
// - 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;
// - 撤销只改变后端公开投影,源站立即拒绝新请求,边缘最多多留一个获批缓存窗口。
//
// 用本地真实栈(api-server + SpacetimeDB + 真实 OSS)与真实后台灰度开关取证:
// 1. 关闭 `game-distribution:publish` 后:创建游戏/版本、上传包、送审、撤回、作者下架
// 与管理员批准新版本全部 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`;
// 2. 同一窗口内目录、详情、发行网关、`/my-games` 与审核队列读取继续可用,
// 当前公开版本与 `publicationRevision` 不变(关闭投稿、保在线);
// 3. 拒绝审核与管理员安全下架 / 恢复始终可用;
// 4. 发行网关响应头与获批缓存窗口(`public, max-age=60, must-revalidate`)和运维文档一致;
// 5. 换版与下架后**新的**发行请求立刻被源站拒绝(404),旧内容只可能留在 60 秒边缘缓存里;
// 6. 运行期日志不出现访问令牌、刷新 Cookie 与 OSS signed URL,边缘 log_format 不记录请求头。
//
// 需要:本地 dev 栈 + 管理员账号。
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-ops-rollback-e2e
import { createHash, randomBytes } from 'node:crypto';
import { readdirSync, readFileSync, statSync } from 'node:fs';
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 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!';
const GATE_KEY = 'game-distribution:publish';
const RELEASE_CACHE_CONTROL = 'public, max-age=60, must-revalidate';
const RELEASE_TTL_SECONDS = 60;
const PUBLISH_DISABLED_CODE = 'GAME_DISTRIBUTION_PUBLISH_DISABLED';
const OPS_DOC_PATH = 'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md';
const EDGE_TEMPLATES = [
'deploy/nginx/genarrative.conf',
'deploy/nginx/genarrative-dev-http.conf',
'deploy/container/nginx.conf',
];
const API_LOG_DIR = path.resolve('logs/api-server');
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。');
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
function section(title) {
console.log(`\n--- ${title} ---`);
}
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,
headers: Object.fromEntries(response.headers.entries()),
setCookies: response.headers.getSetCookie?.() ?? [],
};
}
async function release(pathname) {
const response = await fetch(`${API}${pathname}`);
const body = await response.text();
return { status: response.status, body, headers: response.headers };
}
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 setPublishGate(admin, enabled, rolloutPercent) {
const response = await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: GATE_KEY,
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布回滚窗口',
},
});
return response;
}
async function uploadCover(token, id) {
const fileName = `ops-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'ops-rollback', 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-ops-rollback',
},
});
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: ['ops'],
coverAssetId,
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
/// 包内同时放 HTML 与 CSS:发行网关对 HTML 单独下发 CSP,对 CSS 只下发通用安全头。
async function buildPackage(marker) {
const zip = new JSZip();
zip.file(
'index.html',
`
${marker}
`,
);
zip.file(
'app.css',
`body { color: ${marker.startsWith('V1') ? '#111' : '#222'}; }`,
);
zip.file('filler.bin', randomBytes(256 * 1024));
const bytes = Buffer.from(
await zip.generateAsync({ type: 'uint8array', compression: 'STORE' }),
);
return { bytes, fileCount: 3 };
}
async function createVersion(token, gameId, metadata, bytes, fileCount, key) {
return api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': key },
body: {
packageSha256: createHash('sha256').update(bytes).digest('hex'),
packageBytes: bytes.length,
packageFileCount: fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
}
async function uploadVersion(token, versionId, bytes, key) {
return api(`/api/game-distribution/versions/${versionId}/package`, {
method: 'PUT',
token,
headers: {
'Idempotency-Key': key,
'Content-Type': 'application/zip',
},
binary: bytes,
});
}
async function submitVersion(token, versionId, revision, key) {
return api(`/api/game-distribution/versions/${versionId}/submit`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': key },
body: { expectedPublicationRevision: revision },
});
}
async function reviewVersion(admin, versionId, payload, key) {
return api(`/admin/api/game-distribution/versions/${versionId}/review`, {
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': key },
body: payload,
});
}
async function adminGameRow(admin, gameId) {
const response = await api('/admin/api/game-distribution/games', {
token: admin,
});
const game = (response.data?.games ?? []).find(
(row) => row.gameId === gameId,
);
return { status: response.status, game };
}
function newestApiLog() {
let entries = [];
try {
entries = readdirSync(API_LOG_DIR).filter((name) => name.endsWith('.log'));
} catch {
return null;
}
if (entries.length === 0) return null;
const files = entries
.map((name) => path.join(API_LOG_DIR, name))
.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs);
return files[files.length - 1];
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
/// 发行网关的响应头契约:通用安全头始终下发,CSP 只跟 HTML 走。
function assertReleaseHeaders(label, response, { html }) {
const headers = response.headers;
check(
`${label}:缓存窗口等于获批 TTL`,
headers.get('cache-control') === RELEASE_CACHE_CONTROL,
`cache-control=${headers.get('cache-control') ?? ''}`,
);
check(
`${label}:nosniff + no-referrer + 无凭据 CORS/跨来源`,
headers.get('x-content-type-options') === 'nosniff' &&
headers.get('referrer-policy') === 'no-referrer' &&
headers.get('cross-origin-resource-policy') === 'cross-origin' &&
headers.get('access-control-allow-origin') === '*',
`nosniff=${headers.get('x-content-type-options') ?? ''} corp=${headers.get('cross-origin-resource-policy') ?? ''}`,
);
const csp = headers.get('content-security-policy') ?? '';
check(
html ? `${label}:HTML 下发收紧 CSP` : `${label}:非 HTML 不下发 CSP`,
html
? csp.includes("default-src 'none'") &&
csp.includes("frame-src 'none'") &&
csp.includes("base-uri 'none'") &&
csp.includes("object-src 'none'")
: csp === '',
`csp=${csp.slice(0, 40)}`,
);
}
async function main() {
const stamp = Date.now();
const key = (label) => `${label}-${stamp}`;
const admin = await adminToken();
if (!admin) process.exit(1);
const gateOn = await setPublishGate(admin, true, 100);
check(
'发布开关可开启(灰度 100%)',
gateOn.status === 200,
`status=${gateOn.status}`,
);
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),
`status=${register.status}`,
);
if (!author) process.exit(1);
const refreshCookieValue = (register.setCookies ?? [])
.map((cookie) => cookie.split(';')[0])
.map((pair) => pair.split('=').slice(1).join('='))
.find((value) => value && value.length > 20);
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': key('ops-game') },
body: metadata,
});
const gameId = created.data?.id;
check('创建游戏成功', created.status === 200 && Boolean(gameId));
if (!gameId) process.exit(1);
section('基线:v1 公开、v2/v3 待审、v4 只有版本记录');
const v1 = await buildPackage('V1-OK');
const v1Version = await createVersion(
author,
gameId,
metadata,
v1.bytes,
v1.fileCount,
key('ops-v1'),
);
const v1Id = v1Version.data?.versionId;
await uploadVersion(author, v1Id, v1.bytes, key('ops-upload-v1'));
await submitVersion(author, v1Id, 0, key('ops-submit-v1'));
const approved = await reviewVersion(
admin,
v1Id,
{ decision: 'approve', expectedPublicationRevision: 0 },
key('ops-approve-v1'),
);
check(
'v1 审核通过并公开',
approved.status === 200,
`status=${approved.status}`,
);
const baselineDetail = await api(`/api/game-distribution/games/${gameId}`);
check(
'公开基线:revision=1 且当前公开版本是 v1',
baselineDetail.status === 200 &&
baselineDetail.data?.publicationRevision === 1 &&
baselineDetail.data?.currentVersion?.id === v1Id,
`status=${baselineDetail.status} revision=${baselineDetail.data?.publicationRevision ?? ''}`,
);
const baselineRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'公开基线:发行入口返回 v1 内容',
baselineRelease.status === 200 && baselineRelease.body.includes('V1-OK'),
`status=${baselineRelease.status}`,
);
assertReleaseHeaders('v1 HTML', baselineRelease, { html: true });
const baselineCss = await release(
`/api/game-distribution/releases/${gameId}/app.css`,
);
assertReleaseHeaders('v1 CSS', baselineCss, { html: false });
const pendingIds = {};
for (const marker of ['V2-OK', 'V3-OK']) {
const built = await buildPackage(marker);
const version = await createVersion(
author,
gameId,
metadata,
built.bytes,
built.fileCount,
key(`ops-${marker}`),
);
const versionId = version.data?.versionId;
await uploadVersion(
author,
versionId,
built.bytes,
key(`ops-upload-${marker}`),
);
const submitted = await submitVersion(
author,
versionId,
1,
key(`ops-submit-${marker}`),
);
pendingIds[marker] = versionId;
check(
`${marker} 送审进入待审`,
submitted.status === 202,
`status=${submitted.status}`,
);
}
const v2Id = pendingIds['V2-OK'];
const v3Id = pendingIds['V3-OK'];
const v4Built = await buildPackage('V4-OK');
const v4Version = await createVersion(
author,
gameId,
metadata,
v4Built.bytes,
v4Built.fileCount,
key('ops-v4'),
);
const v4Id = v4Version.data?.versionId;
check('v4 只建版本记录(未上传,用于验证上传被开关拦住)', Boolean(v4Id));
section('关闭发布开关:关投稿、保在线');
const gateOff = await setPublishGate(admin, false, 0);
check(
'发布开关可关闭(紧急关闭投稿)',
gateOff.status === 200,
`status=${gateOff.status}`,
);
const blocked = [
[
'创建游戏',
await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-game') },
body: metadata,
}),
],
[
'创建版本',
await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-version') },
body: {
packageSha256: createHash('sha256')
.update(v4Built.bytes)
.digest('hex'),
packageBytes: v4Built.bytes.length,
packageFileCount: v4Built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
}),
],
[
'上传包',
await uploadVersion(
author,
v4Id,
v4Built.bytes,
key('ops-blocked-upload'),
),
],
['送审', await submitVersion(author, v4Id, 1, key('ops-blocked-submit'))],
[
'撤回',
await api(`/api/game-distribution/versions/${v4Id}/cancel`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-cancel') },
body: { expectedPublicationRevision: 1 },
}),
],
[
'作者下架',
await api(`/api/game-distribution/games/${gameId}/unpublish`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-unpublish') },
body: { expectedPublicationRevision: 1 },
}),
],
[
'管理员批准新版本',
await reviewVersion(
admin,
v3Id,
{ decision: 'approve', expectedPublicationRevision: 1 },
key('ops-blocked-approve'),
),
],
];
for (const [name, response] of blocked) {
check(
`开关关闭时「${name}」被拦(503 + 发布关闭码)`,
response.status === 503 && response.error?.code === PUBLISH_DISABLED_CODE,
`status=${response.status} code=${response.error?.code ?? ''}`,
);
}
const closedCatalog = await api('/api/game-distribution/games');
const closedDetail = await api(`/api/game-distribution/games/${gameId}`);
const closedMine = await api('/api/game-distribution/my-games', {
token: author,
});
const closedReviews = await api('/admin/api/game-distribution/reviews', {
token: admin,
});
const closedRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'开关关闭时读取全部可用(目录/详情/我的游戏/审核队列)',
closedCatalog.status === 200 &&
closedDetail.status === 200 &&
closedMine.status === 200 &&
closedReviews.status === 200,
`catalog=${closedCatalog.status} detail=${closedDetail.status} mine=${closedMine.status} reviews=${closedReviews.status}`,
);
check(
'开关关闭时当前公开版本不变(回滚窗口保留在线旧版)',
closedDetail.data?.publicationRevision === 1 &&
closedDetail.data?.currentVersion?.id === v1Id,
`revision=${closedDetail.data?.publicationRevision ?? ''} active=${closedDetail.data?.currentVersion?.id ?? ''}`,
);
check(
'开关关闭时已公开游戏仍可游玩(发行入口继续服务 v1)',
closedRelease.status === 200 && closedRelease.body.includes('V1-OK'),
`status=${closedRelease.status}`,
);
const rejected = await reviewVersion(
admin,
v2Id,
{
decision: 'reject',
expectedPublicationRevision: 1,
reviewReason: 'E2E 开关关闭仍可拒绝审核',
},
key('ops-reject-v2'),
);
check(
'开关关闭时拒绝审核始终可用',
rejected.status === 200,
`status=${rejected.status} code=${rejected.error?.code ?? ''}`,
);
const suspended = await api(
`/admin/api/game-distribution/games/${gameId}/suspend`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': key('ops-suspend') },
body: {
expectedPublicationRevision: 1,
reason: 'E2E 开关关闭仍可安全下架',
},
},
);
check(
'开关关闭时管理员安全下架始终可用',
suspended.status === 200,
`status=${suspended.status} code=${suspended.error?.code ?? ''}`,
);
const suspendedDetail = await api(`/api/game-distribution/games/${gameId}`);
const suspendedRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'下架后新的发行请求立刻被源站拒绝(不依赖 CDN purge)',
suspendedRelease.status === 404 && suspendedDetail.status === 404,
`release=${suspendedRelease.status} detail=${suspendedDetail.status}`,
);
const afterSuspend = await adminGameRow(admin, gameId);
const suspendedRevision = afterSuspend.game?.publicationRevision;
const restored = await api(
`/admin/api/game-distribution/games/${gameId}/restore`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': key('ops-restore') },
body: { expectedPublicationRevision: suspendedRevision },
},
);
const restoredRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'安全下架可恢复,恢复后发行入口重新服务同一公开版本',
restored.status === 200 &&
restoredRelease.status === 200 &&
restoredRelease.body.includes('V1-OK'),
`restore=${restored.status} release=${restoredRelease.status}`,
);
section('重新开放后换版:旧公开版本被保留为已撤回记录');
const reopened = await setPublishGate(admin, true, 100);
check(
'发布开关可重新开放',
reopened.status === 200,
`status=${reopened.status}`,
);
const beforeSwitch = await adminGameRow(admin, gameId);
const switchRevision = beforeSwitch.game?.publicationRevision;
const switched = await reviewVersion(
admin,
v3Id,
{ decision: 'approve', expectedPublicationRevision: switchRevision },
key('ops-approve-v3'),
);
check(
'开关恢复后管理员可以激活新版本',
switched.status === 200,
`status=${switched.status} code=${switched.error?.code ?? ''}`,
);
const switchedRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'换版后发行入口改为新版本内容',
switchedRelease.status === 200 && switchedRelease.body.includes('V3-OK'),
`status=${switchedRelease.status} marker=${switchedRelease.body.includes('V3-OK')}`,
);
const mine = await api('/api/game-distribution/my-games', { token: author });
const mineGame = (mine.data?.games ?? []).find((game) => game.id === gameId);
const mineVersions = mineGame?.versions ?? [];
const v1Record = mineVersions.find((version) => version.versionId === v1Id);
check(
'换版后旧公开版本仍作为已撤回记录保留(可追溯、未删除)',
v1Record?.status === 'revoked',
`v1=${v1Record?.status ?? 'missing'} versions=${mineVersions.length}`,
);
section('日志脱敏与边缘日志格式');
await sleep(500);
const logFile = newestApiLog();
const logText = logFile ? readFileSync(logFile, 'utf8') : '';
check(
'运行期日志不出现访问令牌与刷新 Cookie',
Boolean(logFile) &&
!logText.includes(author) &&
(!refreshCookieValue || !logText.includes(refreshCookieValue)),
`log=${path.basename(logFile ?? '(none)')} tokenHit=${logText.includes(author)} cookieChecked=${Boolean(refreshCookieValue)}`,
);
check(
'日志脱敏正向对照:本轮的发行请求确实落在同一份日志里',
Boolean(logFile) && logText.includes(gameId),
`log=${path.basename(logFile ?? '(none)')} gameHit=${logText.includes(gameId)}`,
);
check(
'运行期日志不出现 OSS signed URL 凭据',
!logText.includes('OSSAccessKeyId') && !logText.includes('Signature='),
);
check(
'关闭投稿与安全下架在日志里按 operation 可观测',
logText.includes('publish_switch_blocked') &&
logText.includes('game_suspended'),
);
const edgeLeaks = EDGE_TEMPLATES.filter((template) => {
const source = readFileSync(template, 'utf8');
const match = source.match(/log_format\s+[\s\S]*?;/u);
const format = match ? match[0] : '';
return (
format.includes('$http_authorization') ||
format.includes('$http_cookie') ||
format.includes('$arg_')
);
});
check(
'三份边缘模板的 log_format 不记录请求头与查询参数',
edgeLeaks.length === 0,
`leaks=${edgeLeaks.join(',')}`,
);
const routeIssues = EDGE_TEMPLATES.filter((template) => {
const source = readFileSync(template, 'utf8');
return !(
source.includes(
'location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"',
) &&
source.includes('proxy_set_header Cookie ""') &&
source.includes(
'proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path',
)
);
});
check(
'三份边缘模板把 /games// 映射到发行网关并清空 Cookie',
routeIssues.length === 0,
`issues=${routeIssues.join(',')}`,
);
const opsDoc = readFileSync(OPS_DOC_PATH, 'utf8');
check(
'运维文档与运行期缓存窗口一致(60 秒上限 + 已下载脚本不可远程抹除)',
opsDoc.includes(`max-age=${RELEASE_TTL_SECONDS}`) &&
opsDoc.includes(`${RELEASE_TTL_SECONDS} 秒`) &&
opsDoc.includes('无法远程抹除'),
);
}
async function run() {
try {
await main();
} finally {
// 无论通过与否都放开灰度,避免把本机后续验证卡在“关投稿”状态。
try {
const admin = await adminToken();
if (admin) await setPublishGate(admin, true, 100);
} catch (error) {
console.error(`恢复发布开关失败:${error}`);
}
console.log(failures === 0 ? '\n全部通过' : `\n${failures} 项失败`);
process.exit(failures === 0 ? 0 : 1);
}
}
await run();