diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 9337933fc..18c7b609f 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -27,7 +27,7 @@ - 阶段 A:真实 ZIP 上传、游戏身份、owner/幂等/CAS、状态机、DTO 与 schema 门禁已完成;真实 SpacetimeDB + 私有 OSS 的创建/上传/确认/重启恢复已有证据。**2026-09-28 更新**:阶段 A 的六条行为与验收已全部勾选(含跨账号越权、并发上传不混写、进程退出后按权威偏移续传、DTO/envelope 一致性),正式验收仍待 owner 评审。 - 阶段 B:人工审核(后台列表、通过需 HTTPS 入口、拒绝需理由)、公开投影、发行网关(按公开版本服务、扩展名白名单、`nosniff`/CORP/CSP、带 Cookie 403)与作者下架已实现;**2026-09-28 更新**:审核治理(待审可见、可批可拒、结论可追溯、作者不能提交审核)、「更新待审/被拒不改变在线旧版」、`publicationRevision` CAS(过期审核、并发激活、过期下架、陈旧审核不复活已下架游戏)与作者/管理员下架后目录/详情/发行读取全部关闭都已在本地真实栈取证(见文末阶段 B 核对);匿名直取发行包这一条已修:`platform-oss` 现在在内部 PUT / 分片追加 / 直传 policy 三处都下发对象级 ACL,严格模式下直传封面与发行包匿名直取都是 403(见 pitfalls 与 decision-log);opaque sandbox 的真实浏览器行为也已用 Chromium 探针包取证(22 项 PASS);每游戏独立来源已有可执行工件 `deploy/nginx/genarrative-release-origin.conf` 与门禁 `npm run check:release-origin-config`,并已在本机用真实 nginx + 真实网关验证按主机映射、Cookie 403 与平台命名空间 404;独立发行域名已由主规范 2026 决策用「平台同源路径 + sandbox 不透明来源」替代,不再是门禁;只剩「撤销传播符合最大缓存窗口」需要生产 CDN/TTL。 - 阶段 C:AGC 客户端「发布到平台」面板与发布链路(dist 归一化根 `index.html`、摘要/字节数声明、幂等键、`localProjectId` 复用)已实现并有请求组装与 Rust 导出测试;网页 `/games/publish` 走同一服务端管道,本地已用真实文件选择验证;AGC GUI 自身的端到端发布仍待客户端环境验收。目录(关键词/分类/设备筛选、滚动与筛选恢复)、详情、游玩页(主动作后加载、超时重试、旋转提示、全屏、移动端门槛)与作者中心(状态、驳回理由、撤回、下架)已实现;本地已在桌面与 `390x844` 移动视口真实游玩。 -- 阶段 D:容量/额度、重启恢复、CDN 撤销与回滚演练尚未开始,依赖生产资源。 +- 阶段 D:**2026-09-28 更新**:200 MiB 上限档与发包边界、真实栈两段式重启恢复(含失败可观测与公开版本不被误删)已在本地真实栈取证;CDN 撤销窗口、发布/回滚演练、真实发行域名与生产账号验收仍依赖生产资源。 细节与命令级证据见[实施计划【游戏分发阶段A领域合同】](【实施计划】游戏分发阶段A领域合同-2026-09-19.md)的「已完成证据」「运行时证据」「尚未完成」。 @@ -121,7 +121,7 @@ - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。 - [x] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界(2026-09-28 在本地真实栈按新上限复跑,见文末阶段 D 核对)。 -- [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。 +- [x] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件(2026-09-28 在本地真实栈按两段式重启复跑,见文末阶段 D 核对)。 - [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。 - [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。 - [ ] 主规范逐条证据矩阵齐全,未验证项明确列出;有任何核心路径未验证时不标记上线完成。 @@ -232,4 +232,16 @@ - **展开量**:9 × 60 MiB = 540 MiB(每 6 字节塞 1 个随机字节,整包约 145 MiB、仍在 200 MiB 内)返回 422 / `ExpandedPackageTooLarge`。 - **压缩比**:10 MiB 全零内容压成 10,556 字节(>100:1)返回 422 / `CompressionRatioTooHigh`。 - **校验不执行上传代码**:校验实现 `module-game-distribution` 的 `package.rs` / `release.rs` 里检索 `Command::new` / `std::process::Command` / `spawn(` 无命中;包内 `index.html` 里那行会打接口的脚本在整个校验过程中没有被执行。 - - 边界说明:失败用例都停在「上传校验」这一步(单发 PUT 会立刻回读校验),版本落到 `upload_failed` 且不会留下半包对象;成功档的 196 MiB 对象会留在 dev bucket 里,属于可丢弃的测试产物。 \ No newline at end of file + - 边界说明:失败用例都停在「上传校验」这一步(单发 PUT 会立刻回读校验),版本落到 `upload_failed` 且不会留下半包对象;成功档的 196 MiB 对象会留在 dev bucket 里,属于可丢弃的测试产物。 + +## 本轮核对(2026-09-28,阶段 D 重启恢复与失败可观测) + +- 已勾选(阶段 D 第 3 条:校验执行器重启可恢复、审核积压与失败可观测、清理不删除仍被公开版本引用的文件)——本轮补齐 + - 新增 `scripts/check-game-distribution-validation-restart-e2e.mjs`(`npm run check:game-distribution-validation-restart-e2e`,本地真实栈 + 真实 OSS,两段式 `E2E_STAGE=prepare|resume`,**11 项 PASS**)。跨进程状态写在 `E2E_STATE_FILE`,api-server 的重启由调用方在 prepare 与 resume 之间完成。 + - **prepare**:管理员登录 → 注册作者 → 建游戏 → v1(1 MiB,包内标记 `V1-OK`)上传、送审、审核公开 → v2 整包 **205,521,777 字节(196 MiB)** PUT 成功 → 立刻发出确认(`POST …/package/complete`)并在同一步杀掉 api-server。确认请求以 `TypeError: fetch failed` 结束,说明进程退出时这次校验不会被写成 `uploaded` 静默成功。 + - **重启**:`npm run dev:api-server`(SpacetimeDB 复用本地 `xushi-p4wfr`,未重启数据库)。 + - **resume(可恢复)**:作者重登 200 → `upload-state.receivedBytes=205,521,777`,与杀进程前一致(暂存对象没因进程退出丢失)→ 用新幂等键重新确认得 200 且版本落 `uploaded` → 送审 202。 + - **审核积压可观测**:`/admin/api/game-distribution/reviews` 的待审队列能查到该版本(本次 `pending=18`,队列里本就有其它积压);`/admin/api/game-distribution/games` 能看到同一游戏的两个版本(`versions=2`)。 + - **失败可观测**:再传一个结构损坏的包返回 **422 `PACKAGE_VALIDATION_FAILED` + `reason=InvalidArchive`**(作者当场可诊断);同一版本回读落到 `upload_failed`;后台游戏列表出现 `statuses=upload_failed,pending_review,published`。失败原因同时落库:`spacetime sql --server http://127.0.0.1:3101 xushi-p4wfr "SELECT version_id, status, last_error_code FROM game_distribution_version WHERE status = 'upload_failed'"` 返回 `(some = "PACKAGE_VALIDATION_FAILED")`。 + - **不误删公开版本**:v2 待审、v3 失败之后,`GET /api/game-distribution/releases//index.html` 仍 200 且正文仍含 `V1-OK`,公开版本内容没有被后续待审/失败版本清理掉。 + - **明确缺口(未当成已验收)**:版本回读 DTO 目前只暴露 `status`,不暴露 `last_error_code` / `last_error_message`;作者或运维事后回看只能看到 `upload_failed`,具体原因只有失败当次的 422 envelope 与库内字段。把它做进回读 DTO 需要改 `GameDistributionVersionSnapshot` / `GameDistributionAdminVersionSnapshot` 的 ABI 并重发模块与绑定,本轮未做,列为后续事项。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 313cdf806..fac9baa20 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6078,4 +6078,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:网页发布页飞快双击「提交审核」,服务端出现两份游戏(各带一个版本)。`isSubmitting` 是 React 状态,双击发生在同一次渲染窗口里时还没生效,再叠上 `prepareGamePackage` 的 ZIP 压缩耗时,两次点击各走一遍创建工作。 - **处理**:`GamePublishPage` 加同步 `submitInFlightRef` 在途守卫(进入提交前同步置位、`finally` 复位),并让同一次发布(含失败后重试)复用同一组 Idempotency-Key(`publishKeyRef`,成功后清空),服务端按幂等重放;`check:game-distribution-web-publish-recovery-e2e` 断言「双击只落一份游戏与一个版本」,`GamePublishPage.test.tsx` 15 passed。 -- **教训**:任何「点一次会建资源」的入口都需要同步守卫或稳定幂等键,只靠 React 状态禁用按钮不够。 \ No newline at end of file +- **教训**:任何「点一次会建资源」的入口都需要同步守卫或稳定幂等键,只靠 React 状态禁用按钮不够。 + +## 2026-09-28 两段式重启 E2E 的管理员账号由 api-server 启动环境决定,重启后换凭据会让管理员断言全红 + +- **现象**:`check:game-distribution-validation-restart-e2e` 的 prepare 段打印 `管理员登录成功 :: status=200`,杀掉 api-server 重启后,用同一组 `E2E_ADMIN_USER` / `E2E_ADMIN_PASSWORD` 跑 resume 变成 `status=401`,连带 `/admin/api/*` 的三条断言(待审队列、后台游戏列表、失败版本可见)一起 FAIL,看起来像重启把后台状态弄丢了;同一轮里作者登录、`upload-state`、重新确认、发行包读取全都正常。 +- **根因**:`.env.local` 里配置的是 `GENARRATIVE_ADMIN_USERNAME=admin` / `GENARRATIVE_ADMIN_PASSWORD=123456`,超管登录直接比对 `state.admin_runtime()`(即进程启动时的环境变量),不走库里的后台账号。上一次 api-server 若是被显式带上 `codex-admin / CodexLocal-4198!` 起起来的,重启回到 `.env.local` 后这组账号就会 401。 +- **做法**:两段式 E2E 的两次启动必须用同一组管理员凭据(本轮统一改成 `.env.local` 的 `admin / 123456`,实测 `admin` → 200、`codex-admin` → 401);遇到 401 先用 `Invoke-WebRequest http://127.0.0.1:8082/admin/api/login` 探一次凭据,再判断是否是产品回归。 +- **附注**:同一个 `E2E_STATE_FILE` 只能 resume 一次。resume 段会额外建一个坏包版本,第二次跑「后台只看到两个版本」这类断言会因为多出 `upload_failed` 版本而失败;需要重跑就重新执行 prepare。同理,prepare 依赖 `game-distribution:publish` 灰度对该作者开启,脚本自己会先 PUT 特征开关。 diff --git a/package.json b/package.json index 7cd8b6869..63922188c 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "check:game-distribution-web-publish-recovery-e2e": "node scripts/check-game-distribution-web-publish-recovery-e2e.mjs", "check:game-distribution-web-a11y-e2e": "node scripts/check-game-distribution-web-a11y-e2e.mjs", "check:game-distribution-package-limits-e2e": "node scripts/check-game-distribution-package-limits-e2e.mjs", + "check:game-distribution-validation-restart-e2e": "node scripts/check-game-distribution-validation-restart-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-validation-restart-e2e.mjs b/scripts/check-game-distribution-validation-restart-e2e.mjs new file mode 100644 index 000000000..3316f83b5 --- /dev/null +++ b/scripts/check-game-distribution-validation-restart-e2e.mjs @@ -0,0 +1,511 @@ +// 游戏分发「校验执行器重启可恢复 + 失败可观测 + 不误删公开版本」检查。 +// +// 两段式: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(); +}