补齐错误 envelope 的请求上下文并扩展游戏分发审核取证
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m59s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 5m26s
Project CI / Backend tests (push) Successful in 4m13s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled

- api-server 的错误响应改用 attach_request_context 建立的 CURRENT_REQUEST_CONTEXT(tokio task_local),错误 envelope 与成功 envelope 一样带 meta.requestId / meta.operation,脱离请求任务时退回原形状
- 越权隔离脚本新增成功/失败 envelope 断言:修复前错误响应 requestId 为空让断言变红,修复后 23 项 PASS
- 媒体链路脚本扩展审核治理段:普通作者不能提交审核动作、更新待审与被拒都不改变在线旧版 URL 与可玩内容、审核结论可在后台追溯,44 项 PASS
- 游戏分发里程碑阶段 A 第 6 条、阶段 B 第 1/2 条改为已勾选并写入取证,阶段 B 其余条目列出具体缺口
- decision-log 补记错误 envelope 同一份 meta 与「按可消费取证」两条口径
This commit is contained in:
kdletters
2026-09-28 18:58:38 +08:00
parent 23de78b476
commit 253f99d921
6 changed files with 256 additions and 14 deletions
@@ -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/<gameId>/`,发行网关 `GET .../releases/<gameId>/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 评审记录),本次只更新可证的勾选项与缺口。
@@ -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 缓存窗口与真实客户端安装版的自动上传仍未验证。
@@ -192,6 +192,26 @@ async function buildZip() {
};
}
/** 更新版本用的小包:入口里带标记,用来证明在线旧版没有被待审/被拒的新版本替换。 */
async function buildZipWithMarker(marker) {
const zip = new JSZip();
zip.file(
'index.html',
`<!doctype html><html><head><meta charset="utf-8"><title>E2E ${marker}</title><script src="assets/app.js"></script></head><body><h1>${marker}</h1></body></html>`,
);
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;
}
@@ -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 },
+12 -2
View File
@@ -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)
}
}
}
@@ -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<B>(request: &HttpRequest<B>) -> Option<String> {