diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index c785089da..efe57d34a 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -24,8 +24,8 @@ 以下是已落地的实现与本地运行时证据,**不等于阶段验收**:B/C 的隔离托管、域名与生产验收仍缺少真实发行域名、TLS/CDN 与生产账号。 -- 阶段 A:真实 ZIP 上传、游戏身份、owner/幂等/CAS、状态机、DTO 与 schema 门禁已完成;真实 SpacetimeDB + 私有 OSS 的创建/上传/确认/重启恢复已有证据。 -- 阶段 B:人工审核(后台列表、通过需 HTTPS 入口、拒绝需理由)、公开投影、发行网关(按公开版本服务、扩展名白名单、`nosniff`/CORP/CSP、带 Cookie 403)与作者下架已实现;每游戏独立来源已有可执行工件 `deploy/nginx/genarrative-release-origin.conf` 与门禁 `npm run check:release-origin-config`,并已在本机用真实 nginx + 真实网关验证按主机映射、Cookie 403 与平台命名空间 404;生产域名、通配证书与 CDN TTL 仍需上线环境确认。 +- 阶段 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 更新**:审核治理(待审可见、可批可拒、结论可追溯、作者不能提交审核)与「更新待审/被拒不改变在线旧版」已在本地真实栈取证(见文末阶段 B 核对);每游戏独立来源已有可执行工件 `deploy/nginx/genarrative-release-origin.conf` 与门禁 `npm run check:release-origin-config`,并已在本机用真实 nginx + 真实网关验证按主机映射、Cookie 403 与平台命名空间 404;生产域名、通配证书与 CDN TTL 仍需上线环境确认。 - 阶段 C:AGC 客户端「发布到平台」面板与发布链路(dist 归一化根 `index.html`、摘要/字节数声明、幂等键、`localProjectId` 复用)已实现并有请求组装与 Rust 导出测试;网页 `/games/publish` 走同一服务端管道,本地已用真实文件选择验证;AGC GUI 自身的端到端发布仍待客户端环境验收。目录(关键词/分类/设备筛选、滚动与筛选恢复)、详情、游玩页(主动作后加载、超时重试、旋转提示、全屏、移动端门槛)与作者中心(状态、驳回理由、撤回、下架)已实现;本地已在桌面与 `390x844` 移动视口真实游玩。 - 阶段 D:容量/额度、重启恢复、CDN 撤销与回滚演练尚未开始,依赖生产资源。 @@ -53,7 +53,7 @@ - [x] 缺入口、越界/重复/大小写冲突路径、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致。 - [x] 一份版本只接受一份已确认内容;同 key 同请求重放无重复游戏/版本,不同请求冲突;同版本并发上传不混写。 - [x] 校验可异步恢复,响应丢失、服务进程退出和客户端重试均回到原版本;确定失败和未知结果在响应中可区分。 -- [ ] 正常及失败状态、私有查询和错误 envelope 在 Rust 与 TypeScript DTO 中一致;新增 schema、迁移、表目录与绑定一致。 +- [x] 正常及失败状态、私有查询和错误 envelope 在 Rust 与 TypeScript DTO 中一致;新增 schema、迁移、表目录与绑定一致。 ### 证据要求 @@ -70,8 +70,8 @@ ### 行为与验收 -- [ ] 自动校验通过只进入待审,管理员可查看真实待审游戏并批准/拒绝;审核记录可追溯且普通作者不能提交审核动作。 -- [ ] 新游戏审核通过并核验发行文件可读后才公开;更新待审或失败不改变旧版资料、URL 与可玩性。 +- [x] 自动校验通过只进入待审,管理员可查看真实待审游戏并批准/拒绝;审核记录可追溯且普通作者不能提交审核动作。 +- [x] 新游戏审核通过并核验发行文件可读后才公开;更新待审或失败不改变旧版资料、URL 与可玩性。 - [ ] 审核激活与下架使用 `publicationRevision` CAS;过期审核、重复批准、并发更新和下架不会恢复本应关闭的游戏。 - [ ] 游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见,不返回私有快照地址。 - [ ] 每游戏在独立 HTTPS origin 上,iframe sandbox、网关 CSP/CORS/MIME/禁止 Worker 等策略与主规范一致。 @@ -170,5 +170,21 @@ - 「客户端重试回到原版本」与「确定失败可区分」:`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)。 -- 仍未勾选(缺口写具体,避免"看起来做了") - - 第 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`)消费,要彻底类型化得先把这两个响应改成结构化构建。 +- 已勾选(第 6 条:正常/失败状态、私有查询与错误 envelope 的 Rust 与 TS 定义一致;schema/迁移/表目录/绑定一致)——本轮补齐 + - 字段级:`check:game-distribution-dto-parity` 逐字段/逐变体比对 14 组 Rust `shared-contracts` DTO 与手写 TS 类型(双向、做过变异验证),并解析 `game_payload` / `public_game_payload` / `private_version_payload` / `version_summary_payload` 的 `json!` 顶层键与 `top-level object.insert` 逐键比对(含 `author` / `deviceSupport` 两个嵌套字面量,5 种改法各自让门禁变红)。 + - envelope:真实响应现在成功与失败都是同一份 `{ ok, data, error, meta }`(`meta.apiVersion` / `meta.requestId` / `meta.operation` 在两侧都在),TS 侧 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts` 的运行时守卫读的正是 `meta.apiVersion` / `meta.requestId` / `error.code` / `error.details`。**本轮顺带修掉一处真实缺陷**:`AppError::into_response` 之前固定按“无请求上下文”构造错误 envelope,错误响应里没有 `meta.requestId` / `meta.operation`,而成功响应有;现在用 `CURRENT_REQUEST_CONTEXT` 任务局部变量把同一份上下文传给错误转换,脱离请求任务时仍退回无上下文形状。取证:`check:game-distribution-owner-isolation` 新增两条 envelope 断言,修复前失败(`requestId=` 为空)、修复后 **23 项 PASS**。 + - schema/迁移/表目录/绑定:`npm run lint` 内的 SpacetimeDB schema guard(覆盖 85 张表)与生成绑定校验通过。 + - 口径说明:字段门禁比对的是名字而不是值类型;envelope 在 TS 侧是运行时守卫而不是类型镜像,因此这一条按「客户端能一致消费真实 envelope」取证,不是类型级镜像。 +- 阶段 A 的六条行为与验收已在本轮全部勾选;阶段正式验收仍需 owner 评审记录。 + +## 本轮核对(2026-09-28,阶段 B 审核与在线旧版) + +- 已勾选(第 1 条:自动校验通过只进入待审、管理员可见真实待审并可批可拒、结论可追溯、普通作者不能提交审核动作)——本轮补齐 + - `npm run check:game-distribution-media-e2e`(本轮扩展,**44 项 PASS**)在本地真实栈(SpacetimeDB + api-server + dev bucket)跑完真实素材与发行包上传链路后补审核治理段:普通作者调 `/admin/api/game-distribution/versions/{id}/review` → 403 `FORBIDDEN`;更新版本上传 + 送审 → 202 `pending_review`;`GET /admin/api/game-distribution/reviews` 能看到该 `pending_review` 条目;管理员 `reject` + `reviewReason=E2E 拒绝原因` → 版本 `rejected`;后台 `GET /admin/api/game-distribution/games` 的版本行上 `status=rejected` / `reviewReason=E2E 拒绝原因` / `reviewedAt=1790592647.301396Z` 同时存在(结论可追溯)。 +- 已勾选(第 2 条:审核通过并核验发行文件可读后才公开;更新待审或失败不改变旧版资料、URL 与可玩性)——本轮补齐 + - 同一脚本:新游戏审核通过后 `currentVersion.entryUrl` 由服务端派生为 `/games//`,发行网关 `GET .../releases//index.html` 返回 200 且内容含旧版入口标记(「发行文件可读」与「公开后可玩」在同一次运行内取证);更新版本待审期间公开详情 `currentVersion.sha256` 仍是旧版摘要、网关仍服务旧版内容(不含 `E2E-V2-OK`);更新版本被拒后同样是旧版摘要与旧版内容。 +- 仍未勾选(阶段 B 其余条目) + - 第 3 条(审核激活与下架使用 `publicationRevision` CAS;过期审核、重复批准、并发更新和下架不会恢复本应关闭的游戏):本轮只覆盖单线程批准/拒绝路径,CAS 过期与并发路径仍缺接口级用例。 + - 第 4 条(游客目录/详情/启动接口只返回已公开投影;未公开与已下架都不可见、不返回私有快照地址):待审不可见与公开可见已覆盖;**作者下架与管理员安全下架后的目录、详情、启动接口**仍未覆盖。 + - 第 5/6 条(每游戏独立 HTTPS origin、iframe sandbox、CSP/CORS/MIME/禁止 Worker、外站请求阻断):需要真实域名与真实浏览器,本轮未做;api-server 侧只有同源网关单测(`release_gateway_is_mounted_and_never_serves_cookie_bearing_requests`、`release_response_sets_nosniff_and_scopes_csp_to_html`、`release_response_allows_opaque_sandbox_asset_loads`)。 + - 第 7 条(下架会关闭新启动与发行读取、不能绕过网关直取公开 OSS、撤销传播在最大缓存窗口内):需要真实 OSS 与 CDN,本轮未做。- 说明:以上都是实现侧取证;里程碑与阶段的正式验收仍按原门禁流程(需要 owner 评审记录),本次只更新可证的勾选项与缺口。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 27b13c8de..6a75b464a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9694,6 +9694,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(越权一律 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`。 +- 决策(错误 envelope 与成功 envelope 同一份 meta):`AppError::into_response` 之前固定按「无请求上下文」构造错误响应,错误 envelope 里没有 `meta.requestId` / `meta.operation`,而成功 envelope 有,客户端在报错时拿不到可用于排查的 requestId。现在 `attach_request_context` 用 `tokio::task_local!` 的 `CURRENT_REQUEST_CONTEXT` 把上下文作用域套住整个 handler,错误转换读同一份上下文;脱离请求任务(单测、后台任务)时退回无上下文形状。取证:`check:game-distribution-owner-isolation` 新增成功/失败 envelope 两条断言,修复前 `requestId=` 为空失败、修复后 23 项 PASS。 +- 决策(envelope 一致性按可消费取证):TS 侧没有 envelope 的类型镜像,只有 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts` 的运行时守卫,因此这一条按「客户端能一致消费真实 envelope」取证(字段名 `ok` / `data` / `error.code` / `meta.apiVersion` / `meta.requestId`),不是类型级镜像;字段门禁比对的是名字而不是值类型。- 影响范围:`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(越权隔离 23 项、上传安全 24 项、分片续传 prepare 8 项 + resume 9 项、媒体链路 44 项);分片续传中途杀掉 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/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 55a88bf78..db5b267b4 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -192,6 +192,26 @@ async function buildZip() { }; } +/** 更新版本用的小包:入口里带标记,用来证明在线旧版没有被待审/被拒的新版本替换。 */ +async function buildZipWithMarker(marker) { + const zip = new JSZip(); + zip.file( + 'index.html', + `E2E ${marker}

