// 游戏分发「发布开关回滚窗口 + 发行网关缓存与响应头」真实栈检查。 // // 对应里程碑阶段 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();