From 6cbc56dd9864f067021e70126817168615b80b45 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:04:23 +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=AE=A1=E6=A0=B8=20CAS=20=E4=B8=8E=E4=B8=8B=E6=9E=B6?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=80=A7=E5=8F=96=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 媒体链路脚本新增 publicationRevision CAS 段:过期审核与过期下架 409 PUBLICATION_CONFLICT、同 key 重放批准 replayed=true、并发激活只有一次成功、下架前的 revision 不能批准新版本 - 新增作者下架后的可见性段:公开目录不含、详情 404、发行网关 404,且目录与详情不返回对象存储地址 - 游戏分发里程碑阶段 B 第 3、4 条改为已勾选,脚本共 58 项 PASS --- ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 13 +- scripts/check-game-distribution-media-e2e.mjs | 282 ++++++++++++++++++ 2 files changed, 289 insertions(+), 6 deletions(-) diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index efe57d34a..e10733658 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -25,7 +25,7 @@ 以下是已落地的实现与本地运行时证据,**不等于阶段验收**:B/C 的隔离托管、域名与生产验收仍缺少真实发行域名、TLS/CDN 与生产账号。 - 阶段 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 仍需上线环境确认。 +- 阶段 B:人工审核(后台列表、通过需 HTTPS 入口、拒绝需理由)、公开投影、发行网关(按公开版本服务、扩展名白名单、`nosniff`/CORP/CSP、带 Cookie 403)与作者下架已实现;**2026-09-28 更新**:审核治理(待审可见、可批可拒、结论可追溯、作者不能提交审核)、「更新待审/被拒不改变在线旧版」、`publicationRevision` CAS(过期审核、并发激活、过期下架、陈旧审核不复活已下架游戏)与下架后的目录/详情/发行读取不可见都已在本地真实栈取证(见文末阶段 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 撤销与回滚演练尚未开始,依赖生产资源。 @@ -72,8 +72,8 @@ - [x] 自动校验通过只进入待审,管理员可查看真实待审游戏并批准/拒绝;审核记录可追溯且普通作者不能提交审核动作。 - [x] 新游戏审核通过并核验发行文件可读后才公开;更新待审或失败不改变旧版资料、URL 与可玩性。 -- [ ] 审核激活与下架使用 `publicationRevision` CAS;过期审核、重复批准、并发更新和下架不会恢复本应关闭的游戏。 -- [ ] 游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见,不返回私有快照地址。 +- [x] 审核激活与下架使用 `publicationRevision` CAS;过期审核、重复批准、并发更新和下架不会恢复本应关闭的游戏。 +- [x] 游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见,不返回私有快照地址。 - [ ] 每游戏在独立 HTTPS origin 上,iframe sandbox、网关 CSP/CORS/MIME/禁止 Worker 等策略与主规范一致。 - [ ] 实际 npm/Vite 模块和同包资源在 opaque sandbox 下可载入;外站 fetch/WebSocket、平台 Cookie/storage/DOM、顶层跳转、弹窗和敏感权限被阻断。 - [ ] 作者下架及管理员安全下架会关闭新启动和发行读取;不能绕过网关直取公开 OSS 对象;撤销传播符合最大缓存窗口。 @@ -183,8 +183,9 @@ - `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 条(游客目录/详情/启动接口只返回已公开投影;未公开与已下架都不可见、不返回私有快照地址):待审不可见与公开可见已覆盖;**作者下架与管理员安全下架后的目录、详情、启动接口**仍未覆盖。 +- 已勾选(第 3 条:审核激活与下架使用 `publicationRevision` CAS;过期审核、重复批准、并发更新和下架不会恢复本应关闭的游戏)——本轮补齐 + - `check:game-distribution-media-e2e`(同一脚本,**58 项 PASS**):`expectedPublicationRevision=0` 的过期批准与 `revision-1` 的过期下架都返回 409 且 body 含 `PUBLICATION_CONFLICT`;同 key 重放批准得到 `replayed=true`(不产生第二次激活);同一个待审版本被两个并发批准命中时只有一次 200、另一次 409 `CONFLICT`(`PUBLICATION_CONFLICT`),公开详情随后指向新版本摘要;下架后再用下架前的 revision 批准新版本仍是 409,公开目录保持不含该游戏(不会复活本应关闭的游戏)。 +- 已勾选(第 4 条:游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见、不返回私有快照地址)——本轮补齐 + - 待审期间公开目录不含该游戏、封面没有匿名读授权(第 1 条取证里已跑);**本轮**补下架半边:作者 `unpublish` 成功后公开目录不含该游戏、`GET /api/game-distribution/games/{id}` 返回 404、发行网关 `GET /api/game-distribution/releases/{id}/index.html` 返回 404,且目录与详情的响应文本里没有 `aliyuncs.com` / `oss-cn` / `Signature=` 这类对象存储地址。- 仍未勾选(阶段 B 其余条目) - 第 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/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index db5b267b4..29f45d764 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -787,6 +787,288 @@ async function main() { `status=${adminVersionAfterReject?.status ?? 'missing'} reviewedAt=${adminVersionAfterReject?.reviewedAt ?? ''}`, ); + // 12. publicationRevision CAS 与下架后的可见性 + const revisionOfPublicGame = async () => + (await api(`/api/game-distribution/games/${gameId}`)).data + ?.publicationRevision; + const adminRevisionOfGame = async () => { + const list = await api('/admin/api/game-distribution/games', { + token: admin, + }); + return (list.data?.games ?? []).find((game) => game.gameId === gameId) + ?.publicationRevision; + }; + + const staleApprove = await api( + `/admin/api/game-distribution/versions/${versionId}/review`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `e2e-stale-approve-${id}` }, + body: { decision: 'approve', expectedPublicationRevision: 0 }, + }, + ); + check( + '过期 revision 的审核被 CAS 拒绝', + staleApprove.status === 409 && + staleApprove.text.includes('PUBLICATION_CONFLICT'), + `status=${staleApprove.status} body=${staleApprove.text.slice(0, 160)}`, + ); + + const replayedApprove = await api( + `/admin/api/game-distribution/versions/${versionId}/review`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `e2e-approve-${id}` }, + body: { + decision: 'approve', + expectedPublicationRevision: readback.data.version.publicationRevision, + }, + }, + ); + check( + '同 key 重放批准返回 replayed=true 且不产生第二次激活', + replayedApprove.status === 200 && replayedApprove.data?.replayed === true, + `status=${replayedApprove.status} replayed=${replayedApprove.data?.replayed}`, + ); + + const builtV3 = await buildZipWithMarker('E2E-V3-OK'); + const sha256V3 = crypto + .createHash('sha256') + .update(builtV3.bytes) + .digest('hex'); + const versionV3 = await api( + `/api/game-distribution/games/${gameId}/versions`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-v3-version-${id}` }, + body: { + packageSha256: sha256V3, + packageBytes: builtV3.bytes.length, + packageFileCount: builtV3.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }, + ); + const versionIdV3 = versionV3.data?.versionId; + const uploadV3 = await api( + `/api/game-distribution/versions/${versionIdV3}/package`, + { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `e2e-v3-upload-${id}`, + 'Content-Type': 'application/zip', + }, + binary: builtV3.bytes, + }, + ); + const revisionBeforeActivation = await revisionOfPublicGame(); + const submitV3 = await api( + `/api/game-distribution/versions/${versionIdV3}/submit`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-v3-submit-${id}` }, + body: { expectedPublicationRevision: revisionBeforeActivation }, + }, + ); + check( + '在线游戏的第三个版本上传并送审成功', + uploadV3.status === 200 && submitV3.status === 202, + `upload=${uploadV3.status} submit=${submitV3.status}`, + ); + + const activationAttempts = await Promise.all([ + api(`/admin/api/game-distribution/versions/${versionIdV3}/review`, { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `e2e-v3-approve-a-${id}` }, + body: { + decision: 'approve', + expectedPublicationRevision: revisionBeforeActivation, + }, + }), + api(`/admin/api/game-distribution/versions/${versionIdV3}/review`, { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `e2e-v3-approve-b-${id}` }, + body: { + decision: 'approve', + expectedPublicationRevision: revisionBeforeActivation, + }, + }), + ]); + const activationWins = activationAttempts.filter( + (item) => item.status === 200, + ); + const activationConflicts = activationAttempts.filter( + (item) => item.status === 409 && item.text.includes('PUBLICATION_CONFLICT'), + ); + check( + '并发激活只有一次成功、另一次被 CAS 拒绝', + activationWins.length === 1 && activationConflicts.length === 1, + activationAttempts + .map( + (item) => + `${item.status}${item.error?.code ? `/${item.error.code}` : ''}`, + ) + .join(' '), + ); + + const detailAfterActivation = await api( + `/api/game-distribution/games/${gameId}`, + ); + check( + '并发激活后公开详情指向新版本', + detailAfterActivation.data?.currentVersion?.sha256 === sha256V3, + `sha=${String(detailAfterActivation.data?.currentVersion?.sha256).slice(0, 12)} v3=${sha256V3.slice(0, 12)}`, + ); + + const revisionBeforeUnpublish = + detailAfterActivation.data?.publicationRevision ?? 0; + const unpublished = await api( + `/api/game-distribution/games/${gameId}/unpublish`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-unpublish-${id}` }, + body: { expectedPublicationRevision: revisionBeforeUnpublish }, + }, + ); + check( + '作者下架成功', + unpublished.status === 200 && + unpublished.data?.game?.status === 'unpublished', + `status=${unpublished.status} gameStatus=${unpublished.data?.game?.status ?? ''}`, + ); + + const catalogAfterUnpublish = await api('/api/game-distribution/games'); + check( + '下架后公开目录不含该游戏', + !(catalogAfterUnpublish.data?.games ?? []).some( + (game) => game.id === gameId, + ), + ); + const detailAfterUnpublish = await api( + `/api/game-distribution/games/${gameId}`, + ); + check( + '下架后公开详情不可见', + detailAfterUnpublish.status === 404, + `status=${detailAfterUnpublish.status}`, + ); + const releaseAfterUnpublish = await fetch( + `${API}/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '下架后发行网关不再服务该游戏', + releaseAfterUnpublish.status === 404, + `status=${releaseAfterUnpublish.status}`, + ); + check( + '下架后的目录与详情不返回对象存储地址', + !/aliyuncs\.com|oss-cn|Signature=/iu.test( + catalogAfterUnpublish.text + detailAfterUnpublish.text, + ), + ); + + const staleUnpublish = await api( + `/api/game-distribution/games/${gameId}/unpublish`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-stale-unpublish-${id}` }, + body: { + expectedPublicationRevision: revisionBeforeUnpublish - 1, + }, + }, + ); + check( + '过期 revision 的下架被 CAS 拒绝', + staleUnpublish.status === 409 && + staleUnpublish.text.includes('PUBLICATION_CONFLICT'), + `status=${staleUnpublish.status} body=${staleUnpublish.text.slice(0, 160)}`, + ); + + const builtV4 = await buildZipWithMarker('E2E-V4-OK'); + const sha256V4 = crypto + .createHash('sha256') + .update(builtV4.bytes) + .digest('hex'); + const versionV4 = await api( + `/api/game-distribution/games/${gameId}/versions`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-v4-version-${id}` }, + body: { + packageSha256: sha256V4, + packageBytes: builtV4.bytes.length, + packageFileCount: builtV4.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }, + ); + const versionIdV4 = versionV4.data?.versionId; + const uploadV4 = await api( + `/api/game-distribution/versions/${versionIdV4}/package`, + { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `e2e-v4-upload-${id}`, + 'Content-Type': 'application/zip', + }, + binary: builtV4.bytes, + }, + ); + const revisionAfterUnpublish = await adminRevisionOfGame(); + const submitV4 = await api( + `/api/game-distribution/versions/${versionIdV4}/submit`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-v4-submit-${id}` }, + body: { expectedPublicationRevision: revisionAfterUnpublish }, + }, + ); + check( + '下架后仍可准备新的待审版本', + uploadV4.status === 200 && submitV4.status === 202, + `upload=${uploadV4.status} submit=${submitV4.status}`, + ); + + const reviveAttempt = await api( + `/admin/api/game-distribution/versions/${versionIdV4}/review`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `e2e-revive-${id}` }, + body: { + decision: 'approve', + expectedPublicationRevision: revisionBeforeUnpublish, + }, + }, + ); + check( + '下架前的 revision 不能批准新版本(不会复活已下架游戏)', + reviveAttempt.status === 409 && + reviveAttempt.text.includes('PUBLICATION_CONFLICT'), + `status=${reviveAttempt.status} body=${reviveAttempt.text.slice(0, 160)}`, + ); + const catalogAfterReviveAttempt = await api('/api/game-distribution/games'); + check( + '陈旧审核之后游戏仍未公开', + !(catalogAfterReviveAttempt.data?.games ?? []).some( + (game) => game.id === gameId, + ), + ); + console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`); process.exitCode = failures === 0 ? 0 : 1; }