From 23de78b4768496842113fb16e2c8d1f6a358148e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:44:16 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B8=B8=E6=88=8F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E5=88=86=E7=89=87=E7=BB=AD=E4=BC=A0=E4=B8=8E=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=81=A2=E5=A4=8D=E7=94=A8=E4=BE=8B=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E9=98=B6=E6=AE=B5=20A=20=E7=AC=AC=205=20=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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、并发落败给权威偏移两条错误码口径 --- ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 9 +- .../shared-memory/decision-log.md | 11 +- package.json | 3 +- .../check-game-distribution-upload-resume.mjs | 455 ++++++++++++++++++ ...check-game-distribution-upload-safety.mjs} | 132 ++++- 5 files changed, 600 insertions(+), 10 deletions(-) create mode 100644 scripts/check-game-distribution-upload-resume.mjs rename scripts/{check-game-distribution-upload-concurrency.mjs => check-game-distribution-upload-safety.mjs} (73%) diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index f4db3314a..c785089da 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -52,7 +52,7 @@ - [x] 服务端接收真实 ZIP 字节,重算摘要/字节数并建立展开清单;只有 metadata 的请求不能获得已上传或已发布状态。 - [x] 缺入口、越界/重复/大小写冲突路径、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致。 - [x] 一份版本只接受一份已确认内容;同 key 同请求重放无重复游戏/版本,不同请求冲突;同版本并发上传不混写。 -- [ ] 校验可异步恢复,响应丢失、服务进程退出和客户端重试均回到原版本;确定失败和未知结果在响应中可区分。 +- [x] 校验可异步恢复,响应丢失、服务进程退出和客户端重试均回到原版本;确定失败和未知结果在响应中可区分。 - [ ] 正常及失败状态、私有查询和错误 envelope 在 Rust 与 TypeScript DTO 中一致;新增 schema、迁移、表目录与绑定一致。 ### 证据要求 @@ -164,8 +164,11 @@ - **本轮顺带修掉一处真实缺陷**:`submit_version` 之前直接调 `get_owner_game_distribution_version`,owner 不匹配被映射成 403,而读版本/上传状态/分片/确认/撤回都按 404 处理——同一条「不属于当前主体」的语义出现两种错误码,送审入口会泄露版本是否存在。改成复用 `load_owner_version_or_404` 后,他人送审作者版本与送审不存在的版本都返回 404;脚本里这两条断言分别覆盖,修复前前者是 403 `FORBIDDEN` 让用例变红。 - 边界:脚本会为「建游戏必须提供封面」写一个 67 字节 PNG 到 dev bucket(走现役直传 + 确认链路);发行包分片与确认全部停在越权拒绝之前,不产生任何对象。 - 已勾选(第 4 条:一份版本只接受一份已确认内容、同 key 重放不产生第二份、换请求体冲突、同版本并发上传不混写)——本轮补齐 - - 领域层原有 `idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package`、`a_version_accepts_only_one_confirmed_package`;**本轮新增** `scripts/check-game-distribution-upload-concurrency.mjs`(`npm run check:game-distribution-upload-concurrency`)在本地真实栈补接口层:同 key 同请求重放建游戏/建版本返回同一条记录、同 key 换请求体建游戏 409 `CONFLICT`、同版本同偏移并发写两片相同字节只成功一片、服务端权威已收字节等于单份包长(没有重复追加)、随后确认校验通过并落到 `uploaded`(重复追加或混写都会让校验失败)、换 key 第二次送审 409 且版本数仍为 1。**17 项全部 PASS**。 + - 领域层原有 `idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package`、`a_version_accepts_only_one_confirmed_package`;**本轮新增** `scripts/check-game-distribution-upload-safety.mjs`(`npm run check:game-distribution-upload-safety`)在本地真实栈补接口层:同 key 同请求重放建游戏/建版本返回同一条记录、同 key 换请求体建游戏 409 `CONFLICT`、同版本同偏移并发写两片相同字节只成功一片、服务端权威已收字节等于单份包长(没有重复追加)、随后确认校验通过并落到 `uploaded`(重复追加或混写都会让校验失败)、换 key 第二次送审 409 且版本数仍为 1。**24 项全部 PASS**(本轮再补「结构合法但摘要与声明不符 → 409 `PACKAGE_MISMATCH` → `upload_failed` → reset → 重传成功」这条失败恢复用例)。 - **本轮顺带修掉并发落败请求的错误码**:分片追加失败时之前直接把 OSS 的 position 冲突映射成 `502 UPSTREAM_ERROR`;现在追加失败后回读权威已收字节,只要长度已前进就返回 409 `PACKAGE_UPLOAD_OFFSET_MISMATCH` 并带上 `receivedBytes`,客户端可直接按权威偏移续传。修复前脚本观察到 `502/UPSTREAM_ERROR`,修复后同一条断言看到 `409/PACKAGE_UPLOAD_OFFSET_MISMATCH receivedBytes=751`。 +- 已勾选(第 5 条:校验失败可恢复、进程退出与重试回到原版本、确定失败与未知结果可区分)——本轮补齐 + - 「客户端重试回到原版本」与「确定失败可区分」:`scripts/check-game-distribution-upload-safety.mjs`(`npm run check:game-distribution-upload-safety`,**24 项 PASS**)里同 key 重放送审得到 `202 replayed=true` 且仍是同一版本、换 key 重复送审 409 且版本数仍为 1;结构合法但摘要与声明不符的包确认时返回 409 `PACKAGE_MISMATCH`,版本落到 `upload_failed` 且 `recoveryAction=reupload`,与「未知结果」停在同一版本的 `awaiting_upload` + 权威偏移(并发落败那片返回 409 `PACKAGE_UPLOAD_OFFSET_MISMATCH`)区分开。 + - 「校验可异步恢复」:同一版本失败后 `POST /api/game-distribution/versions/{id}/package/reset` 把权威已收字节归零,重传正确字节后确认成功回到 `uploaded`;域侧另有 `validation_failure_can_retry_same_confirmed_package`、`recovery_action_covers_every_version_status`。 + - 「服务进程退出后回到原版本」:`scripts/check-game-distribution-upload-resume.mjs`(`npm run check:game-distribution-upload-resume`,`prepare` / `resume` 两段)。prepare 造 **9,437,658** 字节真实 ZIP、按分片协议只传第一片 **8,388,608** 字节(8 项 PASS);随后杀掉 api-server(PID `53844`,重启后为 `7728`);resume 重新登录同一作者、`/api/runtime/frontend-config` 仍显示灰度开启、`upload-state` 的权威已收字节仍是 `8,388,608`(来自对象存储而不是进程内存)、从该偏移续传剩余 `1,049,050` 字节、确认成功落到 `uploaded` 并送审 `202 pending_review`(9 项 PASS)。 - 仍未勾选(缺口写具体,避免"看起来做了") - - 第 5 条(校验可异步恢复;响应丢失、服务进程退出和客户端重试回到原版本;确定失败和未知结果可区分):`recovery_action_covers_every_version_status` 覆盖状态到恢复动作的映射;**本轮补齐「客户端重试」**——`check:game-distribution-upload-concurrency` 里同 key 重放送审得到 `202 replayed=true` 且仍是同一个版本、换 key 重复送审 409 且不产生第二份提交。**仍缺**「响应丢失与 api-server 进程退出后回到原版本」:需要在 8 MiB 分片上传中途重启 api-server 再续传的编排用例,本轮未做。 - 第 6 条(状态/私有查询/错误 envelope 的 Rust 与 TS DTO 一致;新增 schema、迁移、表目录与绑定一致):后半句有门禁(`npm run lint` 内的 SpacetimeDB schema guard 覆盖 85 张表、生成绑定校验通过)。**本轮新增** `check:game-distribution-dto-parity`(已接进 `npm run lint`):按显式映射表逐字段/逐变体比对 14 组 Rust `shared-contracts` DTO 与手写 `packages/shared/src/contracts/gameDistribution.ts`,两个方向都做过变异验证——TS 侧把 `name` 改成 `displayName`、Rust 侧给 `GameDistributionAuthor` 加 `extra_field`,各自都让门禁失败并指出缺哪个字段;脚本同时登记了 7 个「服务端逐字段手拼 JSON、没有 Rust 结构体」的 TS 类型。**本轮补齐** `coverObjectKey` / `screenshots` / `publicationRevision` 四个字段进 Rust 结构体(依据是 `game_payload` 与 `private_version_payload` 实际发出的键),并删掉脚本里用来豁免它们的 `TS_ONLY_FIELDS` 白名单:现在任一方向多出字段都会让门禁失败,作者侧响应省略 `currentVersion` 这一条差异改用 TS 可选字段描述。**本轮再补构建器一层**:门禁新增 `RESPONSE_BUILDERS`,解析 `server-rs/crates/api-server/src/modules/game_distribution.rs` 里 `game_payload` / `public_game_payload` / `private_version_payload` / `version_summary_payload` 的 `json!` 顶层键与顶层 `object.insert(…)`,逐键比对 TS 类型:发出的键必须都在类型里、类型的必需字段必须都发出、`public_game_payload` 还必须发出被 TS 标成可选的 `currentVersion`,`game_payload` 的 `author` / `deviceSupport` 两个嵌套字面量同样逐键比对。变异验证五种改法各自让门禁失败并指出具体键:删掉 `game_payload.publicationRevision`、删掉 `deviceSupport.touch`、把 `author.avatarUrl` 改名、把插入的 `currentVersion` 改名、给 `version_summary_payload` 加一个 TS 没有的键;恢复后通过。**剩余缺口**:门禁比对的是键而不是值的类型,也覆盖不到未登记的嵌套对象与 envelope——成功/失败 envelope 的字段由 TS 侧运行时守卫(`packages/shared/src/http.ts`、`src/services/apiClient.ts`)消费,要彻底类型化得先把这两个响应改成结构化构建。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a0cf73e0b..27b13c8de 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9687,4 +9687,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(跟随 master 退役诊断详情入口):master `5398a53e6` 明确把诊断引用移出用户可见文案并删除 `read_agent_runtime_error_detail` 与渲染层读取逻辑,本分支《AGC统一错误诊断与验收反馈》里程碑第 6 条因此被上游取代,实现与专属用例一并退役;失败说明继续由 `turn.completed.failure` 事件投影。 - 影响范围:AGC 客户端(`direct_thread_manager`、`direct_runtime`、`platform_maintenance`、`llm_catalog`、`account_api`、`auth_session`、`platform_asset_upload`、`assets`、`game_distribution_publish`、`agent/generation/canvas_generation`、渲染层 chat 控制器与视图、`tests/`)、nginx SPA 白名单、`.gitignore`、mobile 检查脚本、capabilities 描述、文档与共享记忆。 - 验证:`cargo check`(0 error)、`cargo fmt -- --check`、Rust 定向 `direct_thread_manager` 17 passed 与 `platform_maintenance` 1 passed、`npx vitest run tests/appSurface.test.ts`(201 passed / 9 skipped)与受影响套件、`npm run ai-game-creator-shell:check:web`、根 `npm test`、`npm run lint`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 -- 边界(未验证):真实维护窗口下的弹窗与并发失败表现、真实 Provider 下长回合的活动回合事件时序,都需要真机运行时证据;本轮只到源码级与定向用例级。 \ No newline at end of file +- 边界(未验证):真实维护窗口下的弹窗与并发失败表现、真实 Provider 下长回合的活动回合事件时序,都需要真机运行时证据;本轮只到源码级与定向用例级。 +## 2026-09-28 游戏分发作者写入口的两条错误码口径:越权一律 404、并发落败给权威偏移 + +- 背景:给游戏分发阶段 A 第 1、4、5 条补接口级用例时,在本地真实栈(SpacetimeDB `xushi-p4wfr` + api-server + dev bucket)发现两处错误码口径不一致。 +- 决策(越权一律 404):`submit_version` 之前直接把 owner 不匹配映射成 403,而读私有版本、读上传状态、写分片、确认分包、撤回都返回 404。现在送审也走 `load_owner_version_or_404`,六个作者入口统一按「资源不存在」回应,不用错误码区分「别人的版本」是否存在。 +- 决策(并发落败给权威偏移):同版本同偏移并发写分片时,OSS 的 position 冲突之前被映射成 `502 UPSTREAM_ERROR`。现在追加失败后回读权威已收字节,只要长度已经前进就返回 `409 PACKAGE_UPLOAD_OFFSET_MISMATCH` 并带 `receivedBytes`,与「偏移不符」的既有语义一致;客户端按权威偏移续传,不必猜测自己有没有写进去。 +- 决策(确定失败与未知结果的分界):结构合法但摘要/体积/文件数/入口与声明不符 → `409 PACKAGE_MISMATCH`,版本落 `upload_failed` 且 `recoveryAction=reupload`;中断、进程退出与并发落败都停在原版本 `awaiting_upload`,靠 `upload-state` 的权威偏移继续,不落成失败态。 +- 影响范围:`server-rs/crates/api-server/src/modules/game_distribution.rs`、`scripts/check-game-distribution-owner-isolation.mjs`、`scripts/check-game-distribution-upload-safety.mjs`、`scripts/check-game-distribution-upload-resume.mjs`、`package.json`、游戏分发里程碑取证。 +- 验证:本地真实栈三个脚本全部 PASS(越权隔离 21 项、上传安全 24 项、分片续传 prepare 8 项 + resume 9 项);分片续传中途杀掉 api-server 进程(PID `53844` → 重启 `7728`)后仍从 `8,388,608` 偏移续传成功;`cargo test -p api-server -- package_` 6 passed 与 `game_distribution` 27 passed、`npm run lint`、`check:encoding`、`check:doc-index`、`git diff --check`。 +- 边界:证据来自本机 dev 栈与 dev bucket;生产域名、CDN 缓存窗口与真实客户端安装版的自动上传仍未验证。 \ No newline at end of file diff --git a/package.json b/package.json index 52699419b..26eaac292 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,8 @@ "check:game-distribution-dto-parity": "node scripts/check-game-distribution-dto-parity.mjs", "check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs", "check:game-distribution-owner-isolation": "node scripts/check-game-distribution-owner-isolation.mjs", - "check:game-distribution-upload-concurrency": "node scripts/check-game-distribution-upload-concurrency.mjs", + "check:game-distribution-upload-safety": "node scripts/check-game-distribution-upload-safety.mjs", + "check:game-distribution-upload-resume": "node scripts/check-game-distribution-upload-resume.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-upload-resume.mjs b/scripts/check-game-distribution-upload-resume.mjs new file mode 100644 index 000000000..b6d251bd4 --- /dev/null +++ b/scripts/check-game-distribution-upload-resume.mjs @@ -0,0 +1,455 @@ +// 游戏分发「分片上传中断 / api-server 进程退出后按权威偏移续传」编排检查。 +// +// 需要本地 dev 栈(SpacetimeDB standalone + api-server),两个阶段之间由调用方重启 api-server: +// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=prepare \ +// npm run check:game-distribution-upload-resume +// <重启 api-server> +// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=resume \ +// npm run check:game-distribution-upload-resume +// +// E2E_STATE_FILE 可覆盖状态文件路径(默认在系统临时目录),E2E_API_BASE 默认 http://127.0.0.1:4198。 +// +// prepare:注册作者 → 建游戏与版本(声明整包摘要/字节数)→ 造一个大于 8 MiB 的真实 ZIP → +// 按分片协议只上传第一片(8 MiB)→ 断言权威已收字节就是第一片长度。 +// resume :重新登录同一作者 → 断言灰度配置在进程重启后仍然生效 → 读权威已收字节(来自 OSS, +// 不是进程内状态)→ 从该偏移续传剩余字节 → 确认整包 → 送审。 +// 这条链路只在本机 dev 数据库与该 dev bucket 的对象键下落行。 +import { createHash, randomBytes } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +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: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 STAGE = (process.env.E2E_STAGE ?? 'prepare').trim(); +const DEV_PASSWORD = 'GenE2e123!'; +const CHUNK_BYTES = 8 * 1024 * 1024; + +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); +} +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, + json, + text, + data: json?.data, + error: json?.error, + }; +} + +async function stateFilePaths() { + const configured = (process.env.E2E_STATE_FILE ?? '').trim(); + if (configured) { + return { state: configured, zip: `${configured}.zip` }; + } + const dir = await mkdtemp(path.join(tmpdir(), 'genarrative-gd-resume-')); + const state = path.join(dir, 'state.json'); + return { state, zip: `${state}.zip` }; +} + +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; +} + +const setGate = (token, enabled, rolloutPercent) => + api('/admin/api/feature-gates', { + method: 'PUT', + token, + body: { + gateKey: 'game-distribution:publish', + enabled, + rolloutPercent, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 分片续传', + }, + }); + +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-resume-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'upload-resume', 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-resume', + }, + }); + if (confirm.status !== 200) { + throw new Error( + `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + ); + } + return confirm.data.assetObject.assetObjectId; +} + +// 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。 +async function buildLargeFixtureZip() { + const archive = new JSZip(); + archive.file('index.html', 'resume'); + archive.file('assets/app.js', "console.log('resume');"); + archive.file('assets/blob.bin', randomBytes(9 * 1024 * 1024)); + const bytes = await archive.generateAsync({ type: 'uint8array' }); + return { bytes: Buffer.from(bytes), fileCount: 3 }; +} + +async function uploadChunk(token, versionId, bytes, offset, key) { + return api(`/api/game-distribution/versions/${versionId}/package/chunk`, { + method: 'PUT', + token, + headers: { + 'Content-Type': 'application/octet-stream', + 'Idempotency-Key': key, + 'x-genarrative-upload-offset': String(offset), + }, + binary: bytes, + }); +} + +async function runPrepare(paths) { + const admin = await adminToken(); + if (!admin) process.exit(1); + const gateOpen = await setGate(admin, true, 100); + check( + '发布灰度可开启并放量', + gateOpen.status === 200, + `status=${gateOpen.status}`, + ); + + const stamp = Date.now(); + const phone = `136${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 built = await buildLargeFixtureZip(); + const zipBytes = built.bytes; + const zipSha256 = createHash('sha256').update(zipBytes).digest('hex'); + check( + 'fixture 发行包大于单个分片', + zipBytes.length > CHUNK_BYTES, + `packageBytes=${zipBytes.length}`, + ); + await writeFile(paths.zip, zipBytes); + + 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': `resume-game-${stamp}` }, + 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 versionResponse = await api( + `/api/game-distribution/games/${gameId}/versions`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `resume-version-${stamp}` }, + body: { + packageSha256: zipSha256, + packageBytes: zipBytes.length, + packageFileCount: built.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }, + ); + const versionId = versionResponse.data?.versionId; + 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 firstChunk = zipBytes.subarray(0, CHUNK_BYTES); + const uploaded = await uploadChunk( + author, + versionId, + firstChunk, + 0, + `resume-chunk-1-${stamp}`, + ); + check( + '第一片上传成功', + uploaded.status === 200 && uploaded.data?.receivedBytes === CHUNK_BYTES, + `status=${uploaded.status} receivedBytes=${uploaded.data?.receivedBytes ?? 'n/a'}`, + ); + + const state = await api( + `/api/game-distribution/versions/${versionId}/package/upload-state`, + { token: author }, + ); + check( + '权威已收字节等于第一片长度', + state.status === 200 && state.data?.receivedBytes === CHUNK_BYTES, + `status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`, + ); + + await writeFile( + paths.state, + JSON.stringify( + { + apiBase: API, + stage: 'prepared', + phone, + password: DEV_PASSWORD, + gameId, + versionId, + packageSha256: zipSha256, + packageBytes: zipBytes.length, + packageFileCount: built.fileCount, + firstChunkBytes: CHUNK_BYTES, + zipPath: paths.zip, + }, + null, + 2, + ), + ); + console.log( + `\nPREPARED gameId=${gameId} versionId=${versionId} packageBytes=${zipBytes.length} receivedBytes=${CHUNK_BYTES}`, + ); + console.log( + '下一步:重启 api-server,再用 E2E_STAGE=resume 运行本脚本(同一 E2E_STATE_FILE)。', + ); + process.exit(failures === 0 ? 0 : 1); +} + +async function runResume(paths) { + const raw = await readFile(paths.state, 'utf8'); + const saved = JSON.parse(raw); + const zipBytes = await readFile(saved.zipPath); + check( + '状态文件与 fixture 齐备', + saved.stage === 'prepared' && + saved.versionId && + zipBytes.length === saved.packageBytes, + `versionId=${saved.versionId ?? ''} packageBytes=${zipBytes.length}/${saved.packageBytes}`, + ); + + const admin = await adminToken(); + if (!admin) process.exit(1); + + const login = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: saved.phone, password: saved.password }, + }); + const author = login.data?.token; + check( + '重启后同一作者仍能登录', + login.status === 200 && Boolean(author), + `status=${login.status}`, + ); + if (!author) process.exit(1); + + const frontendConfig = await api('/api/runtime/frontend-config', { + token: author, + }); + check( + '灰度配置在 api-server 重启后仍然生效', + frontendConfig.status === 200 && + frontendConfig.data?.gameDistributionPublishEnabled === true, + `status=${frontendConfig.status} enabled=${frontendConfig.data?.gameDistributionPublishEnabled}`, + ); + + const state = await api( + `/api/game-distribution/versions/${saved.versionId}/package/upload-state`, + { token: author }, + ); + check( + '重启后权威已收字节仍等于第一片长度(来自对象存储)', + state.status === 200 && state.data?.receivedBytes === saved.firstChunkBytes, + `status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`, + ); + + const remaining = zipBytes.subarray(saved.firstChunkBytes); + const resumed = await uploadChunk( + author, + saved.versionId, + remaining, + saved.firstChunkBytes, + `resume-chunk-2-${saved.versionId}`, + ); + check( + '按权威偏移续传剩余字节成功', + resumed.status === 200 && + resumed.data?.receivedBytes === saved.packageBytes, + `status=${resumed.status} receivedBytes=${resumed.data?.receivedBytes ?? 'n/a'} expected=${saved.packageBytes}`, + ); + + const completed = await api( + `/api/game-distribution/versions/${saved.versionId}/package/complete`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `resume-complete-${saved.versionId}` }, + }, + ); + check( + '整包确认成功(回到可送审状态)', + completed.status === 200 && completed.data?.status === 'uploaded', + `status=${completed.status} versionStatus=${completed.data?.status ?? ''} msg=${completed.error?.message ?? ''}`, + ); + + const submitted = await api( + `/api/game-distribution/versions/${saved.versionId}/submit`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `resume-submit-${saved.versionId}` }, + body: { + expectedPublicationRevision: completed.data?.publicationRevision ?? 0, + }, + }, + ); + check( + '送审成功(202 + pending_review)', + submitted.status === 202 && + submitted.data?.version?.status === 'pending_review', + `status=${submitted.status} versionStatus=${submitted.data?.version?.status ?? ''}`, + ); + + const gateClosed = await setGate(admin, false, 0); + check( + '发布灰度恢复关闭', + gateClosed.status === 200, + `status=${gateClosed.status}`, + ); + + await rm(saved.zipPath, { force: true }); + console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); + process.exit(failures === 0 ? 0 : 1); +} + +const paths = await stateFilePaths(); +if (STAGE === 'prepare') { + await runPrepare(paths); +} else { + await runResume(paths); +} diff --git a/scripts/check-game-distribution-upload-concurrency.mjs b/scripts/check-game-distribution-upload-safety.mjs similarity index 73% rename from scripts/check-game-distribution-upload-concurrency.mjs rename to scripts/check-game-distribution-upload-safety.mjs index d2d234a5e..4efa5bfb9 100644 --- a/scripts/check-game-distribution-upload-concurrency.mjs +++ b/scripts/check-game-distribution-upload-safety.mjs @@ -1,8 +1,9 @@ -// 游戏分发「同版本并发上传不混写 + 幂等重放」真实链路检查(需要本地 dev 栈:SpacetimeDB + api-server)。 +// 游戏分发「同版本并发上传不混写 + 幂等重放 + 失败后可恢复上传」真实链路检查 +// (需要本地 dev 栈:SpacetimeDB + api-server)。 // // 用法: // E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \ -// npm run check:game-distribution-upload-concurrency +// npm run check:game-distribution-upload-safety // E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。 // // 覆盖: @@ -12,6 +13,8 @@ // 已收字节等于单份包长,随后的确认校验必须通过(混写或重复追加都会让校验失败)。 // 3. 送审成功后用同一个 Key 重放 → 202 `replayed=true` 且仍是同一版本; // 不同 Key 重复送审不会产生第二份提交(版本数仍为 1、状态不变)。 +// 4. 内容与声明摘要不符的分片:确认时明确失败、版本落到 `upload_failed`、半包对象被清理 +// (确定失败),随后同一版本重新上传正确字节仍能确认成功(失败可恢复)。 // 整条链路只在本地 dev 数据库与该 dev bucket 的对象前缀下落行;一个几百字节的发行包对象 // 会在确认后留在 bucket 里(与现役上传链路的产物一致),另有 67 字节封面 PNG。 import { createHash } from 'node:crypto'; @@ -77,11 +80,11 @@ async function api(path, options = {}) { }; } -// 两份内容不同但长度一致的条目,保证 fixture ZIP 的可复现长度(分片并发用例需要固定声明大小)。 -async function buildFixtureZip() { +// filler 只影响条目内容:换 filler 得到「结构同样合法、长度一致、摘要不同」的另一份包。 +async function buildFixtureZip(filler = 'x') { const archive = new JSZip(); archive.file('index.html', 'concurrency'); - archive.file('assets/app.js', `console.log('${'x'.repeat(384)}');`); + archive.file('assets/app.js', `console.log('${filler.repeat(384)}');`); const bytes = await archive.generateAsync({ type: 'uint8array' }); return Buffer.from(bytes); } @@ -422,6 +425,125 @@ async function main() { `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( '发布灰度恢复关闭',