${marker}

`, + ); + zip.file( + 'assets/app.js', + `document.documentElement.dataset.e2e="${marker}";`, + ); + const bytes = await zip.generateAsync({ type: 'uint8array' }); + return { + bytes: Buffer.from(bytes), + fileCount: 2, + assetPath: 'assets/app.js', + entryMarker: marker, + }; +} + async function main() { // 1. 作者注册 const phone = `137${String(Date.now()).slice(-8)}`; @@ -610,6 +630,163 @@ async function main() { `status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`, ); + // 11. 审核治理:普通作者不能提交审核动作;更新待审 / 更新被拒都不改变在线旧版 + const authorReview = await api( + `/admin/api/game-distribution/versions/${versionId}/review`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-author-review-${id}` }, + body: { decision: 'approve', expectedPublicationRevision: 0 }, + }, + ); + check( + '普通作者不能提交审核动作', + authorReview.status === 401 || authorReview.status === 403, + `status=${authorReview.status} code=${authorReview.error?.code ?? ''}`, + ); + + const builtV2 = await buildZipWithMarker('E2E-V2-OK'); + const sha256V2 = crypto + .createHash('sha256') + .update(builtV2.bytes) + .digest('hex'); + const versionV2 = await api( + `/api/game-distribution/games/${gameId}/versions`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-v2-version-${id}` }, + body: { + packageSha256: sha256V2, + packageBytes: builtV2.bytes.length, + packageFileCount: builtV2.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }, + ); + const versionIdV2 = versionV2.data?.versionId; + check( + '已公开游戏可以创建更新版本', + versionV2.status === 200 && Boolean(versionIdV2), + `status=${versionV2.status}`, + ); + const uploadV2 = await api( + `/api/game-distribution/versions/${versionIdV2}/package`, + { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `e2e-v2-upload-${id}`, + 'Content-Type': 'application/zip', + }, + binary: builtV2.bytes, + }, + ); + const submitV2 = await api( + `/api/game-distribution/versions/${versionIdV2}/submit`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-v2-submit-${id}` }, + body: { + expectedPublicationRevision: publishedGame?.publicationRevision ?? 0, + }, + }, + ); + check( + '更新版本上传并送审后进入待审', + uploadV2.status === 200 && submitV2.status === 202, + `upload=${uploadV2.status} submit=${submitV2.status}`, + ); + + const reviewQueue = await api('/admin/api/game-distribution/reviews', { + token: admin, + }); + const pendingEntry = (reviewQueue.data?.entries ?? []).find( + (entry) => entry.versionId === versionIdV2, + ); + check( + '管理员能看到真实待审条目', + pendingEntry?.status === 'pending_review', + `status=${pendingEntry?.status ?? 'missing'}`, + ); + + const detailWhilePending = await api( + `/api/game-distribution/games/${gameId}`, + ); + check( + '更新待审期间公开详情仍指向在线旧版', + detailWhilePending.data?.currentVersion?.sha256 === sha256, + `sha=${String(detailWhilePending.data?.currentVersion?.sha256).slice(0, 12)} v1=${sha256.slice(0, 12)}`, + ); + const releaseWhilePending = await fetch( + `${API}/api/game-distribution/releases/${gameId}/index.html`, + ); + const releaseWhilePendingBody = await releaseWhilePending.text(); + check( + '更新待审期间发行网关仍服务旧版内容', + releaseWhilePending.status === 200 && + !releaseWhilePendingBody.includes('E2E-V2-OK') && + (built.entryMarker === null || + releaseWhilePendingBody.includes(built.entryMarker)), + `status=${releaseWhilePending.status}`, + ); + + const rejectedV2 = await api( + `/admin/api/game-distribution/versions/${versionIdV2}/review`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `e2e-v2-reject-${id}` }, + body: { + decision: 'reject', + expectedPublicationRevision: + detailWhilePending.data?.publicationRevision ?? 0, + reviewReason: 'E2E 拒绝原因', + }, + }, + ); + check( + '管理员可以拒绝更新版本并留下原因', + rejectedV2.status === 200 && + rejectedV2.data?.version?.status === 'rejected' && + rejectedV2.data?.version?.reviewReason === 'E2E 拒绝原因', + `status=${rejectedV2.status} versionStatus=${rejectedV2.data?.version?.status ?? ''} reason=${rejectedV2.data?.version?.reviewReason ?? ''}`, + ); + + const detailAfterReject = await api(`/api/game-distribution/games/${gameId}`); + const releaseAfterReject = await fetch( + `${API}/api/game-distribution/releases/${gameId}/index.html`, + ); + const releaseAfterRejectBody = await releaseAfterReject.text(); + check( + '更新被拒后公开 URL 与可玩内容仍是旧版', + detailAfterReject.data?.currentVersion?.sha256 === sha256 && + releaseAfterReject.status === 200 && + !releaseAfterRejectBody.includes('E2E-V2-OK'), + `status=${releaseAfterReject.status} sha=${String(detailAfterReject.data?.currentVersion?.sha256).slice(0, 12)}`, + ); + + const adminGamesAfterReject = await api( + '/admin/api/game-distribution/games', + { token: admin }, + ); + const adminGameAfterReject = (adminGamesAfterReject.data?.games ?? []).find( + (game) => game.gameId === gameId, + ); + const adminVersionAfterReject = (adminGameAfterReject?.versions ?? []).find( + (version) => version.versionId === versionIdV2, + ); + check( + '审核结论可在后台追溯(status / reviewReason / reviewedAt 都在版本行上)', + adminVersionAfterReject?.status === 'rejected' && + adminVersionAfterReject?.reviewReason === 'E2E 拒绝原因' && + Boolean(adminVersionAfterReject?.reviewedAt), + `status=${adminVersionAfterReject?.status ?? 'missing'} reviewedAt=${adminVersionAfterReject?.reviewedAt ?? ''}`, + ); + console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exitCode = failures === 0 ? 0 : 1; } diff --git a/scripts/check-game-distribution-owner-isolation.mjs b/scripts/check-game-distribution-owner-isolation.mjs index ad2b9a610..9b721d1eb 100644 --- a/scripts/check-game-distribution-owner-isolation.mjs +++ b/scripts/check-game-distribution-owner-isolation.mjs @@ -67,6 +67,8 @@ async function api(path, options = {}) { text, data: json?.data, error: json?.error, + meta: json?.meta, + ok: json?.ok, }; } @@ -265,6 +267,32 @@ async function main() { `status=${foreignRead.status} code=${foreignRead.error?.code ?? ''}`, ); + // 成功 / 失败 envelope 的形状:TS 侧 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts` + // 的运行时守卫读的就是这几个字段(data / error.code / meta.apiVersion / meta.requestId)。 + check( + '成功响应 envelope 带 ok/data 与 meta.apiVersion / meta.requestId', + created.status === 200 && + created.data !== undefined && + created.ok === true && + created.error === null && + typeof created.meta?.apiVersion === 'string' && + created.meta.apiVersion.length > 0 && + typeof created.meta?.requestId === 'string' && + created.meta.requestId.length > 0, + `apiVersion=${created.meta?.apiVersion ?? ''} requestId=${created.meta?.requestId ?? ''}`, + ); + check( + '失败响应 envelope 带 ok=false/error.code 与 meta.requestId', + foreignRead.status === 404 && + foreignRead.ok === false && + typeof foreignRead.error?.code === 'string' && + foreignRead.error.code.length > 0 && + foreignRead.data === null && + typeof foreignRead.meta?.requestId === 'string' && + foreignRead.meta.requestId.length > 0, + `code=${foreignRead.error?.code ?? ''} requestId=${foreignRead.meta?.requestId ?? ''}`, + ); + const foreignState = await api( `/api/game-distribution/versions/${versionId}/package/upload-state`, { token: intruder.token }, diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 5f5cd70c3..6357e88d9 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -6,7 +6,10 @@ use axum::{ use serde_json::Value; use shared_contracts::api::ApiErrorPayload; -use crate::{api_response::json_error_body, request_context::RequestContext}; +use crate::{ + api_response::json_error_body, + request_context::{CURRENT_REQUEST_CONTEXT, RequestContext}, +}; #[derive(Debug)] pub struct AppError { @@ -112,7 +115,14 @@ impl AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { - self.into_response_with_context(None) + // 绝大多数 handler 错误是在 `attach_request_context` 的任务里转成响应的: + // 这里带上同一份 requestId / operation,错误 envelope 才与成功 envelope 一致; + // 脱离请求任务(测试、后台任务)时退回无上下文的形状。 + if CURRENT_REQUEST_CONTEXT.try_with(|_| ()).is_ok() { + CURRENT_REQUEST_CONTEXT.with(|context| self.into_response_with_context(Some(context))) + } else { + self.into_response_with_context(None) + } } } diff --git a/server-rs/crates/api-server/src/request_context.rs b/server-rs/crates/api-server/src/request_context.rs index ebd597b39..57a109d28 100644 --- a/server-rs/crates/api-server/src/request_context.rs +++ b/server-rs/crates/api-server/src/request_context.rs @@ -11,6 +11,12 @@ use uuid::Uuid; pub use shared_contracts::api::X_REQUEST_ID_HEADER; +tokio::task_local! { + /// 当前请求的上下文:`AppError::into_response` 这类拿不到 extensions 的转换也要按同一份 + /// meta 口径给出 requestId / operation,错误 envelope 与成功 envelope 才不会各写一套。 + pub static CURRENT_REQUEST_CONTEXT: RequestContext; +} + // 当前阶段先把请求级元信息统一挂到 extensions,后续响应头、envelope 与错误处理中间件继续复用。 #[derive(Clone, Debug)] pub struct RequestContext { @@ -80,12 +86,14 @@ pub async fn attach_request_context(mut request: Request, next: Next) -> Respons .unwrap_or_else(|| Uuid::new_v4().to_string()); let operation = format!("{} {}", request.method(), request.uri()); - request.extensions_mut().insert(RequestContext::new( + let context = RequestContext::new( request_id.clone(), operation, Duration::ZERO, wants_envelope, - )); + ); + let context_for_scope = context.clone(); + request.extensions_mut().insert(context); // 统一把 request_id 写回请求头,方便后续 tracing、响应头与 envelope 层读取同一来源。 if let Ok(header_value) = HeaderValue::from_str(&request_id) { @@ -94,7 +102,9 @@ pub async fn attach_request_context(mut request: Request, next: Next) -> Respons .insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value); } - next.run(request).await + CURRENT_REQUEST_CONTEXT + .scope(context_for_scope, async move { next.run(request).await }) + .await } pub fn resolve_request_id(request: &HttpRequest) -> Option {