diff --git a/docs/project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md b/docs/project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md index 5082295be..6f5f0ded7 100644 --- a/docs/project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md +++ b/docs/project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md @@ -44,7 +44,7 @@ - 服务端:块 A `c6bd2c081`(三列 + 契约 + 绑定)、块 B `9403a47c0`(工程包校验器)、块 C+D `d061b2e37`(上行 5 条路由 + 下行优先 + 缓存资产维度);文档块 E `3aa145d91`。 - 客户端:`4d070dd1a`(规则对齐 + `check-project-bundle-policy-parity` 机器门禁)、`5dd9d1c5d`(发布时上传,失败不阻断)、`72f08c264`(取件按 `source` 分支建项 + 来源记录 v2 兼容 v1)、`da24dd5be`(取件提示按形态如实措辞);`ff763abe5`(端到端验收脚本)。 -- 证据(按冻结点实测重数):`npm run check:game-distribution-project-bundle-e2e` **51 个 `check(` 调用点**;`npm run check-project-bundle-policy-parity` OK(服务端 51 条规则全被客户端覆盖 + 路径形状维度);`cargo test -p module-game-distribution` **73 passed**(其中 `project_bundle::tests` **19 条**);`cargo test -p api-server game_distribution` 60 passed;AGC `vitest` 1961 passed、客户端打包器 `cargo test -- project_bundle` **24 passed**(含 dev-dependency 执行级交叉测试)。 +- 证据(按冻结点实测重数):`npm run (本地夹具脚本,无 npm 条目)` **51 个 `check(` 调用点**;`npm run check-project-bundle-policy-parity` OK(服务端 51 条规则全被客户端覆盖 + 路径形状维度);`cargo test -p module-game-distribution` **73 passed**(其中 `project_bundle::tests` **19 条**);`cargo test -p api-server game_distribution` 60 passed;AGC `vitest` 1961 passed、客户端打包器 `cargo test -- project_bundle` **24 passed**(含 dev-dependency 执行级交叉测试)。 - **未做(含原因)**:① 已公开版本的补传(backfill)——按产品口径不做,阶段门与发行包确认一致;② 真实 >200 MiB / 10 001 条目 / 极端半包续传边界——未测(成本高,靠常量单测钉住);③ AGC 真机取件→建项链路——未跑(需要真机客户端窗口);④ 结构化埋点事件——未接(待产品定事件口径)。 - **已知留白(对抗性复核后如实登记,别当成已全覆盖)**:归档格式未纳入 `.bz2` / `.xz` / `.zst`;数据库转储 `*.db` / `*.sql` 未纳入;凭据目录未纳入 `.gcloud` / `.azure` / `.password-store`;zip 条目名的 CP437 解码与 `external_attributes == 0` 的条目未覆盖;「爆炸包峰值内存」只有机制级封顶 + 常量单测,未做真实量测;内容嗅探的误报率无实测数据(只做了「不误报」的反例:`.sshrc` / `Dockerfile` / 非私钥证书块 / `AKIA` 长度不足 / 二进制扩展名不扫)。 @@ -53,7 +53,7 @@ > 标注:✅ = 已落地并有证据(单测 / 门禁 / 端到端脚本,本地);⏳ = 仍待补(真机或极端边界);⏭ = 明确不做。 - [x] ✅ 工程包校验器拒绝清单齐备:绝对路径、`..`、盘符、反斜杠、通配符、符号链接、加密条目、**嵌套归档扩展名并集(`.zip`/`.tar`/`.gz`/`.tgz`/`.7z`/`.rar`/`.jar`/`.whl`/`.nupkg`)与 magic bytes 嗅探(改名也拦)**、完全重复/大小写折叠重复路径、任意层级 `node_modules` / `.git` / `.svn` / `.agent` / **`.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`**、根级 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`、凭据与隐私文件(`.env*`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`.npmrc`、`.netrc`、`.git-credentials`、`id_rsa*`、`id_ed25519*`、**`credentials*` / `id_ecdsa*` / `id_dsa*` / `terraform.tfstate*` / `service-account*` / `*.jks` / `*.keystore` / `*.ppk` / `*.p8` / `*.kdbx` / `*.der` / `.htpasswd` / `.pgpass` / `*.map`**)、**小体积文本条目的内容嗅探(PEM 私钥块 / `AKIA…` / `ghp_` / `github_pat_` / `xox*`)**、单文件/累计/压缩比超限;合法源码包(**不含** `index.html`)通过。证据:`module-game-distribution` **19 条校验器单测**(+ `package.rs` 6 条)与 `check-project-bundle-policy-parity`(客户端打包器与它逐项对齐)。 -- [x] ✅ 同一版本重复确认同一工程包不产生第二份对象(同内容幂等重放);换内容重传按冲突拒绝(409 `同一版本已存在不同的工程源包`)。证据:模块事务 + `check-game-distribution-project-bundle-e2e` A4。 +- [x] ✅ 同一版本重复确认同一工程包不产生第二份对象(同内容幂等重放);换内容重传按冲突拒绝(409 `同一版本已存在不同的工程源包`)。证据:模块事务 + 本地夹具脚本(工程源包)A4。 - [x] ✅ 阶段门:只有 `awaiting_upload` / `upload_failed` 可写;已上传、验证中、待审核、已拒绝、已公开、已撤回、已取消一律拒绝。**顺序按实现**:「已存在工程包」先判(409 `PROJECT_BUNDLE_ALREADY_EXISTS`),故「已公开且已有工程包」返回 `ALREADY_EXISTS`;「已公开但无工程包」才返回 `UPLOAD_NOT_ALLOWED`。证据:e2e A5 / A5b + api-server 单测。 - [x] ✅ 非作者上传 → **404**(按「版本不存在」处理,与发行包上行族同口径,不泄露版本存在性),未带 Bearer → 401。证据:e2e A6 + 路由级 401 单测。 - [x] ✅ 上行整包 PUT(`application/octet-stream`)+ 服务端独立复核 + 失败 422 `PROJECT_BUNDLE_VALIDATION_FAILED` 且清理半包对象。证据:e2e(整包/校验失败/清理)+ 单测。 @@ -68,6 +68,6 @@ ## 证据要求 -- 自动化(已落地的命令):`npm run check:game-distribution-project-bundle-e2e`(**46/46**)、`npm run check-project-bundle-policy-parity`(两端规则一致)、`cargo test -p module-game-distribution`(69 passed,含校验器 16)、`cargo test -p api-server game_distribution`(60 passed)、AGC `npx vitest run apps/ai-game-creator-shell/tests`(1961 passed)与定向 `cargo test -- fork|project_bundle`(17 + 16 passed)、DTO parity 与 schema/编码/文档索引门禁。 +- 自动化(已落地的命令):`npm run (本地夹具脚本,无 npm 条目)`(**46/46**)、`npm run check-project-bundle-policy-parity`(两端规则一致)、`cargo test -p module-game-distribution`(69 passed,含校验器 16)、`cargo test -p api-server game_distribution`(60 passed)、AGC `npx vitest run apps/ai-game-creator-shell/tests`(1961 passed)与定向 `cargo test -- fork|project_bundle`(17 + 16 passed)、DTO parity 与 schema/编码/文档索引门禁。 - 运行时:真实 api-server + 真实对象存储 + 真实客户端跑通「拿到源码 → 改核心逻辑 → 试玩 → 发布 → 溯源与代际正确」,附对比截图或录屏。**(取件 → 建项链路的真机窗口验证仍未跑,见验收标准 ⏳)** - 边界:越权下载、无授权下载、来源已下架、摘要不符、超限包、解压失败、上传中断续传、账号切换后的迟到响应、同版本双来源优先级。 diff --git a/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md index b9fd75377..955d0b68c 100644 --- a/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md +++ b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md @@ -166,7 +166,7 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。 ### H. 测试清单 -**已落地**:纯函数(`module-game-distribution`,含 `033e3aa79` 补的 status 过滤 / 文本校验)、事务结构断言(`spacetime-module`)、api-server 路由与错误码定向测试、契约 parity。**未做**:前端 vitest、真实 dev 栈 e2e(`scripts/check-game-distribution-theme-e2e.mjs` 尚未创建)。 +**已落地**:纯函数(`module-game-distribution`,含 `033e3aa79` 补的 status 过滤 / 文本校验)、事务结构断言(`spacetime-module`)、api-server 路由与错误码定向测试、契约 parity。**未做**:前端 vitest、真实 dev 栈 e2e。**2026-10-07 更新**:真实栈 e2e 已写好并跑通(71/71,运行在隔离实例上),但它是**本地验收脚本、未入库**(只在本地工作树,路径见本地 `info/exclude`)。 | 层 | 用例 | | --- | --- | @@ -201,14 +201,14 @@ npx vitest run src/components/game-distribution src/services/gameDistributionCli npm run typecheck && npm run lint:eslint npm run check:nginx-spa-routes && npm run check:pingora-route-parity -# 真实栈端到端(新增脚本,形状照 scripts/check-game-distribution-collection-e2e.mjs: -# 起本地完整 dev 栈 + 管理员账号,走真实 HTTP,逐条 check(...) 断言,失败非零退出) -E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-distribution-theme-e2e.mjs +# 真实栈端到端(本地夹具脚本,未入库;形状照同族的收藏夹具脚本: +# 起本地完整隔离实例 + 管理员账号,走真实 HTTP,逐条 check(...) 断言,失败非零退出) +E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node <本地夹具脚本路径> ``` -新增 npm script `check:game-distribution-theme-e2e` → `node scripts/check-game-distribution-theme-e2e.mjs`(与 `check:game-distribution-collection-e2e` 同族命名)。 +真实栈端到端**没有新增 npm script**:夹具脚本本身未入库(只在本地工作树,路径见本地 `info/exclude`),所以 `npm run` 列表里不会出现指向未入库文件的条目。 -**执行状态**:已跑门禁 —— `check:spacetime-schema`(**96** tables)、`check:game-distribution-dto-parity`(**58 组 / 10 构建器**)、`check:encoding`、`check:doc-index`、`git diff --check`、`cargo test`(见下「验收标准」的勾选与「证据要求」的现状)。**未跑**:前端 vitest / typecheck / lint、`check:nginx-spa-routes`、`check:pingora-route-parity`(前端未动,且未新增 SPA 路由)、以及 `check-game-distribution-theme-e2e.mjs`(脚本尚未创建)。 +**执行状态**:已跑门禁 —— `check:spacetime-schema`(**96** tables)、`check:game-distribution-dto-parity`(**58 组 / 10 构建器**)、`check:encoding`、`check:doc-index`、`git diff --check`、`cargo test`(见下「验收标准」的勾选与「证据要求」的现状)。**未跑**:前端 vitest / typecheck / lint、`check:nginx-spa-routes`、`check:pingora-route-parity`(前端未动,且未新增 SPA 路由)、以及真实栈 e2e(当时本地夹具脚本尚未写上;后续已补并在隔离实例上跑通,脚本未入库)。 ## 验收标准 @@ -238,16 +238,16 @@ E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-d **已落地(本条补)**:后台成员**名单读**接口——原缺口「后台要成员名单只能借道**公开投影**(`GET /api/game-distribution/themes/{theme_id}`),而公开投影只服务 `published` 主题,于是**草稿 / 归档主题在后台看不到成员**」已随 `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=` 补掉:不套公开可见性过滤、含当前不可见成员、每行带 `visible` / `visibility` 两个独立口径,`draft` / `archived` 照常可读。技术方案侧同步写在 §3.4 后台表与 §3.10.8。 -**勾选口径**:勾选项由已落地的纯函数单测 / 事务结构断言 / api-server 定向测试 / DTO parity / schema 门禁证实;**未跑真实 dev 栈的端到端整链**(`scripts/check-game-distribution-theme-e2e.mjs` 未创建),所以凡依赖真实库存的运行时分支(未知主题 404、作品不存在 404、同键换请求 409、下架→重公开自动回归等)目前只到单元 / 结构 / 映射层。第 17 / 18 条(前端)**等前端**。 +**勾选口径**:勾选项由已落地的纯函数单测 / 事务结构断言 / api-server 定向测试 / DTO parity / schema 门禁证实;**未跑真实 dev 栈的端到端整链**(本地夹具脚本(共创主题) 未创建),所以凡依赖真实库存的运行时分支(未知主题 404、作品不存在 404、同键换请求 409、下架→重公开自动回归等)目前只到单元 / 结构 / 映射层。第 17 / 18 条(前端)**等前端**。 ## 证据要求 -**现状**:自动化已覆盖纯函数 / 事务结构 / api-server 定向 / 契约 / schema;**运行时**(真实本地栈整链、浏览器桌面与窄屏)**未跑**,`scripts/check-game-distribution-theme-e2e.mjs` **未创建**——「证据要求」的运行时与脚本两项仍缺。 +**现状**:自动化已覆盖纯函数 / 事务结构 / api-server 定向 / 契约 / schema;**运行时**(真实本地栈整链、浏览器桌面与窄屏)**未跑**,本地夹具脚本(共创主题) **未创建**——「证据要求」的运行时与脚本两项仍缺。 - 自动化:`module-game-distribution` 纯函数单测(可见性 / 排序 / 游标 / 根约束)、`spacetime-module` 事务断言、api-server 路由与错误码定向测试、DTO parity、schema 与生成绑定检查、前端 vitest、`check:encoding` / `check:doc-index` / `git diff --check`。 - 运行时:真实本地栈上完成「建主题(draft)→ 加两个根成员 → 发布 → 匿名读列表与详情 → 作品详情看到主题 → 把一个成员下架(从投影消失、行仍在)→ 重新公开(自动回来)→ 归档主题(公开侧不可见、行仍在)→ 后台列表仍可见」整链;浏览器在桌面与窄屏走一遍共创 Tab 与主题页。 - 边界:已发布但零可见成员、非根作品入成员、跨主题同根、非法游标、末页游标、未知主题 404、未带 admin 会话 401、重复成员写入、`DELETE` 不存在成员。 -- 脚本:`scripts/check-game-distribution-theme-e2e.mjs` 的 `check(...)` 计数与失败项;脚本头部按既有惯例列出「契约来源」与「与工单描述不一致、按实现断言」的条目。 +- 脚本:本地夹具脚本(共创主题) 的 `check(...)` 计数与失败项;脚本头部按既有惯例列出「契约来源」与「与工单描述不一致、按实现断言」的条目。 ## 待确认项(需用户 / 产品拍板) diff --git a/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md index dfdfd01cf..5ad60aa8c 100644 --- a/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md +++ b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md @@ -59,5 +59,5 @@ ## 实施记录 - 2026-10-04:rebase 到含 #565(作品管理与 Phaser4 客户端发布)的新 master,9 个冲突文件按「两侧业务逻辑都保留」解完;详情页按 #565 的新结构改用共享 `GameDetailDisplay`,共创卡改由 `infoCards` 槽位渲染;并按 #565 的软删除语义收敛三处(来源校验 / 衍生计数 / 溯源摘要)。冲突解法与具体落点见技术方案 §3.9.1–§3.9.2,门禁与验收证据见 §5.1。 -- 2026-10-06:按产品拍板收敛「授权继承」口径(取代「衍生作品落默认 `forbidden`、作者自己提升」):创建游戏事务在带血缘声明时取**父行当时的档位**写入新行(`resolve_game_distribution_fork_declaration_tx` 把父档位一并返回,新增纯函数 `resolve_game_distribution_creation_fork_authorization` 承载「衍生继承 / 母版按请求值」的裁决),客户端传值一律忽略且不报错;**收窄入口不做**;`PUT …/fork-authorization` 的只升不降语义当时**未改**(同日第二笔收紧,见下一条)。口径与落点见技术方案 §2.3 / §3.9 补充,证据见 §5.3。既有按旧语义写的两个 e2e 脚本(`scripts/check-game-distribution-lineage-e2e.mjs` 富树段、`scripts/capture-game-lineage-visual.mjs`)需要按新语义改成「创建后不再从 `forbidden` 提升」,本轮未改(不属本工作树权限),见 §5.3 的遗留项。 +- 2026-10-06:按产品拍板收敛「授权继承」口径(取代「衍生作品落默认 `forbidden`、作者自己提升」):创建游戏事务在带血缘声明时取**父行当时的档位**写入新行(`resolve_game_distribution_fork_declaration_tx` 把父档位一并返回,新增纯函数 `resolve_game_distribution_creation_fork_authorization` 承载「衍生继承 / 母版按请求值」的裁决),客户端传值一律忽略且不报错;**收窄入口不做**;`PUT …/fork-authorization` 的只升不降语义当时**未改**(同日第二笔收紧,见下一条)。口径与落点见技术方案 §2.3 / §3.9 补充,证据见 §5.3。既有按旧语义写的两个 e2e 脚本(本地夹具脚本(族谱/血缘) 富树段、`scripts/capture-game-lineage-visual.mjs`)需要按新语义改成「创建后不再从 `forbidden` 提升」,本轮未改(不属本工作树权限),见 §5.3 的遗留项。 - 2026-10-06(同日第二笔):**衍生作品的授权档位收敛为「终态」**。上一笔只改了创建(继承父档位),但 `PUT …/fork-authorization` 仍对任意作品开放「只升不降」,作者可把继承来的 `nonCommercial` 再提成 `full`,得到比祖先更宽的子作品,把父作品在非商用授权下公开的工程内容变成可用商用。现在:**存在血缘行(衍生作品)→ 任何 PUT 一律拒绝**(含传同值),新稳定码 `FORK_AUTHORIZATION_INHERITED` → 409;判定在 `set_game_distribution_fork_authorization_tx` 里**先于 CAS**,规则抽成纯函数 `module_game_distribution::resolve_fork_authorization_promotion`(衍生终态 → CAS → 只升不降);母版语义完全不变。验收标准里「授权状态机」一项据此理解为「母版单向提升 + 衍生作品不可改」。口径见技术方案 §2.3 / §3.4 / §3.9 第二条补充,证据见 §5.5,决策理由见 `decision-log.md` 同日条目。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2c5ad794f..26bc75648 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 口径(照此实现):**衍生作品的任何 PUT 都拒绝**,含「传同值」这种幂等重试——客户端因此不会遇到「有时成功有时失败」的随机性。母版(无血缘行)语义**一律不变**:只升不降(可跳级)+ `expectedForkAuthorization` CAS + 幂等重放。 - 判定落点(一个事务的一步):`server-rs/crates/spacetime-module/src/game_distribution.rs` 的 `set_game_distribution_fork_authorization_tx`——**幂等重放分支之后、CAS 之前**查 `game_distribution_lineage().game_id().find(&game_id)`,把 `has_lineage_row` 连同 `current` / `expected_fork_authorization` / `target` 交给新增纯函数 `module_game_distribution::resolve_fork_authorization_promotion` 一次裁决(顺序:**衍生终态 → CAS → 只升不降**,失败都不写库)。衍生判定放在 CAS 之前是有意的:同一个衍生作品不会因为载荷不同(传同值 / 传新值 / 期望值过期)而回不同的码。判据只有这一份实现(事务里不再出现 `can_promote_to`,也不自己比 CAS 期望值)。 - 错误码可达性三件套:模块 `Display` 产出 `"{CODE}: 中文"`(码常量 `GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE`,Display 插值它);api-server 的 `FORK_` 映射表登记该码(409);纳入既有「FORK 码由模块真实文案可达」枚举测试(并保留「未登记码退化成 `FORK_ERROR`/409」的反证)。 -- 测试改动:**没有既有 Rust 用例断言过「衍生作品 PUT 提升会成功」**——Rust 侧此前对该路径只有「未知档位 → 400 信封」与「路由未带 Bearer → 401」两条用例。故本轮的测试是**新增**:`module-game-distribution` 的 `derivative_put_is_rejected_even_with_the_same_or_stale_value`(同值 / 再提一级 / 期望值过期都回同码)、`master_put_still_promotes_with_unchanged_cas_semantics`(母版提升照旧 + 同级 / 降级 / CAS 不符语义未变)、`errors.rs` 的前缀断言,`spacetime-module` 的结构断言 `set_fork_authorization_tx_rejects_derivatives_before_cas`(血缘行先于裁决、裁决先于写库),api-server 的可达性枚举 + 常量字面量断言。断言旧语义的是 `scripts/check-game-distribution-lineage-e2e.mjs` / `scripts/capture-game-lineage-visual.mjs`(不属本工作树权限,见「遗留」)。 +- 测试改动:**没有既有 Rust 用例断言过「衍生作品 PUT 提升会成功」**——Rust 侧此前对该路径只有「未知档位 → 400 信封」与「路由未带 Bearer → 401」两条用例。故本轮的测试是**新增**:`module-game-distribution` 的 `derivative_put_is_rejected_even_with_the_same_or_stale_value`(同值 / 再提一级 / 期望值过期都回同码)、`master_put_still_promotes_with_unchanged_cas_semantics`(母版提升照旧 + 同级 / 降级 / CAS 不符语义未变)、`errors.rs` 的前缀断言,`spacetime-module` 的结构断言 `set_fork_authorization_tx_rejects_derivatives_before_cas`(血缘行先于裁决、裁决先于写库),api-server 的可达性枚举 + 常量字面量断言。断言旧语义的是 本地夹具脚本(族谱/血缘) / `scripts/capture-game-lineage-visual.mjs`(不属本工作树权限,见「遗留」)。 - 契约与形状:**无列 / 表 / 索引 / DTO 形状变更**,只新增一个错误码常量与错误变体;`GameDistributionSetForkAuthorizationRequest` 的 serde 形状未动(幂等摘要不变)。 - 文档:技术方案 §2.3(授权模式 + 状态机)、§2.4(`/games/mine`、`/games/publish`、`/games/lineage`)、§2.6 时序图、§3.3 状态表、§3.4(`PUT` 行、`POST /games` 行、公开详情行、`/lineage` 行)、§3.9 新补充、§5.3 遗留、§7 第 2 条;数据契约文档的 `fork_authorization` 条目;里程碑日志。 - 顺带写清的三条口径(无代码):① `/lineage` 的**累计世代数由前端自算**——本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),服务端不提供该字段;② 改动说明**已在版本摘要 payload 上**(公开详情 `currentVersion.changeSummary`,衍生为字符串、母版 `null`),族谱页右侧面板只对**选中节点**请求一次公开详情即可拿到——**不做 N+1、不给 `LineageNode` 贴该字段**;③ **衍生作品不会继承到 `forbidden`**:父作品为 `forbidden` 时不允许共创(`403 FORK_NOT_AUTHORIZED`),根本产生不出衍生作品。 @@ -35,7 +35,7 @@ - 契约与形状:`GameDistributionCreateGameRequest.forkAuthorization` 字段保留、serde 形状(非 `Option` + `#[serde(default)]`)不变——改了会让创建请求的幂等摘要漂移;不新增/删除列,无 schema 迁移。 - 未改(后被同日「档位终态」决策收紧,见上一条;「原样保留」现在只对**母版**成立):`PUT /api/game-distribution/games/{gameId}/fork-authorization` 的语义(只升不降、`expectedForkAuthorization` CAS、幂等重放)原样保留;它继续服务「提升」。 - 影响面:`server-rs/crates/spacetime-module/src/game_distribution.rs`(血缘解析返回值、创建事务、纯函数与测试)、`server-rs/crates/api-server/src/modules/game_distribution.rs`(创建 handler 注释)、`server-rs/crates/shared-contracts/src/game_distribution.rs` 与 `server-rs/crates/spacetime-client/src/game_distribution.rs` 与 `packages/shared/src/contracts/gameDistribution.ts`(字段注释)、技术方案 §2.3 / §3.2.1 / §3.4 / §3.9 / §7、数据契约文档、里程碑文档。 -- 遗留(不在本次工作树权限内):`scripts/check-game-distribution-lineage-e2e.mjs` 与 `scripts/capture-game-lineage-visual.mjs` 仍按旧语义「创建衍生作品(落 `forbidden`)→ 再 PUT 从 `forbidden` 提升」,H 段富树还刻意让一个衍生节点保持 `forbidden`;改口径后那些 PUT 会因 CAS 不符返回 409。两脚本在工作树划分里属其它 owner,未改。 +- 遗留(不在本次工作树权限内):本地夹具脚本(族谱/血缘) 与 `scripts/capture-game-lineage-visual.mjs` 仍按旧语义「创建衍生作品(落 `forbidden`)→ 再 PUT 从 `forbidden` 提升」,H 段富树还刻意让一个衍生节点保持 `forbidden`;改口径后那些 PUT 会因 CAS 不符返回 409。两脚本在工作树划分里属其它 owner,未改。 - 验证方式:新增/改写 3 条 `spacetime-module` 用例(继承矩阵 / 母版按请求值 + 未知失败关闭 / 创建事务取父行档位的结构断言),`cargo test -p spacetime-module` 301 passed / 1 ignored;`cargo test -p module-game-distribution` 119 passed;`cargo test -p api-server game_distribution` 104 passed(既有「缺省 `forbidden` 且幂等摘要不变」用例继续通过);`cargo test -p spacetime-client` 43 passed;wasm build、`cargo check --all-targets`、`check-game-distribution-dto-parity`、`check-project-bundle-policy-parity`、`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`、`npm run check:encoding`、`cargo fmt --check`、`git diff --check` 全部 exit 0。**没有改动任何 Rust 既有断言**(Rust 侧原本没有断言「衍生作品默认 `forbidden`」的用例;断言旧语义的是上面两个 e2e 脚本)。 ## 2026-10-05 播放会话前缀在三处入口清空 Cookie,网关 403 纵深防御不变 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d6b39a606..af96c3dbd 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -106,20 +106,20 @@ npm run dev:all 通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 -### 本地 e2e 必须用隔离栈(2026-10-07) +### 本地 e2e / 夹具必须用隔离实例(2026-10-07) -**背景(实际事故)**:夹具类 e2e 脚本过去缺省跟着 `.app/dev-stack.json` 打**用户正在用的开发栈**,而「换端口」并不等于「换库」——`npm run dev` 与早期 pinned 端口那套都指向同一个 `genarrative-game-creator-dev` + 同一份 data dir。结果是跑几轮 e2e 就把夹具写进用户的库:实测盘点时该库有 **105 条作品 = 13 条演示 + 92 条夹具**(lineage 4 轮×9、theme 1 轮×54、collection 2、project-bundle 2、fork-authorization 2),而公开目录上限只有 48 条,用户的共创页几乎被夹具占满。 +**背景(实际事故)**:夹具类 e2e 脚本过去缺省跟着 `.app/dev-stack.json` 打**用户正在用的开发栈**,而「换端口」并不等于「换库」——`npm run dev` 与早期 pinned 端口那套都指向同一个 `genarrative-game-creator-dev` + 同一份 data dir。结果是跑几轮 e2e 就把夹具写进用户的库:实测盘点时该库有 **105 条作品 = 13 条演示 + 92 条夹具**(族谱 4 轮×9、主题 1 轮×54、收藏 2、工程源包 2、共创授权 2),而公开目录一次只出最新 48 条,用户的共创页几乎被夹具占满。 -**现在的口径(`scripts/e2e-target.mjs` 是单一事实来源)**: +**要求(这些是纪律,不是建议;本地夹具工具的路径不入库,团队按下面的做法自己准备工具即可)**: -1. **缺省隔离**:夹具类脚本在未显式给 `E2E_API_BASE` 时,目标固定为隔离栈 `http://127.0.0.1:8188`(web `3112`),不再回落到 dev-stack 标记。 -2. **护栏**:解析出的目标与 `.app/dev-stack.json` 记录的 api-server **同地址或同端口**时,脚本**直接拒绝启动**(`exit 2`),提示二选一——用隔离栈,或 `E2E_API_BASE=… E2E_ALLOW_DEV_STACK=1` 明确越权。护栏只管**会写夹具**的脚本;只读工具(截图工具的「只截现有作品」模式)仍按 dev-stack 解析。 -3. **一键隔离栈**:`npm run e2e:stack` 只起栈(前台长驻,Ctrl-C 结束);`npm run e2e:run -- --suite scripts/check-game-distribution-lineage-e2e.mjs [--suite …]` 起栈→等 `/healthz`→顺序跑 suite→自动停栈。隔离栈用**专用端口**(api 8188 / bgfilter 8189 / spacetime 3110 / admin-web 3111 / web 3112)、**专用库名** `genarrative-game-creator-e2e` 与 **临时 data-dir**(`%TEMP%/genarrative-e2e-spacetime-*`,默认跑完删除;`--keep-data` / `--keep-stack` 可保留)。 -4. **夹具统一前缀 `E2E·`**:所有夹具标题都带它(`fixtureTitle()`),盘点/清理不再依赖随机数字后缀;`tags` 之外,标题前缀是**盘点唯一可靠标识**。 -5. **跑批后自清理**:每个脚本正常结束时用本轮 owner token 把「自己造的、标题带 `E2E·` 且含本轮后缀」的作品逐条软删(owner 维度 `DELETE /api/game-distribution/my-games/{id}?expectedPublicationRevision=…`),并打印删除条数;清理失败或脚本中断时,`main().catch()` 会打印残留清单(owner + gameId + 标题),便于人工补删。 -6. **例外**:`scripts/seed-game-distribution-co-creation.mjs` **有意**写用户的 dev 栈(它就是演示数据灌装工具,幂等、判重不绑账号),不受护栏约束;要写到别处请显式给 `E2E_API_BASE`。 +1. **缺省隔离**:任何会往库里写测试数据的脚本,未显式给目标地址时**必须**指向**隔离实例**(专用端口 + 专用库名 + 临时 data-dir),不得回落到 `.app/dev-stack.json` 里用户正在用的那套。 +2. **硬护栏**:脚本在启动阶段就要判定目标——解析出的 api-server 与 `.app/dev-stack.json` 记录的**同地址或同端口**即视为「用户开发栈」,**直接拒绝启动**(非零退出),除非调用方显式越权(`E2E_ALLOW_DEV_STACK=1` 之类)并在提示里写清后果。护栏只管**会写夹具**的脚本;只读工具可以照常读开发栈。 +3. **隔离实例的组成**:专用端口组(api / bgfilter / spacetime / admin-web / web 各一份)、**专用库名**(例如 `*-e2e`,与 `genarrative-game-creator-dev` 区分开)、**临时 data-dir**(跑完删除,另留 `--keep-*` 逃生口)。隔离实例还必须把自己的 dev-stack 标记写到临时目录(`GENARRATIVE_DEV_STACK_STATE_PATH`),**绝不覆盖仓库根的 `.app/dev-stack.json`**,也**不做**「按 exe 路径清旧 api-server」的兜底清扫——两者都会把用户正在跑的那套栈踢下线(2026-10-07 实测踩到并已修)。 +4. **夹具必须可辨识**:统一标题前缀(本仓口径 `E2E·`)+ 统一 tag,盘点/清理不依赖随机后缀;**演示数据**另有自己的前缀(`共创演示·`),两者不得混用。 +5. **跑批后自清理**:脚本正常结束时用本轮 owner 身份把自己造的作品逐条**软删**(owner 维度 `DELETE /api/game-distribution/my-games/{id}?expectedPublicationRevision=…`),并打印删除条数;清理失败或脚本中断时,必须打印**残留清单**(owner + gameId + 标题)便于人工补删。**跑完不留残留**是交付判据之一。 +6. **例外**:演示数据灌装工具**有意**写开发栈(它的用途就是把演示数据放进 dev 栈,且幂等、判重不绑账号),不受护栏约束;要写到别处请显式给目标地址。 -**软删的语义**:`DELETE …/my-games/{id}` 只是软删(撤销当前公开版本 + 写 `deleted_at`),公开目录、族谱树、`my-games` 立刻都看不到,但**行仍在库里**、可由后台 `restore` 路径恢复;已软删的行不会出现在任何列表接口里,所以「清理后列表为空」不等于「行被物理删除」。 +**软删的语义**:`DELETE …/my-games/{id}` 只是软删(撤销当前公开版本 + 写 `deleted_at`),公开目录、族谱树、`my-games` 立刻都看不到,但**行仍在库里**、可由后台恢复;已软删的行不会出现在任何列表接口里,所以「清理后列表为空」不等于「行被物理删除」。 单独启动主站前端: diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index f1802a47f..af8e6c4bf 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -317,6 +317,7 @@ pub(crate) project_bundle_sha256: Option, | 方法 / 路径 | 说明 | | --- | --- | +| `GET /games?search=&category=&authorId=&limit=&cursor=`(**既有,2026-10-07 补真分页**) | 公开游戏目录(游戏广场)。匿名可读 + `no-store`;只含公开可玩的作品(`published` + 未软删除 + 有当前公开版本)。**真游标分页**:`limit` 缺省 **48**(保持既有首屏语义,AGC 共创页首屏就是 48 条)、上限 **100**(约束单响应体量),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{gameId}"`(与 `/my-collections`、主题列表同一套 `"{micros}:{id}"` 惯例,解析只切第一个冒号);**游标格式非法 → 400 `CATALOG_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页)。响应 `{ games: [<公开作品投影>], nextCursor }`;排序 `createdAt` 倒序 + `gameId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」。**修正的问题**:此前 `limit` 写死 48 且 `nextCursor` 恒为 `null`,库里第 49 条起的作品(含最老的母版与主干)在 HTTP 面永久不可见(客户端只能显示「已检查最新 48 个作品」)——数据一直在、详情与 `/lineage` 都读得到,缺的只是翻页通道 | | `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段。**另追加 `collected`(仅登录用户,2026-10-06)**:登录已认证时返回 `true` / `false`(真实投影,值来自 `is_game_distribution_collected_and_return`);**匿名请求不返回该字段**(也不发 `false`——`false` 会把「未登录」说成「没收藏」)。该字段随请求者变化,所以这条路径必须 `no-store`、不得有任何共享缓存。**再追加 `themes: [{ themeId, name, badge }]`(2026-10-06 增量,共创主题)**:该作品所属的**公开**主题(只含 `status == published`);**匿名与登录都发、无主题时恒发空数组**(与「仅登录才发」的 `collected` 不同——没有主题与没登录因此不会被混成同一种缺键)。口径是「先取该作品的**根**、再按根反查公开主题」,所以**第 N 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取)**。**版本摘要 `currentVersion` 再追加 `changeSummary`(2026-10-06,衍生作品发布必填项)**:该版本相对**改编来源**作品的核心改动说明——衍生作品必有(`20–500` 个字符,详见下方作者路由的 `POST /versions` 行),0 代母版为 **`null`**(发键不发值:客户端不必靠缺键猜),因此详情页与族谱溯源能逐代展示「这一版改了什么」。它不含任何私有字段(没有对象键 / 素材 id),匿名可读,也不需要按查看者变化 | | `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status, coverObjectKey }`(`coverObjectKey` 为该节点作品**当前生效的封面对象键**,与公开目录 / 详情**同源同口径**、都来自游戏行 `cover_object_key`,**无封面时为 `null`**;只发对象键,不带 `coverAssetId` 等私有 id),按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点。**「累计世代数」不是服务端字段**:本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),前端从同一次响应的 `generation` 自算,服务端不新增字段也不为此多查一次。**右侧面板的「本次核心改动说明」不贴到节点上**:`LineageNode` 保持现在的字段集(不加 `changeSummary`),面板只对**选中节点**请求一次公开详情取 `currentVersion.changeSummary`(衍生作品为字符串、母版为 `null`,见上方公开详情行)——即**不做 N+1**:不是每渲染一个节点就查一次详情(两条前端口径已落地:`bd73acfcc`) | | `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 | @@ -461,6 +462,10 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr - **默认大纲式缩进树**(`LineageOutlineList`):全宽行、层级用左缩进 + 肘形导线表达、主干链导线加粗用主题色;**无横向滚动**、无需缩放平移;行高 ≥44px,点击行 = 选中(无 hover)。 - **图谱视图为可选开关**(形态 B = **横版画布**,`orientation: 'horizontal'`:根在左、代际向右、兄弟分支沿纵向排开,窄屏上比顶→下更省横向空间):切到画布后支持拖拽平移 / 缩放;横版**不渲染左侧代际轴**,代际信息由节点自带角标承担。**初次进入按宽度适配**(整棵树横向看完;触及最小缩放时给出「可拖拽平移」提示),并把**当前作品**用最小位移拉进视口——贴最近的边,父代 / 兄弟仍留在视口里,不会出现「停在 100% 缩放、一个节点都看不到」的空屏。桌面仍为纵向画布 + 宽高同时适配并居中。 - **信息面板用 bottom sheet**:从底部升起(含简介 / 授权 / 核心改动说明 / 前往体验 / Fork 此版本),遮罩点击或关闭按钮收起;桌面保持画布右上浮层。 + - **父代 / 子代区分与「只看父代与子代」开关**(2026-10-06 追加): + - 每个节点相对**当前作品**有四种角色(纯函数 `collectLineageRelationRoles`):`self` / `ancestor`(**父代** = 祖先链)/ `descendant`(**子代** = 整棵后代子树)/ `side`(旁支)。渲染层打 `data-relation`,并在节点上给**可读角标**「父代」「子代」(自己不标——已有「当前作品」角标;旁支不标)。**不靠颜色深浅表达**,因此可断言、读屏可读。 + - **「只看父代与子代」开关**在筛选行,默认**关**(= 以根为作用域的整棵树)。打开后只留 **祖先链(父代)+ 自己 + 直接子代**(`collectLineageImmediateScope`,**不含孙代**),语义是「看这个作品的上一步与下一步」;**再点一次取消**回到整棵树。作用域里找不到锚点时不筛选(避免空图);切换后仍重新定位到当前作品。反馈有三重:`aria-pressed`、按钮文案变「(已开启)」、旁边一行说明。 + - 侧栏共创卡文案按同一口径:`{N 代…} · 改编自父代《…》` + `父代作者 …`;子代侧沿用「累计衍生作品 N」(全部世代)与「累计创作世代数」。 - 面包屑横向可滚动且**当前项常驻可见**;尊重 `prefers-reduced-motion`;底部留 `env(safe-area-inset-bottom)`。 - **移动端门限豁免(2026-10-06)**:平台原有的「移动端仅支持作品展示」欢迎弹窗只看 `!isDesktopLayout`、忽略路由,会把公开只读的族谱树 / 共创主题页首屏盖住。现在按**显式白名单**豁免这三个 stage:`game-lineage` / `game-themes` / `game-theme`(清单在 `PlatformActiveMobileWelcomeDialog.tsx` 的 `MOBILE_WELCOME_EXEMPT_STAGES`,键名即 stage 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。 - **截断 / 空树 / 不可用父节点**在两种形态下都有降级:截断提示、空态、以及「原作品已不可用」的占位节点(可聚焦、不可点进详情、不展示被删作品标题与作者名)。 @@ -936,7 +941,7 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`, | 工程包校验器 | `module-game-distribution` 的 `validate_project_bundle_zip`:拒绝清单见 §3.5.2,上限与发行包**逐项相等**(测试用 `assert_eq!` 钉住);`package.rs` 已同批补强(读取层按声明大小封顶 + 压缩比分母改为条目压缩字节,+70/−4) | ✅ 已验证(**19 条校验器单测**;`cargo test -p module-game-distribution` **73 passed**) | | 上行路由族 | 5 条路由(整包 / upload-state / chunk / complete / reset);对象键服务端派生且与发行包不同;唯一共享阶段门;`complete` 独立复核 zip + 422 映射 | ✅ 已验证(`cargo test -p api-server game_distribution` **60 passed**,含 6 条路由与阶段门/资产选择用例) | | 下行优先与缓存 | `source` 取 `Project`(优先)/`Package`;半写行回落 `package`;`/fork-source/project` 无工程包时 409 而非静默回落;缓存键 = 对象键(资产维度) | ✅ 已验证(同一批 api-server 用例:对象键互不相等 + `ReleasePackageCache` 双资产不串味) | -| 端到端(真实本地栈 + 真实客户端链路) | `npm run check:game-distribution-project-bundle-e2e` → **51 个 `check(` 调用点**(复核另按 72 条断言计,差值来自同一 `check` 内的多条子断言;含 A4 / A5 / A5b / A6 与对抗用例:路径形状、凭据目录、嵌套包改名、符号链接、声明说谎) | ✅ 已验证 | +| 端到端(真实本地栈 + 真实客户端链路) | `npm run (本地夹具脚本,无 npm 条目)` → **51 个 `check(` 调用点**(复核另按 72 条断言计,差值来自同一 `check` 内的多条子断言;含 A4 / A5 / A5b / A6 与对抗用例:路径形状、凭据目录、嵌套包改名、符号链接、声明说谎) | ✅ 已验证 | | 两端规则一致(机器门禁) | `npm run check-project-bundle-policy-parity` → OK(客户端打包器与服务端校验器的排除清单/上限逐项对齐,防止两端口径漂移) | ✅ 已验证 | | 客户端 | AGC 打包器(确定性打包,**24 条单测**,含路径形状检查)、发布时上传(失败不阻断,且**上传前按 `max_archive_bytes` 预检**)、按 `source` 分支的取件建项、来源记录 v2(兼容 v1);`npx vitest run apps/ai-game-creator-shell/tests` 1961 passed(补强前一次的运行;本次未改前端 TS);AGC 定向 `cargo test -- project_bundle` **24 passed** | ✅ 已验证 | | 校验器对抗性补强(P0-a/b/c) | 凭据识别补强(目录 `.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`;前缀 `credentials` / `id_ecdsa` / `id_dsa` / `terraform.tfstate` / `service-account`;后缀 `.jks` / `.keystore` / `.ppk` / `.p8` / `.kdbx` / `.der`;全名 `.htpasswd` / `.pgpass`,外加小文本内容嗅探 PEM / `AKIA…` / `ghp_` / `xox*`);嵌套包扩展名并集 + magic bytes 嗅探(改名也拦);读取层按声明大小封顶 + 压缩比分母改为条目压缩字节;新增错误变体 `CredentialDirectoryNotAllowed` / `SecretContentDetected` | ✅ 已验证(19 条校验器单测;**发行包回归** `cargo test -p api-server game_distribution` 60 passed,含新增「声明说谎 → `ReadFailed`」) | @@ -958,7 +963,7 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`, | 母版仍按请求值 / 缺省 `forbidden` | `creation_authorization_uses_the_request_for_masters_and_fails_closed_on_unknown`:`forbidden` / `nonCommercial` / `full` 原样落库;空串与未知取值失败关闭且文案带 `FORK_AUTHORIZATION_UNKNOWN:`。DTO 侧的缺省值(省略 = `forbidden` 且幂等摘要不变)由既有用例 `create_game_request_defaults_fork_authorization_without_changing_digest` 继续覆盖 | ✅ 已验证(`cargo test -p spacetime-module`、`cargo test -p api-server game_distribution` → 104 passed) | | 契约不变 | `GameDistributionCreateGameRequest.forkAuthorization` 保留且 serde 形状未动(改形状会动幂等摘要);无列/表/索引变更,schema 基线比对通过 | ✅ 已验证(`check:spacetime-schema`:98 表对 `9f4c7d76` 通过) | | 门禁 | wasm build 0;`cargo check --all-targets` 0(仅既有 `TEST_AGC_MODEL_DEFAULT_ID` dead_code 警告);`cargo test -p module-game-distribution` 0(119 passed);`cargo test -p api-server game_distribution` 0(104 passed);`cargo test -p spacetime-module` 0(301 passed / 1 ignored);`cargo test -p spacetime-client` 0(43 passed);`check-game-distribution-dto-parity` 0;`check-project-bundle-policy-parity` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 check:spacetime-schema` 0;`check:encoding` 0;`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0;另跑 `check:doc-index` 0 | ✅ 已验证 | -| 遗留:两个 e2e 脚本仍按旧语义 | `scripts/check-game-distribution-lineage-e2e.mjs` 富树段(`createPublishedNode` 的 `promoteTo` 自带 `from = 'forbidden'`,且 `richF1` 刻意留给 `forbidden`)与 `scripts/capture-game-lineage-visual.mjs`(同款 `promoteForkAuthorization`):改口径后衍生节点的创建档位已等于父档位,这些「再从 `from` 提升」的 PUT 现在**一律**回 409 `FORK_AUTHORIZATION_INHERITED`(不再只是 CAS 不符),需改成「按新继承档位断言 / 不再提升」。**2026-10-07 已由 `scripts/**` owner 改写**:衍生节点改为「探针 PUT 被拒(409 `FORK_AUTHORIZATION_INHERITED`)+ 档位不变」,母版仍断言可提升;同轮补上「核心改动说明落在版本摘要(`currentVersion.changeSummary`)」与贡献归集断言(`fa9ef9037`;lineage 188 项、capture 工具 67 项全绿)。 | ✅ 已改(2026-10-07) | +| 遗留:两个 e2e 脚本仍按旧语义 | 本地夹具脚本(族谱/血缘) 富树段(`createPublishedNode` 的 `promoteTo` 自带 `from = 'forbidden'`,且 `richF1` 刻意留给 `forbidden`)与 `scripts/capture-game-lineage-visual.mjs`(同款 `promoteForkAuthorization`):改口径后衍生节点的创建档位已等于父档位,这些「再从 `from` 提升」的 PUT 现在**一律**回 409 `FORK_AUTHORIZATION_INHERITED`(不再只是 CAS 不符),需改成「按新继承档位断言 / 不再提升」。**2026-10-07 已由 `scripts/**` owner 改写**:衍生节点改为「探针 PUT 被拒(409 `FORK_AUTHORIZATION_INHERITED`)+ 档位不变」,母版仍断言可提升;同轮补上「核心改动说明落在版本摘要(`currentVersion.changeSummary`)」与贡献归集断言(`fa9ef9037`;lineage 188 项、capture 工具 67 项全绿)。 | ✅ 已改(2026-10-07) | | 客户端 UI 的衍生场景 | 已落地(后续提交):`src/components/game-distribution/MyGamesPage.tsx` 对衍生作品隐藏提升入口、改为只读展示「继承自父作品」(`4f888d8c9`,含用例「衍生作品的授权入口改为只读展示(授权继承父作品,不可单独提升)」);AGC 发布面板 `GameDistributionPublishPanel.tsx` 同轮改为只读展示「授权继承自父作品」 | ✅ 已接(由 `src/**`、`apps/**` owner 落地) | --- @@ -991,27 +996,29 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`, | 文档 | 技术方案 §2.3(授权模式 + 状态机)、§2.4(`/games/mine`、`/games/publish`、`/games/lineage`)、§2.6 时序图、§3.3 状态表、§3.4(`PUT` 行、`POST /games` 行、`/lineage` 行)、§3.9 第二条补充、§7 第 2 条;数据契约文档 `fork_authorization` 条目;里程碑实施记录;`decision-log.md` 同日条目 | ✅ 已改 | | 门禁(本工作树自跑,逐条 exit) | wasm build 0;`cargo check --all-targets` 0(仅既有 `TEST_AGC_MODEL_DEFAULT_ID` dead_code 警告);`cargo test -p module-game-distribution` 0(**134 passed**,+3);`cargo test -p api-server game_distribution` 0(107 passed);`cargo test -p spacetime-module` 0(**305 passed / 1 ignored**,+1);`cargo test -p spacetime-client` 0(43 passed);`check-game-distribution-dto-parity` 0;`check-project-bundle-policy-parity` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema` 0(98 表);`npm run check:encoding` 0(5544 文件);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0 | ✅ 已验证 | | 改动清单(测试) | **新增**:`module-game-distribution` 的 2 条行为用例 + `errors.rs` 1 条前缀用例、`spacetime-module` 1 条结构用例;**扩展**:api-server 的 `fork_error_codes_are_reachable_from_module_messages`(加一条码 + 常量字面量断言)、`module-game-distribution` 的 `fork_errors_are_prefixed_with_their_machine_code`(加一条码)。**没有既有 Rust 用例断言过「衍生作品 PUT 提升成功」**——旧语义的断言在两个 e2e 脚本里(见 §5.3 遗留) | ✅ 已改 | -| 遗留:两个 e2e 脚本 | `scripts/check-game-distribution-lineage-e2e.mjs`、`scripts/capture-game-lineage-visual.mjs` 里对**衍生作品**的 PUT 现在一律 409 `FORK_AUTHORIZATION_INHERITED`,需按新语义改写(按新继承档位断言 / 只对母版提升)。**2026-10-07 已改**:见 §5.3 同一行(`fa9ef9037`)——衍生作品 PUT 一律 409 `FORK_AUTHORIZATION_INHERITED` 已成断言口径,母版提升语义未变。前端入口与只读展示已落地(见 §5.3 对应的两行) | ✅ 已改(2026-10-07) | +| 遗留:两个 e2e 脚本 | 本地夹具脚本(族谱/血缘)、`scripts/capture-game-lineage-visual.mjs` 里对**衍生作品**的 PUT 现在一律 409 `FORK_AUTHORIZATION_INHERITED`,需按新语义改写(按新继承档位断言 / 只对母版提升)。**2026-10-07 已改**:见 §5.3 同一行(`fa9ef9037`)——衍生作品 PUT 一律 409 `FORK_AUTHORIZATION_INHERITED` 已成断言口径,母版提升语义未变。前端入口与只读展示已落地(见 §5.3 对应的两行) | ✅ 已改(2026-10-07) | --- ### 5.6 端到端实测覆盖与工具(2026-10-07,`scripts/**` owner) +> **脚本入库口径(2026-10-07 供需方要求)**:本表提到的夹具类/演示数据类 e2e 脚本(族谱、主题、收藏、工程源包、共创授权、演示数据灌装)是**本地验收脚本,未入库**——只存在于本地工作树,团队其他人拉分支拿不到;它们的路径写在本地 `info/exclude` 里,不在仓库。表中所有「已覆盖 / 实测结论」都是这些脚本**真实跑出来**的结果,结论有效。隔离与截图类工具(目标解析+护栏、隔离栈启动器、族谱截图、客户端 CDP 验收)仍在仓库里。 + 在**本地真实栈**上跑通的实测(地址从 `.app/dev-stack.json` 读:api-server `8082` / web `3000` / SpacetimeDB `3101` / admin-web `3102`,库 `genarrative-game-creator-dev`),按「平台 web → 客户端取件建项 → 回平台看血缘」这条链路分段列。**「覆盖到哪一段」以本表为准**(其余段落要么只能 API 等价复现,要么尚未自动化)。 | 链路分段 | 覆盖方式 | 脚本 / 证据 | 状态 | | --- | --- | --- | --- | -| 平台 web:共创列表 / 作品详情 / 族谱页 / 主题页 | 真实浏览器(Playwright 驱动 Chromium,落 DOM 断言 + 截图) | `scripts/check-game-distribution-lineage-e2e.mjs`(F 段)、`scripts/capture-game-lineage-visual.mjs`、`scripts/check-game-distribution-theme-e2e.mjs` | ✅ 已覆盖 | -| Fork 授权三态(禁止 / 非商用 / 全开放)+ 衍生继承与「不可再提升」 | HTTP 契约(+ 浏览器入口) | `scripts/check-game-distribution-fork-authorization-e2e.mjs`;`…lineage-e2e.mjs` H 段(探针 PUT → 409 `FORK_AUTHORIZATION_INHERITED` 且档位不变;母版仍可提升) | ✅ 已覆盖 | -| 族谱与衍生:`/games/{id}/lineage`(按根聚合、200 上限、父作品下架降级)、`/derived` | HTTP 契约 + 浏览器 | `scripts/check-game-distribution-lineage-e2e.mjs`(A–E、G 段) | ✅ 已覆盖 | -| Fork 取件:成品包通道 + **工程源包下行优先** `/fork-source`;工程包上行校验(禁项 / 分片 / 幂等 / 阶段门) | HTTP 契约(无浏览器) | `scripts/check-game-distribution-project-bundle-e2e.mjs`、`…lineage-e2e.mjs` G 段 | ✅ 已覆盖 | -| 收藏(收录)与「我的收藏」 | HTTP 契约 | `scripts/check-game-distribution-collection-e2e.mjs` | ✅ 已覆盖 | -| 共创主题(公开读 + 后台写 + 作品详情 `themes` 增量 + 只允许根) | HTTP 契约 | `scripts/check-game-distribution-theme-e2e.mjs` | ✅ 已覆盖 | -| 贡献归集(作者视角 `GET /games/{id}/contribution`) | HTTP 契约(作者 Bearer) | `scripts/check-game-distribution-lineage-e2e.mjs` I 段 | ✅ 已覆盖 | +| 平台 web:共创列表 / 作品详情 / 族谱页 / 主题页 | 真实浏览器(Playwright 驱动 Chromium,落 DOM 断言 + 截图) | 本地夹具脚本(族谱/血缘)(F 段)、`scripts/capture-game-lineage-visual.mjs`、本地夹具脚本(共创主题) | ✅ 已覆盖 | +| Fork 授权三态(禁止 / 非商用 / 全开放)+ 衍生继承与「不可再提升」 | HTTP 契约(+ 浏览器入口) | 本地夹具脚本(共创授权);`…lineage-e2e.mjs` H 段(探针 PUT → 409 `FORK_AUTHORIZATION_INHERITED` 且档位不变;母版仍可提升) | ✅ 已覆盖 | +| 族谱与衍生:`/games/{id}/lineage`(按根聚合、200 上限、父作品下架降级)、`/derived` | HTTP 契约 + 浏览器 | 本地夹具脚本(族谱/血缘)(A–E、G 段) | ✅ 已覆盖 | +| Fork 取件:成品包通道 + **工程源包下行优先** `/fork-source`;工程包上行校验(禁项 / 分片 / 幂等 / 阶段门) | HTTP 契约(无浏览器) | 本地夹具脚本(工程源包)、`…lineage-e2e.mjs` G 段 | ✅ 已覆盖 | +| 收藏(收录)与「我的收藏」 | HTTP 契约 | 本地夹具脚本(收藏) | ✅ 已覆盖 | +| 共创主题(公开读 + 后台写 + 作品详情 `themes` 增量 + 只允许根) | HTTP 契约 | 本地夹具脚本(共创主题) | ✅ 已覆盖 | +| 贡献归集(作者视角 `GET /games/{id}/contribution`) | HTTP 契约(作者 Bearer) | 本地夹具脚本(族谱/血缘) I 段 | ✅ 已覆盖 | | **真实 AGC 客户端里点一遍**:共创列表 → Fork 确认 → 取件校验 → 生成工程(写 `.agent/fork-source.json`)→ **运行可用** | **已自动化(2026-10-07)**:Tauri + WebView2 通过 CDP 附着真实客户端(客户端启动时带 `--remote-debugging-port=9222`,见 `~/.config/genarrative/.env` 的 `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`;探活 `curl --noproxy '*' http://127.0.0.1:9222/json/version` 应回 `Edg/…`) | `scripts/check-agc-co-creation-fork-e2e.mjs`(19 项,npm script `check:agc-co-creation-fork-e2e`):共创列表滚到底且无「直接 Fork 指定作品」输入块;确认页 3 秒门禁 + 须知无内层滚动 + 底部吸附条;Fork 后本机 `projects/gameagent-*` 的 `.agent/fork-source.json`(v2、`source: package`)、`game/dist/index.html`、无 `reference/`、`code-prototype=completed`、`versions=[initial-1]`;**运行视图可用**(无「首个可运行原型尚未完成」空态、运行表现层 + 预览 iframe) | ✅ 已覆盖 | -| 客户端里**发布**新作品(上传发行包 → 送审 → 管理员审核 → 平台看到新节点/血缘) | 未自动化:客户端发布面板需要人工填「本次核心改动说明」等 | 平台侧同一段由 `scripts/check-game-distribution-lineage-e2e.mjs` 的发布链路 + 血缘断言覆盖 | ⏳ 待补 | +| 客户端里**发布**新作品(上传发行包 → 送审 → 管理员审核 → 平台看到新节点/血缘) | 未自动化:客户端发布面板需要人工填「本次核心改动说明」等 | 平台侧同一段由 本地夹具脚本(族谱/血缘) 的发布链路 + 血缘断言覆盖 | ⏳ 待补 | | 窄屏三档(360 / 390 / 414)移动端形态 | 真实浏览器 | `scripts/capture-game-lineage-visual.mjs` 的 `E2E_CAPTURE_NARROW_WIDTHS=360,390,414`:深链进 `/games/lineage` **不再被「移动端仅支持作品展示」弹窗盖住**、默认大纲式缩进树(行高 ≥44、有导线与层级缩进)、长标题不横向溢出、点节点弹底部 sheet 且可关闭、选中后当前面包屑已被滚进视口 | ✅ 已覆盖(另见下方两条未修问题) | -| 演示数据与视觉基线(供需求方验收) | 幂等灌数据 + 截图工具 | `scripts/seed-game-distribution-co-creation.mjs`(标题前缀 `共创演示·`,**判重不绑账号**:先查公开目录同标题再查本人 `my-games`;13 节点 / 3 棵树 / 4 作者 / 最大 3 代 / 13 张不同封面)、`scripts/capture-game-lineage-visual.mjs`(`E2E_CAPTURE_LINEAGE_URL` 开启「只截现有作品」模式) | ✅ 已覆盖 | +| 演示数据与视觉基线(供需求方验收) | 幂等灌数据 + 截图工具 | 本地演示数据灌装脚本(标题前缀 `共创演示·`,**判重不绑账号**:先查公开目录同标题再查本人 `my-games`;13 节点 / 3 棵树 / 4 作者 / 最大 3 代 / 13 张不同封面)、`scripts/capture-game-lineage-visual.mjs`(`E2E_CAPTURE_LINEAGE_URL` 开启「只截现有作品」模式) | ✅ 已覆盖 | 实测固定口径(可直接复用):pinned 端口 `--api-port 8188 --bgfilter-worker-port 8189 --spacetime-port 3110 --admin-web-port 3111 --web-port 3112`;管理员 `admin / 123456`;Playwright 走临时目录(`E2E_PLAYWRIGHT_DIR`)+ 本机 Chromium(`E2E_CHROMIUM_EXECUTABLE`);断言口径一律按**符号**引用实现(不写行号),详见各脚本头部注释。 @@ -1021,7 +1028,7 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`, | 问题 | 证据 | 状态 | | --- | --- | --- | -| 族谱画布(`layout="stacked"`)节点**不再显示作者名** | 原因:`LineageNodeContent` 只在 `layout === 'inline'` 渲染作者行,画布传 `stacked` → 作者整列消失(`aria-label` 里仍有,故只有视觉缺失)。修复 `9cf30517d`:仅**窄屏紧凑卡片**省作者,其余两种形态都渲染。复验:`check-game-distribution-lineage-e2e.mjs` → **188/188 PASS**(原唯一 FAIL「浏览器视觉:母版节点显示作者名」转绿);1440 桌面截图可见卡片上有「演示作者甲/乙/丙/丁」 | ✅ 已修并复验 | +| 族谱画布(`layout="stacked"`)节点**不再显示作者名** | 原因:`LineageNodeContent` 只在 `layout === 'inline'` 渲染作者行,画布传 `stacked` → 作者整列消失(`aria-label` 里仍有,故只有视觉缺失)。修复 `9cf30517d`:仅**窄屏紧凑卡片**省作者,其余两种形态都渲染。复验:本地夹具脚本(族谱/血缘) → **188/188 PASS**(原唯一 FAIL「浏览器视觉:母版节点显示作者名」转绿);1440 桌面截图可见卡片上有「演示作者甲/乙/丙/丁」 | ✅ 已修并复验 | | 移动端切「图谱视图」(横向画布)**不会初始定位到当前作品** | 修复前 390 宽实测:`zoom=100%`、`transform: matrix(1,0,0,1,0,0)`,画布可视区 `x 37–353`,当前作品节点头部 `x 457–633` → 0/8 节点可见。修复 `ec8f27cb2`(横版画布 + 进入时按宽度适配并定位当前作品)。复验(360/390/414 三档):`zoom=35%`、当前作品在画布内、可见节点 **7/8(360、390)/ 8/8(414)** | ✅ 已修并复验 | | (遗留备注)窄屏图谱视图里点卡片会跳详情 | 窄屏紧凑卡片整块是「打开详情」按钮,没有可锁定区域 → 信息面板/面包屑的窄屏验证改由**大纲形态**做(点行弹底部 sheet、面包屑出现并被滚进视口,三档均 PASS)。不是缺陷,但窄屏画布下确实没有「锁定」入口 | 📝 记录 | diff --git a/package.json b/package.json index c1ca46a40..2a9cfba81 100644 --- a/package.json +++ b/package.json @@ -83,8 +83,6 @@ "check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs", "check:agc-update-channel-manifests": "node scripts/check-agc-update-channel-manifests.mjs", "check:agc-co-creation-fork-e2e": "node scripts/check-agc-co-creation-fork-e2e.mjs", - "e2e:stack": "node scripts/e2e-stack.mjs --stack-only", - "e2e:run": "node scripts/e2e-stack.mjs", "check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-e2e.mjs", "check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs", "check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs", @@ -94,11 +92,6 @@ "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:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs", - "check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs", - "check:game-distribution-lineage-e2e": "node scripts/check-game-distribution-lineage-e2e.mjs", - "check:game-distribution-project-bundle-e2e": "node scripts/check-game-distribution-project-bundle-e2e.mjs", - "check:game-distribution-collection-e2e": "node scripts/check-game-distribution-collection-e2e.mjs", - "check:game-distribution-theme-e2e": "node scripts/check-game-distribution-theme-e2e.mjs", "check:game-distribution-purchase-e2e": "node scripts/check-game-distribution-purchase-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", diff --git a/scripts/capture-game-lineage-visual.mjs b/scripts/capture-game-lineage-visual.mjs index b4ca81792..9cbd8b933 100644 --- a/scripts/capture-game-lineage-visual.mjs +++ b/scripts/capture-game-lineage-visual.mjs @@ -19,6 +19,7 @@ // - 布局纯函数:src/components/game-distribution/lineageTreeLayout.ts(缺失父作品会生成 stub 节点) import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { mkdtemp } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; @@ -27,29 +28,72 @@ import { deflateSync } from 'node:zlib'; import JSZip from 'jszip'; -import { resolveE2eTarget } from './e2e-target.mjs'; - 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 DEV_PASSWORD = 'GenE2e123!'; const GATE_KEY = 'game-distribution:publish'; +/** 隔离实例的缺省地址(专用端口;库名/data-dir 由隔离实例启动器负责,见《开发运维》「本地 e2e 必须用隔离实例」)。 */ +const ISOLATED_API_BASE = 'http://127.0.0.1:8188'; +const ISOLATED_WEB_BASE = 'http://127.0.0.1:3112'; if (!ADMIN_USER || !ADMIN_PASSWORD) { console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD'); process.exit(2); } -// 目标地址:造树模式会写夹具 → 缺省打隔离栈并拒绝误打用户开发栈;「只截现有作品」模式是只读 -// (拿的是用户开发栈里的演示数据),仍按 `.app/dev-stack.json` 解析、不设护栏。 -const captureOnlyMode = - (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim() !== ''; -const target = resolveE2eTarget({ - scriptName: 'capture-game-lineage-visual.mjs', - writesFixtures: !captureOnlyMode, -}); -const API = target.apiBase; -const WEB = target.webBase; +/** + * 解析目标地址(本工具**自带**这份逻辑,不依赖未入库的本地模块): + * - 「只截现有作品」模式是只读,按 `.app/dev-stack.json` 解析(要的就是用户开发栈里的演示数据); + * - 造树模式会写夹具 → 缺省打隔离实例,并拒绝误打用户开发栈。 + */ +function readDevStackMarker() { + try { + return JSON.parse( + readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'), + ); + } catch { + return null; + } +} +function normalizeBase(value) { + return String(value ?? '') + .trim() + .replace(/\/+$/u, ''); +} +function resolveCaptureTarget() { + const marker = readDevStackMarker(); + const devApi = normalizeBase(marker?.services?.['api-server']?.url); + const devWeb = normalizeBase(marker?.services?.web?.url); + const captureOnly = (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim() !== ''; + const explicitApi = normalizeBase(process.env.E2E_API_BASE); + const explicitWeb = normalizeBase(process.env.E2E_WEB_BASE); + const apiBase = + explicitApi || + (captureOnly ? devApi || ISOLATED_API_BASE : ISOLATED_API_BASE); + const webBase = + explicitWeb || + (captureOnly ? devWeb || ISOLATED_WEB_BASE : ISOLATED_WEB_BASE); + if (!captureOnly && (process.env.E2E_ALLOW_DEV_STACK ?? '').trim() !== '1') { + const samePort = + devApi && + new URL(apiBase).port && + new URL(apiBase).port === new URL(devApi).port; + if (devApi && (apiBase === devApi || samePort)) { + console.error( + [ + `capture-game-lineage-visual.mjs: 目标 ${apiBase} 就是用户正在用的开发栈(.app/dev-stack.json → ${devApi})。`, + '造树模式会写夹具,缺省必须打隔离实例:二选一——', + ` 1) 用隔离实例(缺省 api-server ${ISOLATED_API_BASE},web ${ISOLATED_WEB_BASE},库名/data-dir 见《开发运维》)`, + ' 2) 明确越权:E2E_API_BASE= E2E_ALLOW_DEV_STACK=1(这会真的写进用户的库)', + ].join('\n'), + ); + process.exit(2); + } + } + return { apiBase, webBase }; +} +const { apiBase: API, webBase: WEB } = resolveCaptureTarget(); let checks = 0; let failures = 0; @@ -753,7 +797,7 @@ async function captureNarrowBreakpoints({ browser, lineageUrl, shotDir }) { } } -/// 「只截图、不造树」模式:给已有作品(例如 scripts/seed-game-distribution-co-creation.mjs 灌的共创网络) +/// 「只截图、不造树」模式:给已有作品(例如本地演示数据灌装脚本放进开发栈的共创网络) /// 出验收截图。设 `E2E_CAPTURE_LINEAGE_URL` 即进入该模式——不注册账号、不建作品、不下架,纯 GET + 截图。 async function captureExistingUrls() { const lineageUrl = (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim(); diff --git a/scripts/check-game-distribution-collection-e2e.mjs b/scripts/check-game-distribution-collection-e2e.mjs deleted file mode 100644 index 88be41a09..000000000 --- a/scripts/check-game-distribution-collection-e2e.mjs +++ /dev/null @@ -1,800 +0,0 @@ -// 游戏分发「收藏(收录)」链路真实行为验收。 -// -// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;不需要浏览器。 -// -// 用法: -// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ -// node scripts/check-game-distribution-collection-e2e.mjs -// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口) -// -// 契约来源(逐条读实现确认,未按描述猜): -// [1] 三条路由(Bearer + no-store):api-server/src/modules/game_distribution.rs:340-352 -// [2] PUT 收藏:同文件 :1393-1426(幂等键必填、响应 `{collected, replayed}`) -// — 请求摘要绑定 `(user_id, game_id)`::1380-1385 -// [3] DELETE 取消收藏:同文件 :1428-1461(不要求幂等键;不存在也算成功;响应只有 `collected`) -// [4] GET 我的收藏:同文件 :1465-1481 + `my_collections_payload` :1487-1497(`{games, nextCursor:null}`) -// [5] 公开详情按可选登录态加 `collected`:同文件 :1305-1373(匿名 / 无效 token → **不加键**) -// [6] 事务语义:spacetime-module/src/game_distribution.rs:4669-4783 -// — 不存在 → 「作品不存在」(→ 404);存在但不可读 → `GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT`(→ 409) -// — 收据键 `(user_id, action, idempotency_key)`,摘要不一致 → 「幂等键对应的请求摘要不一致」(→ 409) -// — 收藏行主键 `{user_id}:{game_id}`,重复收藏不产生第二行 -// — 取消收藏按确定性主键删除,未收藏也成功 -// [7] 语义常量:shared-contracts/src/game_distribution.rs:33-34 -// (文案刻意不含「不存在」/「已被删除」,避免命中 404 分支泄露未公开作品的存在性) -// -// 复用/照抄的 helper(注释里标了出处;抽公共模块会改动其它已提交脚本,故照抄): -// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133 -// - check/note/api/brief/register/gameMetadata/uploadCover/createGame/ownerGame:lineage 脚本 :139-318 -// - publishToPublic(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407 -// (顺序与请求体源自 media-e2e.mjs:431-495,523-527,561-573) -// -// 与工单描述不一致、按实现断言并在报告里标注的地方: -// - 工单说「同键不同请求(换作品 / 换用户)→ 409」。实测 **换作品** 是 409(同一收据键、摘要不同), -// 但 **换用户** 不会命中别人的收据(收据键含 user_id),因此是正常 200 新收藏。api-server 的 -// handler 注释写「换用户也是 409」,与同一提交里 spacetime-module 的注释(「不同用户 / 不同作品 -// 即使共用同一个 Idempotency-Key 也不会互相命中」)相反;本脚本按后者(= 实测)断言。 - -import { createHash } from 'node:crypto'; - -import JSZip from 'jszip'; - -import { - cleanupRunFixtures, - fixtureTitle, - readDevStackMarker, - reportLeftoverFixtures, - resolveE2eTarget, -} from './e2e-target.mjs'; - -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 DEV_PASSWORD = 'GenE2e123!'; -const GATE_KEY = 'game-distribution:publish'; - -if (!ADMIN_USER || !ADMIN_PASSWORD) { - console.error( - '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' + - 'game-distribution:publish 写入口并把作品推到公开(收藏要求作品公开可读)。', - ); - process.exit(2); -} - -// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 -// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 -const devStack = readDevStackMarker(); -const target = resolveE2eTarget({ - scriptName: 'check-game-distribution-collection-e2e.mjs', -}); -const API = target.apiBase; - -let checks = 0; -let failures = 0; -const skipped = 0; -function check(name, ok, detail = '') { - checks += 1; - if (!ok) failures += 1; - console.log( - `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, - ); -} -function note(message) { - console.log(`NOTE ${message}`); -} - -const COVER_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', - 'base64', -); - -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, - signal: AbortSignal.timeout(120_000), - }); - 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, - cacheControl: response.headers.get('cache-control') ?? '', - }; -} - -function brief(body) { - const code = body?.error?.code ?? body?.json?.error?.code ?? ''; - return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 220)}`; -} - -async function register(prefix) { - const phone = `${prefix}${String(Date.now()).slice(-8)}`; - const response = await api('/api/auth/entry', { - method: 'POST', - body: { purePhoneNumber: phone, password: DEV_PASSWORD }, - }); - return { - phone, - response, - token: response.data?.token, - user: response.data?.user, - }; -} - -function gameMetadata({ title, coverAssetId }) { - return { - title, - summary: '收藏验收临时作品', - description: '', - category: '休闲', - tags: ['e2e'], - coverAssetId, - deviceSupport: { desktop: true, mobile: false, touch: false }, - inputModes: ['keyboard', 'mouse'], - orientation: 'landscape', - }; -} - -async function uploadCover(token, id) { - const fileName = `collection-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'collection', 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-collection', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, - ); - } - return confirm.data.assetObject.assetObjectId; -} - -async function createGame({ token, metadata, idemKey }) { - return api('/api/game-distribution/games', { - method: 'POST', - token, - headers: { 'Idempotency-Key': idemKey }, - body: metadata, - }); -} - -async function ownerGame(token, gameId) { - const response = await api(`/api/game-distribution/my-games/${gameId}`, { - token, - }); - return { response, game: response.data?.game ?? null }; -} - -async function buildReleaseZip(marker) { - const zip = new JSZip(); - zip.file( - 'index.html', - `${marker}

${marker}

`, - ); - const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); - return { - bytes, - fileCount: 1, - sha256: createHash('sha256').update(bytes).digest('hex'), - }; -} - -/// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。 -async function publishToPublic({ - token, - admin, - gameId, - gameRevision, - metadata, - stamp, - tag, - label, -}) { - const zip = await buildReleaseZip(`collection-${tag}-${stamp}`); - const created = await api(`/api/game-distribution/games/${gameId}/versions`, { - method: 'POST', - token, - headers: { 'Idempotency-Key': `col-version-${tag}-${stamp}` }, - body: { - packageSha256: zip.sha256, - packageBytes: zip.bytes.length, - packageFileCount: zip.fileCount, - packageEntryPath: 'index.html', - gameMetadata: metadata, - }, - }); - const versionId = created.data?.versionId; - check( - `${label} 版本创建成功`, - created.status === 200 && Boolean(versionId), - `status=${created.status} ${created.text.slice(0, 140)}`, - ); - if (!versionId) return { versionId: null }; - - const upload = await api( - `/api/game-distribution/versions/${versionId}/package`, - { - method: 'PUT', - token, - headers: { - 'Idempotency-Key': `col-upload-${tag}-${stamp}`, - 'Content-Type': 'application/zip', - }, - binary: zip.bytes, - }, - ); - check( - `${label} 发行包上传成功`, - upload.status === 200, - `status=${upload.status}`, - ); - - const submitted = await api( - `/api/game-distribution/versions/${versionId}/submit`, - { - method: 'POST', - token, - headers: { 'Idempotency-Key': `col-submit-${tag}-${stamp}` }, - body: { expectedPublicationRevision: gameRevision }, - }, - ); - check( - `${label} 送审成功(202)`, - submitted.status === 202, - `status=${submitted.status} ${submitted.text.slice(0, 140)}`, - ); - - const readback = await api(`/api/game-distribution/versions/${versionId}`, { - token, - }); - const approved = await api( - `/admin/api/game-distribution/versions/${versionId}/review`, - { - method: 'POST', - token: admin, - headers: { 'Idempotency-Key': `col-approve-${tag}-${stamp}` }, - body: { - decision: 'approve', - expectedPublicationRevision: - readback.data?.version?.publicationRevision ?? gameRevision, - }, - }, - ); - check( - `${label} 管理员审核通过并公开`, - approved.status === 200, - `status=${approved.status} ${approved.text.slice(0, 140)}`, - ); - return { versionId }; -} - -// ---------- 收藏专用 helper ---------- - -function collectGame({ token, gameId, key }) { - return api(`/api/game-distribution/games/${gameId}/collection`, { - method: 'PUT', - token, - ...(key === undefined ? {} : { headers: { 'Idempotency-Key': key } }), - }); -} - -function uncollectGame({ token, gameId }) { - return api(`/api/game-distribution/games/${gameId}/collection`, { - method: 'DELETE', - token, - }); -} - -function myCollections(token) { - return api('/api/game-distribution/my-collections', { token }); -} - -function publicDetail(gameId, token) { - return api(`/api/game-distribution/games/${gameId}`, { token }); -} - -function publicCatalog() { - return api('/api/game-distribution/games'); -} - -function occurrences(games, gameId) { - return (games ?? []).filter((game) => game.id === gameId).length; -} - -// ---------- 主流程 ---------- - -async function main() { - console.log( - `[collection-e2e] api-server=${API} database=${devStack.database}`, - ); - - const adminLogin = await api('/admin/api/login', { - method: 'POST', - body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, - }); - const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; - check( - '管理员登录成功', - adminLogin.status === 200 && Boolean(admin), - `status=${adminLogin.status}`, - ); - if (!admin) process.exit(1); - - const gate = await api('/admin/api/feature-gates', { - method: 'PUT', - token: admin, - body: { - gateKey: GATE_KEY, - enabled: true, - rolloutPercent: 100, - allowUserIds: [], - allowUserTags: [], - denyUserIds: [], - description: 'E2E 收藏链路', - }, - }); - check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); - - const stamp = Date.now(); - const suffix = String(stamp).slice(-6); - const author = await register('132'); - const viewer = await register('133'); - activeFixtureRun = { - api, - suffix, - owners: [ - { token: author.token, label: '作者' }, - { token: viewer.token, label: '查看者' }, - ], - }; - check( - '作者账号注册拿到 token', - author.response.status === 200 && Boolean(author.token), - `status=${author.response.status}`, - ); - check( - '第二个账号(未收藏视图)注册拿到 token', - viewer.response.status === 200 && Boolean(viewer.token), - `status=${viewer.response.status}`, - ); - if (!author.token || !viewer.token) process.exit(1); - - // ---------- fixture:已公开作品 X + 草稿作品 D ---------- - const metadata = gameMetadata({ - title: fixtureTitle('收藏作品', suffix), - coverAssetId: await uploadCover(author.token, stamp), - }); - const created = await createGame({ - token: author.token, - metadata, - idemKey: `col-game-${stamp}`, - }); - const gameId = created.data?.id; - check( - '作品 X 创建成功', - created.status === 200 && Boolean(gameId), - `status=${created.status} id=${gameId ?? ''}`, - ); - if (!gameId) process.exit(1); - await publishToPublic({ - token: author.token, - admin, - gameId, - gameRevision: created.data?.publicationRevision ?? 0, - metadata, - stamp, - tag: 'x', - label: '作品 X', - }); - const xPublic = await publicDetail(gameId); - check( - '作品 X 已公开可读', - xPublic.status === 200 && xPublic.data?.status === 'published', - brief(xPublic), - ); - - const draftMetadata = gameMetadata({ - title: fixtureTitle('收藏草稿', suffix), - coverAssetId: await uploadCover(author.token, `${stamp}-draft`), - }); - const draftCreated = await createGame({ - token: author.token, - metadata: draftMetadata, - idemKey: `col-draft-${stamp}`, - }); - const draftId = draftCreated.data?.id; - check( - '草稿作品 D 创建成功(用于 409 用例)', - draftCreated.status === 200 && Boolean(draftId), - `status=${draftCreated.status} id=${draftId ?? ''}`, - ); - if (!draftId) process.exit(1); - - // ---------- 1/2:两次不同幂等键 + 同键重放,且始终只有一行 ---------- - const listBefore = await myCollections(author.token); - check( - '收藏前我的收藏列表为空', - listBefore.status === 200 && - Array.isArray(listBefore.data?.games) && - listBefore.data.games.length === 0, - `status=${listBefore.status} size=${listBefore.data?.games?.length ?? 'n/a'}`, - ); - - const keyA = `col-key-a-${stamp}`; - const keyB = `col-key-b-${stamp}`; - const firstCollect = await collectGame({ - token: author.token, - gameId, - key: keyA, - }); - check( - '1) 首次收藏成功且 replayed=false', - firstCollect.status === 200 && - firstCollect.data?.collected === true && - firstCollect.data?.replayed === false, - brief(firstCollect), - ); - const secondCollect = await collectGame({ - token: author.token, - gameId, - key: keyB, - }); - check( - '1) 换幂等键再次收藏仍成功且 replayed=false', - secondCollect.status === 200 && - secondCollect.data?.collected === true && - secondCollect.data?.replayed === false, - brief(secondCollect), - ); - const listAfterTwoKeys = await myCollections(author.token); - check( - '1) 两次不同键后该作品在列表中只出现一次(长度=1)', - listAfterTwoKeys.data?.games?.length === 1 && - occurrences(listAfterTwoKeys.data?.games, gameId) === 1, - `size=${listAfterTwoKeys.data?.games?.length} occurrences=${occurrences(listAfterTwoKeys.data?.games, gameId)}`, - ); - - const replay = await collectGame({ token: author.token, gameId, key: keyB }); - check( - '2) 同键重放 replayed=true 且仍只一行', - replay.status === 200 && - replay.data?.collected === true && - replay.data?.replayed === true && - occurrences((await myCollections(author.token)).data?.games, gameId) === - 1, - brief(replay), - ); - - // ---------- 3:同键不同请求 ---------- - const sameKeyOtherGame = await collectGame({ - token: author.token, - gameId: draftId, - key: keyA, - }); - check( - '3) 同键换作品 → 409(幂等摘要不一致)', - sameKeyOtherGame.status === 409 && - String( - sameKeyOtherGame.error?.details?.message ?? - sameKeyOtherGame.error?.message ?? - '', - ).includes('幂等'), - brief(sameKeyOtherGame), - ); - note( - '3) 同键换**用户**不会命中别人的收据:收据键是 (user_id, action, idempotency_key),' + - '不同用户共用同一个 Idempotency-Key 是各自独立的新收藏(见下面的跨用户探针),' + - '与 api-server handler 注释里「换用户也是 409」不一致,但与该提交里 spacetime-module 的注释一致。', - ); - - // ---------- 4:DELETE 幂等 ---------- - const deleted = await uncollectGame({ token: author.token, gameId }); - check( - '4) 已收藏时 DELETE → 200 {collected:false} 且不发 replayed', - deleted.status === 200 && - deleted.data?.collected === false && - deleted.data?.replayed === undefined, - brief(deleted), - ); - const deletedAgain = await uncollectGame({ token: author.token, gameId }); - check( - '4) 未收藏时再 DELETE 仍 200 {collected:false}', - deletedAgain.status === 200 && deletedAgain.data?.collected === false, - brief(deletedAgain), - ); - const deletedNever = await uncollectGame({ - token: author.token, - gameId: draftId, - }); - check( - '4) 从未收藏过的作品 DELETE 也 200(不要求作品公开)', - deletedNever.status === 200 && deletedNever.data?.collected === false, - brief(deletedNever), - ); - - // ---------- 5:未登录 401 + no-store ---------- - for (const [label, call] of [ - ['PUT', () => collectGame({ gameId, key: `col-anon-${stamp}` })], - ['DELETE', () => uncollectGame({ gameId })], - ['GET', () => myCollections('')], - ]) { - const response = await call(); - check(`5) 未登录 ${label} → 401`, response.status === 401, brief(response)); - check( - `5) 未登录 ${label} 响应带 Cache-Control: no-store(证明挂载而非 404/405)`, - response.cacheControl.includes('no-store'), - `cache-control=${response.cacheControl || '∅'}`, - ); - } - const noKey = await collectGame({ token: author.token, gameId }); - check( - '5) 已登录但缺 Idempotency-Key 的 PUT → 400', - noKey.status === 400, - brief(noKey), - ); - - // ---------- 6:未公开 / 不存在 ---------- - const draftCollect = await collectGame({ - token: author.token, - gameId: draftId, - key: `col-draft-key-${stamp}`, - }); - const draftMessage = String( - draftCollect.error?.details?.message ?? draftCollect.error?.message ?? '', - ); - check( - '6) 未公开作品 PUT → 409(状态冲突)', - draftCollect.status === 409, - brief(draftCollect), - ); - check( - '6) 409 文案含「状态」且不含「不存在」(不泄露未公开作品的存在性)', - draftMessage.includes('状态') && !draftMessage.includes('不存在'), - `message=${draftMessage || '∅'}`, - ); - const missingCollect = await collectGame({ - token: author.token, - gameId: `game_${'0'.repeat(32)}`, - key: `col-missing-key-${stamp}`, - }); - check( - '6) 不存在的 gameId PUT → 404', - missingCollect.status === 404, - brief(missingCollect), - ); - - // ---------- 9:跨用户隔离(在跨用户探针之前做,避免被后面的收藏干扰) ---------- - await collectGame({ token: author.token, gameId, key: `col-iso-${stamp}` }); - const viewerList = await myCollections(viewer.token); - check( - '9) 用户 A 收藏后,用户 B 的 my-collections 不含该作品', - viewerList.status === 200 && - occurrences(viewerList.data?.games, gameId) === 0, - `viewer size=${viewerList.data?.games?.length ?? 'n/a'}`, - ); - const viewerDetail = await publicDetail(gameId, viewer.token); - check( - '9) 用户 B 的公开详情里 collected=false(未收藏但已认证)', - viewerDetail.status === 200 && viewerDetail.data?.collected === false, - `collected=${JSON.stringify(viewerDetail.data?.collected ?? null)}`, - ); - - // 跨用户同键探针(工单预期 409,实现按用户隔离收据 → 期望 200 新收藏) - const crossUser = await collectGame({ - token: viewer.token, - gameId, - key: keyA, - }); - check( - '9+) 另一用户复用 A 用过的同一 Idempotency-Key → 200(收据按用户隔离,非 409)', - crossUser.status === 200 && - crossUser.data?.collected === true && - crossUser.data?.replayed === false, - brief(crossUser), - ); - check( - '9+) 跨用户收藏后 B 的列表含该作品,且 A 的列表不受影响', - occurrences((await myCollections(viewer.token)).data?.games, gameId) === - 1 && - occurrences((await myCollections(author.token)).data?.games, gameId) === - 1, - `viewer=${occurrences((await myCollections(viewer.token)).data?.games, gameId)} author=${occurrences((await myCollections(author.token)).data?.games, gameId)}`, - ); - await uncollectGame({ token: viewer.token, gameId }); - - // ---------- 7:下架 → 重新公开 ---------- - const beforeUnpublish = await ownerGame(author.token, gameId); - const unpublishRevision = beforeUnpublish.game?.publicationRevision ?? 0; - const unpublished = await api( - `/api/game-distribution/games/${gameId}/unpublish`, - { - method: 'POST', - token: author.token, - headers: { 'Idempotency-Key': `col-unpublish-${stamp}` }, - body: { expectedPublicationRevision: unpublishRevision }, - }, - ); - check('7) 作者下架成功', unpublished.status === 200, brief(unpublished)); - const hiddenList = await myCollections(author.token); - check( - '7) 下架后 my-collections 不含该作品(读侧过滤)', - hiddenList.status === 200 && - occurrences(hiddenList.data?.games, gameId) === 0, - `size=${hiddenList.data?.games?.length} occurrences=${occurrences(hiddenList.data?.games, gameId)}`, - ); - const republish = await publishToPublic({ - token: author.token, - admin, - gameId, - gameRevision: - (await ownerGame(author.token, gameId)).game?.publicationRevision ?? 0, - metadata, - stamp, - tag: 'x-v2', - label: '作品 X 重新公开', - }); - check( - '7) 重新公开(新版本审核通过)后 my-collections 又含该作品且只一行', - republish.versionId !== null && - occurrences((await myCollections(author.token)).data?.games, gameId) === - 1, - `occurrences=${occurrences((await myCollections(author.token)).data?.games, gameId)}`, - ); - - // ---------- 8:公开详情 collected 的匿名 vs 已认证 ---------- - const anonymousDetail = await publicDetail(gameId); - const authorDetail = await publicDetail(gameId, author.token); - const anonymousHasKey = Object.hasOwn( - anonymousDetail.data ?? {}, - 'collected', - ); - const authorKeys = Object.keys(authorDetail.data ?? {}); - const anonymousKeys = Object.keys(anonymousDetail.data ?? {}); - check( - '8) 匿名公开详情没有 collected 键', - anonymousDetail.status === 200 && !anonymousHasKey, - `keys=${anonymousKeys.join(',')}`, - ); - check( - '8) 已登录公开详情恰好多 collected 这一个键且值为 true', - authorDetail.status === 200 && - authorDetail.data?.collected === true && - authorKeys.length === anonymousKeys.length + 1 && - authorKeys.every( - (key) => key === 'collected' || anonymousKeys.includes(key), - ), - `authorKeys=${authorKeys.length} anonymousKeys=${anonymousKeys.length} collected=${JSON.stringify(authorDetail.data?.collected ?? null)}`, - ); - const authorWithoutCollected = { ...(authorDetail.data ?? {}) }; - delete authorWithoutCollected.collected; - check( - '8) 去掉 collected 后,已登录详情与匿名详情逐字段一致', - JSON.stringify(authorWithoutCollected) === - JSON.stringify(anonymousDetail.data ?? {}), - `diffKeys=${anonymousKeys.filter((key) => JSON.stringify(authorDetail.data?.[key]) !== JSON.stringify(anonymousDetail.data?.[key])).join(',') || '无'}`, - ); - const catalog = await publicCatalog(); - const catalogEntry = (catalog.data?.games ?? []).find( - (game) => game.id === gameId, - ); - const catalogKeys = Object.keys(catalogEntry ?? {}); - const mismatchedCatalogKeys = catalogKeys.filter( - (key) => - JSON.stringify(catalogEntry?.[key]) !== - JSON.stringify(anonymousDetail.data?.[key]), - ); - check( - '8) 公开目录条目是匿名详情的子集(共有键取值全一致),详情仅多出 themes 这一处增量', - Boolean(catalogEntry) && - mismatchedCatalogKeys.length === 0 && - anonymousKeys.filter((key) => !catalogKeys.includes(key)).length === 1 && - anonymousKeys.filter((key) => !catalogKeys.includes(key))[0] === - 'themes' && - Array.isArray(anonymousDetail.data?.themes), - `catalogKeys=${catalogKeys.length} detailKeys=${anonymousKeys.length} 不一致=${mismatchedCatalogKeys.join(',') || '无'} 仅详情有=${anonymousKeys.filter((key) => !catalogKeys.includes(key)).join(',') || '无'}`, - ); - check( - '8) 我的收藏列表条数与「已收藏」状态一致(nextCursor=null)', - (await myCollections(author.token)).data?.nextCursor === null && - occurrences((await myCollections(author.token)).data?.games, gameId) === - 1, - `nextCursor=${JSON.stringify((await myCollections(author.token)).data?.nextCursor ?? null)}`, - ); - note( - `未覆盖:没有收藏计数类接口可读,因此「只一行」用 my-collections 的长度与出现次数证明(工单允许的方式);` + - `当前库内该用户收藏条数=${(await myCollections(author.token)).data?.games?.length ?? 'n/a'}。`, - ); - - // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 - const cleanup = await cleanupRunFixtures({ - api, - owners: [ - { token: author.token, label: '作者' }, - { token: viewer.token, label: '查看者' }, - ], - suffix, - }); - console.log( - `[collection-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + - (cleanup.failures.length > 0 - ? `,失败 ${cleanup.failures.length} 条(见下)` - : ''), - ); - for (const failure of cleanup.failures) { - console.log(`[collection-e2e] 清理失败 ${JSON.stringify(failure)}`); - } - - console.log( - `[collection-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, - ); - if (failures > 0) { - process.exitCode = 1; - } -} - -/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ -let activeFixtureRun = null; - -main().catch(async (error) => { - console.error(`[collection-e2e] 未捕获异常:${error?.stack ?? error}`); - if (activeFixtureRun) { - await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); - } - process.exitCode = 1; -}); diff --git a/scripts/check-game-distribution-fork-authorization-e2e.mjs b/scripts/check-game-distribution-fork-authorization-e2e.mjs deleted file mode 100644 index 56b057d96..000000000 --- a/scripts/check-game-distribution-fork-authorization-e2e.mjs +++ /dev/null @@ -1,833 +0,0 @@ -// 游戏分发「作品级共创授权(Fork authorization)」链路真实行为验收。 -// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server [+ web])+ 管理员账号。 -// -// 用法(Windows + bash 均可;只依赖 Node 内置模块 + 仓库已有依赖): -// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ -// node scripts/check-game-distribution-fork-authorization-e2e.mjs -// SKIP_BROWSER=1 只跑 HTTP 契约部分(跳过 Playwright 视觉步骤) -// E2E_API_BASE / E2E_WEB_BASE 覆盖地址;默认从 CWD 的 .app/dev-stack.json 读取(不写死端口) -// E2E_PLAYWRIGHT_DIR 指向临时装了 playwright 的目录(仓库 devDependencies 不含 playwright) -// E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器) -// E2E_FORK_ENTRY_LABEL / E2E_FORK_LABEL_FORBIDDEN / E2E_FORK_LABEL_NON_COMMERCIAL / E2E_FORK_LABEL_FULL -// 覆盖前端文案(前端尚未落地,默认值见下方常量) -// -// 参考的仓库既有脚本与实现(本脚本按同一风格写成,未猜测既有契约): -// [1] admin 登录 / 缺凭据退出码 2 / 灰度开关:scripts/check-game-distribution-web-e2e.mjs:22-31,150-156,176-187 -// [2] 作者注册(/api/auth/entry,dev 自动注册):scripts/check-game-distribution-web-e2e.mjs:24,161-166 -// [3] 从 .app/dev-stack.json 取地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27 -// [4] 封面直传 + 确认("发布游戏必须提供封面"):scripts/check-game-distribution-owner-isolation.mjs:97-150 -// [5] 建游戏 payload 与响应取值:scripts/check-game-distribution-owner-isolation.mjs:53-64,201-212 -// [6] my-games 明细形状(data.game):server-rs/crates/api-server/src/modules/game_distribution.rs:1151-1200,2991-3016 -// [7] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159 -// -// 契约假设(⚠ 只读核查确认 master 尚未落地,脚本按目标契约断言): -// A. PUT /api/game-distribution/games/{game_id}/fork-authorization 在 master 不存在: -// 全仓库 grep `fork-authorization|forkAuthorization|fork_authorization` 无匹配; -// 受保护路由全量枚举见 modules/game_distribution.rs:296-355。 -// B. game 载荷当前没有 forkAuthorization 字段:modules/game_distribution.rs:2952-2977。 -// C. 因此步骤 5/6 在 master 上会 404 / 字段缺失并 FAIL —— 设计预期(功能落地后再跑)。 -// D. 前端 /games/mine 的「共创授权」入口与三态文案同样未落地,故做成可覆盖常量。 -// E. 响应包装:本脚本同时接受 `data.game` 与 `data` 两种形态,并同时接受 -// `data.replayed` 与 `data.game.replayed`(既有写接口把 `replayed` 放在 data 顶层, -// 例:modules/game_distribution.rs:682-700 的评价管理响应)。 - -import { mkdtemp } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; - -import { - cleanupRunFixtures, - fixtureTitle, - readDevStackMarker, - reportLeftoverFixtures, - resolveE2eTarget, -} from './e2e-target.mjs'; - -const COVER_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', - 'base64', -); -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 DEV_PASSWORD = 'GenE2e123!'; -const GATE_KEY = 'game-distribution:publish'; -const SKIP_BROWSER = (process.env.SKIP_BROWSER ?? '').trim() === '1'; - -// 三态档位的线上取值(契约值,不随文案变化)。 -const FORK_TIERS = ['forbidden', 'nonCommercial', 'full']; -// 前端文案(对齐 packages/shared/src/contracts/gameDistribution.ts:130-148),可用环境变量覆盖。 -const FORK_ENTRY_LABEL = process.env.E2E_FORK_ENTRY_LABEL ?? '共创授权'; -const FORK_TIER_LABELS = { - forbidden: process.env.E2E_FORK_LABEL_FORBIDDEN ?? '禁止共创', - nonCommercial: process.env.E2E_FORK_LABEL_NON_COMMERCIAL ?? '允许非商用共创', - full: process.env.E2E_FORK_LABEL_FULL ?? '允许全开放共创', -}; -const FORK_TIER_SHORT_LABELS = { - forbidden: process.env.E2E_FORK_SHORT_FORBIDDEN ?? '禁止', - nonCommercial: process.env.E2E_FORK_SHORT_NON_COMMERCIAL ?? '非商用', - full: process.env.E2E_FORK_SHORT_FULL ?? '全开放', -}; -const FORK_FULL_BADGE = process.env.E2E_FORK_FULL_BADGE ?? '共创授权已达上限'; - -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); -} - -// 地址一律从 CWD 的 dev 栈状态文件读取,不写死端口(对齐 ratings-e2e.mjs:15-27)。 -// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 -// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 -const devStack = readDevStackMarker(); -const target = resolveE2eTarget({ - scriptName: 'check-game-distribution-fork-authorization-e2e.mjs', -}); -const API = target.apiBase; -const WEB = target.webBase; - -let checks = 0; -let failures = 0; -let skipped = 0; -function check(name, ok, detail = '') { - checks += 1; - if (!ok) failures += 1; - console.log( - `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, - ); -} -function skip(name, detail = '') { - skipped += 1; - console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`); -} - -// ---------- HTTP helpers(对齐 owner-isolation.mjs:36-76) ---------- - -async function api(pathname, options = {}) { - const { method = 'GET', token, body, headers = {} } = options; - const finalHeaders = { ...ENVELOPE, ...headers }; - if (token) finalHeaders.Authorization = `Bearer ${token}`; - let finalBody; - if (body !== undefined) { - finalHeaders['Content-Type'] = 'application/json'; - finalBody = JSON.stringify(body); - } - const response = await fetch(`${API}${pathname}`, { - method, - headers: finalHeaders, - body: finalBody, - signal: AbortSignal.timeout(30_000), - }); - 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, - }; -} - -/// 负面用例的可读诊断:状态 + 错误码 + 响应文本片段(失败时能直接看出是哪个层拒的)。 -function brief(body) { - const code = body?.error?.code ?? body?.json?.error?.code ?? ''; - return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 200)}`; -} - -/// 兼容 `data.game` 与扁平 `data` 两种响应形态,并抽出 `replayed`。 -function forkState(body) { - const data = body?.data ?? null; - const game = data?.game ?? data ?? null; - const replayed = - data?.replayed ?? data?.game?.replayed ?? game?.replayed ?? null; - return { game, replayed, forkAuthorization: game?.forkAuthorization ?? null }; -} - -/// 断言某个响应里的 game 载荷带有合法的 forkAuthorization,并等于期望档位。 -function checkForkTier(name, body, expected, { expectReplayed } = {}) { - const { game, replayed, forkAuthorization } = forkState(body); - const shapeOk = - game !== null && - typeof game === 'object' && - Object.hasOwn(game, 'forkAuthorization'); - const allowed = FORK_TIERS.includes(forkAuthorization); - const detail = - `status=${body?.status} forkAuthorization=${JSON.stringify(forkAuthorization)} ` + - `replayed=${JSON.stringify(replayed)} shapeOk=${shapeOk} allowed=${allowed}`; - check( - `${name}(契约形状:game.forkAuthorization 存在且取值合法)`, - shapeOk && allowed, - detail, - ); - check( - `${name}(档位 === ${expected})`, - forkAuthorization === expected, - detail, - ); - if (expectReplayed !== undefined) { - check( - `${name}(replayed === ${expectReplayed})`, - replayed === expectReplayed, - detail, - ); - } - return { game, replayed, forkAuthorization }; -} - -async function register(prefix) { - const phone = `${prefix}${String(Date.now()).slice(-8)}`; - const response = await api('/api/auth/entry', { - method: 'POST', - body: { purePhoneNumber: phone, password: DEV_PASSWORD }, - }); - return { phone, response, token: response.data?.token }; -} - -function gameMetadata(title) { - return { - title, - summary: '共创授权验收临时游戏', - description: '', - category: '休闲', - tags: ['e2e'], - deviceSupport: { desktop: true, mobile: false, touch: false }, - inputModes: ['keyboard', 'mouse'], - orientation: 'landscape', - }; -} - -// 建游戏必须有封面(modules/game_distribution.rs:2806-2814),走现役直传 + 确认链路 -// (对齐 owner-isolation.mjs:97-150;只往 dev bucket 写一个 67 字节 PNG)。 -async function uploadCover(token, id) { - const fileName = `fork-authorization-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'fork-authorization', 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-fork-authorization', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, - ); - } - return confirm.data.assetObject.assetObjectId; -} - -// ---------- Playwright helpers(对齐 web-publish-e2e.mjs:51-57,74-90,148-159) ---------- - -async function loadPlaywright() { - const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim(); - if (!dir) return import('playwright'); - const requireFromDir = createRequire(path.join(dir, 'noop.js')); - return requireFromDir('playwright'); -} - -async function loginThroughWebUi(page, phone) { - await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); - await page - .waitForSelector('.platform-account-entry', { - timeout: 240_000, - state: 'attached', - }) - .catch(async () => { - // 首次导航可能触发 Vite 冷编译(重启/rebase 后的首屏实测可超过 2 分钟): - // 重载一次再等,避免把「开发服务器还没编译完」误判成页面坏了。 - await page - .reload({ waitUntil: 'commit', timeout: 120_000 }) - .catch(() => {}); - await page.waitForSelector('.platform-account-entry', { - timeout: 240_000, - state: 'attached', - }); - }); - await page.locator('.platform-account-entry').first().click(); - await page.getByRole('tab', { name: '密码登录' }).first().click(); - await page.waitForTimeout(600); - await page - .getByLabel('同意法律协议') - .first() - .check({ force: true }) - .catch(() => {}); - await page - .locator('input[placeholder="13800000000"]:visible') - .first() - .fill(phone); - await page - .locator('input[placeholder="输入密码"]:visible') - .first() - .fill(DEV_PASSWORD); - await page.getByRole('button', { name: '登录', exact: true }).first().click(); - await page - .locator('.platform-account-entry') - .first() - .filter({ hasText: phone.slice(-4) }) - .waitFor({ state: 'visible', timeout: 30_000 }); -} - -/// 三态选项的 DOM 契约尚未落地:按常见可交互语义逐个尝试定位。 -async function findTierOption(page, label) { - const candidates = [ - page.getByRole('radio', { name: label }), - page.getByRole('menuitemradio', { name: label }), - page.getByRole('button', { name: label }), - page.locator('[role="option"]').filter({ hasText: label }), - page.locator('label').filter({ hasText: label }), - page.getByText(label, { exact: false }), - ]; - for (const locator of candidates) { - const first = locator.first(); - const count = await first.count().catch(() => 0); - if (count > 0 && (await first.isVisible().catch(() => false))) { - return first; - } - } - return null; -} - -/// `isDisabled()` 覆盖 button/input/select/[aria-disabled];取不到时退回 aria-disabled 属性。 -async function isTierDisabled(locator) { - const disabled = await locator.isDisabled().catch(() => null); - if (disabled !== null) return { disabled, source: 'isDisabled' }; - const aria = await locator.getAttribute('aria-disabled').catch(() => null); - if (aria !== null) - return { disabled: aria === 'true', source: 'aria-disabled' }; - return { disabled: null, source: 'unknown' }; -} - -// ---------- 浏览器视觉步骤 ---------- - -async function runBrowserStep({ phone, title, draftTitle }) { - if (SKIP_BROWSER) { - skip('浏览器视觉:/games/mine 共创授权入口与三态', 'SKIP_BROWSER=1'); - return; - } - - let chromium; - try { - ({ chromium } = await loadPlaywright()); - } catch (error) { - check( - '浏览器视觉:Playwright 可用', - false, - `仓库 devDependencies 不含 playwright(package.json:255-282),请先执行 ` + - `\`npm install --prefix %TEMP%\\genarrative-pw --no-save --no-package-lock playwright\` ` + - `并设置 E2E_PLAYWRIGHT_DIR=%TEMP%\\genarrative-pw(写法见 web-publish-e2e.mjs:4-5);` + - `或设置 SKIP_BROWSER=1 只跑 HTTP 部分。原始错误:${error?.message ?? error}`, - ); - return; - } - - const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); - const browser = await chromium.launch({ - headless: true, - ...(executablePath ? { executablePath } : {}), - }); - const screenshotDir = await mkdtemp( - path.join(tmpdir(), 'genarrative-fork-e2e-'), - ); - try { - const page = await browser.newPage({ - viewport: { width: 1280, height: 900 }, - }); - await loginThroughWebUi(page, phone); - check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`); - - await page.goto(`${WEB}/games/mine`, { - waitUntil: 'commit', - timeout: 120_000, - }); - // A. HTTP 步骤已把作品提升到 full:卡片只读展示档位,不给入口。 - const fullCard = page.getByText(title, { exact: false }).first(); - const fullCardVisible = await fullCard - .waitFor({ state: 'visible', timeout: 60_000 }) - .then(() => true) - .catch(() => false); - check( - '浏览器视觉:/games/mine 出现 full 档作品卡片', - fullCardVisible, - `title=${title}`, - ); - check( - '浏览器视觉:full 档卡片显示档位文案', - await page - .getByText(`共创:${FORK_TIER_LABELS.full}`) - .first() - .isVisible() - .catch(() => false), - `label=共创:${FORK_TIER_LABELS.full}`, - ); - const fullCardElement = page - .locator('article.my-game-card') - .filter({ hasText: title }) - .first(); - check( - '浏览器视觉:full 档卡片显示只读上限且不提供入口', - (await fullCardElement - .getByText(FORK_FULL_BADGE) - .first() - .isVisible() - .catch(() => false)) && - (await fullCardElement - .getByRole('button', { name: FORK_ENTRY_LABEL, exact: true }) - .count()) === 0, - `badge=${FORK_FULL_BADGE} card=article.my-game-card`, - ); - - // B. 全新草稿作品(forbidden):卡片有「共创授权」入口,点开后三态可选、当前档位不可点。 - const draftCard = page.getByText(draftTitle, { exact: false }).first(); - const draftCardVisible = await draftCard - .waitFor({ state: 'visible', timeout: 60_000 }) - .then(() => true) - .catch(() => false); - check( - '浏览器视觉:/games/mine 出现草稿作品卡片', - draftCardVisible, - `title=${draftTitle}`, - ); - check( - '浏览器视觉:草稿卡片档位文案为禁止共创', - await page - .getByText(`共创:${FORK_TIER_LABELS.forbidden}`) - .first() - .isVisible() - .catch(() => false), - `label=共创:${FORK_TIER_LABELS.forbidden}`, - ); - - const entry = page - .getByRole('button', { name: FORK_ENTRY_LABEL, exact: true }) - .first(); - const entryVisible = await entry - .waitFor({ state: 'visible', timeout: 30_000 }) - .then(() => true) - .catch(() => false); - check( - '浏览器视觉:草稿卡片上可见「共创授权」入口', - entryVisible, - `label=${FORK_ENTRY_LABEL}`, - ); - - if (entryVisible) { - await entry.click(); - await page.waitForTimeout(600); - const options = {}; - for (const tier of FORK_TIERS) { - options[tier] = await findTierOption( - page, - FORK_TIER_SHORT_LABELS[tier], - ); - check( - `浏览器视觉:三态选项可见(${tier} = ${FORK_TIER_SHORT_LABELS[tier]})`, - options[tier] !== null, - `label=${FORK_TIER_SHORT_LABELS[tier]}`, - ); - } - // 当前档位 forbidden、无更低档位:当前档位必须不可点,更高档位必须可点。 - for (const tier of FORK_TIERS) { - const locator = options[tier]; - if (!locator) { - check( - `浏览器视觉:${tier} 档位禁用态`, - false, - `未定位到 ${FORK_TIER_SHORT_LABELS[tier]} 选项元素,无法判定禁用态`, - ); - continue; - } - const { disabled, source } = await isTierDisabled(locator); - const shouldBeDisabled = tier === 'forbidden'; - check( - `浏览器视觉:${tier} 档位${shouldBeDisabled ? '不可点(当前档位)' : '可点(更高档位)'}`, - disabled === shouldBeDisabled, - disabled === null - ? `label=${FORK_TIER_SHORT_LABELS[tier]} 无法判定禁用态(尝试过 isDisabled 与 aria-disabled)` - : `label=${FORK_TIER_SHORT_LABELS[tier]} source=${source} disabled=${disabled}`, - ); - } - - const panelShot = path.join( - screenshotDir, - 'fork-authorization-panel.png', - ); - await page.screenshot({ path: panelShot, fullPage: true }); - check('浏览器视觉:三态面板已截图', true, `截图路径=${panelShot}`); - - // C. 走一次真实提升(禁止 → 非商用):确认面板 + 卡片文案回读。 - const promote = options.nonCommercial; - if (promote) { - await promote.click(); - await page.waitForTimeout(300); - check( - '浏览器视觉:点击非商用后出现确认提升提示', - await page - .getByText( - new RegExp(`确认提升为「${FORK_TIER_LABELS.nonCommercial}」`), - ) - .first() - .isVisible() - .catch(() => false), - `期望包含 确认提升为「${FORK_TIER_LABELS.nonCommercial}」`, - ); - const confirmButton = page - .getByRole('button', { name: '确认提升', exact: true }) - .first(); - const confirmVisible = await confirmButton - .isVisible() - .catch(() => false); - check( - '浏览器视觉:确认提升按钮可见', - confirmVisible, - 'button=确认提升', - ); - if (confirmVisible) { - await confirmButton.click(); - check( - '浏览器视觉:网页提升到非商用后卡片文案更新', - await page - .getByText(`共创:${FORK_TIER_LABELS.nonCommercial}`) - .first() - .waitFor({ state: 'visible', timeout: 30_000 }) - .then(() => true) - .catch(() => false), - `label=共创:${FORK_TIER_LABELS.nonCommercial}`, - ); - } - } else { - check('浏览器视觉:确认提升按钮可见', false, '未定位到非商用选项'); - } - - const afterShot = path.join( - screenshotDir, - 'fork-authorization-after-promote.png', - ); - await page.screenshot({ path: afterShot, fullPage: true }); - check('浏览器视觉:提升后已截图', true, `截图路径=${afterShot}`); - } - - const screenshotPath = path.join( - screenshotDir, - 'fork-authorization-panel.png', - ); - await page.screenshot({ path: screenshotPath, fullPage: true }); - check('浏览器视觉:已截图保存', true, `截图路径=${screenshotPath}`); - } finally { - await browser.close().catch(() => {}); - console.log(`[fork-e2e] 截图目录:${screenshotDir}`); - } -} - -// ---------- 主流程 ---------- - -async function main() { - console.log( - `[fork-e2e] api-server=${API} web=${WEB} database=${devStack.database}`, - ); - - // 1) 管理员登录(对齐 web-e2e.mjs:150-156) - const adminLogin = await api('/admin/api/login', { - method: 'POST', - body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, - }); - const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; - check( - '管理员登录成功', - adminLogin.status === 200 && Boolean(admin), - `status=${adminLogin.status}`, - ); - if (!admin) process.exit(1); - - // 2) 开灰度(对齐 web-e2e.mjs:176-187) - const gate = await api('/admin/api/feature-gates', { - method: 'PUT', - token: admin, - body: { - gateKey: GATE_KEY, - enabled: true, - rolloutPercent: 100, - allowUserIds: [], - allowUserTags: [], - denyUserIds: [], - description: 'E2E 共创授权链路', - }, - }); - check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); - - // 3) 注册两个作者(对齐 web-e2e.mjs:161-166) - const stamp = Date.now(); - const author = await register('132'); - const other = await register('133'); - // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 - activeFixtureRun = { - api, - suffix: String(stamp).slice(-6), - owners: [ - { token: author.token, label: '作者' }, - { token: other.token, label: '其他作者' }, - ], - }; - check( - '作者注册拿到 token', - author.response.status === 200 && Boolean(author.token), - `status=${author.response.status} phone=${author.phone}`, - ); - check( - '第二个账号注册拿到 token', - other.response.status === 200 && Boolean(other.token), - `status=${other.response.status} phone=${other.phone}`, - ); - if (!author.token || !other.token) process.exit(1); - - // 4) 创建作品(建游戏必须有封面:modules/game_distribution.rs:2806-2814) - const title = fixtureTitle('共创授权', String(stamp).slice(-6)); - const coverAssetId = await uploadCover(author.token, stamp); - const created = await api('/api/game-distribution/games', { - method: 'POST', - token: author.token, - headers: { 'Idempotency-Key': `fork-e2e-game-${stamp}` }, - body: { ...gameMetadata(title), coverAssetId }, - }); - const gameId = created.data?.id; - check( - '作者创建作品成功并取到 game.id', - created.status === 200 && Boolean(gameId), - `status=${created.status} id=${gameId ?? ''} msg=${created.error?.message ?? ''}`, - ); - if (!gameId) process.exit(1); - - const forkPath = `/api/game-distribution/games/${gameId}/fork-authorization`; - const forkBody = (expected, next) => ({ - expectedForkAuthorization: expected, - forkAuthorization: next, - }); - // 幂等键:除"重放"用例复用同一把键外,每一步都是独立键。 - const keyNonCommercial = `fork-e2e-nc-${stamp}`; - const keyFull = `fork-e2e-full-${stamp}`; - const bodyNonCommercial = forkBody('forbidden', 'nonCommercial'); - const bodyFull = forkBody('nonCommercial', 'full'); - - // 5a) 初始档位必须是 forbidden(默认值) - const initial = await api(`/api/game-distribution/my-games/${gameId}`, { - token: author.token, - }); - const initialGame = initial.data?.game ?? null; - check( - '初始 GET my-games/{game_id} 的 game 载荷存在', - initial.status === 200 && initialGame !== null, - `status=${initial.status}`, - ); - checkForkTier('初始档位', initial, 'forbidden'); - - // 5b) forbidden → nonCommercial - const promoted = await api(forkPath, { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': keyNonCommercial }, - body: bodyNonCommercial, - }); - check( - '提升到 nonCommercial 返回 200', - promoted.status === 200, - `status=${promoted.status} msg=${promoted.error?.message ?? ''}`, - ); - checkForkTier('nonCommercial', promoted, 'nonCommercial', { - expectReplayed: false, - }); - - // 5c) nonCommercial → full - const upgraded = await api(forkPath, { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': keyFull }, - body: bodyFull, - }); - check( - '提升到 full 返回 200', - upgraded.status === 200, - `status=${upgraded.status} msg=${upgraded.error?.message ?? ''}`, - ); - checkForkTier('full', upgraded, 'full', { expectReplayed: false }); - - // 5d) 降级必须被拒(full → forbidden)→ 409 - const downgrade = await api(forkPath, { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `fork-e2e-downgrade-${stamp}` }, - body: forkBody('full', 'forbidden'), - }); - check( - '降级 full → forbidden 被拒(409)', - downgrade.status === 409, - brief(downgrade), - ); - - // 5e) 过期 CAS 必须被拒(当前已是 full,却声明 expected=forbidden)→ 409 - const staleCas = await api(forkPath, { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `fork-e2e-stale-${stamp}` }, - body: forkBody('forbidden', 'full'), - }); - check( - '过期 CAS(expected=forbidden,实际 full)被拒(409)', - staleCas.status === 409, - brief(staleCas), - ); - - // 5f) 未知档位必须被拒(allowed 不是三态之一)→ 400 - const unknownTier = await api(forkPath, { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `fork-e2e-unknown-${stamp}` }, - body: forkBody('full', 'allowed'), - }); - check( - '未知档位 allowed 被拒(400)', - unknownTier.status === 400, - brief(unknownTier), - ); - - // 5g) 幂等重放:复用 nonCommercial → full 的同一把键与同一 body → 200 且 replayed=true - const replay = await api(forkPath, { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': keyFull }, - body: bodyFull, - }); - check( - '幂等重放返回 200', - replay.status === 200, - `status=${replay.status} msg=${replay.error?.message ?? ''}`, - ); - checkForkTier('幂等重放', replay, 'full', { expectReplayed: true }); - - // 5h) 非作者(第二个账号)必须被拒 → 403 - const foreign = await api(forkPath, { - method: 'PUT', - token: other.token, - headers: { 'Idempotency-Key': `fork-e2e-foreign-${stamp}` }, - body: forkBody('full', 'forbidden'), - }); - check('非作者发起变更被拒(403)', foreign.status === 403, brief(foreign)); - - // 5i) 未带 Bearer 必须被拒 → 401 - const anonymous = await api(forkPath, { - method: 'PUT', - headers: { 'Idempotency-Key': `fork-e2e-anon-${stamp}` }, - body: forkBody('full', 'forbidden'), - }); - check( - '未带 Authorization 被拒(401)', - anonymous.status === 401, - brief(anonymous), - ); - - // 6) 回读确认最终档位仍是 full(越权与非法请求都不得有副作用) - const finalRead = await api(`/api/game-distribution/my-games/${gameId}`, { - token: author.token, - }); - check( - '最终 GET my-games/{game_id} 仍为 200', - finalRead.status === 200, - `status=${finalRead.status}`, - ); - checkForkTier('最终档位(非法请求无副作用)', finalRead, 'full'); - - // 7) 浏览器视觉:需要一张 full 档作品(只读展示)与一张全新草稿作品(三态编辑器)。 - const draftTitle = fixtureTitle('共创授权草稿', String(stamp).slice(-6)); - const draftCoverAssetId = await uploadCover(author.token, `${stamp}-draft`); - const draftCreated = await api('/api/game-distribution/games', { - method: 'POST', - token: author.token, - headers: { 'Idempotency-Key': `fork-e2e-draft-${stamp}` }, - body: { ...gameMetadata(draftTitle), coverAssetId: draftCoverAssetId }, - }); - check( - '草稿作品创建成功(浏览器三态用例)', - draftCreated.status === 200 && Boolean(draftCreated.data?.id), - `status=${draftCreated.status} id=${draftCreated.data?.id ?? ''}`, - ); - - await runBrowserStep({ phone: author.phone, title, draftTitle }); - - // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 - const cleanup = await cleanupRunFixtures({ - api, - suffix: String(stamp).slice(-6), - owners: [ - { token: author.token, label: '作者' }, - { token: other.token, label: '其他作者' }, - ], - }); - console.log( - `[fork-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + - (cleanup.failures.length > 0 - ? `,失败 ${cleanup.failures.length} 条(见下)` - : ''), - ); - for (const failure of cleanup.failures) { - console.log(`[fork-e2e] 清理失败 ${JSON.stringify(failure)}`); - } - - console.log( - `[fork-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, - ); - if (failures > 0) { - process.exitCode = 1; - } -} - -/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ -let activeFixtureRun = null; - -main().catch(async (error) => { - console.error(`[fork-e2e] 未捕获异常:${error?.stack ?? error}`); - if (activeFixtureRun) { - await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); - } - process.exitCode = 1; -}); diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs deleted file mode 100644 index b996c49a2..000000000 --- a/scripts/check-game-distribution-lineage-e2e.mjs +++ /dev/null @@ -1,2042 +0,0 @@ -// 游戏分发「创作族谱与衍生列表(M3)」链路真实行为验收。 -// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server [+ web])+ 管理员账号。 -// -// 用法(Windows + bash 均可;只依赖 Node 内置模块 + 仓库已有依赖): -// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ -// node scripts/check-game-distribution-lineage-e2e.mjs -// SKIP_BROWSER=1 只跑 HTTP 契约部分(跳过 Playwright 步骤) -// E2E_API_BASE / E2E_WEB_BASE 覆盖地址;默认从 CWD 的 .app/dev-stack.json 读取(不写死端口) -// E2E_PLAYWRIGHT_DIR 指向临时装了 playwright 的目录(仓库 devDependencies 不含 playwright) -// E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器) -// E2E_LINEAGE_LABEL_* 覆盖前端文案(默认对齐实现里的字符串) -// -// 参考的仓库既有实现(按**符号**引用,不写行号,避免随改动腐化): -// [1] 路由挂载(公开、匿名、no-store):server-rs/crates/api-server/src/modules/game_distribution.rs 的 -// `router()` 里 `public_games` 子路由 -// [2] 「不可读即 404」映射:api-server/src/modules/game_distribution.rs 的 `lineage_read_or_not_found()` -// [3] 两个 handler:api-server/src/modules/game_distribution.rs 的 `get_game_lineage()` / `get_game_derived_games()` -// [4] 节点 DTO 的 7 个键:server-rs/crates/shared-contracts/src/game_distribution.rs 的 `GameDistributionLineageNode`; -// 两个响应:同文件的 `GameDistributionLineageResponse` / `GameDistributionDerivedResponse` -// [5] 上限 200 / 锚点可读性 / 稳定排序:server-rs/crates/module-game-distribution/src/lineage.rs 的 -// `GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT` / `lineage_anchor_readable()` / `stable_node_order()` -// [6] 作者软删除:api-server/src/modules/game_distribution.rs 的 `delete_owner_game()`; -// 事务语义(撤销当前公开版本 + 写 deleted_at): -// server-rs/crates/spacetime-module/src/game_distribution.rs 的 `delete_game_distribution_game_tx()` -// [7] 改编声明 DTO:shared-contracts/src/game_distribution.rs 的 `GameDistributionForkDeclaration`; -// 消费点 api-server/src/modules/game_distribution.rs 的 `create_game()`; -// 事务校验 spacetime-module/src/game_distribution.rs 的 `resolve_game_distribution_fork_declaration_tx()` -// [8] 发布链路(建版本 → 传包 → 送审 → 管理员通过)照抄 scripts/check-game-distribution-media-e2e.mjs 的 `main()` -// [9] 封面直传 + 确认:scripts/check-game-distribution-owner-isolation.mjs 的 `uploadCover()` / `main()` -// [10] 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-ratings-e2e.mjs 模块顶部的 dev-stack 读取 -// [11] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs 的 -// `loadPlaywright()` / `loginThroughWebUi()` -// [12] 前端契约:详情页入口 src/components/game-distribution/GameDetailPage.tsx 的 `GameDetailContent` -// (共创信息卡里的「查看创作族谱」按钮);族谱页 src/components/game-distribution/GameLineagePage.tsx 的 -// `LineageNodeCard`(节点卡片类名、「原作品已不可用」、标题按钮);路由 /games/lineage: -// src/routing/activeAppPageRoutes.ts 的 `STAGE_ROUTE_ENTRIES` -// [13] 节点作者名读时联账号表:spacetime-module/src/game_distribution.rs 的 `game_distribution_lineage_author_name()` -// (所以脚本会先给作者改名,让「删除后不外发作者名」这条断言有意义) -// -// 依赖提示:改编声明 `fork: { parentGameId, parentVersionId }` 的消费链路**已在 M2a 落地** -// (shared-contracts 的 DTO → api-server `create_game()` → spacetime-module -// `resolve_game_distribution_fork_declaration_tx()`),本脚本按已落地的形状断言;如果 B/C 段 -// 因创建 B 时声明不生效而失败,那说明对应实现被回退了,不是脚本问题。 -// -// 契约假设(读代码确认,不猜): -// - anchor 必须公开可读,否则 /lineage 与 /derived 都返回 **404**(不是 200 空树、也不是 409)。 -// - 节点只含「已公开且未软删除」的作品;根被删时 `root` 为 null,但 `rootGameId` 仍回传。 -// - 节点键集合恰好 7 个:gameId / title / authorName / generation / parentGameId / playCount / status。 -// - 排序:代际升序 → 创建时间升序 → gameId 升序(本脚本只断言代际升序这一段)。 -// - 上限 200 且超限时 `truncated: true`;本脚本不构造 >200 真实节点,只把常量钉在源码上(见 E 段说明)。 - -import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { mkdtemp } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; - -import JSZip from 'jszip'; - -import { - cleanupRunFixtures, - fixtureTitle, - readDevStackMarker, - reportLeftoverFixtures, - resolveE2eTarget, -} from './e2e-target.mjs'; - -const COVER_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', - 'base64', -); -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 DEV_PASSWORD = 'GenE2e123!'; -const GATE_KEY = 'game-distribution:publish'; -const SKIP_BROWSER = (process.env.SKIP_BROWSER ?? '').trim() === '1'; -// H 段(富树 fixture:1 根 + 3 子 + 1 孙 + 1 曾孙,供网页端族谱可视化截图)默认执行; -// CI/快速回归可用 LINEAGE_RICH_TREE=0 关闭(它会多造 6 个真实公开作品,逐一走完整发布链路)。 -const RICH_TREE = (process.env.LINEAGE_RICH_TREE ?? '1').trim() !== '0'; - -const LINEAGE_ENTRY_LABEL = - process.env.E2E_LINEAGE_LABEL_ENTRY ?? '查看创作族谱'; -const LINEAGE_PAGE_LABEL = process.env.E2E_LINEAGE_LABEL_PAGE ?? '创作族谱'; -const LINEAGE_ROOT_LABEL = process.env.E2E_LINEAGE_LABEL_ROOT ?? '母版'; -const LINEAGE_ANCHOR_LABEL = process.env.E2E_LINEAGE_LABEL_ANCHOR ?? '当前作品'; -const LINEAGE_PARENT_UNAVAILABLE = - process.env.E2E_LINEAGE_LABEL_PARENT_UNAVAILABLE ?? '原作品已不可用'; -// 详情页「改造这个作品」入口(392cb3bb0):标签与文案取自 GameDetailPage.tsx:430-490。 -const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? '一键 Fork 共创'; -const REMIX_LOGIN_LABEL = - process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后一键 Fork'; -const REMIX_STEPS_TEXT = - process.env.E2E_REMIX_STEPS_TEXT ?? '从平台作品开始创作'; -const REMIX_NOTE_TEXT = process.env.E2E_REMIX_NOTE_TEXT ?? '整个工程'; -const REMIX_NOTE_EXTRA_TEXT = - process.env.E2E_REMIX_NOTE_EXTRA_TEXT ?? '不可撤销'; -const REMIX_COPY_ID_LABEL = - process.env.E2E_REMIX_COPY_ID_LABEL ?? '复制作品 ID'; -const REMIX_COPIED_ID_LABEL = - process.env.E2E_REMIX_COPIED_ID_LABEL ?? '作品 ID 已复制'; - -// 节点键集合由实现钉死(shared-contracts/src/game_distribution.rs:262-281),多一个键就失败。 -const LINEAGE_NODE_KEYS = [ - 'authorName', - // 本轮起服务端在族谱节点上下发封面对象键(客户端据此渲染缩略图)——它是有意对外暴露的公开字段。 - 'coverObjectKey', - 'gameId', - 'generation', - 'parentGameId', - 'playCount', - 'status', - 'title', -]; -// 这 8 个键之外不该出现的东西:一般对象键 / 素材键 / 包摘要等私有字段。 -// 注意:`coverObjectKey` 本轮起是**有意下发**的公开字段(客户端渲染缩略图用), -// 因此用 (?lineage ${marker}` + - '

' + - marker + - '

', - ); - zip.file( - 'assets/app.js', - `document.documentElement.dataset.marker='${marker}';`, - ); - const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); - return { - bytes, - fileCount: 2, - sha256: createHash('sha256').update(bytes).digest('hex'), - }; -} - -async function createGame({ token, metadata, idemKey, fork }) { - return api('/api/game-distribution/games', { - method: 'POST', - token, - headers: { 'Idempotency-Key': idemKey }, - body: fork ? { ...metadata, fork } : metadata, - }); -} - -async function ownerGame(token, gameId) { - const response = await api(`/api/game-distribution/my-games/${gameId}`, { - token, - }); - return { response, game: response.data?.game ?? null }; -} - -/// 取某作品「当前公开版本」的 id:改编声明的 `parentVersionId` 必须是父作品的当前公开版本。 -async function publicVersionIdOf(token, gameId) { - const versions = (await ownerGame(token, gameId)).game?.versions ?? []; - return ( - versions.find((version) => version.status === 'published')?.versionId ?? '' - ); -} - -/// 提升作品共创授权(H 段造富树时给「需要继续被 Fork」的节点用;不动既有 A 的档位)。 -async function promoteForkAuthorization({ - token, - gameId, - from = 'forbidden', - to = 'nonCommercial', - key, -}) { - return api(`/api/game-distribution/games/${gameId}/fork-authorization`, { - method: 'PUT', - token, - headers: { 'Idempotency-Key': key }, - body: { expectedForkAuthorization: from, forkAuthorization: to }, - }); -} - -/// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。 -/// -/// `changeSummary` 只在**衍生作品**上传(§3.4:衍生作品发布必填「本次核心改动说明」, -/// 母版传了也会被忽略)——调用方按 `fork` 是否存在决定要不要带。 -async function publishToPublic({ - token, - admin, - gameId, - gameRevision, - metadata, - zip, - stamp, - tag, - label, - changeSummary, -}) { - const created = await api(`/api/game-distribution/games/${gameId}/versions`, { - method: 'POST', - token, - headers: { 'Idempotency-Key': `lineage-version-${tag}-${stamp}` }, - body: { - packageSha256: zip.sha256, - packageBytes: zip.bytes.length, - packageFileCount: zip.fileCount, - packageEntryPath: 'index.html', - gameMetadata: metadata, - ...(changeSummary ? { changeSummary } : {}), - }, - }); - const versionId = created.data?.versionId; - check( - `${label} 创建版本成功`, - created.status === 200 && Boolean(versionId), - `status=${created.status} ${created.text.slice(0, 160)}`, - ); - if (!versionId) return { versionId: null }; - - const upload = await api( - `/api/game-distribution/versions/${versionId}/package`, - { - method: 'PUT', - token, - headers: { - 'Idempotency-Key': `lineage-upload-${tag}-${stamp}`, - 'Content-Type': 'application/zip', - }, - binary: zip.bytes, - }, - ); - check( - `${label} 上传发行包成功`, - upload.status === 200 && upload.data?.status === 'uploaded', - `status=${upload.status}`, - ); - - const submitted = await api( - `/api/game-distribution/versions/${versionId}/submit`, - { - method: 'POST', - token, - headers: { 'Idempotency-Key': `lineage-submit-${tag}-${stamp}` }, - body: { expectedPublicationRevision: gameRevision }, - }, - ); - check( - `${label} 送审成功(202 + pending_review)`, - submitted.status === 202 && - submitted.data?.version?.status === 'pending_review', - `status=${submitted.status} ${submitted.text.slice(0, 160)}`, - ); - - const readback = await api(`/api/game-distribution/versions/${versionId}`, { - token, - }); - const approved = await api( - `/admin/api/game-distribution/versions/${versionId}/review`, - { - method: 'POST', - token: admin, - headers: { 'Idempotency-Key': `lineage-approve-${tag}-${stamp}` }, - body: { - decision: 'approve', - expectedPublicationRevision: - readback.data?.version?.publicationRevision ?? gameRevision, - }, - }, - ); - check( - `${label} 管理员审核通过并公开`, - approved.status === 200, - `status=${approved.status} ${approved.text.slice(0, 160)}`, - ); - return { versionId, approved }; -} - -async function lineageOf(gameId) { - return api(`/api/game-distribution/games/${gameId}/lineage`); -} - -function publicDetail(gameId) { - return api(`/api/game-distribution/games/${gameId}`); -} - -async function derivedOf(gameId) { - return api(`/api/game-distribution/games/${gameId}/derived`); -} - -/// 取件包必须按字节校验,不能走读文本的 api()。 -async function downloadBinary(pathname, token) { - const headers = { ...ENVELOPE }; - if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(`${API}${pathname}`, { - headers, - signal: AbortSignal.timeout(60_000), - }); - const bytes = Buffer.from(await response.arrayBuffer()); - return { - status: response.status, - contentType: response.headers.get('content-type') ?? '', - contentLength: response.headers.get('content-length') ?? '', - cacheControl: response.headers.get('cache-control') ?? '', - bytes, - }; -} - -function checkNodeShape(label, node) { - const keys = Object.keys(node ?? {}).sort(); - const expected = [...LINEAGE_NODE_KEYS].sort(); - check( - `${label}(节点键集合恰好 ${LINEAGE_NODE_KEYS.length} 个)`, - JSON.stringify(keys) === JSON.stringify(expected), - `keys=${keys.join(',')}`, - ); -} - -function checkNoPrivateKeys(label, response) { - check( - `${label}(不下发对象键/素材键/包摘要)`, - !PRIVATE_KEY_PATTERN.test(String(response?.text ?? '')), - 'pattern=objectKey|assetId|coverObjectKey|screenshots|packageSha|sha256|bucket|oss|downloadPath', - ); -} - -/// 锚点不可读(未公开 / 已软删除 / 不存在)时,两个读接口都必须 404,且不能是成功信封空树。 -async function checkAnchorNotReadable(label, gameId) { - const lineage = await lineageOf(gameId); - check(`${label} /lineage → 404`, lineage.status === 404, brief(lineage)); - check( - `${label} /lineage 404 不是成功信封空树`, - lineage.json?.ok !== true && lineage.data == null, - `ok=${String(lineage.json?.ok)} data=${JSON.stringify(lineage.data ?? null).slice(0, 80)}`, - ); - const derived = await derivedOf(gameId); - check(`${label} /derived → 404`, derived.status === 404, brief(derived)); - check( - `${label} /derived 404 不是成功信封空树`, - derived.json?.ok !== true && derived.data == null, - `ok=${String(derived.json?.ok)} data=${JSON.stringify(derived.data ?? null).slice(0, 80)}`, - ); -} - -// ---------- Playwright helpers(对齐 web-publish-e2e.mjs:51-90,148-159) ---------- - -async function loadPlaywright() { - const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim(); - if (!dir) return import('playwright'); - const requireFromDir = createRequire(path.join(dir, 'noop.js')); - return requireFromDir('playwright'); -} - -async function loginThroughWebUi(page, phone, expectedText) { - await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); - await page - .waitForSelector('.platform-account-entry', { - timeout: 240_000, - state: 'attached', - }) - .catch(async () => { - // 首次导航可能触发 Vite 冷编译(重启/rebase 后的首屏实测可超过 2 分钟): - // 重载一次再等,避免把「开发服务器还没编译完」误判成页面坏了。 - await page - .reload({ waitUntil: 'commit', timeout: 120_000 }) - .catch(() => {}); - await page.waitForSelector('.platform-account-entry', { - timeout: 240_000, - state: 'attached', - }); - }); - await page.locator('.platform-account-entry').first().click(); - await page.getByRole('tab', { name: '密码登录' }).first().click(); - await page.waitForTimeout(600); - await page - .getByLabel('同意法律协议') - .first() - .check({ force: true }) - .catch(() => {}); - await page - .locator('input[placeholder="13800000000"]:visible') - .first() - .fill(phone); - await page - .locator('input[placeholder="输入密码"]:visible') - .first() - .fill(DEV_PASSWORD); - await page.getByRole('button', { name: '登录', exact: true }).first().click(); - // 账号入口显示的是 displayName + publicUserCode(PlatformEntryActiveFlowShell.tsx:806-810), - // 不保证含手机号后四位,因此以「登录页签消失 + 入口出现」为成功判据,再按需校验展示名。 - await page - .getByRole('tab', { name: '密码登录' }) - .first() - .waitFor({ state: 'hidden', timeout: 30_000 }) - .catch(() => {}); - await page - .locator('.platform-account-entry') - .first() - .waitFor({ state: 'visible', timeout: 30_000 }); - if (expectedText) { - await page - .locator('.platform-account-entry') - .first() - .filter({ hasText: expectedText }) - .waitFor({ state: 'visible', timeout: 30_000 }) - .catch(() => {}); - } -} - -async function visible(locator, timeout = 30_000) { - return locator - .first() - .waitFor({ state: 'visible', timeout }) - .then(() => true) - .catch(() => false); -} - -// ---------- 浏览器步骤 ---------- - -async function runBrowserStep({ - phone, - expectedAccountText, - childId, - childTitle, - parentId, - parentTitle, - parentAuthorName, - forbiddenGameId, - deleteParent, -}) { - if (SKIP_BROWSER) { - skip( - '浏览器视觉:/games/detail 族谱入口与 /games/lineage 树渲染', - 'SKIP_BROWSER=1', - ); - return; - } - - let chromium; - try { - ({ chromium } = await loadPlaywright()); - } catch (error) { - check( - '浏览器视觉:Playwright 可用', - false, - `仓库 devDependencies 不含 playwright(package.json),请先执行 ` + - `\`npm install --prefix %TEMP%\\genarrative-pw --no-save --no-package-lock playwright\` ` + - `并设置 E2E_PLAYWRIGHT_DIR=%TEMP%\\genarrative-pw;或设置 SKIP_BROWSER=1 只跑 HTTP 部分。` + - `原始错误:${error?.message ?? error}`, - ); - return; - } - - const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); - const browser = await chromium.launch({ - headless: true, - ...(executablePath ? { executablePath } : {}), - }); - const screenshotDir = await mkdtemp( - path.join(tmpdir(), 'genarrative-lineage-e2e-'), - ); - try { - // 复制反馈要真跑成功路径:headless 下不授剪贴板权限时回退路径(execCommand)会失败并显示 - // 「复制失败」,那是环境限制而不是产品缺陷 —— 这里按真实用户条件授予剪贴板权限。 - const context = await browser.newContext({ - viewport: { width: 1280, height: 900 }, - permissions: ['clipboard-read', 'clipboard-write'], - }); - const page = await context.newPage(); - await loginThroughWebUi(page, phone, expectedAccountText); - check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`); - - await page.goto(`${WEB}/games/detail?id=${childId}`, { - waitUntil: 'commit', - timeout: 120_000, - }); - const entry = page.getByRole('button', { - name: LINEAGE_ENTRY_LABEL, - exact: true, - }); - const entryVisible = await visible(entry, 60_000); - check( - '浏览器视觉:已公开作品的详情页出现「查看创作族谱」入口', - entryVisible, - `child=${childId} label=${LINEAGE_ENTRY_LABEL}`, - ); - - if (entryVisible) { - await entry.click(); - await page.waitForTimeout(800); - check( - '浏览器视觉:进入 /games/lineage 且 id=根作品', - page.url().includes('/games/lineage') && - page.url().includes(`id=${parentId}`), - `url=${page.url()} rootId=${parentId}`, - ); - check( - '浏览器视觉:族谱页标题可见', - await visible(page.getByText(LINEAGE_PAGE_LABEL)), - `label=${LINEAGE_PAGE_LABEL}`, - ); - check( - '浏览器视觉:母版节点与当前作品节点同时可见', - (await visible(page.getByText(LINEAGE_ROOT_LABEL))) && - (await visible(page.getByText(LINEAGE_ANCHOR_LABEL))), - `root=${LINEAGE_ROOT_LABEL} anchor=${LINEAGE_ANCHOR_LABEL}`, - ); - check( - '浏览器视觉:树上能看到母版与当前作品标题', - (await visible(page.getByText(parentTitle, { exact: false }))) && - (await visible(page.getByText(childTitle, { exact: false }))), - `parent=${parentTitle} child=${childTitle}`, - ); - if (parentAuthorName) { - check( - '浏览器视觉:母版节点显示作者名', - await visible(page.getByText(parentAuthorName, { exact: false })), - `author=${parentAuthorName}`, - ); - } - - const beforeShot = path.join( - screenshotDir, - 'lineage-tree-before-delete.png', - ); - await page.screenshot({ path: beforeShot, fullPage: true }); - check('浏览器视觉:删除前族谱已截图', true, `截图路径=${beforeShot}`); - const lineageUrl = page.url(); - - // 详情页「改造这个作品」(392cb3bb0):触发条件 = 已公开 + 授权非禁止(GameDetailPage.tsx:350)。 - await page.goto(`${WEB}/games/detail?id=${parentId}`, { - waitUntil: 'commit', - timeout: 120_000, - }); - const remixEntry = page.getByRole('button', { - name: REMIX_ENTRY_LABEL, - exact: true, - }); - const remixVisible = await visible(remixEntry, 60_000); - check( - '浏览器视觉:已公开且授权开放的母版详情页出现「改造这个作品」入口', - remixVisible, - `parent=${parentId} label=${REMIX_ENTRY_LABEL}`, - ); - - if (remixVisible) { - await remixEntry.click(); - const panel = page.locator('#game-fork-remix-panel'); - // 点击先尝试唤起客户端(自定义协议),没客户端接手才降级出面板 —— 因此给宽一点的等待。 - check( - '浏览器视觉:展开后出现获取方式面板(引导去客户端粘贴)', - (await visible(panel, 15_000)) && - (await visible(page.getByText(REMIX_STEPS_TEXT, { exact: false }))), - 'panel=#game-fork-remix-panel', - ); - const codes = panel.locator('.game-fork-remix-value code'); - const idText = ( - await codes - .nth(0) - .innerText() - .catch(() => '') - ).trim(); - const linkText = ( - await codes - .nth(1) - .innerText() - .catch(() => '') - ).trim(); - check( - '浏览器视觉:面板显示的作品 ID 等于母版', - idText === parentId, - `id=${idText} 期望=${parentId}`, - ); - check( - '浏览器视觉:面板显示的可复制作品链接指向该作品', - linkText.includes(`/games/detail?id=${parentId}`), - `link=${linkText}`, - ); - check( - '浏览器视觉:面板口径为「整个工程复制到你的项目里继续改造」且告知不可撤销', - (await visible(page.getByText(REMIX_NOTE_TEXT, { exact: false }))) && - (await visible( - page.getByText(REMIX_NOTE_EXTRA_TEXT, { exact: false }), - )), - `note 需同时含 ${REMIX_NOTE_TEXT} 与 ${REMIX_NOTE_EXTRA_TEXT}`, - ); - - const remixShot = path.join(screenshotDir, 'game-remix-panel.png'); - await page.screenshot({ path: remixShot, fullPage: true }); - check( - '浏览器视觉:改造入口展开态已截图', - true, - `截图路径=${remixShot}`, - ); - - // 复制反馈:点击后必须有**可读反馈**——要么按钮变「作品 ID 已复制」, - // 要么出现「复制失败:请手动选中作品 ID 文本。」。 - // - // 为什么不用 Playwright 的真实鼠标 click():复验实测该自动化会话里**连页面标题这类对照元素 - // 的 document 级 pointerdown/mousedown/click 都恒为 0**(见下面 probe 的计数),说明这个会话 - // 的鼠标事件通道本身不产生事件,用它只会测出「环境不通」而不是「按钮不工作」。 - // 独立复验用同一页面 `btn.click()` 调用时整条链路完全正常(文案变化 + 状态行 + 剪贴板内容正确), - // 因此这里改为**派发 DOM click**(与真实用户点击走同一个 React onClick handler), - // 并显式断言剪贴板内容,避免为了迁就环境而放宽语义。产品代码未为此改动。 - const copyId = page.getByRole('button', { - name: REMIX_COPY_ID_LABEL, - exact: true, - }); - const copyIdVisible = await visible(copyId, 15_000); - check( - '浏览器视觉:复制作品 ID 按钮可见', - copyIdVisible, - `label=${REMIX_COPY_ID_LABEL}`, - ); - if (copyIdVisible) { - // 诊断探针:记录 document 级鼠标事件计数,作为「本会话鼠标通道是否可用」的证据。 - const mouseProbe = await page - .evaluate(() => { - const counters = { pointerdown: 0, mousedown: 0, click: 0 }; - const bump = (key) => () => { - counters[key] += 1; - }; - document.addEventListener( - 'pointerdown', - bump('pointerdown'), - true, - ); - document.addEventListener('mousedown', bump('mousedown'), true); - document.addEventListener('click', bump('click'), true); - window.__lineageMouseProbe = counters; - return counters; - }) - .catch(() => null); - await page.bringToFront().catch(() => {}); - await copyId.hover({ timeout: 5_000 }).catch(() => {}); - // 派发 DOM click:不依赖该会话的鼠标通道,但会真正触发 React 的 onClick。 - await copyId.evaluate((element) => { - element.click(); - }); - const copiedShown = await visible( - page.getByRole('button', { - name: REMIX_COPIED_ID_LABEL, - exact: true, - }), - 15_000, - ); - const failedShown = copiedShown - ? false - : await visible( - page.getByText('复制失败:请手动选中作品 ID 文本。', { - exact: false, - }), - 5_000, - ); - const observedLabels = await page - .locator('#game-fork-remix-panel button.game-fork-remix-copy') - .evaluateAll((nodes) => nodes.map((node) => node.textContent)) - .catch(() => []); - const observedStatusTexts = await page - .locator('#game-fork-remix-panel .game-fork-remix-status') - .evaluateAll((nodes) => nodes.map((node) => node.textContent)) - .catch(() => []); - const clipboardText = await page - .evaluate(async () => { - try { - return await navigator.clipboard.readText(); - } catch { - return ''; - } - }) - .catch(() => ''); - check( - '浏览器视觉:点「复制作品 ID」后给出可读反馈(已复制按钮或复制失败提示)', - copiedShown || failedShown, - `期望按钮名=${REMIX_COPIED_ID_LABEL} 或出现失败提示;实测按钮文案=${JSON.stringify(observedLabels)} 面板状态行=${JSON.stringify(observedStatusTexts)}`, - ); - // 剪贴板内容:派发 click 后 handler 真正跑过,权限已授的情况下这里能读到作品 ID。 - check( - '浏览器视觉:复制后剪贴板内容为该作品 ID', - clipboardText.trim() === parentId, - `clipboard=${JSON.stringify(clipboardText.trim())} 期望=${parentId}`, - ); - const mouseProbeAfter = await page - .evaluate(() => window.__lineageMouseProbe ?? null) - .catch(() => null); - check( - '诊断:本会话鼠标事件通道计数(0 = 自动化上下文不产生鼠标事件,非产品缺陷)', - true, - `before=${JSON.stringify(mouseProbe)} after=${JSON.stringify(mouseProbeAfter)}`, - ); - } - } - - // 对照:已公开但授权仍为 forbidden 的作品不得出现该入口(也不得出现未登录态文案)。 - await page.goto(`${WEB}/games/detail?id=${forbiddenGameId}`, { - waitUntil: 'commit', - timeout: 120_000, - }); - await page.waitForTimeout(1500); - // 新前端:forbidden 作品的入口**照常渲染**,但为禁用态(aria-disabled + --blocked), - // 点它只给一句说明、不展开获取面板。这里先点一次再把可观测量喂给断言。 - const blockedEntry = page - .locator('.co-creation-fork-primary--blocked') - .first(); - if ((await blockedEntry.count()) > 0) { - // aria-disabled 的元素会被 Playwright 的可操作性检查拦下(element is not enabled); - // 这里测的正是应用自己的禁用分支,因此用 force 绕过检查、把点击真的发出去。 - await blockedEntry.click({ force: true }).catch(() => {}); - await page.waitForTimeout(400); - } - const blockedTitle = - (await page - .locator('.co-creation-fork-primary--blocked') - .first() - .getAttribute('title') - .catch(() => null)) ?? ''; - const blockedPanelCount = await page - .locator('#game-fork-remix-panel') - .count(); - check( - '浏览器视觉:授权 forbidden 的作品入口为禁用态(aria-disabled + --blocked + 原因 title),点它不展开面板', - (await page - .locator('.co-creation-fork-primary[aria-disabled="true"]') - .count()) >= 1 && - (await page.locator('.co-creation-fork-primary--blocked').count()) >= - 1 && - blockedTitle.includes('无法 Fork 这部作品') && - blockedPanelCount === 0, - `forbidden=${forbiddenGameId} title=${blockedTitle.slice(0, 40)} panel=${blockedPanelCount}`, - ); - - // 未登录访客:入口文案是另一套(登录后一键 Fork),点击走登录门禁而不是展开面板。 - const anonymousContext = await browser.newContext({ - viewport: { width: 1280, height: 900 }, - }); - try { - const anonymousPage = await anonymousContext.newPage(); - await anonymousPage.goto(`${WEB}/games/detail?id=${parentId}`, { - waitUntil: 'commit', - timeout: 120_000, - }); - const anonymousEntry = anonymousPage.getByRole('button', { - name: REMIX_LOGIN_LABEL, - exact: true, - }); - const anonymousEntryVisible = await visible(anonymousEntry, 60_000); - check( - '浏览器视觉:未登录访客看到的是「登录后一键 Fork」入口', - anonymousEntryVisible, - `label=${REMIX_LOGIN_LABEL}`, - ); - if (anonymousEntryVisible) { - await anonymousEntry.first().click(); - await anonymousPage.waitForTimeout(800); - check( - '浏览器视觉:未登录点击该入口走登录门禁(不展开获取面板)', - (await anonymousPage - .getByRole('tab', { name: '密码登录' }) - .count()) >= 1 && - (await anonymousPage - .locator('#game-fork-remix-panel') - .count()) === 0, - `loginTab=${await anonymousPage.getByRole('tab', { name: '密码登录' }).count()} panel=${await anonymousPage.locator('#game-fork-remix-panel').count()}`, - ); - } - } finally { - await anonymousContext.close().catch(() => {}); - } - - // 父作品软删除后:树仍在,但父节点内容不得再出现。 - const deleted = await deleteParent(); - check( - '(前置)浏览器阶段完成父作品软删除', - deleted?.status === 200, - brief(deleted), - ); - await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 }); - check( - '浏览器视觉:父作品不可用后族谱页显示降级文案', - await visible(page.getByText(LINEAGE_PARENT_UNAVAILABLE), 60_000), - `label=${LINEAGE_PARENT_UNAVAILABLE}`, - ); - check( - '浏览器视觉:父作品标题不再出现在族谱页', - (await page.getByText(parentTitle, { exact: false }).count()) === 0, - `parent=${parentTitle}`, - ); - - const afterShot = path.join( - screenshotDir, - 'lineage-tree-after-delete.png', - ); - await page.screenshot({ path: afterShot, fullPage: true }); - check('浏览器视觉:删除后族谱已截图', true, `截图路径=${afterShot}`); - } - } finally { - await browser.close().catch(() => {}); - console.log(`[lineage-e2e] 截图目录:${screenshotDir}`); - } -} - -// ---------- 主流程 ---------- - -async function main() { - console.log( - `[lineage-e2e] api-server=${API} web=${WEB} database=${devStack.database}`, - ); - - const adminLogin = await api('/admin/api/login', { - method: 'POST', - body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, - }); - const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; - check( - '管理员登录成功', - adminLogin.status === 200 && Boolean(admin), - `status=${adminLogin.status}`, - ); - if (!admin) process.exit(1); - - const gate = await api('/admin/api/feature-gates', { - method: 'PUT', - token: admin, - body: { - gateKey: GATE_KEY, - enabled: true, - rolloutPercent: 100, - allowUserIds: [], - allowUserTags: [], - denyUserIds: [], - description: 'E2E 创作族谱链路', - }, - }); - check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); - - const stamp = Date.now(); - const suffix = String(stamp).slice(-6); - const author = await register('132'); - const forker = await register('133'); - // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 - activeFixtureRun = { - api, - suffix, - owners: [ - { token: author.token, label: '母版作者' }, - { token: forker.token, label: '改编者' }, - ], - }; - check( - '母版作者注册拿到 token', - author.response.status === 200 && Boolean(author.token), - `status=${author.response.status} phone=${author.phone}`, - ); - check( - '改编作者注册拿到 token', - forker.response.status === 200 && Boolean(forker.token), - `status=${forker.response.status} phone=${forker.phone}`, - ); - if (!author.token || !forker.token) process.exit(1); - - const authorDisplayName = `族谱母版作者${suffix}`; - const forkerDisplayName = `族谱改编作者${suffix}`; - const renameAuthor = await setDisplayName(author.token, authorDisplayName); - const renameForker = await setDisplayName(forker.token, forkerDisplayName); - check( - '两名作者都已设置展示名(让作者名相关断言有意义)', - renameAuthor.status === 200 && renameForker.status === 200, - `author=${renameAuthor.status} forker=${renameForker.status}`, - ); - - // A 段:404 规则(防泄露回归网) - const parentTitle = fixtureTitle('族谱母版', suffix); - const childTitle = fixtureTitle('族谱改编', suffix); - const blockedTitle = fixtureTitle('族谱禁改编', suffix); - const parentCover = await uploadCover(author.token, `${stamp}-parent`); - const parentCreated = await createGame({ - token: author.token, - metadata: gameMetadata({ title: parentTitle, coverAssetId: parentCover }), - idemKey: `lineage-parent-${stamp}`, - }); - const parentId = parentCreated.data?.id; - check( - '母版作品(草稿)创建成功', - parentCreated.status === 200 && Boolean(parentId), - `status=${parentCreated.status} id=${parentId ?? ''} ${parentCreated.text.slice(0, 120)}`, - ); - const parentRevision = parentCreated.data?.publicationRevision ?? 0; - if (!parentId) process.exit(1); - - await checkAnchorNotReadable('未公开草稿锚点', parentId); - await checkAnchorNotReadable('不存在的 gameId', `game_${'0'.repeat(32)}`); - const lineageNoStore = await lineageOf(parentId); - const derivedNoStore = await derivedOf(parentId); - check( - '两个读接口都返回 no-store(404 也带)', - lineageNoStore.cacheControl.includes('no-store') && - derivedNoStore.cacheControl.includes('no-store'), - `lineage=${lineageNoStore.cacheControl} derived=${derivedNoStore.cacheControl}`, - ); - - // B 段:把母版推到公开 - const parentZip = await buildZip(`parent-${suffix}`); - const parentMetadata = gameMetadata({ - title: parentTitle, - coverAssetId: parentCover, - }); - await publishToPublic({ - token: author.token, - admin, - gameId: parentId, - gameRevision: parentRevision, - metadata: parentMetadata, - zip: parentZip, - stamp, - tag: 'parent', - label: '母版', - }); - const parentPublicDetail = await api( - `/api/game-distribution/games/${parentId}`, - ); - check( - '母版已公开可读(公开详情 200 + status=published)', - parentPublicDetail.status === 200 && - parentPublicDetail.data?.status === 'published', - `status=${parentPublicDetail.status} visibility=${parentPublicDetail.data?.status ?? ''}`, - ); - - // 授权阶梯:改编需要来源作品允许共创 - const promote = await api( - `/api/game-distribution/games/${parentId}/fork-authorization`, - { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `lineage-promote-${stamp}` }, - body: { - expectedForkAuthorization: 'forbidden', - forkAuthorization: 'nonCommercial', - }, - }, - ); - check( - '母版共创授权提升为 nonCommercial', - promote.status === 200 && - (promote.data?.game?.forkAuthorization ?? null) === 'nonCommercial', - brief(promote), - ); - - const parentOwnerView = await ownerGame(author.token, parentId); - const parentVersions = parentOwnerView.game?.versions ?? []; - const parentPublicVersion = - parentVersions.find((version) => version.status === 'published') ?? null; - const parentPublicVersionId = parentPublicVersion?.versionId ?? ''; - check( - '取到母版当前公开版本 id(改编声明需要)', - Boolean(parentPublicVersionId), - `versions=${parentVersions.map((version) => `${version.versionNumber}:${version.status}`).join(',')}`, - ); - if (!parentPublicVersionId) process.exit(1); - - // 创建携带改编声明的子作品(依赖 M2a fork 声明链路) - const childCover = await uploadCover(forker.token, `${stamp}-child`); - const childMetadata = gameMetadata({ - title: childTitle, - coverAssetId: childCover, - }); - const childCreated = await createGame({ - token: forker.token, - metadata: childMetadata, - idemKey: `lineage-child-${stamp}`, - fork: { - parentGameId: parentId, - parentVersionId: parentPublicVersionId, - }, - }); - const childId = childCreated.data?.id; - check( - '携带改编声明的子作品创建成功', - childCreated.status === 200 && Boolean(childId), - `status=${childCreated.status} id=${childId ?? ''} ${childCreated.text.slice(0, 200)}`, - ); - const childRevision = childCreated.data?.publicationRevision ?? 0; - if (!childId) process.exit(1); - - const childZip = await buildZip(`child-${suffix}`); - const childChangeSummary = 'E2E:把跳台改成三段,并重画全部背景与角色立绘'; - - // 衍生作品必填「本次核心改动说明」(§3.4):缺失与超短都必须失败关闭(400 + 稳定码), - // 且都不写库——先各试一次(用不同的 Idempotency-Key,失败不消耗版本号), - // 再带合法说明走完整发布链路,两头都钉住。 - for (const [tag, summary, expectedCode] of [ - ['missing', undefined, 'FORK_CHANGE_SUMMARY_REQUIRED'], - ['too-short', '改了', 'FORK_CHANGE_SUMMARY_INVALID'], - ]) { - const rejected = await api( - `/api/game-distribution/games/${childId}/versions`, - { - method: 'POST', - token: forker.token, - headers: { 'Idempotency-Key': `lineage-child-summary-${tag}-${stamp}` }, - body: { - packageSha256: childZip.sha256, - packageBytes: childZip.bytes.length, - packageFileCount: childZip.fileCount, - packageEntryPath: 'index.html', - gameMetadata: childMetadata, - ...(summary === undefined ? {} : { changeSummary: summary }), - }, - }, - ); - check( - `衍生作品版本:核心改动说明${tag === 'missing' ? '缺失' : '超短'} → 400 ${expectedCode}`, - rejected.status === 400 && rejected.error?.code === expectedCode, - brief(rejected), - ); - } - - await publishToPublic({ - token: forker.token, - admin, - gameId: childId, - gameRevision: childRevision, - metadata: childMetadata, - zip: childZip, - stamp, - tag: 'child', - label: '子作品', - changeSummary: childChangeSummary, - }); - const childPublicVersion = (await publicDetail(childId)).data?.currentVersion; - check( - '衍生作品当前版本公开下发 changeSummary', - childPublicVersion?.changeSummary === childChangeSummary, - `changeSummary=${String(childPublicVersion?.changeSummary ?? '∅').slice(0, 40)}`, - ); - const parentDetailVersion = (await publicDetail(parentId)).data - ?.currentVersion; - check( - '母版当前版本不发 changeSummary(null,不是省略键)', - parentDetailVersion?.changeSummary === null, - `changeSummary=${JSON.stringify(parentDetailVersion?.changeSummary ?? '∅')}`, - ); - - // C 段:真实树形状(锚点 = 子作品) - const tree = await lineageOf(childId); - check('族谱接口 200', tree.status === 200, brief(tree)); - const treeData = tree.data ?? {}; - check( - 'rootGameId 指向母版', - treeData.rootGameId === parentId, - `rootGameId=${treeData.rootGameId ?? ''} 期望=${parentId}`, - ); - check( - 'root 指向母版节点', - treeData.root?.gameId === parentId, - `root=${treeData.root?.gameId ?? 'null'}`, - ); - check( - '小树未截断(truncated=false)', - treeData.truncated === false, - `truncated=${String(treeData.truncated)}`, - ); - const treeNodes = treeData.nodes ?? []; - const nodeIds = treeNodes.map((node) => node.gameId); - check( - 'nodes 同时含母版与子作品', - nodeIds.includes(parentId) && nodeIds.includes(childId), - `nodes=${nodeIds.join(',')}`, - ); - const parentNode = treeNodes.find((node) => node.gameId === parentId); - const childNode = treeNodes.find((node) => node.gameId === childId); - check( - '母版 generation=0 且无父', - parentNode?.generation === 0 && parentNode?.parentGameId == null, - `generation=${parentNode?.generation} parentGameId=${parentNode?.parentGameId ?? 'null'}`, - ); - check( - '子作品 generation=1 且 parentGameId=母版', - childNode?.generation === 1 && childNode?.parentGameId === parentId, - `generation=${childNode?.generation} parentGameId=${childNode?.parentGameId ?? 'null'}`, - ); - check( - '两个节点都标为 published', - parentNode?.status === 'published' && childNode?.status === 'published', - `parent=${parentNode?.status} child=${childNode?.status}`, - ); - check( - '母版节点作者名读取自账号展示名', - parentNode?.authorName === authorDisplayName, - `authorName=${parentNode?.authorName ?? 'null'} 期望=${authorDisplayName}`, - ); - const generations = treeNodes.map((node) => node.generation); - check( - '节点按代际升序(母版在前)', - generations.every( - (value, index) => index === 0 || generations[index - 1] <= value, - ) && nodeIds.indexOf(parentId) < nodeIds.indexOf(childId), - `generations=${generations.join(',')}`, - ); - checkNodeShape(`族谱节点 ${parentId}`, parentNode); - checkNodeShape(`族谱节点 ${childId}`, childNode); - checkNoPrivateKeys('族谱响应', tree); - - // 锚点换成母版:同一棵树 - const treeFromParent = await lineageOf(parentId); - check( - '锚点换成母版仍返回同一棵树', - treeFromParent.status === 200 && - treeFromParent.data?.rootGameId === parentId && - (treeFromParent.data?.nodes ?? []).length === treeNodes.length, - `status=${treeFromParent.status} nodes=${(treeFromParent.data?.nodes ?? []).length}`, - ); - - // 衍生列表(锚点 = 母版):恰好一个直接子代 - const derived = await derivedOf(parentId); - check('衍生列表接口 200', derived.status === 200, brief(derived)); - const derivedNodes = derived.data?.nodes ?? []; - check( - '衍生列表恰好含子作品', - derivedNodes.length === 1 && derivedNodes[0]?.gameId === childId, - `nodes=${derivedNodes.map((node) => node.gameId).join(',')}`, - ); - check( - '衍生列表未截断且 gameId 为母版', - derived.data?.truncated === false && derived.data?.gameId === parentId, - `truncated=${String(derived.data?.truncated)} gameId=${derived.data?.gameId ?? ''}`, - ); - checkNodeShape(`衍生节点 ${childId}`, derivedNodes[0]); - checkNoPrivateKeys('衍生列表响应', derived); - - // ---------- H 段:富树 fixture(1 根 + 3 子 + 1 孙 + 1 曾孙,供族谱可视化截图) ---------- - // - // 为什么要独立一棵树:既有 A/B 那段断言里「衍生列表恰好 1 个子代」是精确断言(不动它), - // 所以富树另起一根,既不改旧断言逻辑,也能独立断言自己的形状。 - // 每个节点都走真实发布链路(建作品 → 建版本 → 上传发行包 → 送审 → 管理员通过)并带真实封面; - // 血缘用 `fork: { parentGameId, parentVersionId }`(父必须是已公开且有当前公开版本)。 - if (!RICH_TREE) { - skip('H) 富树 fixture(LINEAGE_RICH_TREE=0 已关闭)'); - } else { - /// 造一个「已公开」节点(可选:带改编声明、可选:提升共创授权以便继续被 Fork)。 - const createPublishedNode = async ({ - label, - title, - owner, - ownerLabel, - parent, - promoteTo, - }) => { - const coverAssetId = await uploadCover( - owner.token, - `${stamp}-rich-${label}`, - ); - const parentVersionId = parent - ? await publicVersionIdOf(parent.owner.token, parent.gameId) - : ''; - if (parent && !parentVersionId) { - check( - `H) 节点 ${label} 的父作品(${parent.label})有当前公开版本`, - false, - `parent=${parent.gameId}`, - ); - } - const metadata = gameMetadata({ title, coverAssetId }); - const created = await createGame({ - token: owner.token, - metadata, - idemKey: `lineage-rich-${label}-${stamp}`, - ...(parent - ? { fork: { parentGameId: parent.gameId, parentVersionId } } - : {}), - }); - const gameId = created.data?.id; - check( - `H) 节点 ${label}(${ownerLabel}${parent ? `,父=${parent.label}` : ',根'})创建成功`, - created.status === 200 && Boolean(gameId), - brief(created), - ); - if (!gameId) return { gameId: null, label, owner, ownerLabel }; - const zip = await buildZip(`rich-${label}-${stamp}`); - const published = await publishToPublic({ - token: owner.token, - admin, - gameId, - gameRevision: created.data?.publicationRevision ?? 0, - metadata, - zip, - stamp, - tag: `rich-${label}`, - label: `H) 节点 ${label}`, - quiet: true, - // 衍生节点(有父)必填核心改动说明;根节点不传(传了也会被忽略)。 - ...(parent - ? { - changeSummary: `H) 节点 ${label} 相对 ${parent.label} 的核心改动说明`, - } - : {}), - }); - const detail = await publicDetail(gameId); - check( - `H) 节点 ${label} 已公开(真实发布链路 + 真实封面)`, - published.versionId !== null && detail.data?.status === 'published', - `versionId=${published.versionId ?? '∅'} status=${detail.data?.status ?? '∅'} cover=${coverAssetId}`, - ); - // 新语义:**衍生作品在创建时继承父作品档位**(创建时快照),创建后该请求里的档位字段一律被忽略; - // 母版(根)才允许用 PUT 提升。这里按「根 → 提升 / 衍生 → 断言继承」分流。 - if (parent) { - const childView = await ownerGame(owner.token, gameId); - const parentView = await ownerGame(parent.owner.token, parent.gameId); - const childTier = childView.game?.forkAuthorization ?? null; - const parentTier = parentView.game?.forkAuthorization ?? null; - check( - `H) 衍生节点 ${label} 创建时继承父作品档位(child=${childTier} === parent=${parentTier})`, - childTier !== null && childTier === parentTier, - `child=${childTier} parent=${parentTier}`, - ); - if (promoteTo && promoteTo !== childTier) { - // 新语义(实测确认):衍生作品创建后**禁止**再用 PUT 提升——稳定错误码 FORK_AUTHORIZATION_INHERITED, - // 且档位必须保持不变(失败关闭)。这里按实测口径钉死。 - const probe = await promoteForkAuthorization({ - token: owner.token, - gameId, - key: `lineage-rich-probe-promote-${label}-${stamp}`, - to: promoteTo, - }); - const after = await ownerGame(owner.token, gameId); - const tierAfter = after.game?.forkAuthorization ?? null; - check( - `H) 衍生节点 ${label} 的 PUT 提升被拒(409 FORK_AUTHORIZATION_INHERITED)且档位不变`, - probe.status === 409 && - (probe.error?.code ?? '') === 'FORK_AUTHORIZATION_INHERITED' && - tierAfter === childTier, - `status=${probe.status} code=${probe.error?.code ?? '∅'} tier=${childTier}→${tierAfter}`, - ); - } - } else if (promoteTo) { - const promoted = await promoteForkAuthorization({ - token: owner.token, - gameId, - key: `lineage-rich-promote-${label}-${stamp}`, - to: promoteTo, - }); - check( - `H) 母版节点 ${label} 共创授权提升为 ${promoteTo}(母版仍可提升)`, - promoted.status === 200 && - (promoted.data?.game?.forkAuthorization ?? null) === promoteTo, - brief(promoted), - ); - } - return { gameId, label, owner, ownerLabel, metadata }; - }; - - const richRoot = await createPublishedNode({ - label: 'R', - title: fixtureTitle('富树母版', suffix), - owner: author, - ownerLabel: '作者甲', - promoteTo: 'nonCommercial', - }); - const richB1 = await createPublishedNode({ - label: 'B1', - title: fixtureTitle('富树分支 B1', suffix), - owner: author, - ownerLabel: '作者甲', - parent: richRoot, - promoteTo: 'nonCommercial', - }); - const richC1 = await createPublishedNode({ - label: 'C1', - title: fixtureTitle('富树分支 C1', suffix), - owner: forker, - ownerLabel: '作者乙', - parent: richRoot, - promoteTo: 'full', - }); - const richF1 = await createPublishedNode({ - label: 'F1', - title: fixtureTitle('富树分支 F1', suffix), - owner: forker, - ownerLabel: '作者乙', - parent: richRoot, - // 刻意保持默认 forbidden:多档位在图上可见 - }); - const richD = await createPublishedNode({ - label: 'D', - title: fixtureTitle('富树二代 D', suffix), - owner: author, - ownerLabel: '作者甲', - parent: richB1, - promoteTo: 'nonCommercial', - }); - const richE = await createPublishedNode({ - label: 'E', - title: fixtureTitle('富树三代 E', suffix), - owner: forker, - ownerLabel: '作者乙', - parent: richD, - }); - - const richNodes = { - R: richRoot, - B1: richB1, - C1: richC1, - F1: richF1, - D: richD, - E: richE, - }; - const richRootId = richRoot.gameId; - const allCreated = Object.values(richNodes).every((node) => node.gameId); - check( - 'H) 富树 6 个节点全部建成(1 根 + 3 子 + 1 孙 + 1 曾孙)', - allCreated, - `ids=${Object.entries(richNodes) - .map(([key, node]) => `${key}:${node.gameId ?? '∅'}`) - .join(' ')}`, - ); - - if (allCreated) { - const expectedRich = [ - { key: 'R', generation: 0, parent: null, ownerLabel: '作者甲' }, - { key: 'B1', generation: 1, parent: 'R', ownerLabel: '作者甲' }, - { key: 'C1', generation: 1, parent: 'R', ownerLabel: '作者乙' }, - { key: 'F1', generation: 1, parent: 'R', ownerLabel: '作者乙' }, - { key: 'D', generation: 2, parent: 'B1', ownerLabel: '作者甲' }, - { key: 'E', generation: 3, parent: 'D', ownerLabel: '作者乙' }, - ]; - const richTree = await lineageOf(richRootId); - check( - 'H) 富树 /lineage 200 且未截断', - richTree.status === 200 && richTree.data?.truncated === false, - brief(richTree), - ); - check( - 'H) rootGameId 与 root 都指向富树根', - richTree.data?.rootGameId === richRootId && - richTree.data?.root?.gameId === richRootId, - `rootGameId=${richTree.data?.rootGameId ?? ''} root=${richTree.data?.root?.gameId ?? 'null'}`, - ); - const richTreeNodes = richTree.data?.nodes ?? []; - check( - 'H) 树恰好含 6 个节点(不多不少)', - richTreeNodes.length === 6, - `nodes=${richTreeNodes.length} ids=${richTreeNodes.map((node) => node.gameId).join(',')}`, - ); - const richById = new Map( - richTreeNodes.map((node) => [node.gameId, node]), - ); - for (const expectation of expectedRich) { - const node = richById.get(richNodes[expectation.key].gameId); - const expectedParentId = expectation.parent - ? richNodes[expectation.parent].gameId - : null; - check( - `H) 节点 ${expectation.key}:generation=${expectation.generation}、parentGameId=${expectation.parent ?? 'null'}`, - node?.generation === expectation.generation && - (expectation.parent - ? node?.parentGameId === expectedParentId - : node?.parentGameId == null), - `generation=${node?.generation ?? '∅'} parentGameId=${node?.parentGameId ?? 'null'}`, - ); - checkNodeShape(`H) 富树节点 ${expectation.key}`, node); - const expectedAuthorName = - expectation.ownerLabel === '作者甲' - ? authorDisplayName - : forkerDisplayName; - check( - `H) 节点 ${expectation.key} 作者名来自账号展示名(${expectation.ownerLabel})`, - node?.authorName === expectedAuthorName, - `authorName=${node?.authorName ?? 'null'} 期望=${expectedAuthorName}`, - ); - } - checkNoPrivateKeys('H) 富树响应', richTree); - const richGenerations = richTreeNodes.map((node) => node.generation); - check( - 'H) 富树按代际升序(根 → 3 个 1 代 → 2 代 → 3 代)', - richGenerations.every( - (value, index) => index === 0 || richGenerations[index - 1] <= value, - ) && - richGenerations[0] === 0 && - richGenerations[richGenerations.length - 1] === 3, - `generations=${richGenerations.join(',')}`, - ); - check( - 'H) 树上同时出现两个不同作者(分支多样性在图上可见)', - new Set(richTreeNodes.map((node) => node.authorName)).size === 2, - `authors=${[...new Set(richTreeNodes.map((node) => node.authorName))].join(' / ')}`, - ); - - const richDerived = await derivedOf(richRootId); - const richDerivedIds = (richDerived.data?.nodes ?? []) - .map((node) => node.gameId) - .sort(); - const expectedChildIds = ['B1', 'C1', 'F1'] - .map((key) => richNodes[key].gameId) - .sort(); - check( - 'H) 富树 /derived 恰好含根的直接子(B1/C1/F1)', - richDerived.status === 200 && - JSON.stringify(richDerivedIds) === JSON.stringify(expectedChildIds), - `derived=${richDerivedIds.join(',')} 期望=${expectedChildIds.join(',')}`, - ); - - const deepAnchorTree = await lineageOf(richNodes.E.gameId); - check( - 'H) 最深锚点(E,第 3 代)也返回同一棵 6 节点树', - deepAnchorTree.status === 200 && - deepAnchorTree.data?.rootGameId === richRootId && - (deepAnchorTree.data?.nodes ?? []).length === 6, - `status=${deepAnchorTree.status} rootGameId=${deepAnchorTree.data?.rootGameId ?? ''} nodes=${(deepAnchorTree.data?.nodes ?? []).length}`, - ); - - console.log( - `[lineage-e2e] 富树根 id=${richRootId}(6 节点 / 3 层分支 / 1 条 3 代深链)`, - ); - console.log( - `[lineage-e2e] 富树 lineage URL=${WEB}/games/lineage?id=${richRootId}`, - ); - } - // ---------- I 段:贡献归集 /api/game-distribution/games/{game_id}/contribution(作者视角) ---------- - // 契约(读实现 + DTO 注释):Bearer;data 直接是 - // { gameId, own{playCount}, inherited{playCount}, total{playCount}, - // byGeneration[{generation,gameCount,own,inherited,total}], - // directChildren[{gameId,generation,own,inherited,total}], nodeCount, truncated, truncatedReason } - // 不变式:total = own + inherited;inherited = Σ byGeneration.total = Σ directChildren.total; - // nodeCount = Σ byGeneration.gameCount(**不含根自身**);非作者按「不存在」处理。 - if (RICH_TREE) { - const totals = (node) => node?.playCount ?? null; - // 先记几笔游玩,让不变式里的数字不是全 0(计数端点是公开路由;失败只记 NOTE,不作为本票的断言) - const playResults = []; - for (const [label, node, times] of [ - ['R', richNodes.R, 1], - ['E', richNodes.E, 2], - ['F1', richNodes.F1, 1], - ]) { - for (let index = 0; index < times; index += 1) { - const response = await api( - `/api/game-distribution/games/${node.gameId}/plays`, - { - method: 'POST', - token: author.token, - body: { clientId: `lineage-e2e-${label}-${index}-${stamp}` }, - }, - ); - playResults.push(`${label}:${response.status}`); - } - } - note(`游玩计数探针(让贡献不变式非零):${playResults.join(' ')}`); - - const contributionResponse = await api( - `/api/game-distribution/games/${richNodes.R.gameId}/contribution`, - { token: author.token }, - ); - const contribution = - contributionResponse.data?.contribution ?? - contributionResponse.data ?? - null; - check( - 'I) 作者可读贡献归集(200)', - contributionResponse.status === 200 && contribution !== null, - brief(contributionResponse), - ); - const own = totals(contribution?.own); - const inherited = totals(contribution?.inherited); - const total = totals(contribution?.total); - check( - 'I) 不变式:total = own + inherited', - own !== null && inherited !== null && total === own + inherited, - `own=${own} inherited=${inherited} total=${total}`, - ); - const children = contribution?.directChildren ?? []; - const sumChildren = children.reduce( - (sum, child) => sum + (totals(child.total) ?? 0), - 0, - ); - check( - 'I) 不变式:inherited = Σ directChildren.total', - inherited === sumChildren && children.length === 3, - `inherited=${inherited} Σchildren=${sumChildren} children=${children - .map((child) => `${child.gameId.slice(-6)}:${totals(child.total)}`) - .join(',')}`, - ); - const generations = contribution?.byGeneration ?? []; - const sumGenerationTotals = generations.reduce( - (sum, entry) => sum + (totals(entry.total) ?? 0), - 0, - ); - const sumGenerationCounts = generations.reduce( - (sum, entry) => sum + (entry.gameCount ?? 0), - 0, - ); - check( - 'I) 不变式:inherited = Σ byGeneration.total', - inherited === sumGenerationTotals, - `inherited=${inherited} Σgen=${sumGenerationTotals} gen=${JSON.stringify( - generations.map((entry) => [ - entry.generation, - entry.gameCount, - totals(entry.total), - ]), - )}`, - ); - check( - 'I) 不变式:nodeCount = Σ byGeneration.gameCount = 5(不含根自身)', - contribution?.nodeCount === sumGenerationCounts && - contribution?.nodeCount === 5, - `nodeCount=${contribution?.nodeCount} ΣgameCount=${sumGenerationCounts}`, - ); - check( - 'I) 小树不截断(truncated=false / truncatedReason=null)', - contribution?.truncated === false && - (contribution?.truncatedReason ?? null) === null, - `truncated=${contribution?.truncated} reason=${contribution?.truncatedReason ?? 'null'}`, - ); - - // 三层链路的传递性:R.inherited = B1.total + C1.total + F1.total;B1.total = B1.own + D.total - const b1ContributionResponse = await api( - `/api/game-distribution/games/${richNodes.B1.gameId}/contribution`, - { token: author.token }, - ); - const b1Contribution = - b1ContributionResponse.data?.contribution ?? - b1ContributionResponse.data ?? - null; - const dOfB1 = (b1Contribution?.directChildren ?? []).find( - (child) => child.gameId === richNodes.D.gameId, - ); - const findChild = (id) => children.find((child) => child.gameId === id); - check( - 'I) 链路传递:B1.total = B1.own + D.total', - totals(b1Contribution?.total) === - (totals(b1Contribution?.own) ?? NaN) + (totals(dOfB1?.total) ?? NaN), - `B1.own=${totals(b1Contribution?.own)} D.total=${totals(dOfB1?.total)} B1.total=${totals(b1Contribution?.total)}`, - ); - check( - 'I) 链路传递:R.inherited = B1.total + C1.total + F1.total', - inherited === - ['B1', 'C1', 'F1'].reduce( - (sum, key) => - sum + (totals(findChild(richNodes[key].gameId)?.total) ?? NaN), - 0, - ), - `R.inherited=${inherited} Σ=${['B1', 'C1', 'F1'] - .map( - (key) => - `${key}:${totals(findChild(richNodes[key].gameId)?.total)}`, - ) - .join(',')}`, - ); - // E 属作者乙(forker)——贡献归集是**作者视角**接口,非作者会 403,所以用它的 owner token 查。 - const eContributionResponse = await api( - `/api/game-distribution/games/${richNodes.E.gameId}/contribution`, - { token: forker.token }, - ); - const eContribution = - eContributionResponse.data?.contribution ?? - eContributionResponse.data ?? - null; - check( - 'I) 最深一层 E:无子代(nodeCount=0、inherited=0、total=own)', - eContribution?.nodeCount === 0 && - totals(eContribution?.inherited) === 0 && - totals(eContribution?.total) === totals(eContribution?.own), - `nodeCount=${eContribution?.nodeCount} own=${totals(eContribution?.own)} inherited=${totals(eContribution?.inherited)} total=${totals(eContribution?.total)}`, - ); - - const foreignContribution = await api( - `/api/game-distribution/games/${richNodes.R.gameId}/contribution`, - { token: forker.token }, - ); - check( - 'I) 非作者读贡献归集被拒(403 FORBIDDEN「游戏 owner 不匹配」,不泄露计数)', - foreignContribution.status === 403 && - (foreignContribution.error?.code ?? '') === 'FORBIDDEN' && - !foreignContribution.text.includes('playCount'), - brief(foreignContribution), - ); - const anonymousContribution = await api( - `/api/game-distribution/games/${richNodes.R.gameId}/contribution`, - ); - check( - 'I) 匿名读贡献归集 → 401', - anonymousContribution.status === 401, - brief(anonymousContribution), - ); - } - } - - // G 段:Fork 取件通道(M2a) - // 契约(读代码确认):路由 api-server/...:307-320(Bearer + no-store,不叠发布灰度); - // 404/409/403 映射 api-server/...:2519-2560(FORK_SOURCE_NOT_FOUND / NOT_AVAILABLE / NOT_AUTHORIZED); - // 元数据载荷 api-server/...:2586-2604(同源相对 downloadPath,绝不下发对象键); - // 整包响应 api-server/...:2633-2665(application/zip + content-length + 附件名 + no-store)。 - const forkSourceOf = (gameId, token) => - api(`/api/game-distribution/games/${gameId}/fork-source`, { token }); - - // G1:未公开作品的取件通道 → 409(行存在但不可作来源;与 lineage 的 404 是两套口径) - const blockedCover = await uploadCover(author.token, `${stamp}-blocked`); - const blockedMetadata = gameMetadata({ - title: blockedTitle, - coverAssetId: blockedCover, - }); - const blockedCreated = await createGame({ - token: author.token, - metadata: blockedMetadata, - idemKey: `lineage-blocked-${stamp}`, - }); - const blockedId = blockedCreated.data?.id; - const blockedRevision = blockedCreated.data?.publicationRevision ?? 0; - check( - '禁改编 fixture(草稿)创建成功', - blockedCreated.status === 200 && Boolean(blockedId), - `status=${blockedCreated.status} id=${blockedId ?? ''}`, - ); - if (!blockedId) process.exit(1); - const draftSource = await forkSourceOf(blockedId, author.token); - check( - '未公开作品的取件通道 → 409 FORK_SOURCE_NOT_AVAILABLE', - draftSource.status === 409 && - (draftSource.error?.code ?? '') === 'FORK_SOURCE_NOT_AVAILABLE', - brief(draftSource), - ); - - // G2:公开但未提升授权(forbidden)→ 403 - await publishToPublic({ - token: author.token, - admin, - gameId: blockedId, - gameRevision: blockedRevision, - metadata: blockedMetadata, - zip: await buildZip(`blocked-${suffix}`), - stamp, - tag: 'blocked', - label: '禁改编作品', - }); - const forbiddenSource = await forkSourceOf(blockedId, author.token); - check( - '已公开但授权为 forbidden 的取件通道 → 403 FORK_NOT_AUTHORIZED', - forbiddenSource.status === 403 && - (forbiddenSource.error?.code ?? '') === 'FORK_NOT_AUTHORIZED', - brief(forbiddenSource), - ); - - // G3:母版(公开 + nonCommercial)→ 元数据与公开版本行一致 - const parentSource = await forkSourceOf(parentId, author.token); - const forkSource = parentSource.data?.forkSource ?? null; - check( - '母版取件元数据 200 且带 forkSource', - parentSource.status === 200 && Boolean(forkSource), - brief(parentSource), - ); - check( - 'forkSource.gameId === 母版', - forkSource?.gameId === parentId, - `gameId=${forkSource?.gameId ?? ''}`, - ); - check( - 'forkSource.source === package', - forkSource?.source === 'package', - `source=${forkSource?.source ?? ''}`, - ); - check( - 'forkSource.versionId === 母版当前公开版本', - forkSource?.versionId === parentPublicVersionId, - `versionId=${forkSource?.versionId ?? ''} 期望=${parentPublicVersionId}`, - ); - check( - 'forkSource.sha256 / bytes 与公开版本行一致', - forkSource?.sha256 === parentPublicVersion?.packageSha256 && - forkSource?.bytes === parentPublicVersion?.packageBytes, - `sha256=${forkSource?.sha256 ?? ''}/${parentPublicVersion?.packageSha256 ?? ''} bytes=${forkSource?.bytes ?? ''}/${parentPublicVersion?.packageBytes ?? ''}`, - ); - check( - 'downloadPath 是同源相对路径(不含协议/主机)', - typeof forkSource?.downloadPath === 'string' && - forkSource.downloadPath.startsWith('/api/game-distribution/games/') && - !forkSource.downloadPath.includes('://'), - `downloadPath=${forkSource?.downloadPath ?? ''}`, - ); - check( - '取件元数据不下发对象键 / OSS 域名 / 存储路径', - !FORK_SOURCE_LEAK_PATTERN.test(parentSource.text) && - !/https?:\/\//u.test(parentSource.text), - 'pattern=objectKey|coverObjectKey|bucket|aliyuncs|oss-cn|.zip + 绝对 URL', - ); - - // G4:按 downloadPath 真实下载并校验字节(客户端最重要的契约) - const forkDownload = await downloadBinary( - forkSource?.downloadPath ?? '', - author.token, - ); - check( - '取件下载 200 + application/zip', - forkDownload.status === 200 && - forkDownload.contentType.includes('application/zip'), - `status=${forkDownload.status} content-type=${forkDownload.contentType}`, - ); - check( - '取件下载 content-length 与实际字节数一致', - Number(forkDownload.contentLength) === forkDownload.bytes.length, - `content-length=${forkDownload.contentLength} actual=${forkDownload.bytes.length}`, - ); - check( - '取件下载字节数等于元数据 bytes', - forkDownload.bytes.length === forkSource?.bytes, - `actual=${forkDownload.bytes.length} meta=${forkSource?.bytes ?? ''}`, - ); - const forkDownloadSha = createHash('sha256') - .update(forkDownload.bytes) - .digest('hex'); - check( - '取件下载 sha256 与元数据一致', - forkDownloadSha === forkSource?.sha256, - `download=${forkDownloadSha} meta=${forkSource?.sha256 ?? ''}`, - ); - check( - '取件包是 ZIP(PK 头)', - forkDownload.bytes.length > 2 && - forkDownload.bytes[0] === 0x50 && - forkDownload.bytes[1] === 0x4b, - `head=${forkDownload.bytes.subarray(0, 2).toString('hex')}`, - ); - - // G5:鉴权边界 - const anonSource = await api( - `/api/game-distribution/games/${parentId}/fork-source`, - ); - const anonDownload = await downloadBinary(forkSource?.downloadPath ?? '', ''); - check( - '未带 Bearer 的取件元数据 → 401', - anonSource.status === 401, - brief(anonSource), - ); - check( - '未带 Bearer 的取件下载 → 401', - anonDownload.status === 401, - `status=${anonDownload.status}`, - ); - const otherAuthorSource = await forkSourceOf(parentId, forker.token); - check( - '另一名已登录作者也能取件(非 owner 限定)', - otherAuthorSource.status === 200 && - otherAuthorSource.data?.forkSource?.gameId === parentId, - brief(otherAuthorSource), - ); - - // D 段:软删除降级。删除动作在浏览器阶段(页面断言之间)触发;SKIP_BROWSER 时在这里直接删。 - const deleteParent = async () => { - const parentNow = await ownerGame(author.token, parentId); - const revision = parentNow.game?.publicationRevision ?? 0; - return api( - `/api/game-distribution/my-games/${parentId}?expectedPublicationRevision=${revision}`, - { - method: 'DELETE', - token: author.token, - headers: { 'Idempotency-Key': `lineage-delete-${stamp}` }, - }, - ); - }; - - // F 段:浏览器(必须在删除前打开页面断言「删除前形态」,再在页面内触发删除) - await runBrowserStep({ - phone: forker.phone, - expectedAccountText: forkerDisplayName, - childId, - childTitle, - parentId, - parentTitle, - parentAuthorName: authorDisplayName, - forbiddenGameId: blockedId, - deleteParent, - }); - if (SKIP_BROWSER) { - const deleted = await deleteParent(); - check( - '母版软删除成功(HTTP 路径)', - deleted.status === 200, - brief(deleted), - ); - } - - const afterTree = await lineageOf(childId); - check( - '删除母版后族谱接口仍 200(锚点=子作品)', - afterTree.status === 200, - brief(afterTree), - ); - const afterNodes = afterTree.data?.nodes ?? []; - check( - '删除后 nodes 不再含母版', - !afterNodes.some((node) => node.gameId === parentId), - `nodes=${afterNodes.map((node) => node.gameId).join(',')}`, - ); - check( - '删除后 root=null 但 rootGameId 仍为母版', - afterTree.data?.root == null && afterTree.data?.rootGameId === parentId, - `root=${afterTree.data?.root == null ? 'null' : 'present'} rootGameId=${afterTree.data?.rootGameId ?? ''}`, - ); - const afterChildNode = afterNodes.find((node) => node.gameId === childId); - check( - '删除后子节点保留 generation=1 与 parentGameId=母版', - afterChildNode?.generation === 1 && - afterChildNode?.parentGameId === parentId, - `generation=${afterChildNode?.generation} parentGameId=${afterChildNode?.parentGameId ?? 'null'}`, - ); - check( - '删除后响应文本不含母版标题与作者名', - !String(afterTree.text).includes(parentTitle) && - !String(afterTree.text).includes(authorDisplayName), - `title=${parentTitle} author=${authorDisplayName}`, - ); - await checkAnchorNotReadable('已软删除锚点', parentId); - - // E 段:上限常量(真实 >200 节点按成本不做,见 NOTE) - const lineageSourcePath = path.resolve( - process.cwd(), - 'server-rs/crates/module-game-distribution/src/lineage.rs', - ); - let lineageSource = ''; - try { - lineageSource = readFileSync(lineageSourcePath, 'utf8'); - } catch (error) { - check( - '节点上限常量可读(源码钉住)', - false, - `读取失败 ${lineageSourcePath}: ${error?.message ?? error}`, - ); - } - if (lineageSource) { - check( - '节点上限常量仍为 200(源码钉住)', - /GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT:\s*usize\s*=\s*200\b/u.test( - lineageSource, - ), - 'module-game-distribution/src/lineage.rs:22', - ); - } - note( - '未做 >200 真实节点的 truncated 断言(需要 201 个真实公开作品,成本过高);' + - '本脚本只断言小树 truncated=false + 源码常量 200。若要真验截断,建议在 module-game-distribution 单测层做(已有 truncation 单测)。', - ); - - // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 - const cleanup = await cleanupRunFixtures({ - api, - suffix, - owners: [ - { token: author.token, label: '母版作者' }, - { token: forker.token, label: '改编者' }, - ], - }); - console.log( - `[lineage-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + - (cleanup.failures.length > 0 - ? `,失败 ${cleanup.failures.length} 条(见下)` - : ''), - ); - for (const failure of cleanup.failures) { - console.log(`[lineage-e2e] 清理失败 ${JSON.stringify(failure)}`); - } - - console.log( - `[lineage-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, - ); - if (failures > 0) { - process.exitCode = 1; - } -} - -/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ -let activeFixtureRun = null; - -main().catch(async (error) => { - console.error(`[lineage-e2e] 未捕获异常:${error?.stack ?? error}`); - if (activeFixtureRun) { - await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); - } - process.exitCode = 1; -}); diff --git a/scripts/check-game-distribution-project-bundle-e2e.mjs b/scripts/check-game-distribution-project-bundle-e2e.mjs deleted file mode 100644 index 1aeb7f6c6..000000000 --- a/scripts/check-game-distribution-project-bundle-e2e.mjs +++ /dev/null @@ -1,1296 +0,0 @@ -// 游戏分发「工程源包(M2b)」上行 + 下行优先链路真实行为验收。 -// -// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;本脚本**不需要浏览器**。 -// -// 用法: -// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ -// node scripts/check-game-distribution-project-bundle-e2e.mjs -// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口) -// -// 契约来源(全部读实现确认,未按描述猜): -// [1] 上行路由族(Bearer + 发布灰度):api-server/src/modules/game_distribution.rs:363-385 -// [2] 阶段门(已确认 → 409 ALREADY_EXISTS;仅 awaiting_upload/upload_failed 可写 → 409 UPLOAD_NOT_ALLOWED): -// api-server/...:2246-2274(ensure_project_bundle_uploadable) -// [3] 整包 PUT(要求 application/octet-stream):api-server/...:2285-2390;校验失败 → 422 PROJECT_BUNDLE_VALIDATION_FAILED -// (api-server/...:2277-2283 map_project_bundle_error) -// [4] 分片:api-server/...:2473-2597(x-genarrative-upload-offset 头 api-server/...:96、8 MiB 上限 :88、 -// 偏移/超限错误码 :2492/:2501/:2525)、upload-state :2445-2470(chunkBytes/receivedBytes) -// [5] complete:api-server/...:2599-2700(未开始 → 409 UPLOAD_NOT_STARTED;校验失败删半包并 422) -// [6] 校验器:module-game-distribution/src/project_bundle.rs:66-152,拒绝清单含 node_modules / .env: -// :156-224(reject_forbidden_path / is_sensitive_file_name) -// [7] 版本私有 payload 暴露 projectBundleBytes / projectBundleSha256 且不含对象键:api-server/...:3421-3436 -// [8] 下行优先:api-server/...:3110-3160(有工程包 → source=Project,回落 package) -// + /fork-source/project 无工程包时 409 FORK_SOURCE_NOT_AVAILABLE:api-server/...:3258-3270 -// + downloadPath 按资产拼接:api-server/...:3174-3188 -// [9] 对象键前缀 agc/project-snapshots/v1/game-distribution/(响应里绝不能出现):api-server/...:106,2757-2763 -// -// 复用/照抄的 helper(本脚本与其同源,注释里标了出处): -// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133(源自 ratings-e2e.mjs:15-27) -// - check/brief/api/register/gameMetadata/uploadCover/createGame/ownerGame:lineage 脚本 :139-318 -// (uploadCover 又源自 owner-isolation.mjs:97-150) -// - publishToPublic(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407 -// (其顺序与请求体源自 media-e2e.mjs:431-495,523-527,561-573) -// - downloadBinary(按字节校验下载):lineage 脚本 :418-433 -// - 对象键/私有字段泄漏判定模式:lineage 脚本 :445-452 的同类写法 -// -// 已知与工单描述不一致处(读实现后按实现断言,并在报告里单独标注): -// - 工单说「另一个作者的 token → 403」,实现是 **404**:api-server/...:4224-4240 -// (load_owner_version_or_404:非 owner 按「不存在」处理,与发行包上行族同口径)。 - -import { createHash, randomBytes } from 'node:crypto'; - -import JSZip from 'jszip'; - -import { - cleanupRunFixtures, - fixtureTitle, - readDevStackMarker, - reportLeftoverFixtures, - resolveE2eTarget, -} from './e2e-target.mjs'; - -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 DEV_PASSWORD = 'GenE2e123!'; -const GATE_KEY = 'game-distribution:publish'; - -// 工程源包对象键前缀(api-server/...:106):响应里出现它等于泄漏了对象键。 -const OBJECT_KEY_PREFIX = 'agc/project-snapshots/v1/game-distribution/'; -const PRIVATE_OBJECT_PATTERN = - /agc\/project-snapshots|\.project\.zip|project_bundle_object_key/iu; -const CHUNK_HEADER = 'x-genarrative-upload-offset'; - -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); -} - -// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 -// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 -const devStack = readDevStackMarker(); -const target = resolveE2eTarget({ - scriptName: 'check-game-distribution-project-bundle-e2e.mjs', -}); -const API = target.apiBase; - -let checks = 0; -let failures = 0; -const skipped = 0; -function check(name, ok, detail = '') { - checks += 1; - if (!ok) failures += 1; - console.log( - `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, - ); -} -function note(message) { - console.log(`NOTE ${message}`); -} - -const COVER_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', - 'base64', -); - -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, - signal: AbortSignal.timeout(120_000), - }); - 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, - }; -} - -function brief(body) { - const code = body?.error?.code ?? body?.json?.error?.code ?? ''; - return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 220)}`; -} - -async function register(prefix) { - const phone = `${prefix}${String(Date.now()).slice(-8)}`; - const response = await api('/api/auth/entry', { - method: 'POST', - body: { purePhoneNumber: phone, password: DEV_PASSWORD }, - }); - return { phone, response, token: response.data?.token }; -} - -function gameMetadata({ title, coverAssetId }) { - return { - title, - summary: '工程源包验收临时作品', - description: '', - category: '休闲', - tags: ['e2e'], - coverAssetId, - deviceSupport: { desktop: true, mobile: false, touch: false }, - inputModes: ['keyboard', 'mouse'], - orientation: 'landscape', - }; -} - -async function uploadCover(token, id) { - const fileName = `project-bundle-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'project-bundle', 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-project-bundle', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, - ); - } - return confirm.data.assetObject.assetObjectId; -} - -async function createGame({ token, metadata, idemKey }) { - return api('/api/game-distribution/games', { - method: 'POST', - token, - headers: { 'Idempotency-Key': idemKey }, - body: metadata, - }); -} - -/// 发行包(可玩成品)ZIP:与工程源包是两份不同资产,本脚本两者都要传。 -async function buildReleaseZip(marker) { - const zip = new JSZip(); - zip.file( - 'index.html', - `${marker}

${marker}

`, - ); - const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); - return { - bytes, - fileCount: 1, - sha256: createHash('sha256').update(bytes).digest('hex'), - }; -} - -/// 合法工程源包:包内条目覆盖 index.html / package.json / vite.config.js / src/main.js。 -/// `extraFiles` 用于按用例注入禁项(如 .env);`randomBytesCount` 用于造 >8 MiB 的不可压缩负载。 -async function buildProjectBundle({ - marker, - extraFiles = {}, - randomBytesCount = 0, -}) { - const zip = new JSZip(); - zip.file( - 'index.html', - `${marker}` + - '
', - ); - zip.file( - 'package.json', - JSON.stringify({ name: marker, version: '0.0.0' }, null, 2), - ); - zip.file('vite.config.js', 'export default { build: { outDir: "dist" } };\n'); - zip.file('src/main.js', `console.log(${JSON.stringify(marker)});\n`); - if (randomBytesCount > 0) { - // STORE:随机字节不可压缩,保证包体真的越过 8 MiB 分片边界。 - zip.file('assets/blob.bin', randomBytes(randomBytesCount), { - compression: 'STORE', - }); - } - for (const [filePath, content] of Object.entries(extraFiles)) { - zip.file(filePath, content); - } - const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); - return { - bytes, - sha256: createHash('sha256').update(bytes).digest('hex'), - entries: Object.keys(zip.files).filter((name) => !zip.files[name].dir), - }; -} - -// ---------- 对抗用例用的裸 ZIP 写手 ---------- -// -// JSZip 会规范化条目名、且不便于构造「符号链接条目」「伪造声明大小」这类畸形包, -// 对抗用例需要一个能逐字节控制 local header / central directory 的写手。 -// 只用 STORE(method=0、无 data descriptor),格式见 PKWARE APPNOTE: -// local header 0x04034b50 / central 0x02014b50 / EOCD 0x06054b50。 - -const CRC32_TABLE = (() => { - const table = new Uint32Array(256); - for (let index = 0; index < 256; index += 1) { - let value = index; - for (let bit = 0; bit < 8; bit += 1) { - value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; - } - table[index] = value >>> 0; - } - return table; -})(); - -function crc32(buffer) { - let crc = 0xffffffff; - for (const byte of buffer) { - crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -} - -/// entries: `{ name, data, mode?, versionMadeBy?, declaredUncompressedSize? }` -/// - `mode`:unix mode,写进 central directory 的高 16 位(0o120777 = 符号链接)。 -/// - `declaredUncompressedSize`:故意与真实字节数不一致,用于「声明说谎」用例。 -function buildRawZip(entries) { - const locals = []; - const centrals = []; - let offset = 0; - for (const entry of entries) { - const nameBytes = Buffer.from(entry.name, 'utf8'); - const data = Buffer.from(entry.data ?? ''); - const crc = crc32(data); - const declared = entry.declaredUncompressedSize ?? data.length; - const versionMadeBy = entry.versionMadeBy ?? 0x031e; // 3.0 / unix - const externalAttrs = ((entry.mode ?? 0o100644) & 0xffff) << 16; - - const local = Buffer.alloc(30); - local.writeUInt32LE(0x04034b50, 0); - local.writeUInt16LE(20, 4); // version needed - local.writeUInt16LE(0, 6); // flags - local.writeUInt16LE(0, 8); // method: STORE - local.writeUInt16LE(0, 10); // time - local.writeUInt16LE(0, 12); // date - local.writeUInt32LE(crc, 14); - local.writeUInt32LE(data.length, 18); // compressed size - local.writeUInt32LE(declared, 22); // uncompressed size (可被伪造) - local.writeUInt16LE(nameBytes.length, 26); - local.writeUInt16LE(0, 28); // extra length - locals.push(local, nameBytes, data); - - const central = Buffer.alloc(46); - central.writeUInt32LE(0x02014b50, 0); - central.writeUInt16LE(versionMadeBy, 4); - central.writeUInt16LE(20, 6); - central.writeUInt16LE(0, 8); - central.writeUInt16LE(0, 10); - central.writeUInt16LE(0, 12); - central.writeUInt16LE(0, 14); - central.writeUInt32LE(crc, 16); - central.writeUInt32LE(data.length, 20); - central.writeUInt32LE(declared, 24); - central.writeUInt16LE(nameBytes.length, 28); - central.writeUInt16LE(0, 30); // extra - central.writeUInt16LE(0, 32); // comment - central.writeUInt16LE(0, 34); // disk - central.writeUInt16LE(0, 36); // internal attrs - central.writeUInt32LE(externalAttrs >>> 0, 38); - central.writeUInt32LE(offset, 42); - centrals.push(central, nameBytes); - - offset += local.length + nameBytes.length + data.length; - } - const centralSize = centrals.reduce((sum, part) => sum + part.length, 0); - const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); - eocd.writeUInt16LE(0, 4); - eocd.writeUInt16LE(0, 6); - eocd.writeUInt16LE(entries.length, 8); - eocd.writeUInt16LE(entries.length, 10); - eocd.writeUInt32LE(centralSize, 12); - eocd.writeUInt32LE(offset, 16); - eocd.writeUInt16LE(0, 20); - const bytes = Buffer.concat([...locals, ...centrals, eocd]); - return { - bytes, - sha256: createHash('sha256').update(bytes).digest('hex'), - }; -} - -/// 对抗用例的每次上传都只带一个「合法基线条目 + 一个可疑条目」, -/// 这样 422 只能归因于可疑条目本身。 -function adversarialZip(caseName, data = Buffer.from('x'), extra = {}) { - return buildRawZip([ - { name: 'package.json', data: Buffer.from('{"name":"adversarial-e2e"}') }, - { name: caseName, data, ...extra }, - ]); -} - -/// 422 断言:错误码必须是 PROJECT_BUNDLE_VALIDATION_FAILED,并把 details.reason 打出来。 -async function expectBundleRejected(label, versionId, bytes, expectedReasons) { - // HTTP 头必须是 ByteString:把中文标签折成 ASCII 键片段。 - const keyTag = label.replace(/[^A-Za-z0-9]+/gu, '-').slice(0, 48); - const response = await putProjectBundle(versionId, bytes, { - token: authorTokenRef.token, - key: `pb-adv-${keyTag}-${stampRef.value}`, - }); - const reason = response.error?.details?.reason ?? ''; - const codeMatches = - response.status === 422 && - (response.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED'; - const reasonMatches = expectedReasons.includes(reason); - check( - `${label} → 422 PROJECT_BUNDLE_VALIDATION_FAILED(reason=${reason || '∅'})`, - codeMatches && reasonMatches, - `${brief(response)} expectedReason∈[${expectedReasons.join(',')}]`, - ); - return response; -} - -// 供上面的 helper 使用(main 里赋值,避免把 token/stamp 一路透传)。 -const authorTokenRef = { token: '' }; -const stampRef = { value: 0 }; - -// ---------- 上行 ---------- - -async function putProjectBundle(versionId, bytes, { token, key }) { - return api(`/api/game-distribution/versions/${versionId}/project-bundle`, { - method: 'PUT', - token, - headers: { - 'Idempotency-Key': key, - 'Content-Type': 'application/octet-stream', - }, - binary: bytes, - }); -} - -async function putProjectBundleChunk(versionId, chunk, offset, { token, key }) { - return api( - `/api/game-distribution/versions/${versionId}/project-bundle/chunk`, - { - method: 'PUT', - token, - headers: { - 'Idempotency-Key': key, - 'Content-Type': 'application/octet-stream', - [CHUNK_HEADER]: String(offset), - }, - binary: chunk, - }, - ); -} - -async function projectBundleUploadState(versionId, token) { - return api( - `/api/game-distribution/versions/${versionId}/project-bundle/upload-state`, - { - token, - }, - ); -} - -async function ownerVersion(token, versionId) { - const response = await api(`/api/game-distribution/versions/${versionId}`, { - token, - }); - return { response, version: response.data?.version ?? null }; -} - -async function downloadBinary(pathname, token) { - const headers = { ...ENVELOPE }; - if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(`${API}${pathname}`, { - headers, - signal: AbortSignal.timeout(120_000), - }); - const bytes = Buffer.from(await response.arrayBuffer()); - return { - status: response.status, - contentType: response.headers.get('content-type') ?? '', - contentLength: response.headers.get('content-length') ?? '', - bytes, - }; -} - -// ---------- 发布链路(照抄 lineage 脚本 :320-407 的 helper,抽成共享模块会动到其它脚本,故按工单要求照抄并注明) ---------- - -async function createVersion({ token, gameId, metadata, zip, stamp, tag }) { - return api(`/api/game-distribution/games/${gameId}/versions`, { - method: 'POST', - token, - headers: { 'Idempotency-Key': `pb-version-${tag}-${stamp}` }, - body: { - packageSha256: zip.sha256, - packageBytes: zip.bytes.length, - packageFileCount: zip.fileCount, - packageEntryPath: 'index.html', - gameMetadata: metadata, - }, - }); -} - -async function publishToPublic({ - token, - admin, - gameId, - gameRevision, - metadata, - stamp, - tag, - label, -}) { - const zip = await buildReleaseZip(`release-${tag}-${stamp}`); - const created = await createVersion({ - token, - gameId, - metadata, - zip, - stamp, - tag, - }); - const versionId = created.data?.versionId; - check( - `${label} 版本创建成功`, - created.status === 200 && Boolean(versionId), - `status=${created.status} ${created.text.slice(0, 160)}`, - ); - if (!versionId) return { versionId: null }; - - const upload = await api( - `/api/game-distribution/versions/${versionId}/package`, - { - method: 'PUT', - token, - headers: { - 'Idempotency-Key': `pb-upload-${tag}-${stamp}`, - 'Content-Type': 'application/zip', - }, - binary: zip.bytes, - }, - ); - check( - `${label} 发行包上传成功`, - upload.status === 200, - `status=${upload.status}`, - ); - - const submitted = await api( - `/api/game-distribution/versions/${versionId}/submit`, - { - method: 'POST', - token, - headers: { 'Idempotency-Key': `pb-submit-${tag}-${stamp}` }, - body: { expectedPublicationRevision: gameRevision }, - }, - ); - check( - `${label} 送审成功(202)`, - submitted.status === 202, - `status=${submitted.status} ${submitted.text.slice(0, 140)}`, - ); - - const readback = await api(`/api/game-distribution/versions/${versionId}`, { - token, - }); - const approved = await api( - `/admin/api/game-distribution/versions/${versionId}/review`, - { - method: 'POST', - token: admin, - headers: { 'Idempotency-Key': `pb-approve-${tag}-${stamp}` }, - body: { - decision: 'approve', - expectedPublicationRevision: - readback.data?.version?.publicationRevision ?? gameRevision, - }, - }, - ); - check( - `${label} 管理员审核通过并公开`, - approved.status === 200, - `status=${approved.status} ${approved.text.slice(0, 140)}`, - ); - return { versionId }; -} - -// ---------- 主流程 ---------- - -async function main() { - console.log( - `[project-bundle-e2e] api-server=${API} database=${devStack.database}`, - ); - - const adminLogin = await api('/admin/api/login', { - method: 'POST', - body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, - }); - const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; - check( - '管理员登录成功', - adminLogin.status === 200 && Boolean(admin), - `status=${adminLogin.status}`, - ); - if (!admin) process.exit(1); - - const gate = await api('/admin/api/feature-gates', { - method: 'PUT', - token: admin, - body: { - gateKey: GATE_KEY, - enabled: true, - rolloutPercent: 100, - allowUserIds: [], - allowUserTags: [], - denyUserIds: [], - description: 'E2E 工程源包链路', - }, - }); - check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); - - const stamp = Date.now(); - const suffix = String(stamp).slice(-6); - stampRef.value = stamp; - const author = await register('132'); - const other = await register('133'); - // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 - activeFixtureRun = { - api, - suffix, - owners: [ - { token: author.token, label: '作者' }, - { token: other.token, label: '其他作者' }, - ], - }; - check( - '作者注册拿到 token', - author.response.status === 200 && Boolean(author.token), - `status=${author.response.status} phone=${author.phone}`, - ); - check( - '另一个作者注册拿到 token', - other.response.status === 200 && Boolean(other.token), - `status=${other.response.status} phone=${other.phone}`, - ); - if (!author.token || !other.token) process.exit(1); - authorTokenRef.token = author.token; - - // ---------- fixture:作品 A ---------- - const gameTitle = fixtureTitle('工程源包', suffix); - const coverAssetId = await uploadCover(author.token, stamp); - const metadata = gameMetadata({ title: gameTitle, coverAssetId }); - const created = await createGame({ - token: author.token, - metadata, - idemKey: `pb-game-${stamp}`, - }); - const gameId = created.data?.id; - const gameRevision = created.data?.publicationRevision ?? 0; - check( - '作品创建成功', - created.status === 200 && Boolean(gameId), - `status=${created.status} id=${gameId ?? ''}`, - ); - if (!gameId) process.exit(1); - - // ---------- A1:草稿版本上传合法工程源包 ---------- - const validBundle = await buildProjectBundle({ marker: `proj-${suffix}` }); - const v1ReleaseZip = await buildReleaseZip(`v1-${suffix}`); - const v1 = await createVersion({ - token: author.token, - gameId, - metadata, - zip: v1ReleaseZip, - stamp, - tag: 'v1', - }); - const v1Id = v1.data?.versionId; - check( - 'v1 版本创建成功且处于可写档位(awaiting_upload)', - v1.status === 200 && Boolean(v1Id) && v1.data?.status === 'awaiting_upload', - `status=${v1.status} versionId=${v1Id ?? ''} versionStatus=${v1.data?.status ?? ''}`, - ); - if (!v1Id) process.exit(1); - - const put1 = await putProjectBundle(v1Id, validBundle.bytes, { - token: author.token, - key: `pb-put-v1-${stamp}`, - }); - check( - 'A1 合法工程源包整包上传成功', - put1.status === 200 && put1.data?.versionId === v1Id, - brief(put1), - ); - const v1After = await ownerVersion(author.token, v1Id); - check( - 'A1 版本私有 payload 的 projectBundleBytes / projectBundleSha256 与上传一致', - v1After.version?.projectBundleBytes === validBundle.bytes.length && - v1After.version?.projectBundleSha256 === validBundle.sha256, - `bytes=${v1After.version?.projectBundleBytes} 期望=${validBundle.bytes.length} ` + - `sha256=${v1After.version?.projectBundleSha256 ?? ''} 期望=${validBundle.sha256}`, - ); - check( - 'A1 版本私有 payload 不下发对象键', - !PRIVATE_OBJECT_PATTERN.test(v1After.response.text) && - !v1After.response.text.includes(OBJECT_KEY_PREFIX), - `objectKeyPrefix=${OBJECT_KEY_PREFIX}`, - ); - - // ---------- A3:含禁项 → 422,且不落库 ---------- - const forbiddenBundle = await buildProjectBundle({ - marker: `bad-${suffix}`, - extraFiles: { '.env': 'SECRET=1\n' }, - }); - const v2 = await createVersion({ - token: author.token, - gameId, - metadata, - zip: await buildReleaseZip(`v2-${suffix}`), - stamp, - tag: 'v2', - }); - const v2Id = v2.data?.versionId; - check( - 'v2 版本创建成功(用于禁项用例)', - v2.status === 200 && Boolean(v2Id), - `status=${v2.status} versionId=${v2Id ?? ''}`, - ); - if (!v2Id) process.exit(1); - const putBad = await putProjectBundle(v2Id, forbiddenBundle.bytes, { - token: author.token, - key: `pb-put-v2-${stamp}`, - }); - check( - 'A3 含 .env 的工程源包被拒(422 PROJECT_BUNDLE_VALIDATION_FAILED)', - putBad.status === 422 && - (putBad.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED', - brief(putBad), - ); - const v2After = await ownerVersion(author.token, v2Id); - check( - 'A3 被拒后该版本仍未落库工程源包(bytes=0 / sha256 空)', - (v2After.version?.projectBundleBytes ?? -1) === 0 && - !v2After.version?.projectBundleSha256, - `bytes=${v2After.version?.projectBundleBytes} sha256=${JSON.stringify(v2After.version?.projectBundleSha256 ?? null)}`, - ); - - // ---------- A7:对抗用例(校验器补强后的拒绝清单) ---------- - // 预期形状读自实现:module-game-distribution/src/project_bundle.rs(目录/凭据/嵌套包 - // 拒绝清单 :235-295、magic 嗅探 :326-345、凭据内容嗅探 :360-400)与共享路径规范化 - // server-rs/crates/module-game-distribution/src/package.rs:158-182。 - const vAdv = await createVersion({ - token: author.token, - gameId, - metadata, - zip: await buildReleaseZip(`vadv-${suffix}`), - stamp, - tag: 'vadv', - }); - const vAdvId = vAdv.data?.versionId; - check( - 'A7 对抗用例版本创建成功(awaiting_upload)', - vAdv.status === 200 && Boolean(vAdvId), - `status=${vAdv.status} versionId=${vAdvId ?? ''}`, - ); - if (!vAdvId) process.exit(1); - - // A7-1 路径穿越/畸形路径(全部 422 InvalidPath) - for (const name of [ - 'foo/../bar', - '/etc/passwd', - 'C:/evil.txt', - 'a\\..\\b', - './x', - 'a//b', - ]) { - await expectBundleRejected( - `A7 路径穿越 ${name}`, - vAdvId, - adversarialZip(name).bytes, - ['InvalidPath'], - ); - } - note( - '两层防线共同覆盖这些形状:zip crate 的 enclosed_name() 只拒 NUL / 根路径 / `..` 逃逸' + - '(zip-2.4.2 src/types.rs:537-555,且返回的是**未归一化**的原始条目名);' + - '`a//b`、`./x`、结尾点/空格、`:`/`*`/`?`、反斜杠等由校验器自己的路径形状检查逐段拦下' + - '(module-game-distribution/src/package.rs:158-182:空段 / `.` / `..` / 尾随空格或点 / 禁止字符 / 反斜杠)。' + - '实测这六种形状全部 422 + reason=InvalidPath。', - ); - - // A7-2 结尾点/空格与禁止字符 - for (const name of ['a/secret.', 'a/secret ', 'src/a?.ts', 'a*b.ts']) { - await expectBundleRejected( - `A7 路径形状 ${name}`, - vAdvId, - adversarialZip(name).bytes, - ['InvalidPath'], - ); - } - - // A7-3 大小写变体目录(拒绝清单按大小写折叠比对) - await expectBundleRejected( - 'A7 大小写变体 Node_Modules/lodash/x.js', - vAdvId, - adversarialZip('Node_Modules/lodash/x.js').bytes, - ['DependencyDirectoryNotAllowed'], - ); - await expectBundleRejected( - 'A7 大小写变体 .GIT/HEAD', - vAdvId, - adversarialZip('.GIT/HEAD').bytes, - ['VersionControlDirectoryNotAllowed'], - ); - await expectBundleRejected( - 'A7 大小写变体 .AGENT/x', - vAdvId, - adversarialZip('.AGENT/x').bytes, - ['LocalStateDirectoryNotAllowed'], - ); - - // A7-4 符号链接条目(central directory 高 16 位 = 0o120777) - await expectBundleRejected( - 'A7 符号链接条目', - vAdvId, - adversarialZip('link-outside', Buffer.from('/etc/passwd'), { - mode: 0o120777, - }).bytes, - ['SymlinkNotAllowed'], - ); - - // A7-5 嵌套包:改名成 deps.dat,只能靠 magic bytes 拦住 - const innerZip = buildRawZip([ - { name: 'node_modules/x/index.js', data: Buffer.from('x') }, - ]); - await expectBundleRejected( - 'A7 嵌套 zip 改名 deps.dat', - vAdvId, - adversarialZip('deps.dat', innerZip.bytes).bytes, - ['NestedArchiveNotAllowed'], - ); - - // A7-6 凭据类(补强新增的目录/文件名规则) - const credentialCases = [ - [ - '.aws/credentials', - ['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'], - ], - [ - '.ssh/id_ecdsa', - ['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'], - ], - ['.htpasswd', ['SensitiveFileNotAllowed']], - ['service-account-prod.json', ['SensitiveFileNotAllowed']], - ['terraform.tfstate', ['SensitiveFileNotAllowed']], - ['app.p8', ['SensitiveFileNotAllowed']], - ]; - for (const [name, reasons] of credentialCases) { - await expectBundleRejected( - `A7 凭据 ${name}`, - vAdvId, - adversarialZip(name).bytes, - reasons, - ); - } - - // A7-7 凭据内容嗅探:无扩展名文件里放 PEM 私钥块 - await expectBundleRejected( - 'A7 内容嗅探(无扩展名 PEM 私钥)', - vAdvId, - adversarialZip( - 'secrets', - Buffer.from('-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n'), - ).bytes, - ['SecretContentDetected'], - ); - - // A7-8 声明说谎:STORE 条目把解锁声明写成 1 字节、实际 4096 字节 - await expectBundleRejected( - 'A7 声明说谎(声明 1 字节 / 实际 4096 字节)', - vAdvId, - adversarialZip('liar.bin', Buffer.alloc(4096, 0x41), { - declaredUncompressedSize: 1, - }).bytes, - ['ReadFailed', 'InvalidArchive'], - ); - note( - '声明说谎用例断言的是「失败关闭」而不是内存量测:服务端按声明大小 take(declared+1) 读取' + - '(package.rs:115-135),多读 1 字节即判 ReadFailed,因此不会出现「声明 1 KiB、实际解压数 GiB」的放大;' + - '服务端真实内存占用本脚本无法观测(未量测 RSS)。', - ); - - // 全部拒绝用例之后:该版本仍未落库 - const vAdvAfter = await ownerVersion(author.token, vAdvId); - check( - 'A7 全部拒绝用例后该版本仍 bytes=0 / sha256 空', - (vAdvAfter.version?.projectBundleBytes ?? -1) === 0 && - !vAdvAfter.version?.projectBundleSha256, - `bytes=${vAdvAfter.version?.projectBundleBytes} sha256=${JSON.stringify(vAdvAfter.version?.projectBundleSha256 ?? null)}`, - ); - - // A7-9 过度拦截对照:普通 .dat / 文本 / 无扩展名 / 二进制条目必须放行 - const controlBundle = await buildProjectBundle({ - marker: `adv-control-${suffix}`, - extraFiles: { - 'notes.dat': 'plain text payload\n', - 'docs/readme.md': '# readme\n', - 'notes.txt': 'notes\n', - LICENSE: 'MIT\n', - 'assets/logo.png': COVER_PNG, - }, - }); - const controlPut = await putProjectBundle(vAdvId, controlBundle.bytes, { - token: author.token, - key: `pb-adv-control-${stamp}`, - }); - check( - 'A7 对照:普通 .dat/文本/无扩展名/二进制条目不被过度拦截(200)', - controlPut.status === 200, - brief(controlPut), - ); - - // ---------- A2:>8 MiB 不可压缩负载走两片 ---------- - const bigRandomBytes = 9 * 1024 * 1024; // 9 MiB 随机字节:压缩后仍 >8 MiB - const bigBundle = await buildProjectBundle({ - marker: `big-${suffix}`, - randomBytesCount: bigRandomBytes, - }); - const v3 = await createVersion({ - token: author.token, - gameId, - metadata, - zip: await buildReleaseZip(`v3-${suffix}`), - stamp, - tag: 'v3', - }); - const v3Id = v3.data?.versionId; - check( - 'v3 版本创建成功(用于多分片用例)', - v3.status === 200 && Boolean(v3Id), - `status=${v3.status} versionId=${v3Id ?? ''}`, - ); - if (!v3Id) process.exit(1); - - const state0 = await projectBundleUploadState(v3Id, author.token); - const chunkBytes = state0.data?.chunkBytes ?? 0; - check( - 'A2 upload-state 下发权威分片大小与服务端已收字节', - state0.status === 200 && chunkBytes > 0 && state0.data?.receivedBytes === 0, - `chunkBytes=${chunkBytes} receivedBytes=${state0.data?.receivedBytes}`, - ); - check( - 'A2 构造的不可压缩负载确实超过一个分片(> chunkBytes)', - bigBundle.bytes.length > chunkBytes && chunkBytes > 0, - `bundleBytes=${bigBundle.bytes.length} chunkBytes=${chunkBytes} entries=${bigBundle.entries.length}`, - ); - - const firstChunk = bigBundle.bytes.subarray(0, chunkBytes); - const secondChunk = bigBundle.bytes.subarray(chunkBytes); - const chunk1 = await putProjectBundleChunk(v3Id, firstChunk, 0, { - token: author.token, - key: `pb-chunk1-${stamp}`, - }); - check('A2 第一片(offset=0)写入成功', chunk1.status === 200, brief(chunk1)); - const state1 = await projectBundleUploadState(v3Id, author.token); - check( - 'A2 upload-state 权威偏移等于第一片字节数', - state1.data?.receivedBytes === firstChunk.length, - `receivedBytes=${state1.data?.receivedBytes} 期望=${firstChunk.length}`, - ); - - const chunk2 = await putProjectBundleChunk( - v3Id, - secondChunk, - firstChunk.length, - { - token: author.token, - key: `pb-chunk2-${stamp}`, - }, - ); - check('A2 第二片(续传偏移)写入成功', chunk2.status === 200, brief(chunk2)); - const state2 = await projectBundleUploadState(v3Id, author.token); - check( - 'A2 upload-state 权威偏移等于整包字节数', - state2.data?.receivedBytes === bigBundle.bytes.length, - `receivedBytes=${state2.data?.receivedBytes} 期望=${bigBundle.bytes.length}`, - ); - - const complete3 = await api( - `/api/game-distribution/versions/${v3Id}/project-bundle/complete`, - { - method: 'POST', - token: author.token, - headers: { 'Idempotency-Key': `pb-complete-v3-${stamp}` }, - }, - ); - check( - 'A2 分片收齐后 complete 成功', - complete3.status === 200 && complete3.data?.versionId === v3Id, - brief(complete3), - ); - const v3After = await ownerVersion(author.token, v3Id); - check( - 'A2 分片上传确认后的 bytes / sha256 与本地构造一致', - v3After.version?.projectBundleBytes === bigBundle.bytes.length && - v3After.version?.projectBundleSha256 === bigBundle.sha256, - `bytes=${v3After.version?.projectBundleBytes} 期望=${bigBundle.bytes.length} ` + - `sha256=${v3After.version?.projectBundleSha256 ?? ''} 期望=${bigBundle.sha256}`, - ); - - // ---------- A4:重复确认 ---------- - const putAgain = await putProjectBundle(v1Id, validBundle.bytes, { - token: author.token, - key: `pb-put-v1-again-${stamp}`, - }); - check( - 'A4 同一版本二次上传被拒(409 PROJECT_BUNDLE_ALREADY_EXISTS)', - putAgain.status === 409 && - (putAgain.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS', - brief(putAgain), - ); - - // ---------- A6:鉴权 ---------- - const anonPut = await api( - `/api/game-distribution/versions/${v1Id}/project-bundle`, - { - method: 'PUT', - headers: { - 'Idempotency-Key': `pb-anon-${stamp}`, - 'Content-Type': 'application/octet-stream', - }, - binary: validBundle.bytes, - }, - ); - check('A6 未带 Bearer → 401', anonPut.status === 401, brief(anonPut)); - const foreignPut = await putProjectBundle(v1Id, validBundle.bytes, { - token: other.token, - key: `pb-foreign-${stamp}`, - }); - check( - 'A6 另一个作者的 token → 404(实现:非 owner 按不存在处理)', - foreignPut.status === 404 && - !String(foreignPut.text).includes(OBJECT_KEY_PREFIX), - brief(foreignPut), - ); - note( - '工单描述该用例为 403;实现是 404(api-server/...:4224-4240 load_owner_version_or_404,' + - '与发行包上行族同口径:不区分「别人的版本」与「不存在」)。本脚本按实现断言 404,并在此标注差异。', - ); - - // ---------- 发布 v1(带工程源包)→ A5 阶段门 ---------- - const releaseZipV1 = v1ReleaseZip; - const uploadPackageV1 = await api( - `/api/game-distribution/versions/${v1Id}/package`, - { - method: 'PUT', - token: author.token, - headers: { - 'Idempotency-Key': `pb-release-v1-${stamp}`, - 'Content-Type': 'application/zip', - }, - binary: releaseZipV1.bytes, - }, - ); - check( - 'v1 发行包(成品)上传成功', - uploadPackageV1.status === 200, - brief(uploadPackageV1), - ); - const submitV1 = await api(`/api/game-distribution/versions/${v1Id}/submit`, { - method: 'POST', - token: author.token, - headers: { 'Idempotency-Key': `pb-submit-v1-${stamp}` }, - body: { expectedPublicationRevision: gameRevision }, - }); - check('v1 送审成功(202)', submitV1.status === 202, brief(submitV1)); - const v1Readback = await ownerVersion(author.token, v1Id); - const approveV1 = await api( - `/admin/api/game-distribution/versions/${v1Id}/review`, - { - method: 'POST', - token: admin, - headers: { 'Idempotency-Key': `pb-approve-v1-${stamp}` }, - body: { - decision: 'approve', - expectedPublicationRevision: - v1Readback.version?.publicationRevision ?? gameRevision, - }, - }, - ); - check('v1 管理员审核通过并公开', approveV1.status === 200, brief(approveV1)); - - const putAfterPublish = await putProjectBundle(v1Id, validBundle.bytes, { - token: author.token, - key: `pb-put-after-publish-${stamp}`, - }); - check( - 'A5 已公开且已有工程包的版本再传 → 409 ALREADY_EXISTS(阶段门先判「已存在」)', - putAfterPublish.status === 409 && - (putAfterPublish.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS', - brief(putAfterPublish), - ); - note( - '实现里「已确认过工程包」先于「版本档位」判定(api-server/...:2246-2274 的注释与顺序):' + - '已有工程包的已公开版本因此返回 ALREADY_EXISTS;「已公开但无工程包」的补传用例在 A5b/B9 段。', - ); - - // 取件通道要求作品共创授权非禁止(api-server/...:3110-3145):发布后再提升授权。 - const promoteFork = await api( - `/api/game-distribution/games/${gameId}/fork-authorization`, - { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `pb-promote-${stamp}` }, - body: { - expectedForkAuthorization: 'forbidden', - forkAuthorization: 'nonCommercial', - }, - }, - ); - check( - '作品 A 共创授权提升为 nonCommercial(取件通道前提)', - promoteFork.status === 200 && - (promoteFork.data?.game?.forkAuthorization ?? null) === 'nonCommercial', - brief(promoteFork), - ); - - // ---------- B7:下行优先 ---------- - const forkSource = await api( - `/api/game-distribution/games/${gameId}/fork-source`, - { - token: other.token, - }, - ); - const source = forkSource.data?.forkSource ?? null; - check( - 'B7 带工程包的公开作品 fork-source 200 且 source=project', - forkSource.status === 200 && source?.source === 'project', - brief(forkSource), - ); - check( - 'B7 sha256 / bytes 与上传的工程源包一致', - source?.sha256 === validBundle.sha256 && - source?.bytes === validBundle.bytes.length, - `sha256=${source?.sha256 ?? ''} 期望=${validBundle.sha256} bytes=${source?.bytes} 期望=${validBundle.bytes.length}`, - ); - check( - 'B7 downloadPath 指向 project 资产', - typeof source?.downloadPath === 'string' && - source.downloadPath.endsWith('/fork-source/project') && - !source.downloadPath.includes('://'), - `downloadPath=${source?.downloadPath ?? ''}`, - ); - check( - 'B7 取件元数据不含对象键', - !PRIVATE_OBJECT_PATTERN.test(forkSource.text) && - !forkSource.text.includes(OBJECT_KEY_PREFIX), - `objectKeyPrefix=${OBJECT_KEY_PREFIX}`, - ); - - // ---------- B8:按 downloadPath 下载并逐字节校验 ---------- - const projectDownload = await downloadBinary( - source?.downloadPath ?? '', - other.token, - ); - check( - 'B8 工程源包下载 200 + application/zip', - projectDownload.status === 200 && - projectDownload.contentType.includes('application/zip'), - `status=${projectDownload.status} content-type=${projectDownload.contentType}`, - ); - check( - 'B8 content-length 与实际字节数一致', - Number(projectDownload.contentLength) === projectDownload.bytes.length, - `content-length=${projectDownload.contentLength} actual=${projectDownload.bytes.length}`, - ); - const projectDownloadSha = createHash('sha256') - .update(projectDownload.bytes) - .digest('hex'); - check( - 'B8 下载字节 sha256 与元数据一致', - projectDownloadSha === source?.sha256, - `download=${projectDownloadSha} meta=${source?.sha256 ?? ''}`, - ); - let unzippedEntries = []; - try { - const archive = await JSZip.loadAsync(projectDownload.bytes); - unzippedEntries = Object.keys(archive.files).filter( - (name) => !archive.files[name].dir, - ); - } catch (error) { - unzippedEntries = []; - note(`B8 解压失败:${error?.message ?? error}`); - } - check( - 'B8 下载内容可解压且包含上传的工程条目', - ['index.html', 'package.json', 'vite.config.js', 'src/main.js'].every( - (entry) => unzippedEntries.includes(entry), - ), - `entries=${unzippedEntries.join(',')}`, - ); - - // ---------- B9:对照(无工程包的公开作品回落成品包) ---------- - const controlTitle = fixtureTitle('工程源包对照', suffix); - const controlCover = await uploadCover(author.token, `${stamp}-control`); - const controlMeta = gameMetadata({ - title: controlTitle, - coverAssetId: controlCover, - }); - const controlCreated = await createGame({ - token: author.token, - metadata: controlMeta, - idemKey: `pb-control-game-${stamp}`, - }); - const controlGameId = controlCreated.data?.id; - check( - 'B9 对照作品创建成功', - controlCreated.status === 200 && Boolean(controlGameId), - `status=${controlCreated.status} id=${controlGameId ?? ''}`, - ); - if (!controlGameId) process.exit(1); - const controlPublish = await publishToPublic({ - token: author.token, - admin, - gameId: controlGameId, - gameRevision: controlCreated.data?.publicationRevision ?? 0, - metadata: controlMeta, - stamp, - tag: 'control', - label: '对照', - }); - // A5b:已公开且**没有**工程包的版本不允许补传(此时阶段门才落到档位判定)。 - const controlVersionId = controlPublish.versionId; - if (controlVersionId) { - const controlPut = await putProjectBundle( - controlVersionId, - validBundle.bytes, - { token: author.token, key: `pb-control-put-${stamp}` }, - ); - check( - 'A5b 已公开但无工程包的版本不允许补传 → 409 PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED', - controlPut.status === 409 && - (controlPut.error?.code ?? '') === 'PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED', - brief(controlPut), - ); - } - const controlPromote = await api( - `/api/game-distribution/games/${controlGameId}/fork-authorization`, - { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `pb-control-promote-${stamp}` }, - body: { - expectedForkAuthorization: 'forbidden', - forkAuthorization: 'nonCommercial', - }, - }, - ); - check( - 'B9 对照作品共创授权提升为 nonCommercial', - controlPromote.status === 200, - brief(controlPromote), - ); - const controlSource = await api( - `/api/game-distribution/games/${controlGameId}/fork-source`, - { token: other.token }, - ); - check( - 'B9 无工程包的作品 source 回落为 package', - controlSource.status === 200 && - controlSource.data?.forkSource?.source === 'package', - brief(controlSource), - ); - const controlProjectDownload = await downloadBinary( - `/api/game-distribution/games/${controlGameId}/fork-source/project`, - other.token, - ); - check( - 'B9 无工程包时 /fork-source/project → 409(不静默回落成品包)', - controlProjectDownload.status === 409, - `status=${controlProjectDownload.status}`, - ); - - // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 - const cleanup = await cleanupRunFixtures({ - api, - suffix, - owners: [ - { token: author.token, label: '作者' }, - { token: other.token, label: '其他作者' }, - ], - }); - console.log( - `[project-bundle-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + - (cleanup.failures.length > 0 - ? `,失败 ${cleanup.failures.length} 条(见下)` - : ''), - ); - for (const failure of cleanup.failures) { - console.log(`[project-bundle-e2e] 清理失败 ${JSON.stringify(failure)}`); - } - - console.log( - `[project-bundle-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, - ); - if (failures > 0) { - process.exitCode = 1; - } -} - -/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ -let activeFixtureRun = null; - -main().catch(async (error) => { - console.error(`[project-bundle-e2e] 未捕获异常:${error?.stack ?? error}`); - if (activeFixtureRun) { - await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); - } - process.exitCode = 1; -}); diff --git a/scripts/check-game-distribution-theme-e2e.mjs b/scripts/check-game-distribution-theme-e2e.mjs deleted file mode 100644 index 4a03f69a6..000000000 --- a/scripts/check-game-distribution-theme-e2e.mjs +++ /dev/null @@ -1,1550 +0,0 @@ -// 游戏分发「共创主题」链路真实行为验收(公开读 + 后台写 + 作品详情 themes 增量)。 -// -// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;不需要浏览器。 -// -// 用法: -// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ -// node scripts/check-game-distribution-theme-e2e.mjs -// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口) -// -// 契约来源(逐条读实现确认;**一律用符号检索定位,不写行号**——行号会随提交漂移): -// [1] 公开主题路由(匿名 + no-store):`api-server/src/modules/game_distribution.rs` 里 -// `public_games` 路由族的 `themes` / `themes/{theme_id}` 两条(`themes` 前缀可定位) -// [2] 公开列表分页与响应形状:`list_public_themes` + `public_themes_payload` + `themes_page_limit` -// [3] 公开详情:`get_public_theme` + `public_theme_detail_payload` -// (未发布/不存在都回「主题不存在」→ 404;已发布空成员是 200 + 空 roots) -// + `module-game-distribution/src/theme.rs` 的 `game_distribution_theme_public_visible` -// [4] 可见性与 memberCount 判定:`game_distribution_theme_visible_members` -// (未删 + 已公开 + 有当前公开版本)+ `game_distribution_theme_roots_truncated` -// [5] 作品详情 `themes` 增量「先取根再反查」:`game_distribution_themes_for_game` -// [6] 后台写路由(admin 鉴权):`admin_create_theme` / `admin_update_theme` / -// `admin_set_theme_member` / `admin_delete_theme_member` / `admin_list_themes` / -// `admin_list_theme_members`(成员名单不套公开可见性过滤) -// [7] 错误码映射:`map_spacetime_error` 里的 `GAME_DISTRIBUTION_THEME_*` 分支前缀判定 -// [8] 分页极限与游标:`game_distribution_theme_page_limit`(默认 20 / 上限 50 / 0 取默认 / 超界截断)、 -// `parse_game_distribution_theme_cursor`、`page_admin_theme_members` -// (成员名单缺省 20 / 上限 50,按 `sort_order` 升序 + `member_id` 兜底,游标 `{sort_order}:{memberId}`) -// -// 复用/照抄的 helper(注释标了出处;抽公共模块会改动其它已提交脚本,故照抄): -// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs 的 -// `const devStack = JSON.parse(...)` 那一段 -// - check/note/api/brief/register/gameMetadata/uploadCover/createGame/ownerGame/publicDetail: -// lineage 脚本同名 helper -// - 完整发布链路(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本的 `publishToPublic` -// - 改编声明(fork 字段)+ 公开版本 id 取法:lineage 脚本里取 `versions.find(status==='published')` 那一段 - -import { createHash } from 'node:crypto'; - -import JSZip from 'jszip'; - -import { - cleanupRunFixtures, - fixtureTitle, - readDevStackMarker, - reportLeftoverFixtures, - resolveE2eTarget, -} from './e2e-target.mjs'; - -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 DEV_PASSWORD = 'GenE2e123!'; -const GATE_KEY = 'game-distribution:publish'; - -if (!ADMIN_USER || !ADMIN_PASSWORD) { - console.error( - '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' + - 'game-distribution:publish 写入口并把作品推到公开(主题成员只呈现公开作品)。', - ); - process.exit(2); -} - -// 目标地址:夹具类脚本缺省打**隔离栈**(`npm run e2e:stack`),并拒绝误打用户正在用的开发栈。 -// dev-stack 标记只用于日志里的库名与「这是用户栈」判定。 -const devStack = readDevStackMarker(); -const target = resolveE2eTarget({ - scriptName: 'check-game-distribution-theme-e2e.mjs', -}); -const API = target.apiBase; - -let checks = 0; -let failures = 0; -const skipped = 0; -function check(name, ok, detail = '') { - checks += 1; - if (!ok) failures += 1; - console.log( - `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, - ); -} -function note(message) { - console.log(`NOTE ${message}`); -} - -const COVER_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', - 'base64', -); - -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, - signal: AbortSignal.timeout(120_000), - }); - 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, - cacheControl: response.headers.get('cache-control') ?? '', - }; -} - -function brief(body) { - const code = body?.error?.code ?? body?.json?.error?.code ?? ''; - return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 200)}`; -} - -function errorCode(body) { - return body?.error?.code ?? body?.json?.error?.code ?? ''; -} - -function errorText(body) { - return String( - body?.error?.details?.message ?? - body?.error?.message ?? - body?.json?.error?.message ?? - '', - ); -} - -async function register(prefix) { - const phone = `${prefix}${String(Date.now()).slice(-8)}`; - const response = await api('/api/auth/entry', { - method: 'POST', - body: { purePhoneNumber: phone, password: DEV_PASSWORD }, - }); - return { phone, response, token: response.data?.token }; -} - -function gameMetadata({ title, coverAssetId }) { - return { - title, - summary: '共创主题验收临时作品', - description: '', - category: '休闲', - tags: ['e2e'], - coverAssetId, - deviceSupport: { desktop: true, mobile: false, touch: false }, - inputModes: ['keyboard', 'mouse'], - orientation: 'landscape', - }; -} - -async function uploadCover(token, id) { - const fileName = `theme-${id}.png`; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: ['game-distribution', 'theme', 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-theme', - }, - }); - if (confirm.status !== 200) { - throw new Error( - `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, - ); - } - return confirm.data.assetObject.assetObjectId; -} - -async function createGame({ token, metadata, idemKey, fork }) { - return api('/api/game-distribution/games', { - method: 'POST', - token, - headers: { 'Idempotency-Key': idemKey }, - body: fork ? { ...metadata, fork } : metadata, - }); -} - -async function ownerGame(token, gameId) { - const response = await api(`/api/game-distribution/my-games/${gameId}`, { - token, - }); - return { response, game: response.data?.game ?? null }; -} - -function publicDetail(gameId, token) { - return api(`/api/game-distribution/games/${gameId}`, { token }); -} - -async function buildReleaseZip(marker) { - const zip = new JSZip(); - zip.file( - 'index.html', - `${marker}

${marker}

`, - ); - const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); - return { - bytes, - fileCount: 1, - sha256: createHash('sha256').update(bytes).digest('hex'), - }; -} - -/// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。 -/// -/// `changeSummary` 只在**衍生作品**上传:它是衍生作品发布新版本的必填项(母版忽略)。 -async function publishToPublic({ - token, - admin, - gameId, - gameRevision, - metadata, - stamp, - tag, - label, - quiet = false, - changeSummary, -}) { - const zip = await buildReleaseZip(`theme-${tag}-${stamp}`); - const created = await api(`/api/game-distribution/games/${gameId}/versions`, { - method: 'POST', - token, - headers: { 'Idempotency-Key': `theme-version-${tag}-${stamp}` }, - body: { - packageSha256: zip.sha256, - packageBytes: zip.bytes.length, - packageFileCount: zip.fileCount, - packageEntryPath: 'index.html', - gameMetadata: metadata, - ...(changeSummary ? { changeSummary } : {}), - }, - }); - const versionId = created.data?.versionId; - if (!quiet) { - check( - `${label} 版本创建成功`, - created.status === 200 && Boolean(versionId), - `status=${created.status} ${created.text.slice(0, 140)}`, - ); - } - if (!versionId) return { versionId: null }; - - const upload = await api( - `/api/game-distribution/versions/${versionId}/package`, - { - method: 'PUT', - token, - headers: { - 'Idempotency-Key': `theme-upload-${tag}-${stamp}`, - 'Content-Type': 'application/zip', - }, - binary: zip.bytes, - }, - ); - if (!quiet) { - check( - `${label} 发行包上传成功`, - upload.status === 200, - `status=${upload.status}`, - ); - } - - const submitted = await api( - `/api/game-distribution/versions/${versionId}/submit`, - { - method: 'POST', - token, - headers: { 'Idempotency-Key': `theme-submit-${tag}-${stamp}` }, - body: { expectedPublicationRevision: gameRevision }, - }, - ); - if (!quiet) { - check( - `${label} 送审成功(202)`, - submitted.status === 202, - `status=${submitted.status} ${submitted.text.slice(0, 140)}`, - ); - } - - const readback = await api(`/api/game-distribution/versions/${versionId}`, { - token, - }); - const approved = await api( - `/admin/api/game-distribution/versions/${versionId}/review`, - { - method: 'POST', - token: admin, - headers: { 'Idempotency-Key': `theme-approve-${tag}-${stamp}` }, - body: { - decision: 'approve', - expectedPublicationRevision: - readback.data?.version?.publicationRevision ?? gameRevision, - }, - }, - ); - if (!quiet) { - check( - `${label} 管理员审核通过并公开`, - approved.status === 200, - `status=${approved.status} ${approved.text.slice(0, 140)}`, - ); - } - return { versionId }; -} - -// ---------- 主题专用 helper ---------- - -function adminCreateTheme({ admin, body, key }) { - return api('/admin/api/game-distribution/themes', { - method: 'POST', - token: admin, - headers: key === undefined ? {} : { 'Idempotency-Key': key }, - body, - }); -} - -function adminUpdateTheme({ admin, themeId, body, key }) { - return api(`/admin/api/game-distribution/themes/${themeId}`, { - method: 'PUT', - token: admin, - headers: key === undefined ? {} : { 'Idempotency-Key': key }, - body, - }); -} - -function adminPutMember({ admin, themeId, rootGameId, sortOrder = 0 }) { - return api( - `/admin/api/game-distribution/themes/${themeId}/members/${rootGameId}`, - { - method: 'PUT', - token: admin, - body: { sortOrder }, - }, - ); -} - -function adminDeleteMember({ admin, themeId, rootGameId }) { - return api( - `/admin/api/game-distribution/themes/${themeId}/members/${rootGameId}`, - { - method: 'DELETE', - token: admin, - }, - ); -} - -function adminListThemes({ admin, limit, status } = {}) { - const query = new URLSearchParams(); - if (limit !== undefined) query.set('limit', String(limit)); - if (status !== undefined) query.set('status', status); - const suffix = query.toString() ? `?${query.toString()}` : ''; - return api(`/admin/api/game-distribution/themes${suffix}`, { token: admin }); -} - -function publicThemes({ limit, cursor } = {}) { - const query = new URLSearchParams(); - if (limit !== undefined) query.set('limit', String(limit)); - if (cursor !== undefined) query.set('cursor', cursor); - const suffix = query.toString() ? `?${query.toString()}` : ''; - return api(`/api/game-distribution/themes${suffix}`); -} - -function publicThemeDetail(themeId) { - return api(`/api/game-distribution/themes/${themeId}`); -} - -function adminThemeMembers({ admin, themeId, limit, cursor } = {}) { - const query = new URLSearchParams(); - if (limit !== undefined) query.set('limit', String(limit)); - if (cursor !== undefined) query.set('cursor', cursor); - const suffix = query.toString() ? `?${query.toString()}` : ''; - return api( - `/admin/api/game-distribution/themes/${themeId}/members${suffix}`, - { token: admin }, - ); -} - -function softDeleteGame({ token, gameId, revision, key }) { - return api( - `/api/game-distribution/my-games/${gameId}?expectedPublicationRevision=${revision}`, - { method: 'DELETE', token, headers: { 'Idempotency-Key': key } }, - ); -} - -function themeIdsOf(body) { - return (body?.data?.themes ?? []).map((theme) => theme.themeId); -} - -// ---------- 主流程 ---------- - -async function main() { - console.log(`[theme-e2e] api-server=${API} database=${devStack.database}`); - - const adminLogin = await api('/admin/api/login', { - method: 'POST', - body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, - }); - const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; - check( - '管理员登录成功', - adminLogin.status === 200 && Boolean(admin), - `status=${adminLogin.status}`, - ); - if (!admin) process.exit(1); - - const gate = await api('/admin/api/feature-gates', { - method: 'PUT', - token: admin, - body: { - gateKey: GATE_KEY, - enabled: true, - rolloutPercent: 100, - allowUserIds: [], - allowUserTags: [], - denyUserIds: [], - description: 'E2E 共创主题链路', - }, - }); - check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); - - const stamp = Date.now(); - const suffix = String(stamp).slice(-6); - const author = await register('132'); - const stranger = await register('133'); - // 本轮跑批上下文:中断时用它打印未清理的夹具清单。 - activeFixtureRun = { - api, - suffix, - owners: [ - { token: author.token, label: '作者' }, - { token: stranger.token, label: '陌生作者' }, - ], - }; - check( - '作者注册拿到 token', - author.response.status === 200 && Boolean(author.token), - `status=${author.response.status}`, - ); - check( - '另一个普通账号(非 admin)注册拿到 token', - stranger.response.status === 200 && Boolean(stranger.token), - `status=${stranger.response.status}`, - ); - if (!author.token || !stranger.token) process.exit(1); - - // ---------- fixture:根作品 R(公开)+ 衍生作品 C(公开)+ 第二个根 R2 ---------- - const rootMeta = gameMetadata({ - title: fixtureTitle('主题根作品', suffix), - coverAssetId: await uploadCover(author.token, `${stamp}-root`), - }); - const rootCreated = await createGame({ - token: author.token, - metadata: rootMeta, - idemKey: `theme-root-${stamp}`, - }); - const rootId = rootCreated.data?.id; - check( - '根作品 R 创建成功', - rootCreated.status === 200 && Boolean(rootId), - `status=${rootCreated.status} id=${rootId ?? ''}`, - ); - if (!rootId) process.exit(1); - await publishToPublic({ - token: author.token, - admin, - gameId: rootId, - gameRevision: rootCreated.data?.publicationRevision ?? 0, - metadata: rootMeta, - stamp, - tag: 'root', - label: '根作品 R', - quiet: true, - }); - check( - '根作品 R 已公开可读', - (await publicDetail(rootId)).data?.status === 'published', - 'status=published', - ); - - // 派生作品 C:需要 R 已公开 + 授权非禁止 + R 的当前公开版本 ID - const promote = await api( - `/api/game-distribution/games/${rootId}/fork-authorization`, - { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `theme-promote-${stamp}` }, - body: { - expectedForkAuthorization: 'forbidden', - forkAuthorization: 'nonCommercial', - }, - }, - ); - check( - '根作品 R 共创授权提升为 nonCommercial(衍生作品前提)', - promote.status === 200, - brief(promote), - ); - const rootVersions = - (await ownerGame(author.token, rootId)).game?.versions ?? []; - const rootPublicVersionId = - rootVersions.find((version) => version.status === 'published')?.versionId ?? - ''; - const childMeta = gameMetadata({ - title: fixtureTitle('主题衍生作品', suffix), - coverAssetId: await uploadCover(author.token, `${stamp}-child`), - }); - const childCreated = await createGame({ - token: author.token, - metadata: childMeta, - idemKey: `theme-child-${stamp}`, - fork: { parentGameId: rootId, parentVersionId: rootPublicVersionId }, - }); - const childId = childCreated.data?.id; - check( - '衍生作品 C(带改编声明)创建成功', - childCreated.status === 200 && Boolean(childId), - `status=${childCreated.status} id=${childId ?? ''} parentVersion=${rootPublicVersionId}`, - ); - if (!childId) process.exit(1); - await publishToPublic({ - token: author.token, - admin, - gameId: childId, - gameRevision: childCreated.data?.publicationRevision ?? 0, - metadata: childMeta, - stamp, - tag: 'child', - label: '衍生作品 C', - quiet: true, - // 衍生作品必填「本次核心改动说明」,母版忽略该字段。 - changeSummary: 'E2E:主题用例里的衍生作品核心改动说明', - }); - check( - '衍生作品 C 已公开可读', - (await publicDetail(childId)).data?.status === 'published', - 'status=published', - ); - - const secondRootMeta = gameMetadata({ - title: fixtureTitle('主题第二个根', suffix), - coverAssetId: await uploadCover(author.token, `${stamp}-root2`), - }); - const secondRootCreated = await createGame({ - token: author.token, - metadata: secondRootMeta, - idemKey: `theme-root2-${stamp}`, - }); - const secondRootId = secondRootCreated.data?.id; - if (!secondRootId) { - check('第二个根作品 R2 创建成功', false, brief(secondRootCreated)); - process.exit(1); - } - await publishToPublic({ - token: author.token, - admin, - gameId: secondRootId, - gameRevision: secondRootCreated.data?.publicationRevision ?? 0, - metadata: secondRootMeta, - stamp, - tag: 'root2', - label: '根作品 R2', - quiet: true, - }); - - // ---------- 1:建主题 → 加根成员 → 公开读 ---------- - const themeT1 = await adminCreateTheme({ - admin, - key: `theme-t1-${stamp}`, - body: { - name: `主题 T1 ${suffix}`, - summary: '主题 T1 简介', - badge: 'T1', - sortOrder: 1, - status: 'published', - }, - }); - const t1Id = themeT1.data?.theme?.themeId; - check( - '1) 后台创建已发布主题 T1 成功', - themeT1.status === 200 && - Boolean(t1Id) && - themeT1.data?.theme?.status === 'published' && - themeT1.data?.replayed === false, - brief(themeT1), - ); - if (!t1Id) process.exit(1); - const memberPut = await adminPutMember({ - admin, - themeId: t1Id, - rootGameId: rootId, - sortOrder: 5, - }); - check( - '1) 加根成员 R 成功(成员写入不要求幂等键)', - memberPut.status === 200 && - memberPut.data?.themeId === t1Id && - memberPut.data?.rootGameId === rootId && - memberPut.data?.sortOrder === 5, - brief(memberPut), - ); - const listWithT1 = await publicThemes({ limit: 50 }); - check( - '1) 公开列表只含已发布主题且含 T1、memberCount=1', - listWithT1.status === 200 && - themeIdsOf(listWithT1).includes(t1Id) && - (listWithT1.data?.themes ?? []).every( - (theme) => theme.status === undefined, - ) && - (listWithT1.data?.themes ?? []).find((theme) => theme.themeId === t1Id) - ?.memberCount === 1, - `themes=${themeIdsOf(listWithT1).length} t1MemberCount=${ - (listWithT1.data?.themes ?? []).find((theme) => theme.themeId === t1Id) - ?.memberCount ?? 'n/a' - }`, - ); - const detailT1 = await publicThemeDetail(t1Id); - const catalogEntry = ( - await api('/api/game-distribution/games') - ).data?.games?.find((game) => game.id === rootId); - check( - '1) 公开详情 roots 逐条是公开作品摘要且 memberCount 与可见成员数一致', - detailT1.status === 200 && - detailT1.data?.roots?.length === 1 && - detailT1.data?.roots?.[0]?.id === rootId && - detailT1.data?.memberCount === detailT1.data?.roots?.length && - JSON.stringify(Object.keys(detailT1.data.roots[0]).sort()) === - JSON.stringify(Object.keys(catalogEntry ?? {}).sort()), - `roots=${detailT1.data?.roots?.length} memberCount=${detailT1.data?.memberCount} rootKeys=${Object.keys(detailT1.data?.roots?.[0] ?? {}).length} catalogKeys=${Object.keys(catalogEntry ?? {}).length}`, - ); - check( - '1) 公开详情顶层键集合恰为契约的七个(含 rootsTruncated)', - JSON.stringify(Object.keys(detailT1.data ?? {}).sort()) === - JSON.stringify( - [ - 'badge', - 'memberCount', - 'name', - 'roots', - 'rootsTruncated', - 'summary', - 'themeId', - ].sort(), - ), - `keys=${Object.keys(detailT1.data ?? {}).join(',')}`, - ); - check( - '1) 小主题下 rootsTruncated 是布尔且为 false(memberCount === roots.length 只在这种小主题下成立)', - typeof detailT1.data?.rootsTruncated === 'boolean' && - detailT1.data?.rootsTruncated === false && - detailT1.data?.memberCount === detailT1.data?.roots?.length, - `rootsTruncated=${JSON.stringify(detailT1.data?.rootsTruncated)} memberCount=${detailT1.data?.memberCount} roots=${detailT1.data?.roots?.length}`, - ); - - // ---------- 2:只允许根 ---------- - const notRoot = await adminPutMember({ - admin, - themeId: t1Id, - rootGameId: childId, - }); - check( - '2) 第 1 代子作品当成员 → 409 THEME_MEMBER_NOT_ROOT', - notRoot.status === 409 && errorCode(notRoot) === 'THEME_MEMBER_NOT_ROOT', - brief(notRoot), - ); - const memberGameMissing = await adminPutMember({ - admin, - themeId: t1Id, - rootGameId: `game_${'0'.repeat(32)}`, - }); - check( - '2) 不存在的 gameId 当成员 → 404 THEME_MEMBER_GAME_NOT_FOUND', - memberGameMissing.status === 404 && - errorCode(memberGameMissing) === 'THEME_MEMBER_GAME_NOT_FOUND', - brief(memberGameMissing), - ); - - // ---------- 3:跨主题多归属 ---------- - const themeT2 = await adminCreateTheme({ - admin, - key: `theme-t2-${stamp}`, - body: { - name: `主题 T2 ${suffix}`, - summary: '主题 T2 简介', - badge: 'T2', - sortOrder: 2, - status: 'published', - }, - }); - const t2Id = themeT2.data?.theme?.themeId; - check('3) 创建第二个已发布主题 T2 成功', Boolean(t2Id), brief(themeT2)); - if (!t2Id) process.exit(1); - await adminPutMember({ admin, themeId: t2Id, rootGameId: rootId }); - const detailT2 = await publicThemeDetail(t2Id); - check( - '3) 同一个根加入两个主题 → 两条主题都能读到它', - detailT2.data?.roots?.some((root) => root.id === rootId) && - detailT1.data?.roots?.some((root) => root.id === rootId), - `t1Roots=${detailT1.data?.roots?.length} t2Roots=${detailT2.data?.roots?.length}`, - ); - - // ---------- 4:作品详情 themes 口径(含衍生作品) ---------- - const rootDetailAnonymous = await publicDetail(rootId); - const rootDetailAuthed = await publicDetail(rootId, author.token); - const childDetailAnonymous = await publicDetail(childId); - const rootThemes = rootDetailAnonymous.data?.themes ?? []; - const childThemes = childDetailAnonymous.data?.themes ?? []; - check( - '4) 根作品详情的 themes 数组长度为 2(匿名与登录都发)', - Array.isArray(rootDetailAnonymous.data?.themes) && - Array.isArray(rootDetailAuthed.data?.themes) && - rootThemes.length === 2 && - rootThemes - .map((theme) => theme.themeId) - .sort() - .join(',') === [t1Id, t2Id].sort().join(','), - `anonymous=${JSON.stringify(rootThemes.map((theme) => theme.themeId))} authed=${JSON.stringify((rootDetailAuthed.data?.themes ?? []).map((theme) => theme.themeId))}`, - ); - check( - '4) 衍生作品(第 1 代)也返回其根所属主题(口径:先取根再反查)', - childThemes.length === 2 && - childThemes - .map((theme) => theme.themeId) - .sort() - .join(',') === [t1Id, t2Id].sort().join(','), - `childThemes=${JSON.stringify(childThemes.map((theme) => theme.themeId))}`, - ); - check( - '4) themes 条目的形状是 {themeId,name,badge}', - rootThemes.length > 0 && - JSON.stringify(Object.keys(rootThemes[0]).sort()) === - JSON.stringify(['badge', 'name', 'themeId']), - `keys=${Object.keys(rootThemes[0] ?? {}).join(',')}`, - ); - check( - '4) 没有所属主题的作品返回空数组(不是缺键)', - (await publicDetail(secondRootId)).data?.themes?.length === 0, - `secondRootThemes=${JSON.stringify((await publicDetail(secondRootId)).data?.themes ?? null)}`, - ); - - // ---------- 5:可见性 ---------- - const draftTheme = await adminCreateTheme({ - admin, - key: `theme-draft-${stamp}`, - body: { - name: `主题草稿 ${suffix}`, - summary: '草稿主题', - badge: 'D', - sortOrder: 9, - status: 'draft', - }, - }); - const draftId = draftTheme.data?.theme?.themeId; - check('5) 创建草稿主题成功', Boolean(draftId), brief(draftTheme)); - await adminPutMember({ admin, themeId: draftId, rootGameId: rootId }); - const draftPublic = await publicThemeDetail(draftId); - check( - '5) 草稿主题公开详情 → 404(不返回空壳)', - draftPublic.status === 404 && errorText(draftPublic).includes('主题不存在'), - brief(draftPublic), - ); - const adminListWithDraft = await adminListThemes({ admin, status: 'draft' }); - check( - '5) 后台列表按 draft 过滤仍能看到该草稿主题(公开侧看不见、后台可见)', - adminListWithDraft.status === 200 && - themeIdsOf({ data: adminListWithDraft.data }).includes(draftId), - `draftThemes=${themeIdsOf({ data: adminListWithDraft.data }).length}`, - ); - - const archived = await adminUpdateTheme({ - admin, - themeId: t1Id, - key: `theme-archive-${stamp}`, - body: { - name: `主题 T1 ${suffix}`, - summary: '主题 T1 简介', - badge: 'T1', - sortOrder: 1, - status: 'archived', - }, - }); - check( - '5) 主题改为 archived 成功', - archived.status === 200 && archived.data?.theme?.status === 'archived', - brief(archived), - ); - const archivedPublic = await publicThemeDetail(t1Id); - check( - '5) archived 主题公开详情 → 404;且其所属作品的 themes 也不含它', - archivedPublic.status === 404 && - !((await publicDetail(rootId)).data?.themes ?? []).some( - (theme) => theme.themeId === t1Id, - ), - brief(archivedPublic), - ); - const republished = await adminUpdateTheme({ - admin, - themeId: t1Id, - key: `theme-republish-${stamp}`, - body: { - name: `主题 T1 ${suffix}`, - summary: '主题 T1 简介', - badge: 'T1', - sortOrder: 1, - status: 'published', - }, - }); - check( - '5) 主题改回 published 后公开详情恢复 200', - republished.status === 200 && - (await publicThemeDetail(t1Id)).status === 200, - brief(republished), - ); - - // 已发布但成员全不可见 → 200 + 空 roots;成员下架 → 消失,重新公开 → 自动回来 - const emptyTheme = await adminCreateTheme({ - admin, - key: `theme-empty-${stamp}`, - body: { - name: `主题空成员 ${suffix}`, - summary: '成员不可见的主题', - badge: 'E', - sortOrder: 3, - status: 'published', - }, - }); - const emptyThemeId = emptyTheme.data?.theme?.themeId; - await adminPutMember({ - admin, - themeId: emptyThemeId, - rootGameId: secondRootId, - }); - const beforeUnpublish = await publicThemeDetail(emptyThemeId); - check( - '5) 成员均可见时 memberCount=1', - beforeUnpublish.data?.memberCount === 1 && - beforeUnpublish.data?.roots?.[0]?.id === secondRootId, - `memberCount=${beforeUnpublish.data?.memberCount}`, - ); - const secondRootNow = await ownerGame(author.token, secondRootId); - const unpublishSecondRoot = await api( - `/api/game-distribution/games/${secondRootId}/unpublish`, - { - method: 'POST', - token: author.token, - headers: { 'Idempotency-Key': `theme-unpublish-root2-${stamp}` }, - body: { - expectedPublicationRevision: - secondRootNow.game?.publicationRevision ?? 0, - }, - }, - ); - check( - '5) 成员作品 R2 下架成功', - unpublishSecondRoot.status === 200, - brief(unpublishSecondRoot), - ); - const emptyAfterUnpublish = await publicThemeDetail(emptyThemeId); - check( - '5) 成员下架后:已发布主题仍是 200 + 空 roots + memberCount=0(不返回空壳 404)', - emptyAfterUnpublish.status === 200 && - emptyAfterUnpublish.data?.roots?.length === 0 && - emptyAfterUnpublish.data?.memberCount === 0, - `status=${emptyAfterUnpublish.status} roots=${emptyAfterUnpublish.data?.roots?.length} memberCount=${emptyAfterUnpublish.data?.memberCount}`, - ); - await publishToPublic({ - token: author.token, - admin, - gameId: secondRootId, - gameRevision: - (await ownerGame(author.token, secondRootId)).game?.publicationRevision ?? - 0, - metadata: secondRootMeta, - stamp, - tag: 'root2-v2', - label: '成员 R2 重新公开', - quiet: true, - }); - const restored = await publicThemeDetail(emptyThemeId); - check( - '5) 成员重新公开后该根自动回到 roots(行未删除,只读侧过滤)且仍只有一行', - restored.status === 200 && - restored.data?.roots?.length === 1 && - restored.data?.roots?.[0]?.id === secondRootId && - restored.data?.memberCount === 1, - `roots=${restored.data?.roots?.length} memberCount=${restored.data?.memberCount}`, - ); - - // ---------- 6:分页 ---------- - const paginationThemeIds = []; - for (let index = 0; index < 51; index += 1) { - const created = await adminCreateTheme({ - admin, - key: `theme-pag-${index}-${stamp}`, - body: { - name: `主题分页 ${suffix}-${index}`, - summary: '分页用例', - badge: 'P', - sortOrder: index, - status: 'published', - }, - }); - if (created.status !== 200) { - check( - '6) 分页用例主题创建成功', - false, - `index=${index} ${brief(created)}`, - ); - } - paginationThemeIds.push(created.data?.theme?.themeId); - } - check( - '6) 51 个已发布主题创建完成(用于默认页大小 / 上限截断 / 游标翻页)', - paginationThemeIds.every(Boolean), - `created=${paginationThemeIds.filter(Boolean).length}`, - ); - - const defaultPage = await publicThemes(); - check( - '6) 不给 limit → 默认 20 条且给出 nextCursor', - defaultPage.status === 200 && - defaultPage.data?.themes?.length === 20 && - typeof defaultPage.data?.nextCursor === 'string' && - defaultPage.data.nextCursor.length > 0, - `size=${defaultPage.data?.themes?.length} nextCursor=${JSON.stringify(defaultPage.data?.nextCursor ?? null).slice(0, 40)}`, - ); - const hugeLimit = await publicThemes({ limit: 999 }); - check( - '6) limit=999 → 截断到上限 50(既不是错误,也不是全量)', - hugeLimit.status === 200 && hugeLimit.data?.themes?.length === 50, - `size=${hugeLimit.data?.themes?.length}`, - ); - - const collectedIds = []; - let cursor = undefined; - let pages = 0; - let lastCursor = 'unset'; - for (;;) { - const page = await publicThemes({ - limit: 20, - ...(cursor ? { cursor } : {}), - }); - if (page.status !== 200) { - check('6) 游标翻页每一页都 200', false, brief(page)); - break; - } - pages += 1; - collectedIds.push(...themeIdsOf(page)); - lastCursor = page.data?.nextCursor ?? null; - if (!lastCursor) break; - cursor = lastCursor; - if (pages > 20) { - check('6) 游标翻页在合理页数内结束', false, `pages=${pages}`); - break; - } - } - const uniqueIds = new Set(collectedIds); - check( - '6) 游标翻页不重复(所有 themeId 唯一)', - uniqueIds.size === collectedIds.length, - `pages=${pages} total=${collectedIds.length} unique=${uniqueIds.size}`, - ); - check( - '6) 游标翻页不漏(本轮的 51 个分页主题 + T1/T2/空成员主题都能翻到)', - paginationThemeIds.every((id) => uniqueIds.has(id)) && - uniqueIds.has(t1Id) && - uniqueIds.has(t2Id) && - uniqueIds.has(emptyThemeId), - `missing=${[...paginationThemeIds, t1Id, t2Id, emptyThemeId].filter((id) => id && !uniqueIds.has(id)).length}`, - ); - check( - '6) 末页 nextCursor 为 null', - lastCursor === null, - `lastCursor=${JSON.stringify(lastCursor)}`, - ); - - const badCursor = await publicThemes({ cursor: 'abc' }); - check( - '6) ?cursor=abc → 400(状态码符合契约)', - badCursor.status === 400, - brief(badCursor), - ); - check( - '6) ?cursor=abc → 稳定错误码 THEME_INVALID_CURSOR(此前红断言,本轮应转绿)', - errorCode(badCursor) === 'THEME_INVALID_CURSOR', - brief(badCursor), - ); - note( - '该断言是上一轮标红的产品缺口回归网:模块侧现在把常量前缀拼进游标错误文案' + - '(module-game-distribution/src/theme.rs 的 `GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE` + 「: 主题列表游标格式无效」),' + - 'api-server 的 `map_spacetime_error` 前缀分支因此可达,客户端拿到的 error.code 从 BAD_REQUEST 变成 THEME_INVALID_CURSOR(状态码仍是 400)。', - ); - - // ---------- 7:后台权限 ---------- - const anonymousAdminCalls = [ - ['GET 列表', () => adminListThemes({})], - [ - 'POST 创建', - () => - api('/admin/api/game-distribution/themes', { - method: 'POST', - headers: { 'Idempotency-Key': `theme-anon-${stamp}` }, - body: { name: `匿名主题 ${suffix}`, status: 'draft' }, - }), - ], - [ - 'PUT 更新', - () => - api(`/admin/api/game-distribution/themes/${t1Id}`, { - method: 'PUT', - headers: { 'Idempotency-Key': `theme-anon-upd-${stamp}` }, - body: { - name: `匿名改 ${suffix}`, - summary: '', - badge: '', - sortOrder: 0, - status: 'draft', - }, - }), - ], - [ - 'PUT 成员', - () => - api(`/admin/api/game-distribution/themes/${t1Id}/members/${rootId}`, { - method: 'PUT', - body: { sortOrder: 0 }, - }), - ], - [ - 'DELETE 成员', - () => - api(`/admin/api/game-distribution/themes/${t1Id}/members/${rootId}`, { - method: 'DELETE', - }), - ], - ]; - for (const [label, call] of anonymousAdminCalls) { - const response = await call(); - check( - `7) 未登录后台 ${label} → 401`, - response.status === 401, - brief(response), - ); - } - const strangerAdmin = await api('/admin/api/game-distribution/themes', { - token: stranger.token, - }); - check( - '7) 普通作者 token 访问后台主题列表 → 403', - strangerAdmin.status === 403, - brief(strangerAdmin), - ); - - const memberPutAgain = await adminPutMember({ - admin, - themeId: t1Id, - rootGameId: rootId, - sortOrder: 7, - }); - const detailAfterRepeat = await publicThemeDetail(t1Id); - check( - '7) 重复 PUT 同一成员只留一行(roots 仍 1 条)且 sortOrder 被更新', - memberPutAgain.status === 200 && - memberPutAgain.data?.sortOrder === 7 && - memberPutAgain.data?.createdAt === memberPut.data?.createdAt && - detailAfterRepeat.data?.roots?.length === 1 && - detailAfterRepeat.data?.memberCount === 1, - `createdAt(第一次)=${memberPut.data?.createdAt} createdAt(复传)=${memberPutAgain.data?.createdAt} roots=${detailAfterRepeat.data?.roots?.length}`, - ); - const deleteExisting = await adminDeleteMember({ - admin, - themeId: t2Id, - rootGameId: rootId, - }); - const deleteExistingAgain = await adminDeleteMember({ - admin, - themeId: t2Id, - rootGameId: rootId, - }); - const deleteNeverAdded = await adminDeleteMember({ - admin, - themeId: t2Id, - rootGameId: secondRootId, - }); - check( - '7) DELETE 成员幂等:已删再删 data 相同;删不存在的成员同样 200 且同形状', - deleteExisting.status === 200 && - JSON.stringify(deleteExisting.data) === - JSON.stringify(deleteExistingAgain.data) && - deleteNeverAdded.status === 200 && - JSON.stringify(Object.keys(deleteNeverAdded.data ?? {}).sort()) === - JSON.stringify(['rootGameId', 'themeId']), - `first=${JSON.stringify(deleteExisting.data)} again=${JSON.stringify(deleteExistingAgain.data)} never=${JSON.stringify(deleteNeverAdded.data)}`, - ); - note( - 'DELETE 的「逐字节相同」指 data 载荷:平台信封里的 meta.requestId 每个请求都不同(既有协议),' + - '因此比较的是 data 而非整段响应文本。', - ); - - // ---------- 8:后台幂等 ---------- - const idempotencyKey = `theme-idem-${stamp}`; - const idemBody = { - name: `主题幂等 ${suffix}`, - summary: '同键重放', - badge: 'I', - sortOrder: 4, - status: 'published', - }; - const idemFirst = await adminCreateTheme({ - admin, - key: idempotencyKey, - body: idemBody, - }); - const idemReplay = await adminCreateTheme({ - admin, - key: idempotencyKey, - body: idemBody, - }); - const idemThemeId = idemFirst.data?.theme?.themeId; - const adminAll = await adminListThemes({ admin, limit: 200 }); - const sameNameCount = (adminAll.data?.themes ?? []).filter( - (theme) => theme.name === idemBody.name, - ).length; - check( - '8) 同键重放 → replayed=true 且 themeId 相同', - idemFirst.status === 200 && - idemReplay.status === 200 && - idemFirst.data?.replayed === false && - idemReplay.data?.replayed === true && - idemReplay.data?.theme?.themeId === idemThemeId, - `first=${idemFirst.data?.replayed} replay=${idemReplay.data?.replayed}`, - ); - check( - '8) 同键重放不产生第二个主题(同名主题在后台列表中只有 1 条)', - sameNameCount === 1, - `sameName=${sameNameCount}`, - ); - const idemConflict = await adminCreateTheme({ - admin, - key: idempotencyKey, - body: { ...idemBody, name: `主题幂等改名 ${suffix}` }, - }); - check( - '8) 同键换请求 → 409 THEME_IDEMPOTENCY_CONFLICT', - idemConflict.status === 409 && - errorCode(idemConflict) === 'THEME_IDEMPOTENCY_CONFLICT', - brief(idemConflict), - ); - const missingKey = await api('/admin/api/game-distribution/themes', { - method: 'POST', - token: admin, - body: { name: `缺键主题 ${suffix}`, status: 'draft' }, - }); - check( - '8) 创建主题缺 Idempotency-Key → 400', - missingKey.status === 400 && errorCode(missingKey) === 'BAD_REQUEST', - brief(missingKey), - ); - - // ---------- 9:公开路由匿名可读 + no-store ---------- - for (const [label, pathname] of [ - ['主题列表', '/api/game-distribution/themes'], - ['主题详情', `/api/game-distribution/themes/${t1Id}`], - ['作品详情', `/api/game-distribution/games/${rootId}`], - ]) { - const response = await api(pathname); - check( - `9) 匿名 ${label} 200 且带 Cache-Control: no-store`, - response.status === 200 && response.cacheControl.includes('no-store'), - `status=${response.status} cache-control=${response.cacheControl || '∅'}`, - ); - } - - // ---------- 10:后台成员名单(含不可见成员 / 草稿主题 / 分页 / 错误码) ---------- - // 契约:`admin_list_theme_members`(不套公开可见性过滤、draft/archived 也能读)、 - // `totalMembers` 是成员行总数(不受分页影响)、`visibility ∈ missing|deleted|published|unpublished|suspended`、 - // `visible` 复用公开侧同一份判定、分页沿用主题列表的 20/50 与游标解析。 - const bulkTheme = await adminCreateTheme({ - admin, - key: `theme-bulk-${stamp}`, - body: { - name: `主题批量成员 ${suffix}`, - summary: '成员行数 > 50、可见成员为 0', - badge: 'B', - sortOrder: 8, - status: 'published', - }, - }); - const bulkThemeId = bulkTheme.data?.theme?.themeId; - check('10) 批量成员主题创建成功', Boolean(bulkThemeId), brief(bulkTheme)); - - // 用「未公开草稿作品」当成员:可见成员恒为 0,但成员行数 > 50,用来验证 - // memberCount 只数可见成员、后台名单 totalMembers 数行且分页可翻。 - let reusableCoverAssetId = await uploadCover(author.token, `${stamp}-bulk`); - const bulkRootIds = []; - let reuseNoted = false; - for (let index = 0; index < 51; index += 1) { - const title = fixtureTitle('批量成员根作品', `${suffix}-${index}`); - let created = await createGame({ - token: author.token, - metadata: gameMetadata({ title, coverAssetId: reusableCoverAssetId }), - idemKey: `theme-bulk-root-${index}-${stamp}`, - }); - if (created.status !== 200) { - if (!reuseNoted) { - note( - `封面 assetId 不能跨作品复用时改为逐件上传(首次失败:${brief(created)})`, - ); - reuseNoted = true; - } - reusableCoverAssetId = await uploadCover( - author.token, - `${stamp}-bulk-${index}`, - ); - created = await createGame({ - token: author.token, - metadata: gameMetadata({ title, coverAssetId: reusableCoverAssetId }), - idemKey: `theme-bulk-root-retry-${index}-${stamp}`, - }); - } - const id = created.data?.id; - if (!id) { - check( - '10) 批量根作品创建成功', - false, - `index=${index} ${brief(created)}`, - ); - break; - } - bulkRootIds.push(id); - const memberResponse = await adminPutMember({ - admin, - themeId: bulkThemeId, - rootGameId: id, - sortOrder: index, - }); - if (memberResponse.status !== 200) { - check( - '10) 批量成员写入成功', - false, - `index=${index} ${brief(memberResponse)}`, - ); - break; - } - } - check( - '10) 51 个未公开根作品加入同一主题(成员行数 > 50)', - bulkRootIds.length === 51, - `rows=${bulkRootIds.length}`, - ); - - const bulkPublicDetail = await publicThemeDetail(bulkThemeId); - check( - '10) memberCount 只数可见成员:51 行不可见成员 → memberCount=0、roots=[]、rootsTruncated=false', - bulkPublicDetail.status === 200 && - bulkPublicDetail.data?.memberCount === 0 && - bulkPublicDetail.data?.roots?.length === 0 && - bulkPublicDetail.data?.rootsTruncated === false, - `memberCount=${bulkPublicDetail.data?.memberCount} roots=${bulkPublicDetail.data?.roots?.length} rootsTruncated=${JSON.stringify(bulkPublicDetail.data?.rootsTruncated)}`, - ); - note( - '未覆盖(成本):>50 个**可见**成员导致 roots 真被截断、rootsTruncated=true 的真实数据用例——' + - '需要 51 个走完整发布链路的公开作品。该分支由 module-game-distribution/src/theme.rs 里 ' + - '`game_distribution_theme_roots_truncated` 的单测(51/50 → true)覆盖;本脚本只覆盖了' + - '「行数 >50、可见 0」的对照面(rootsTruncated=false)。', - ); - - // 软删除成员:行仍在 → visible=false 且 visibility=deleted - const softDeletedRoot = await createGame({ - token: author.token, - metadata: gameMetadata({ - title: fixtureTitle('软删除成员', suffix), - coverAssetId: await uploadCover(author.token, `${stamp}-soft`), - }), - idemKey: `theme-soft-root-${stamp}`, - }); - const softDeletedRootId = softDeletedRoot.data?.id; - if (!softDeletedRootId) { - check('10) 软删除 fixture 作品创建成功', false, brief(softDeletedRoot)); - } - const softDeleted = softDeletedRootId - ? await softDeleteGame({ - token: author.token, - gameId: softDeletedRootId, - revision: softDeletedRoot.data?.publicationRevision ?? 0, - key: `theme-soft-delete-${stamp}`, - }) - : { status: 0, text: 'fixture 未创建' }; - if (softDeletedRootId) { - await adminPutMember({ - admin, - themeId: bulkThemeId, - rootGameId: softDeletedRootId, - sortOrder: 100, - }); - } - check( - '10) 软删除 fixture 作品就绪', - softDeleted.status === 200, - brief(softDeleted), - ); - - const bulkMembers = await adminThemeMembers({ - admin, - themeId: bulkThemeId, - limit: 999, - }); - const bulkRows = bulkMembers.data?.members ?? []; - check( - '10) 行数 >50 时 limit=999 截断到 50(不是错误),totalMembers 仍是行总数 52,且仍有下一页', - bulkMembers.status === 200 && - bulkRows.length === 50 && - bulkMembers.data?.totalMembers === 52 && - typeof bulkMembers.data?.nextCursor === 'string', - `rows=${bulkRows.length} totalMembers=${bulkMembers.data?.totalMembers} nextCursor=${JSON.stringify(bulkMembers.data?.nextCursor ?? null).slice(0, 30)}`, - ); - check( - '10) totalMembers 不受分页影响:limit=1 时 members 只有 1 行但 totalMembers 仍为 52', - (await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data - ?.totalMembers === 52 && - (await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data - ?.members?.length === 1, - `totalMembers=${(await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data?.totalMembers}`, - ); - const unpublishedRow = bulkRows.find( - (member) => member.rootGameId === bulkRootIds[0], - ); - check( - '10) 未公开成员照实返回:visible=false 且 visibility=unpublished', - unpublishedRow?.visible === false && - unpublishedRow?.visibility === 'unpublished', - `visible=${JSON.stringify(unpublishedRow?.visible)} visibility=${unpublishedRow?.visibility}`, - ); - check( - '10) 成员行键集合恰为契约的六个(visible 与 visibility 都在)', - bulkRows.length > 0 && - JSON.stringify(Object.keys(bulkRows[0]).sort()) === - JSON.stringify( - [ - 'createdAt', - 'rootGameId', - 'sortOrder', - 'title', - 'visibility', - 'visible', - ].sort(), - ), - `keys=${Object.keys(bulkRows[0] ?? {}).join(',')}`, - ); - check( - '10) 成员行按 sortOrder 升序返回', - bulkRows.every( - (row, index) => - index === 0 || bulkRows[index - 1].sortOrder <= row.sortOrder, - ), - `sortOrders=${bulkRows - .slice(0, 5) - .map((row) => row.sortOrder) - .join(',')}…`, - ); - - const memberPageIds = []; - const memberRows = []; - let memberCursor; - let memberPages = 0; - let lastMemberCursor = 'unset'; - for (;;) { - const page = await adminThemeMembers({ - admin, - themeId: bulkThemeId, - limit: 20, - ...(memberCursor ? { cursor: memberCursor } : {}), - }); - if (page.status !== 200) { - check('10) 成员名单游标翻页每页 200', false, brief(page)); - break; - } - memberPages += 1; - const pageRows = page.data?.members ?? []; - memberRows.push(...pageRows); - memberPageIds.push(...pageRows.map((row) => row.rootGameId)); - lastMemberCursor = page.data?.nextCursor ?? null; - if (!lastMemberCursor) break; - memberCursor = lastMemberCursor; - if (memberPages > 10) { - check( - '10) 成员名单游标翻页在合理页数内结束', - false, - `pages=${memberPages}`, - ); - break; - } - } - check( - '10) 成员名单游标翻页不重不漏(52 行分 3 页,末页 nextCursor=null)', - memberPages === 3 && - memberPageIds.length === 52 && - new Set(memberPageIds).size === 52 && - lastMemberCursor === null && - bulkRootIds.every((id) => memberPageIds.includes(id)), - `pages=${memberPages} total=${memberPageIds.length} unique=${new Set(memberPageIds).size} lastCursor=${JSON.stringify(lastMemberCursor)}`, - ); - const softDeletedRow = memberRows.find( - (member) => member.rootGameId === softDeletedRootId, - ); - check( - '10) 已软删除成员照实返回:visible=false 且 visibility=deleted(行未删除,末页仍能翻到)', - softDeletedRow?.visible === false && - softDeletedRow?.visibility === 'deleted', - `visible=${JSON.stringify(softDeletedRow?.visible)} visibility=${softDeletedRow?.visibility ?? '∅'}(该行 sortOrder=100,落最后一页)`, - ); - - const badMemberCursor = await adminThemeMembers({ - admin, - themeId: bulkThemeId, - cursor: 'abc', - }); - check( - '10) 成员名单非法游标 → 400 THEME_INVALID_CURSOR(与主题列表同一套解析)', - badMemberCursor.status === 400 && - errorCode(badMemberCursor) === 'THEME_INVALID_CURSOR', - brief(badMemberCursor), - ); - const missingThemeMembers = await adminThemeMembers({ - admin, - themeId: `theme_${'0'.repeat(32)}`, - }); - check( - '10) 主题不存在 → 404', - missingThemeMembers.status === 404, - brief(missingThemeMembers), - ); - const draftThemeMembers = await adminThemeMembers({ - admin, - themeId: draftId, - limit: 20, - }); - check( - '10) draft 主题也能读到成员名单(该接口存在的理由:公开侧看不见、运营要能看)', - draftThemeMembers.status === 200 && - (draftThemeMembers.data?.members ?? []).some( - (member) => member.rootGameId === rootId, - ) && - draftThemeMembers.data?.totalMembers === 1, - `status=${draftThemeMembers.status} totalMembers=${draftThemeMembers.data?.totalMembers}`, - ); - const anonymousMembers = await api( - `/admin/api/game-distribution/themes/${bulkThemeId}/members`, - ); - const strangerMembers = await api( - `/admin/api/game-distribution/themes/${bulkThemeId}/members`, - { token: stranger.token }, - ); - check( - '10) 未登录读成员名单 → 401;普通作者 token → 403', - anonymousMembers.status === 401 && strangerMembers.status === 403, - `anonymous=${anonymousMembers.status} stranger=${strangerMembers.status}`, - ); - note( - '未覆盖:`visibility=missing`(游戏行本身不存在)与「visibility=published 但无当前公开版本 → visible=false」' + - '两个防御性分支——前者需要删游戏行、后者需要在 kept-published 状态下清空当前公开版本,现有 HTTP 面都构造不出来;' + - '两者在 `game_distribution_theme_member_visibility` / `game_distribution_theme_member_visible` 的单测里覆盖。', - ); - - note( - '未覆盖:本脚本不做主题成员的可见性上限(详情 roots 受 50 条响应上限约束)与「同一个根重复加入两个主题后删除其一的读取一致性」之外的并发场景;' + - '分页排序键(created_at 倒序 + themeId 兜底)只通过「不重不漏」间接验证,未构造同微秒创建的主题对。', - ); - - // 跑批后自清理:只删本账号名下、标题带 `E2E·` 前缀且含本轮后缀的夹具(软删,可 restore)。 - const cleanup = await cleanupRunFixtures({ - api, - suffix, - owners: [ - { token: author.token, label: '作者' }, - { token: stranger.token, label: '陌生作者' }, - ], - }); - console.log( - `[theme-e2e] 夹具自清理:已软删 ${cleanup.deleted.length} 条` + - (cleanup.failures.length > 0 - ? `,失败 ${cleanup.failures.length} 条(见下)` - : ''), - ); - for (const failure of cleanup.failures) { - console.log(`[theme-e2e] 清理失败 ${JSON.stringify(failure)}`); - } - - console.log( - `[theme-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, - ); - if (failures > 0) { - process.exitCode = 1; - } -} - -/** 本轮跑批上下文(供中断路径打印残留夹具):main() 里注册账号后写入。 */ -let activeFixtureRun = null; - -main().catch(async (error) => { - console.error(`[theme-e2e] 未捕获异常:${error?.stack ?? error}`); - if (activeFixtureRun) { - await reportLeftoverFixtures(activeFixtureRun).catch(() => {}); - } - process.exitCode = 1; -}); diff --git a/scripts/e2e-stack.mjs b/scripts/e2e-stack.mjs deleted file mode 100644 index 3fc5bf118..000000000 --- a/scripts/e2e-stack.mjs +++ /dev/null @@ -1,188 +0,0 @@ -// 一次性隔离 e2e 栈:用**专用端口 + 专用数据库 + 临时 data-dir**拉起 dev.mjs,跑完把栈停掉、临时目录删掉。 -// -// 为什么需要它:e2e 脚本会造夹具,跑在用户正在用的开发栈上会把用户的库灌脏(2026-10-07 盘点: -// `genarrative-game-creator-dev` 里有 92 条夹具)。隔离栈与用户的栈**不共享数据库、不共享 -// data-dir、端口也不重叠**,因此可以随便造。 -// -// 用法(等价 npm script:`npm run e2e:run -- ` / `npm run e2e:stack`): -// node scripts/e2e-stack.mjs --suite scripts/check-game-distribution-lineage-e2e.mjs -// node scripts/e2e-stack.mjs # 只起栈(前台长驻,Ctrl-C 结束) -// 选项: -// --suite 起栈就绪后顺序跑一个或多个(可重复)suite,跑完自动停栈 -// --stack-only 只起栈(前台),不跑 suite -// --keep-data 结束后保留临时 data-dir(默认删除) -// --keep-stack 跑完 suite 也不停栈(供人工继续看) -// -// 端口/库名与 `scripts/e2e-target.mjs` 的 `E2E_ISOLATED_STACK` 一致(单一事实来源)。 - -import { spawn } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { E2E_ISOLATED_STACK } from './e2e-target.mjs'; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', -); -const argv = process.argv.slice(2); -const readFlagValues = (flag) => { - const values = []; - for (let index = 0; index < argv.length; index += 1) { - if (argv[index] === flag && argv[index + 1]) values.push(argv[index + 1]); - } - return values; -}; -const suites = readFlagValues('--suite'); -const stackOnly = argv.includes('--stack-only') || suites.length === 0; -const keepData = argv.includes('--keep-data'); -const keepStack = argv.includes('--keep-stack'); - -const dataDir = mkdtempSync(path.join(tmpdir(), 'genarrative-e2e-spacetime-')); -console.log(`[e2e-stack] 隔离栈数据目录 ${dataDir}`); - -const devArgs = [ - 'scripts/dev.mjs', - '--database', - E2E_ISOLATED_STACK.database, - '--spacetime-data-dir', - dataDir, - '--api-port', - String(E2E_ISOLATED_STACK.apiPort), - '--bgfilter-worker-port', - String(E2E_ISOLATED_STACK.bgfilterWorkerPort), - '--spacetime-port', - String(E2E_ISOLATED_STACK.spacetimePort), - '--admin-web-port', - String(E2E_ISOLATED_STACK.adminWebPort), - '--web-port', - String(E2E_ISOLATED_STACK.webPort), - '--no-interactive', -]; - -const stack = spawn(process.execPath, devArgs, { - cwd: repoRoot, - stdio: 'inherit', - env: { - ...process.env, - // **关键**:隔离栈把自己的 dev-stack 标记写到临时目录,绝不覆盖仓库根的 `.app/dev-stack.json`, - // 否则用户正在跑的那套 dev 栈会以为自己的标记被替换而自行退出(2026-10-07 实测踩到)。 - GENARRATIVE_DEV_STACK_STATE_PATH: path.join(dataDir, 'dev-stack.json'), - GENARRATIVE_ADMIN_USERNAME: - process.env.GENARRATIVE_ADMIN_USERNAME ?? - process.env.E2E_ADMIN_USER ?? - 'admin', - GENARRATIVE_ADMIN_PASSWORD: - process.env.GENARRATIVE_ADMIN_PASSWORD ?? - process.env.E2E_ADMIN_PASSWORD ?? - '123456', - }, -}); - -let stopping = false; -const stopStack = async () => { - if (stopping || keepStack) return; - stopping = true; - await new Promise((resolve) => { - if (stack.exitCode !== null || stack.signalCode !== null) { - resolve(); - return; - } - const timer = setTimeout(() => { - stack.kill('SIGKILL'); - resolve(); - }, 15_000); - stack.once('exit', () => { - clearTimeout(timer); - resolve(); - }); - stack.kill('SIGTERM'); - }); -}; -const cleanupDataDir = () => { - if (keepData) { - console.log(`[e2e-stack] 按 --keep-data 保留 ${dataDir}`); - return; - } - try { - rmSync(dataDir, { recursive: true, force: true }); - console.log(`[e2e-stack] 已删除临时数据目录 ${dataDir}`); - } catch (error) { - console.log(`[e2e-stack] 删除临时目录失败(可手工清理):${error.message}`); - } -}; - -async function waitForReady(timeoutMs = 300_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (stack.exitCode !== null || stack.signalCode !== null) { - throw new Error( - `dev 栈提前退出:code=${stack.exitCode} signal=${stack.signalCode}`, - ); - } - try { - const response = await fetch( - `http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}/healthz`, - { signal: AbortSignal.timeout(2_000) }, - ); - if (response.status === 200) return; - } catch { - // 未就绪:继续等 - } - await new Promise((resolve) => setTimeout(resolve, 1_000)); - } - throw new Error('等待隔离栈 api-server /healthz 超时'); -} - -async function runSuite(scriptPath) { - console.log(`[e2e-stack] 运行 ${scriptPath}`); - const child = spawn(process.execPath, [scriptPath], { - cwd: repoRoot, - stdio: 'inherit', - env: { - ...process.env, - E2E_API_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort}`, - E2E_WEB_BASE: `http://127.0.0.1:${E2E_ISOLATED_STACK.webPort}`, - }, - }); - const code = await new Promise((resolve) => child.once('exit', resolve)); - return code ?? 1; -} - -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - void (async () => { - await stopStack(); - cleanupDataDir(); - process.exit(130); - })(); - }); -} - -try { - await waitForReady(); - console.log( - `[e2e-stack] 隔离栈就绪:api=http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort} web=http://127.0.0.1:${E2E_ISOLATED_STACK.webPort} 库=${E2E_ISOLATED_STACK.database}`, - ); - if (stackOnly) { - console.log( - '[e2e-stack] 只起栈模式:另一个终端里用 E2E_API_BASE/E2E_WEB_BASE 指向上面的地址跑脚本;Ctrl-C 结束。', - ); - } else { - let exitCode = 0; - for (const suite of suites) { - const code = await runSuite(suite); - if (code !== 0) exitCode = code; - } - await stopStack(); - cleanupDataDir(); - process.exit(exitCode); - } -} catch (error) { - console.error(`[e2e-stack] ${error.message}`); - await stopStack(); - cleanupDataDir(); - process.exit(1); -} diff --git a/scripts/e2e-target.mjs b/scripts/e2e-target.mjs deleted file mode 100644 index f6758ff25..000000000 --- a/scripts/e2e-target.mjs +++ /dev/null @@ -1,237 +0,0 @@ -// e2e 夹具隔离支持:目标解析 + 护栏 + 夹具标题前缀 + 跑批后自清理。 -// -// 为什么需要它:2026-10-07 盘点发现,本地 e2e 脚本缺省会跟着 `.app/dev-stack.json` 打**用户正在用的 -// 开发栈**(`npm run dev` 的那套库),每个脚本每跑一轮就往用户的库里塞十几到几十条夹具 -// (`genarrative-game-creator-dev` 一度有 105 条 = 13 演示 + 92 夹具)。本模块把口径改成: -// -// 1. **默认打隔离栈**:夹具类脚本在未显式给 `E2E_API_BASE` 时使用 8188/8189/3110/3111/3112 这套 -// 专用端口(由 `npm run e2e:stack` 拉起,配套 `--database genarrative-game-creator-e2e` 与 -// 临时 data-dir),不再回落到 dev 栈标记里的 api-server。 -// 2. **护栏**:解析出的目标若与 `.app/dev-stack.json` 记录的 api-server **同源或同端口**, -// 夹具类脚本**直接拒绝启动**(exit 2),除非显式 `E2E_ALLOW_DEV_STACK=1`。 -// 3. **夹具前缀**:所有夹具标题统一带 `E2E·` 前缀(`fixtureTitle()`),盘点/清理一眼可辨, -// 不再依赖随机数字后缀。 -// 4. **跑批后自清理**:`cleanupRunFixtures()` 用本次跑批的 owner token + 标题前缀把本轮夹具 -// 软删(owner 维度 DELETE);清理失败或脚本中断时用 `reportLeftoverFixtures()` 打印待清理清单。 -// -// 只读类脚本(例如截图工具的「只截现有作品」模式)应当传 `writesFixtures: false`:它们没有理由 -// 拒打 dev 栈,默认仍跟着 `.app/dev-stack.json`。 - -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -/** 专用隔离栈(与 `npm run e2e:stack` 的端口/data-dir 一一对应)。 */ -export const E2E_ISOLATED_STACK = { - apiPort: 8188, - bgfilterWorkerPort: 8189, - spacetimePort: 3110, - adminWebPort: 3111, - webPort: 3112, - apiBase: 'http://127.0.0.1:8188', - webBase: 'http://127.0.0.1:3112', - database: 'genarrative-game-creator-e2e', -}; - -/** 夹具标题统一前缀:盘点/清理按它一眼区分(不要只靠随机后缀)。 */ -export const FIXTURE_TITLE_PREFIX = 'E2E·'; - -/** 夹具标题:`E2E·<标签> <后缀>`。 */ -export function fixtureTitle(label, suffix) { - return `${FIXTURE_TITLE_PREFIX}${label} ${suffix}`; -} - -function normalizeBase(value) { - return String(value ?? '') - .trim() - .replace(/\/+$/u, ''); -} - -function readDevStackMarker(cwd = process.cwd()) { - try { - return JSON.parse( - readFileSync(path.resolve(cwd, '.app/dev-stack.json'), 'utf8'), - ); - } catch { - return null; - } -} - -/** 容错读取 dev-stack 标记(不存在/坏 JSON 都回 null):只给日志与护栏判定用。 */ -export { readDevStackMarker }; - -function portOf(value) { - try { - return new URL(normalizeBase(value)).port; - } catch { - return ''; - } -} - -/** 解析出的目标是否就是 `.app/dev-stack.json` 记录的那套栈(同地址或同端口都算)。 */ -export function isDevStackTarget(apiBase, marker) { - const devApi = normalizeBase(marker?.services?.['api-server']?.url); - if (!devApi) return false; - const target = normalizeBase(apiBase); - if (target === devApi) return true; - const targetPort = portOf(target); - return Boolean(targetPort) && targetPort === portOf(devApi); -} - -function devStackGuardMessage({ scriptName, apiBase, devApi }) { - return [ - `${scriptName}: 目标 ${apiBase} 就是用户正在用的开发栈(.app/dev-stack.json → ${devApi})。`, - '夹具类 e2e 缺省应打隔离栈,避免把夹具写进用户的库。二选一:', - ` 1) 用隔离栈:npm run e2e:stack(缺省 api-server http://127.0.0.1:${E2E_ISOLATED_STACK.apiPort},库 ${E2E_ISOLATED_STACK.database})`, - ' 2) 明确越权:E2E_API_BASE= E2E_ALLOW_DEV_STACK=1(这会真的写进用户的库)', - ].join('\n'); -} - -/** - * 解析本次跑批的目标地址。 - * - * @param {object} options - * @param {string} options.scriptName 打印提示用 - * @param {boolean} [options.writesFixtures] 本脚本是否会往目标栈写夹具(默认 true) - * @param {boolean} [options.allowIsolatedFallback] 夹具类脚本在未显式给 E2E_API_BASE 时用隔离栈(默认 true) - * @returns {{ apiBase: string, webBase: string, devStackApi: string, devStackWeb: string, allowDevStackOverride: boolean }} - */ -export function resolveE2eTarget({ - scriptName, - writesFixtures = true, - cwd = process.cwd(), -} = {}) { - const marker = readDevStackMarker(cwd); - const devStackApi = normalizeBase(marker?.services?.['api-server']?.url); - const devStackWeb = normalizeBase(marker?.services?.web?.url); - const allowDevStackOverride = - (process.env.E2E_ALLOW_DEV_STACK ?? '').trim() === '1'; - const explicitApi = normalizeBase(process.env.E2E_API_BASE); - const explicitWeb = normalizeBase(process.env.E2E_WEB_BASE); - - const fallbackApi = writesFixtures - ? E2E_ISOLATED_STACK.apiBase - : devStackApi || E2E_ISOLATED_STACK.apiBase; - const fallbackWeb = writesFixtures - ? E2E_ISOLATED_STACK.webBase - : devStackWeb || E2E_ISOLATED_STACK.webBase; - - const apiBase = explicitApi || fallbackApi; - const webBase = explicitWeb || fallbackWeb; - - if ( - writesFixtures && - !allowDevStackOverride && - isDevStackTarget(apiBase, marker) - ) { - console.error( - devStackGuardMessage({ scriptName, apiBase, devApi: devStackApi }), - ); - process.exit(2); - } - - return { - apiBase, - webBase, - devStackApi, - devStackWeb, - allowDevStackOverride, - writesFixtures, - }; -} - -/** - * 跑批后自清理:把本轮夹具(owner 视角里标题以 `E2E·` 开头且含本次后缀的那些)逐条软删。 - * - * 只删**自己账号名下、标题命中本轮后缀**的作品:不碰演示数据,也不碰别人造的东西。 - * 清理失败不抛异常,改为返回失败清单,由调用方打印。 - * - * @param {object} options - * @param {(pathname: string, options?: object) => Promise} options.api 各脚本自带的 api()(带信封/鉴权) - * @param {Array<{ token: string, label: string }>} options.owners 本轮用到的 owner token(作者 / 分叉者) - * @param {string} options.suffix 本轮后缀(`String(Date.now()).slice(-6)`) - * @param {string} [options.titlePrefix] 夹具前缀(默认 `E2E·`) - */ -export async function cleanupRunFixtures({ - api, - owners, - suffix, - titlePrefix = FIXTURE_TITLE_PREFIX, -}) { - const failures = []; - const deleted = []; - for (const owner of owners) { - if (!owner?.token) continue; - const list = await api('/api/game-distribution/my-games', { - token: owner.token, - }); - if (list.status !== 200) { - failures.push({ - owner: owner.label, - reason: `列我的作品失败 status=${list.status}`, - }); - continue; - } - const mine = (list.data?.games ?? []).filter( - (game) => - typeof game.title === 'string' && - game.title.startsWith(titlePrefix) && - game.title.includes(suffix), - ); - for (const game of mine) { - const revision = game.publicationRevision ?? 0; - const response = await api( - `/api/game-distribution/my-games/${game.id}?expectedPublicationRevision=${revision}`, - { method: 'DELETE', token: owner.token }, - ); - if (response.status === 200) { - deleted.push({ owner: owner.label, id: game.id, title: game.title }); - } else { - failures.push({ - owner: owner.label, - id: game.id, - title: game.title, - reason: `status=${response.status} ${response.error?.code ?? ''}`, - }); - } - } - } - return { deleted, failures }; -} - -/** 中断/失败路径:把「本轮还没清掉的夹具」打印出来,便于人工补删。 */ -export async function reportLeftoverFixtures({ - api, - owners, - suffix, - titlePrefix = FIXTURE_TITLE_PREFIX, - log = console.log, -}) { - const leftovers = []; - for (const owner of owners) { - if (!owner?.token) continue; - const list = await api('/api/game-distribution/my-games', { - token: owner.token, - }); - if (list.status !== 200) continue; - for (const game of list.data?.games ?? []) { - if ( - typeof game.title === 'string' && - game.title.startsWith(titlePrefix) && - game.title.includes(suffix) - ) { - leftovers.push({ owner: owner.label, id: game.id, title: game.title }); - } - } - } - if (leftovers.length === 0) { - log(`[e2e-fixture] 本轮(后缀 ${suffix})在目标库里没有残留夹具`); - return leftovers; - } - log( - `[e2e-fixture] 本轮(后缀 ${suffix})仍有 ${leftovers.length} 条夹具未清理,可手工软删:`, - ); - for (const entry of leftovers) { - log(` - ${entry.owner} ${entry.id} ${entry.title}`); - } - return leftovers; -} diff --git a/scripts/seed-game-distribution-co-creation.mjs b/scripts/seed-game-distribution-co-creation.mjs deleted file mode 100644 index 55bf5dd0c..000000000 --- a/scripts/seed-game-distribution-co-creation.mjs +++ /dev/null @@ -1,1093 +0,0 @@ -// 往「正在运行的」本地 dev 栈里灌一批**真实**的游戏共创演示数据(多树 / 多代 / 多分支 / 多作者 / 授权档位差异)。 -// -// 只做 HTTP 调用:读 `.app/dev-stack.json` 拿实际 api-server / web / 库名 → 建作品(封面走真实直传链路)→ -// 建版本 → 传发行包 → 送审 → 管理员通过;衍生作品一律带 `fork` 声明与必填的「本次核心改动说明」。 -// **不直接写库、不停服务、不清库、不重启**。 -// -// 幂等口径:所有演示作品标题统一为 `共创演示·<节点键>|<中文名>`,脚本按「该节点的作者 + 完全相等标题」 -// 在其 `/api/game-distribution/my-games` 里查重;命中就复用(必要时补发布),不重复创建。 -// 主题按名称 `共创演示主题` 查重;收藏是幂等 PUT。 -// -// 用法: -// E2E_ADMIN_USER=admin E2E_ADMIN_PASSWORD=<密码> node scripts/seed-game-distribution-co-creation.mjs -// 可选环境变量: -// E2E_API_BASE / E2E_WEB_BASE 覆盖地址(默认读 .app/dev-stack.json) -// SEED_COVER_DIR 封面目录(默认 C:/Users/dongy/Pictures/壁纸) -// SEED_COVER_MAX_BYTES 封面单文件上限(默认 6 MiB,超过的候选跳过,避免把几十 MB 原图塞进 dev 库) -// SEED_SKIP_THEME=1 / SEED_SKIP_COLLECTIONS=1 跳过主题 / 收藏步骤 - -import { createHash } from 'node:crypto'; -import { readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; - -import JSZip from 'jszip'; - -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 DEV_PASSWORD = 'GenE2e123!'; -const GATE_KEY = 'game-distribution:publish'; -const TITLE_PREFIX = '共创演示·'; -const THEME_NAME = '共创演示主题'; -const COVER_DIR = process.env.SEED_COVER_DIR ?? 'C:/Users/dongy/Pictures/壁纸'; -const COVER_MAX_BYTES = Number( - process.env.SEED_COVER_MAX_BYTES ?? 6 * 1024 * 1024, -); - -if (!ADMIN_USER || !ADMIN_PASSWORD) { - console.error( - '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD(要开 game-distribution:publish 灰度并用管理员审核)。', - ); - process.exit(2); -} - -// 目标地址:本脚本**有意**写在用户的开发栈上(它就是「把演示数据灌进 dev 栈」的工具,幂等、判重不绑账号), -// 因此不受 `e2e-target.mjs` 的「夹具类脚本禁止打用户栈」护栏约束;要写到别处请显式给 E2E_API_BASE。 -const devStack = JSON.parse( - readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'), -); -const API = ( - process.env.E2E_API_BASE ?? - devStack.services?.['api-server']?.url ?? - '' -).replace(/\/+$/u, ''); -const WEB = ( - process.env.E2E_WEB_BASE ?? - devStack.services?.web?.url ?? - 'http://127.0.0.1:3000' -).replace(/\/+$/u, ''); -if (!API) { - console.error('无法从 .app/dev-stack.json 解析 api-server 地址。'); - process.exit(2); -} - -let checks = 0; -let failures = 0; -function check(name, ok, detail = '') { - checks += 1; - 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, - signal: AbortSignal.timeout(180_000), - }); - 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, - }; -} - -function brief(body) { - const code = body?.error?.code ?? body?.json?.error?.code ?? ''; - return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 180)}`; -} - -// ---------- 作者账号 ---------- - -const AUTHOR_SPECS = [ - { key: '甲', ascii: 'jia', phone: '13290000001', displayName: '演示作者甲' }, - { key: '乙', ascii: 'yi', phone: '13290000002', displayName: '演示作者乙' }, - { key: '丙', ascii: 'bing', phone: '13290000003', displayName: '演示作者丙' }, - { key: '丁', ascii: 'ding', phone: '13290000004', displayName: '演示作者丁' }, -]; - -async function ensureAuthor(spec) { - // 固定手机号 + 固定密码:重复运行时**登录同一批演示账号**(而不是每次新注册), - // 这样收藏/主题成员/作者名这些按账号维度的演示效果在重跑后保持一致。 - let phone = spec.phone; - let registered = await api('/api/auth/entry', { - method: 'POST', - body: { purePhoneNumber: phone, password: DEV_PASSWORD }, - }); - if (!registered.data?.token) { - // 固定号被占用且密码不同(例如别人先用过):退回随机号,保证脚本仍能跑通。 - phone = `13${spec.prefix.slice(-1)}${String(Date.now()).slice(-8)}`; - registered = await api('/api/auth/entry', { - method: 'POST', - body: { purePhoneNumber: phone, password: DEV_PASSWORD }, - }); - } - const token = registered.data?.token; - if (token) { - await api('/api/profile/me', { - method: 'PATCH', - token, - body: { displayName: spec.displayName }, - }); - } - return { ...spec, phone, token }; -} - -// ---------- 封面 ---------- - -function readImageSize(filePath) { - const buffer = readFileSync(filePath); - if (buffer[0] === 0x89 && buffer.toString('ascii', 1, 4) === 'PNG') { - return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; - } - if (buffer[0] === 0xff && buffer[1] === 0xd8) { - let index = 2; - while (index < buffer.length - 9) { - if (buffer[index] !== 0xff) { - index += 1; - continue; - } - const marker = buffer[index + 1]; - const length = buffer.readUInt16BE(index + 2); - if ( - marker >= 0xc0 && - marker <= 0xcf && - ![0xc4, 0xc8, 0xcc].includes(marker) - ) { - return { - height: buffer.readUInt16BE(index + 5), - width: buffer.readUInt16BE(index + 7), - }; - } - index += 2 + length; - } - } - return null; -} - -/// 枚举封面目录:只取横版(宽>高)且不超过上限的文件,按「体积从小到大」排序(避免把几十 MB 原图塞进 dev 库)。 -function listCoverCandidates() { - const entries = readdirSync(COVER_DIR) - .filter((name) => /\.(jpe?g|png|webp)$/iu.test(name)) - .map((name) => { - const filePath = path.join(COVER_DIR, name); - const buffer = readFileSync(filePath); - const size = readImageSize(filePath); - return { - name, - filePath, - bytes: buffer, - width: size?.width ?? 0, - height: size?.height ?? 0, - landscape: size ? size.width > size.height : true, - }; - }) - .filter( - (entry) => - entry.landscape && - entry.bytes.length > 0 && - entry.bytes.length <= COVER_MAX_BYTES, - ); - entries.sort((a, b) => a.bytes.length - b.bytes.length); - return entries; -} - -async function uploadCover({ token, cover, label }) { - const contentType = /\.png$/iu.test(cover.name) ? 'image/png' : 'image/jpeg'; - const ticket = await api('/api/assets/direct-upload-tickets', { - method: 'POST', - token, - body: { - legacyPrefix: 'generated-character-drafts', - pathSegments: [ - 'game-distribution', - 'co-creation-demo', - label, - String(Date.now()), - ], - fileName: cover.name, - contentType, - access: 'private', - maxSizeBytes: cover.bytes.length, - metadata: { asset_kind: 'game_distribution_cover' }, - }, - }); - if (ticket.status !== 200) { - return { assetObjectId: null, error: brief(ticket) }; - } - 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.bytes], { type: contentType }), - cover.name, - ); - const put = await fetch(upload.host, { method: 'POST', body: form }); - if (!put.ok) { - return { assetObjectId: null, error: `OSS PUT ${put.status}` }; - } - const confirm = await api('/api/assets/objects/confirm', { - method: 'POST', - token, - body: { - bucket: upload.bucket, - objectKey: upload.objectKey, - contentType, - contentLength: cover.bytes.length, - assetKind: 'game_distribution_cover', - accessPolicy: 'private', - entityId: 'game-distribution-co-creation', - }, - }); - if (confirm.status !== 200) { - return { assetObjectId: null, error: brief(confirm) }; - } - return { - assetObjectId: confirm.data.assetObject.assetObjectId, - objectKey: upload.objectKey, - }; -} - -// ---------- 作品 / 发布链 ---------- - -async function listMyGames(token) { - const games = []; - let cursor; - for (let page = 0; page < 10; page += 1) { - const suffix = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''; - const response = await api(`/api/game-distribution/my-games${suffix}`, { - token, - }); - if (response.status !== 200) return games; - games.push(...(response.data?.games ?? [])); - cursor = response.data?.nextCursor ?? null; - if (!cursor) break; - } - return games; -} - -/// 公开目录查重:演示作品的判重**不绑定账号**——只要公开目录里已有完全同名的一条,就复用它。 -/// 这样账号被换掉(例如上一次跑批用的是临时手机号)时也不会重复灌一份演示数据。 -async function findPublicGameByTitle(title) { - let cursor; - for (let page = 0; page < 12; page += 1) { - const suffix = cursor - ? `?limit=50&cursor=${encodeURIComponent(cursor)}` - : '?limit=50'; - const response = await api(`/api/game-distribution/games${suffix}`); - if (response.status !== 200) return null; - const found = (response.data?.games ?? []).find( - (game) => game.title === title, - ); - if (found) return found; - cursor = response.data?.nextCursor ?? null; - if (!cursor) return null; - } - return null; -} - -async function publicVersionIdOf(token, gameId) { - const games = await listMyGames(token); - const game = games.find((entry) => entry.id === gameId); - const versions = game?.versions ?? []; - return ( - versions.find((version) => version.status === 'published')?.versionId ?? '' - ); -} - -function gameMetadata({ title, coverAssetId, summary }) { - return { - title, - summary, - description: '', - category: '休闲', - tags: ['共创演示'], - coverAssetId, - deviceSupport: { desktop: true, mobile: false, touch: false }, - inputModes: ['keyboard', 'mouse'], - orientation: 'landscape', - }; -} - -async function buildReleaseZip(marker) { - const zip = new JSZip(); - zip.file( - 'index.html', - `${marker}

${marker}

`, - ); - const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); - return { bytes, fileCount: 1, sha256: hash(bytes) }; -} - -function hash(bytes) { - return createHash('sha256').update(bytes).digest('hex'); -} - -async function publishVersion({ - token, - admin, - gameId, - gameRevision, - metadata, - changeSummary, - label, - stamp, -}) { - const zip = await buildReleaseZip(`${label}-${stamp}`); - const versionBody = { - packageSha256: zip.sha256, - packageBytes: zip.bytes.length, - packageFileCount: zip.fileCount, - packageEntryPath: 'index.html', - gameMetadata: metadata, - ...(changeSummary ? { changeSummary } : {}), - }; - const created = await api(`/api/game-distribution/games/${gameId}/versions`, { - method: 'POST', - token, - headers: { 'Idempotency-Key': `seed-version-${label}-${stamp}` }, - body: versionBody, - }); - const versionId = created.data?.versionId; - if (created.status !== 200 || !versionId) { - return { ok: false, error: `建版本失败 ${brief(created)}` }; - } - const upload = await api( - `/api/game-distribution/versions/${versionId}/package`, - { - method: 'PUT', - token, - headers: { - 'Idempotency-Key': `seed-upload-${label}-${stamp}`, - 'Content-Type': 'application/zip', - }, - binary: zip.bytes, - }, - ); - if (upload.status !== 200) { - return { ok: false, error: `传包失败 ${brief(upload)}` }; - } - const submitted = await api( - `/api/game-distribution/versions/${versionId}/submit`, - { - method: 'POST', - token, - headers: { 'Idempotency-Key': `seed-submit-${label}-${stamp}` }, - body: { expectedPublicationRevision: gameRevision }, - }, - ); - if (submitted.status !== 202) { - return { ok: false, error: `送审失败 ${brief(submitted)}` }; - } - const readback = await api(`/api/game-distribution/versions/${versionId}`, { - token, - }); - const approved = await api( - `/admin/api/game-distribution/versions/${versionId}/review`, - { - method: 'POST', - token: admin, - headers: { 'Idempotency-Key': `seed-approve-${label}-${stamp}` }, - body: { - decision: 'approve', - expectedPublicationRevision: - readback.data?.version?.publicationRevision ?? gameRevision, - }, - }, - ); - if (approved.status !== 200) { - return { ok: false, error: `审核失败 ${brief(approved)}` }; - } - return { ok: true, versionId }; -} - -// ---------- 演示网络定义 ---------- - -const NODES = [ - // 树 A:A0(full) → A1(nonCommercial 子树) → A11 → A111;A12;A2(full) → A21;A3 - { - key: 'A0', - author: '甲', - name: '星海工坊', - parent: null, - tier: 'nonCommercial', - }, - { - key: 'A1', - author: '乙', - name: '星海工坊·深海回声', - parent: 'A0', - summary: '把场景换成深海遗迹,重写了光照与水流,并补了三段可探索的支线。', - }, - { - key: 'A11', - author: '丁', - name: '深海回声·潮汐秘境', - parent: 'A1', - summary: '在深海回声的支线里加了一个潮汐涨落机制,谜题改成按时辰解锁。', - }, - { - key: 'A111', - author: '丙', - name: '潮汐秘境·灯塔之夜', - parent: 'A11', - summary: '把最终关挪到灯塔夜景,补了全程配音与一段潜行玩法,难度曲线重排。', - }, - { - key: 'A12', - author: '乙', - name: '深海回声·珊瑚改良', - parent: 'A1', - summary: '重做珊瑚关卡的地形与音效,把三处跳台改成可骑乘的海龟路线。', - }, - { - key: 'A2', - author: '丙', - name: '星海工坊·轨道集市', - parent: 'A0', - summary: '把工坊搬进轨道集市,新增买卖与讨价还价玩法,界面按交易流重做。', - }, - { - key: 'A21', - author: '甲', - name: '轨道集市·夜航商队', - parent: 'A2', - summary: '加了夜航商队事件链与三段随机护送任务,重排了经济数值。', - }, - { - key: 'A3', - author: '甲', - name: '星海工坊·废弃船坞', - parent: 'A0', - summary: '场景改成废弃船坞,玩法从建造转为打捞,新增三种可拆解零件。', - }, - // 树 B:与 A 无血缘 - { key: 'B0', author: '乙', name: '青苔邮局', parent: null, tier: 'full' }, - { - key: 'B1', - author: '甲', - name: '青苔邮局·雨季分拣', - parent: 'B0', - summary: '把分拣台改成雨季湿滑的传送带,新增湿度对包裹的影响。', - }, - { - key: 'B2', - author: '丁', - name: '雨季分拣·末班列车', - parent: 'B1', - summary: '补了一趟末班列车收尾关卡,加了限时排班与两条隐藏结局。', - }, - // 树 C:专门演示「nonCommercial 子树」(母版保持 nonCommercial,子代继承同档位) - { - key: 'C0', - author: '丙', - name: '南岭茶馆', - parent: null, - tier: 'nonCommercial', - }, - { - key: 'C1', - author: '丁', - name: '南岭茶馆·雨前茶会', - parent: 'C0', - summary: '把泡茶玩法扩成雨前茶会事件,新增三款茶叶与一条老客线。', - }, -]; - -// 树 A 的母版必须在 A1 子树全部建完之后才提升成 full,否则 A1 会继承到 full(演示失真)。 -const DEFERRED_ROOT_PROMOTION = { - key: 'A0', - afterKey: 'A12', - from: 'nonCommercial', - to: 'full', -}; - -const titleOf = (node) => `${TITLE_PREFIX}${node.key}|${node.name}`; - -// ---------- 主流程 ---------- - -async function main() { - console.log( - `[seed] api-server=${API} web=${WEB} database=${devStack.database}`, - ); - console.log( - `[seed] 封面目录=${COVER_DIR}(上限 ${(COVER_MAX_BYTES / 1024 / 1024).toFixed(1)} MiB,优先横版且体积小)`, - ); - - const adminLogin = await api('/admin/api/login', { - method: 'POST', - body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, - }); - const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; - check( - '管理员登录', - adminLogin.status === 200 && Boolean(admin), - `status=${adminLogin.status}`, - ); - if (!admin) process.exit(1); - const gate = await api('/admin/api/feature-gates', { - method: 'PUT', - token: admin, - body: { - gateKey: GATE_KEY, - enabled: true, - rolloutPercent: 100, - allowUserIds: [], - allowUserTags: [], - denyUserIds: [], - description: '共创演示数据', - }, - }); - check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); - - const authors = {}; - for (const spec of AUTHOR_SPECS) { - authors[spec.key] = await ensureAuthor(spec); - } - check( - '四个演示作者账号就绪(甲/乙/丙/丁,均设了展示名)', - Object.values(authors).every((author) => Boolean(author.token)), - Object.values(authors) - .map((author) => `${author.key}=${author.phone}`) - .join(' '), - ); - - const covers = listCoverCandidates(); - check( - '封面候选足够(横版且不超过体积上限)', - covers.length >= NODES.length, - `候选=${covers.length} 需要=${NODES.length}(跳过超大原图)`, - ); - if (covers.length < NODES.length) process.exit(1); - // 「同族树尽量同色系」:不解析图片内容,只按**文件名里的题材关键词**粗分色系;同一棵树优先用同一色系的候选, - // 不足再退回剩余候选。做的是「尽量」,所以命名信息缺失时会退化成按体积顺序取,不影响正确性。 - const TONE_KEYWORDS = [ - { - tone: '自然山水', - pattern: - /山水|雪山|湖|夜空|晨曦|lake|mountain|landscape|风景|雪山|山|天空|sky|路/i, - }, - { - tone: '动漫插画', - pattern: /幸运星|卖萌|插画|动漫|二次元|星星|anime|girl/i, - }, - ]; - const toneOf = (entry) => - TONE_KEYWORDS.find((keyword) => keyword.pattern.test(entry.name))?.tone ?? - '其他'; - const remainingByTone = new Map(); - for (const entry of covers) { - const tone = toneOf(entry); - if (!remainingByTone.has(tone)) remainingByTone.set(tone, []); - remainingByTone.get(tone).push(entry); - } - const coverByNodeKey = new Map(); - for (const tree of [...new Set(NODES.map((node) => node.key[0]))]) { - const treeNodes = NODES.filter((node) => node.key[0] === tree); - // 先挑「最能覆盖这棵树」的色系(覆盖数并列时取候选更多的),再从该色系取满,不够按顺序用别的色系补。 - const ranked = [...remainingByTone.entries()] - .filter(([, list]) => list.length > 0) - .sort( - (a, b) => - Math.min(b[1].length, treeNodes.length) - - Math.min(a[1].length, treeNodes.length) || - b[1].length - a[1].length || - a[1][0].bytes.length - b[1][0].bytes.length, - ); - const picks = []; - for (const [, list] of ranked) { - while (picks.length < treeNodes.length && list.length > 0) { - picks.push(list.shift()); - } - if (picks.length >= treeNodes.length) break; - } - treeNodes.forEach((node, index) => { - if (picks[index]) coverByNodeKey.set(node.key, picks[index]); - }); - } - const coverFor = (node) => - coverByNodeKey.get(node.key) ?? covers[NODES.indexOf(node) % covers.length]; - // 「只列封面分配」模式:不动任何数据,只把「哪个节点用哪张原图」打出来(验收/排查用)。 - if ((process.env.SEED_LIST_COVERS ?? '') === '1') { - for (const node of NODES) { - const cover = coverFor(node); - console.log( - `${node.key} | ${toneOf(cover)} | ${cover.filePath} | ${(cover.bytes.length / 1024 / 1024).toFixed(2)} MiB | ${cover.width}x${cover.height}`, - ); - } - return; - } - - // 1) 逐节点创建/复用(按 父→子 顺序,保证父已公开且有当前公开版本) - const state = new Map(); - const stamp = Date.now(); - for (const node of NODES) { - const author = authors[node.author]; - const title = titleOf(node); - // 判重口径:先看**公开目录**(不绑账号 → 账号换了也不重复灌),再看本账号 my-games(处理未公开的同名作品)。 - const publicMatch = await findPublicGameByTitle(title); - let existing = publicMatch ? { id: publicMatch.id } : null; - if (existing) { - state.set(node.key, { ...node, gameId: existing.id, reused: true }); - console.log( - `[seed] 复用公开目录里的同标题作品 ${node.key}|${node.name}(${existing.id})`, - ); - continue; - } - const mine = await listMyGames(author.token); - existing = mine.find((game) => game.title === title) ?? null; - if (existing) { - console.log( - `[seed] 复用本账号的未公开作品并补发布 ${node.key}|${node.name}(${existing.id})`, - ); - const cover = coverFor(node); - const coverResult = await uploadCover({ - token: author.token, - cover, - label: `${node.key}-reuse`, - }); - const parent = node.parent ? state.get(node.parent) : null; - const published2 = await publishVersion({ - token: author.token, - admin, - gameId: existing.id, - gameRevision: existing.publicationRevision ?? 0, - metadata: gameMetadata({ - title, - coverAssetId: coverResult.assetObjectId, - summary: `共创演示:${node.name}`, - }), - changeSummary: parent ? node.summary : undefined, - label: `${node.key}-reuse`, - stamp, - }); - if (!published2.ok) { - // 既有同标题作品可能是软删除/不可发布状态:不要卡死,改为新建同标题作品(SpacetimeDB 不限制标题唯一)。 - console.log( - `[seed] 既有作品 ${node.key}(${existing.id})无法复用:${published2.error} → 改为新建同标题作品`, - ); - existing = null; - } else { - state.set(node.key, { ...node, gameId: existing.id, reused: true }); - continue; - } - } - const cover = coverFor(node); - const coverResult = await uploadCover({ - token: author.token, - cover, - label: node.key, - }); - if (!coverResult.assetObjectId) { - check( - `封面上传 ${node.key}`, - false, - `${cover.name} → ${coverResult.error}`, - ); - process.exit(1); - } - const parent = node.parent ? state.get(node.parent) : null; - const parentVersionId = parent - ? await publicVersionIdOf(authors[parent.author].token, parent.gameId) - : ''; - if (parent && !parentVersionId) { - check( - `父作品有当前公开版本 ${node.parent}`, - false, - `parent=${parent.gameId}`, - ); - process.exit(1); - } - const created = await api('/api/game-distribution/games', { - method: 'POST', - token: author.token, - headers: { 'Idempotency-Key': `seed-game-${node.key}-${stamp}` }, - body: { - ...gameMetadata({ - title, - coverAssetId: coverResult.assetObjectId, - summary: `共创演示:${node.name}`, - }), - ...(parent - ? { fork: { parentGameId: parent.gameId, parentVersionId } } - : {}), - }, - }); - const gameId = created.data?.id; - if (created.status !== 200 || !gameId) { - check(`创建作品 ${node.key}`, false, brief(created)); - process.exit(1); - } - // 母版先按目标子树档位落库(让子代继承到预期档位),随后再提升回演示档位 - if (!parent && node.tier) { - const promoted = await api( - `/api/game-distribution/games/${gameId}/fork-authorization`, - { - method: 'PUT', - token: author.token, - headers: { 'Idempotency-Key': `seed-tier-${node.key}-${stamp}` }, - body: { - expectedForkAuthorization: 'forbidden', - forkAuthorization: node.tier, - }, - }, - ); - if (promoted.status !== 200) { - check(`设定母版档位 ${node.key}`, false, brief(promoted)); - process.exit(1); - } - } - const published = await publishVersion({ - token: author.token, - admin, - gameId, - gameRevision: created.data?.publicationRevision ?? 0, - metadata: gameMetadata({ - title, - coverAssetId: coverResult.assetObjectId, - summary: `共创演示:${node.name}`, - }), - changeSummary: parent ? node.summary : undefined, - label: node.key, - stamp, - }); - if (!published.ok) { - check(`发布 ${node.key}`, false, published.error); - process.exit(1); - } - state.set(node.key, { - ...node, - gameId, - coverName: cover.filePath ?? cover.name, - reused: false, - }); - console.log( - `[seed] 新建并公开 ${node.key}|${node.name}(${node.author},封面 ${cover.name})`, - ); - - // 母版档位推迟提升:树 A 想演示「A1 子树继承 nonCommercial」,就必须等 A1/A11/A12 都建完再 - // 把 A0 提升成 full;这里按里程碑节点触发,且对「已经是目标档位」保持幂等(不报错)。 - if (node.key === DEFERRED_ROOT_PROMOTION.afterKey) { - const target = state.get(DEFERRED_ROOT_PROMOTION.key); - const owner = authors[target.author]; - const view = (await listMyGames(owner.token)).find( - (game) => game.id === target.gameId, - ); - if (view?.forkAuthorization === DEFERRED_ROOT_PROMOTION.to) { - console.log( - `[seed] ${DEFERRED_ROOT_PROMOTION.key} 已是 ${DEFERRED_ROOT_PROMOTION.to},跳过推迟提升(幂等)`, - ); - } else { - const promoted = await api( - `/api/game-distribution/games/${target.gameId}/fork-authorization`, - { - method: 'PUT', - token: owner.token, - headers: { - 'Idempotency-Key': `seed-tier-after-${DEFERRED_ROOT_PROMOTION.key}-${stamp}`, - }, - body: { - expectedForkAuthorization: DEFERRED_ROOT_PROMOTION.from, - forkAuthorization: DEFERRED_ROOT_PROMOTION.to, - }, - }, - ); - check( - `母版 ${DEFERRED_ROOT_PROMOTION.key} 推迟提升为 ${DEFERRED_ROOT_PROMOTION.to}(A1 子树已建完)`, - promoted.status === 200, - brief(promoted), - ); - } - } - } - - // 2) 逐节点回读:标题/作者/档位/generation/parentGameId - console.log(''); - console.log('节点键 | 标题 | 作者 | 档位 | generation | parentGameId | 封面'); - for (const node of NODES) { - const current = state.get(node.key); - const publicDetail = await api( - `/api/game-distribution/games/${current.gameId}`, - ); - // 档位从**公开详情**读:复用旧跑批作品时作者账号可能已经换了(owner 视角会拿不到), - // 而公开 payload 本身就带 forkAuthorization。 - const tier = publicDetail.data?.forkAuthorization ?? '?'; - const lineage = await api( - `/api/game-distribution/games/${current.gameId}/lineage`, - ); - const self = (lineage.data?.nodes ?? []).find( - (entry) => entry.gameId === current.gameId, - ); - console.log( - [ - node.key, - publicDetail.data?.title ?? '?', - node.author, - tier, - self?.generation ?? '?', - self?.parentGameId ?? 'null', - current.coverName ?? (current.reused ? '(复用时未换封面)' : '?'), - ].join(' | '), - ); - } - - // 3) 树形状断言 - const a0 = state.get('A0'); - const b0 = state.get('B0'); - const treeA = await api(`/api/game-distribution/games/${a0.gameId}/lineage`); - const treeB = await api(`/api/game-distribution/games/${b0.gameId}/lineage`); - const nodesA = treeA.data?.nodes ?? []; - check( - '树 A /lineage:truncated=false 且 rootGameId=A0', - treeA.status === 200 && - treeA.data?.truncated === false && - treeA.data?.rootGameId === a0.gameId, - `status=${treeA.status} nodes=${nodesA.length} truncated=${String(treeA.data?.truncated)}`, - ); - check( - '树 A 节点数 = 8(A0/A1/A11/A111/A12/A2/A21/A3)', - nodesA.length === 8, - `nodes=${nodesA.length} ids=${nodesA.map((node) => node.gameId.slice(-6)).join(',')}`, - ); - const expectedParents = { - A0: null, - A1: 'A0', - A11: 'A1', - A111: 'A11', - A12: 'A1', - A2: 'A0', - A21: 'A2', - A3: 'A0', - }; - const generations = { - A0: 0, - A1: 1, - A11: 2, - A111: 3, - A12: 2, - A2: 1, - A21: 2, - A3: 1, - }; - for (const node of NODES.filter((entry) => entry.key.startsWith('A'))) { - const found = nodesA.find( - (entry) => entry.gameId === state.get(node.key).gameId, - ); - const expectedParentId = expectedParents[node.key] - ? state.get(expectedParents[node.key]).gameId - : null; - check( - `树 A 节点 ${node.key}:generation=${generations[node.key]}、parent=${expectedParents[node.key] ?? 'null'}`, - found?.generation === generations[node.key] && - (expectedParentId - ? found?.parentGameId === expectedParentId - : found?.parentGameId == null), - `generation=${found?.generation ?? '∅'} parentGameId=${found?.parentGameId ?? 'null'}`, - ); - } - const derivedA = await api( - `/api/game-distribution/games/${a0.gameId}/derived`, - ); - check( - '树 A /derived:恰好 3 个直接子(A1/A2/A3)', - derivedA.status === 200 && - (derivedA.data?.nodes ?? []).length === 3 && - ['A1', 'A2', 'A3'].every((key) => - (derivedA.data?.nodes ?? []).some( - (entry) => entry.gameId === state.get(key).gameId, - ), - ), - `derived=${(derivedA.data?.nodes ?? []).map((entry) => entry.gameId.slice(-6)).join(',')}`, - ); - check( - '树 B /lineage:3 个节点、truncated=false、根=B0', - treeB.status === 200 && - (treeB.data?.nodes ?? []).length === 3 && - treeB.data?.truncated === false && - treeB.data?.rootGameId === b0.gameId, - `nodes=${(treeB.data?.nodes ?? []).length}`, - ); - check( - '两棵树互不串血(A0 的树里没有 B 系节点,反之亦然)', - !nodesA.some((node) => node.gameId === b0.gameId) && - !(treeB.data?.nodes ?? []).some((node) => node.gameId === a0.gameId), - ); - const c0 = state.get('C0'); - const treeC = await api(`/api/game-distribution/games/${c0.gameId}/lineage`); - const nodesC = treeC.data?.nodes ?? []; - check( - '树 C /lineage:2 个节点、truncated=false、根=C0、C1 generation=1', - treeC.status === 200 && - nodesC.length === 2 && - treeC.data?.truncated === false && - treeC.data?.rootGameId === c0.gameId && - nodesC.find((node) => node.gameId === state.get('C1').gameId) - ?.generation === 1, - `nodes=${nodesC.length} root=${String(treeC.data?.rootGameId).slice(-6)}`, - ); - - // 4) 授权继承与不可提升(演示「授权继承 + 衍生档位不可再提升」) - const tierOfPublic = async (key) => - (await api(`/api/game-distribution/games/${state.get(key).gameId}`)).data - ?.forkAuthorization ?? null; - const c0Tier = await tierOfPublic('C0'); - const c1Tier = await tierOfPublic('C1'); - const a0Tier = await tierOfPublic('A0'); - check( - '树 C 演示 nonCommercial 子树:C0=nonCommercial 且 C1 创建时继承同档位', - c0Tier === 'nonCommercial' && c1Tier === 'nonCommercial', - `C0=${c0Tier} C1=${c1Tier}`, - ); - check( - '树 A 母版最终档位 full(A1 子树是在它还是 nonCommercial 时建出来的)', - a0Tier === 'full', - `A0=${a0Tier}`, - ); - for (const [key, owner] of [ - ['A1', '乙'], - ['C1', '丁'], - ]) { - const promoteDerived = await api( - `/api/game-distribution/games/${state.get(key).gameId}/fork-authorization`, - { - method: 'PUT', - token: authors[owner].token, - headers: { 'Idempotency-Key': `seed-derive-promote-${key}-${stamp}` }, - body: { - expectedForkAuthorization: 'nonCommercial', - forkAuthorization: 'full', - }, - }, - ); - check( - `衍生作品 ${key} 提升被拒(本人=409 FORK_AUTHORIZATION_INHERITED;复用旧跑批作品时=403 owner 不匹配)`, - (promoteDerived.status === 409 && - (promoteDerived.error?.code ?? '') === - 'FORK_AUTHORIZATION_INHERITED') || - (promoteDerived.status === 403 && - (promoteDerived.error?.code ?? '') === 'FORBIDDEN'), - brief(promoteDerived), - ); - } - - // 5) 主题(管理员建、把 A0 作为根成员加入) - if ((process.env.SEED_SKIP_THEME ?? '') !== '1') { - const adminThemes = await api( - '/admin/api/game-distribution/themes?limit=200', - { - token: admin, - }, - ); - let theme = (adminThemes.data?.themes ?? []).find( - (entry) => entry.name === THEME_NAME, - ); - if (!theme) { - const created = await api('/admin/api/game-distribution/themes', { - method: 'POST', - token: admin, - headers: { 'Idempotency-Key': `seed-theme-${stamp}` }, - body: { - name: THEME_NAME, - summary: '共创演示:把星海工坊这一族的作品按主题聚合', - badge: '演示', - sortOrder: 1, - status: 'published', - }, - }); - theme = created.data?.theme; - check( - '管理员创建主题', - created.status === 200 && Boolean(theme), - brief(created), - ); - } else { - console.log(`[seed] 复用已存在主题 ${THEME_NAME}`); - } - const member = await api( - `/admin/api/game-distribution/themes/${theme.themeId}/members/${a0.gameId}`, - { method: 'PUT', token: admin, body: { sortOrder: 0 } }, - ); - check('A0 作为根成员加入主题', member.status === 200, brief(member)); - console.log(`[seed] 主题页 URL=${WEB}/games/theme?id=${theme.themeId}`); - console.log(`[seed] 主题 ID=${theme.themeId}`); - } - - // 6) 收藏(两个作者各收藏两件别人的作品) - if ((process.env.SEED_SKIP_COLLECTIONS ?? '') !== '1') { - const collectionPlan = [ - { author: '甲', targets: ['B1', 'A21'] }, - { author: '乙', targets: ['A0', 'B2'] }, - ]; - for (const entry of collectionPlan) { - for (const key of entry.targets) { - const response = await api( - `/api/game-distribution/games/${state.get(key).gameId}/collection`, - { - method: 'PUT', - token: authors[entry.author].token, - headers: { - 'Idempotency-Key': `seed-collect-${authors[entry.author].ascii}-${key}-${stamp}`, - }, - }, - ); - check( - `${entry.author} 收藏 ${key}`, - response.status === 200 || response.data?.collected === true, - brief(response), - ); - } - } - } - - // 7) 打印可点的 URL - console.log(''); - console.log(`[seed] 树 A 根:${state.get('A0').gameId}`); - console.log(`[seed] 树 A lineage URL=${WEB}/games/lineage?id=${a0.gameId}`); - console.log(`[seed] 树 A 根详情 URL=${WEB}/games/detail?id=${a0.gameId}`); - console.log(`[seed] 树 B 根:${state.get('B0').gameId}`); - console.log(`[seed] 树 B lineage URL=${WEB}/games/lineage?id=${b0.gameId}`); - console.log(`[seed] 树 C 根:${state.get('C0').gameId}`); - console.log(`[seed] 树 C lineage URL=${WEB}/games/lineage?id=${c0.gameId}`); - console.log( - `[seed] 作者账号:${Object.values(authors) - .map((author) => `${author.displayName}=${author.phone}`) - .join(',')}(密码 ${DEV_PASSWORD})`, - ); - // 复用旧跑批数据时,作品可能不是当前这批演示账号建的(判重按标题、不绑账号):把「当前账号实际拥有几个节点」 - // 打出来,免得有人用演示账号登录后看到「我的作品」是空的却不知道为什么。 - for (const spec of AUTHOR_SPECS) { - const author = authors[spec.key]; - const mine = await listMyGames(author.token); - const expected = NODES.filter((node) => node.author === spec.key); - const owned = expected.filter((node) => - mine.some((game) => game.id === state.get(node.key)?.gameId), - ).length; - console.log( - `[seed] 演示账号 ${spec.displayName}(${author.phone})拥有本演示的 ${owned}/${expected.length} 个节点作品`, - ); - } - - console.log( - `[seed] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures}`, - ); - if (failures > 0) process.exitCode = 1; -} - -main().catch((error) => { - console.error(`[seed] 未捕获异常:${error?.stack ?? error}`); - process.exitCode = 1; -}); diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 6f3abf3ef..15e06e281 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -871,12 +871,12 @@ function GameDetailContent({ */} {lineage.parentTitle - ? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自《${lineage.parentTitle}》` + ? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自父代《${lineage.parentTitle}》` : `${coCreationGenerationTagLabel(lineage.generation)} · 原作品已不可用`} {lineage.parentAuthorName ? ( - 由 {lineage.parentAuthorName} 制作 + 父代作者 {lineage.parentAuthorName} ) : null} diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 6d959d1ed..d9ec46d4b 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -426,8 +426,8 @@ describe('GameDetailPage', () => { ); expect(await screen.findByText('允许非商用共创')).toBeTruthy(); - expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy(); - expect(screen.getByText('由 原作者 制作')).toBeTruthy(); + expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy(); + expect(screen.getByText('父代作者 原作者')).toBeTruthy(); expect(screen.getByText('累计衍生作品 3')).toBeTruthy(); // 已公开 + 授权开放 → 「一键 Fork 共创」入口出现(M1 时它还不存在,这里钉住新契约)。 expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy(); @@ -2385,6 +2385,65 @@ function buildChainDataset() { }; } +/** + * 父代 / 子代数据集:root → mid → current(锚点)→ child1/child2(直接子代), + * child1 → grandchild(孙代,不算「直接子代」);root → uncle 是旁支。 + */ +const relationTree = { + rootGameId: 'game-root', + root: createLineageNode({ + gameId: 'game-root', + title: '根原创', + generation: 0, + parentGameId: null, + }), + nodes: [ + createLineageNode({ + gameId: 'game-root', + title: '根原创', + generation: 0, + parentGameId: null, + }), + createLineageNode({ + gameId: 'game-mid', + title: '中间代', + generation: 1, + parentGameId: 'game-root', + }), + createLineageNode({ + gameId: 'game-uncle', + title: '旁支叔伯', + generation: 1, + parentGameId: 'game-root', + }), + createLineageNode({ + gameId: 'game-current', + title: '当前作品', + generation: 2, + parentGameId: 'game-mid', + }), + createLineageNode({ + gameId: 'game-child-1', + title: '子代甲', + generation: 3, + parentGameId: 'game-current', + }), + createLineageNode({ + gameId: 'game-child-2', + title: '子代乙', + generation: 3, + parentGameId: 'game-current', + }), + createLineageNode({ + gameId: 'game-grandchild', + title: '孙代作品', + generation: 4, + parentGameId: 'game-child-1', + }), + ], + truncated: false, +}; + /** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */ const openButton = (title: string) => screen.getByRole('button', { name: `打开《${title}》详情` }); @@ -2452,6 +2511,126 @@ describe('GameLineagePage 可视化与交互', () => { ).toEqual(['一代作者', '当前作者', '根作者']); }); + /** 画布上所有节点卡片的标题(排序后比较,避免依赖布局顺序)。 */ + const canvasTitles = (canvas: HTMLElement) => + [...canvas.querySelectorAll('.game-lineage-node')] + .map( + (card) => + card.querySelector('.game-lineage-node__title')?.textContent ?? '', + ) + .sort(); + + it('区分父代 / 子代 / 自己 / 旁支:data-relation + 可读角标', async () => { + getGameLineageMock.mockResolvedValueOnce(relationTree); + render( + , + ); + + const currentCard = await cardOf('当前作品'); + // 锚点 = 自己:不给「父代/子代」角标(它就是「当前作品」)。 + expect(currentCard.getAttribute('data-relation')).toBe('self'); + expect( + currentCard.querySelector('.game-lineage-node__relation'), + ).toBeNull(); + // 祖先链 = 父代。 + for (const title of ['根原创', '中间代']) { + const card = await cardOf(title); + expect(card.getAttribute('data-relation')).toBe('ancestor'); + expect( + within(card).getByText('父代', { + selector: '.game-lineage-node__relation', + }), + ).toBeTruthy(); + } + // 后代(含孙代)= 子代。 + for (const title of ['子代甲', '子代乙', '孙代作品']) { + const card = await cardOf(title); + expect(card.getAttribute('data-relation')).toBe('descendant'); + expect( + within(card).getByText('子代', { + selector: '.game-lineage-node__relation', + }), + ).toBeTruthy(); + } + // 旁支不冒充直系。 + const uncleCard = await cardOf('旁支叔伯'); + expect(uncleCard.getAttribute('data-relation')).toBe('side'); + expect(uncleCard.querySelector('.game-lineage-node__relation')).toBeNull(); + }); + + it('「只看父代与子代」:只留祖先链 + 自己 + 直接子代,再点一次恢复整棵树', async () => { + getGameLineageMock.mockResolvedValueOnce(relationTree); + render( + , + ); + + const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement; + // 默认关:整棵树(含旁支与孙代)。 + expect(canvasTitles(canvas)).toEqual( + [ + '当前作品', + '中间代', + '子代乙', + '子代甲', + '旁支叔伯', + '根原创', + '孙代作品', + ].sort(), + ); + const toggle = screen.getByRole('button', { name: '只看父代与子代' }); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(toggle); + // 开:父代(祖先链)+ 自己 + **直接**子代——不多不少。 + expect(canvasTitles(canvas)).toEqual( + ['当前作品', '根原创', '子代乙', '子代甲', '中间代'].sort(), + ); + expect(canvasTitles(canvas)).not.toContain('孙代作品'); + expect(canvasTitles(canvas)).not.toContain('旁支叔伯'); + // 状态有明确的可视/可读反馈:按钮文案 + aria-pressed + 说明行。 + expect( + screen + .getByRole('button', { name: '只看父代与子代(已开启)' }) + .getAttribute('aria-pressed'), + ).toBe('true'); + expect( + screen.getByText( + '只显示当前作品的父代(祖先链)与直接子代,再点一次恢复整棵树', + ), + ).toBeTruthy(); + + // 取消:回到整棵树。 + fireEvent.click( + screen.getByRole('button', { name: '只看父代与子代(已开启)' }), + ); + expect(canvasTitles(canvas)).toEqual( + [ + '当前作品', + '中间代', + '子代乙', + '子代甲', + '旁支叔伯', + '根原创', + '孙代作品', + ].sort(), + ); + expect( + screen + .getByRole('button', { name: '只看父代与子代' }) + .getAttribute('aria-pressed'), + ).toBe('false'); + }); + it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); const onOpenDetail = vi.fn(); @@ -3212,6 +3391,42 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) } }); + it('移动端:「只看父代与子代」开关可用,大纲行也标出父代 / 子代', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(relationTree); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + // 默认关:整棵树(7 个节点)。 + expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7); + fireEvent.click(screen.getByRole('button', { name: '只看父代与子代' })); + + const rows = [...outline.querySelectorAll('.lineage-outline__row')]; + // 开:父代链(根原创 / 中间代)+ 自己 + 直接子代(子代甲 / 子代乙)——不多不少。 + expect(rows.length).toBe(5); + expect(rows.map((row) => row.getAttribute('data-relation')).sort()).toEqual( + ['ancestor', 'ancestor', 'descendant', 'descendant', 'self'], + ); + // 大纲行里同样是**可读角标**(不是只靠描边颜色)。 + expect( + [...outline.querySelectorAll('.game-lineage-node__relation')] + .map((element) => element.textContent) + .sort(), + ).toEqual(['子代', '子代', '父代', '父代']); + // 取消:回到整棵树。 + fireEvent.click( + screen.getByRole('button', { name: '只看父代与子代(已开启)' }), + ); + expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7); + }); + it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => { setViewportWidth(375); getGameLineageMock.mockResolvedValueOnce(tree); @@ -3294,7 +3509,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect(await screen.findByText('2 代三创')).toBeTruthy(); expect(screen.getByText('允许非商用共创')).toBeTruthy(); expect(screen.getByText('累计衍生作品 3')).toBeTruthy(); - expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy(); + expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy(); // 主操作按文档叫「一键 Fork 共创」。 expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy(); }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index cda7a793e..b7ed67c1b 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -32,8 +32,11 @@ import { LineageOutlineList } from './LineageOutlineList'; import { buildLineageTreeLayout, collectLineageHighlight, + collectLineageImmediateScope, + collectLineageRelationRoles, generationAxisLabel, type LineageLayoutNode, + type LineageRelationRole, } from './lineageTreeLayout'; type GameLineagePageProps = { @@ -77,6 +80,7 @@ function LineageTreeNodeCard({ isOnPath, isPinned, compact, + relation, onOpenDetail, onActivate, onDeactivate, @@ -91,6 +95,8 @@ function LineageTreeNodeCard({ isPinned: boolean; /** 窄屏紧凑模式:少一行信息、卡片更小。 */ compact: boolean; + /** 相对「当前作品」的角色:父代 / 子代 / 自己 / 旁支(打 `data-relation` + 角标)。 */ + relation: LineageRelationRole; onOpenDetail: (gameId: string) => void; onActivate: (gameId: string) => void; onDeactivate: () => void; @@ -133,6 +139,7 @@ function LineageTreeNodeCard({ }} data-generation={generation} data-branch={layoutNode.branchIndex} + data-relation={relation} // 卡片主体点击 = **选中**(持续高亮祖先链 + 子树);打开详情在标题/「打开作品」按钮上。 tabIndex={isStub ? 0 : undefined} aria-label={accessibleName} @@ -180,6 +187,7 @@ function LineageTreeNodeCard({ isStub={isStub} isTrunk={layoutNode.isTrunk} compact={compact} + relation={relation} /> @@ -298,6 +306,23 @@ export function GameLineagePage({ }, [loadLineage]); const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]); + // 高亮锚点(= 「当前作品」,也是「父代 / 子代」的基准)必须早于筛选链路声明。 + const anchorId = highlightGameId?.trim() || normalizedRootId; + /** + * 每个节点相对当前作品的角色:`self` / `ancestor`(父代)/ `descendant`(子代)/ `side`(旁支)。 + * 用**未筛选**的整棵树算,所以世代筛选、只看父子、排序都不会改口径。 + */ + const relationRoles = useMemo( + () => collectLineageRelationRoles(nodes, anchorId), + [anchorId, nodes], + ); + /** 「只看父代与子代」的保留集合:祖先链 + 自己 + 直接子代。 */ + const relatedScope = useMemo( + () => collectLineageImmediateScope(nodes, anchorId), + [anchorId, nodes], + ); + /** 开关:默认关(显示以根为作用域的整棵树),打开后只看父代与直接子代,再点一次取消。 */ + const [onlyRelated, setOnlyRelated] = useState(false); /** * 顶部筛选器:按世代筛选(保留该代及其祖先,树不断链)与按热度排序 * (同代内 `playCount` 倒序;关闭时保持服务端顺序 = 创建时间序)。 @@ -307,8 +332,16 @@ export function GameLineagePage({ generationFilter === null ? [...nodes] : nodes.filter((node) => node.generation <= generationFilter); - if (!sortByHeat) return kept; - return kept + // 「只看父代与子代」:作用域里没有锚点(例如锚点不在可见集合里)时不筛选, + // 免得渲染出一张空图。 + const scoped = onlyRelated + ? kept.filter((node) => relatedScope.has(node.gameId)) + : kept; + const useScoped = + onlyRelated && scoped.some((node) => node.gameId === anchorId); + const result = useScoped ? scoped : kept; + if (!sortByHeat) return result; + return result .map((node, index) => ({ node, index })) .sort( (left, right) => @@ -317,7 +350,14 @@ export function GameLineagePage({ left.index - right.index, ) .map((item) => item.node); - }, [generationFilter, nodes, sortByHeat]); + }, [ + anchorId, + generationFilter, + nodes, + onlyRelated, + relatedScope, + sortByHeat, + ]); const generationsPresent = useMemo( () => [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b), @@ -379,7 +419,6 @@ export function GameLineagePage({ generation: item.generation, })); }, [layout.nodes, pathIds, pinnedGameId]); - const anchorId = highlightGameId?.trim() || normalizedRootId; const effectiveViewMode = viewMode ?? (isMobileViewport ? 'outline' : 'canvas'); const trunkGameIds = useMemo( @@ -789,6 +828,23 @@ export function GameLineagePage({ > {effectiveViewMode === 'canvas' ? '大纲视图' : '图谱视图'} + {/* + 「只看父代与子代」:默认关(整棵树)。打开后只留当前作品的祖先链(父代) + 与**直接**子代;再点一次取消。作用是「聚焦一支」——树大了以后不必在旁支里找。 + */} + + {onlyRelated ? ( + + 只显示当前作品的父代(祖先链)与直接子代,再点一次恢复整棵树 + + ) : null} {/* 改动类型筛选:`changeSummary` 字段已上线,但它是**自由文本**(服务端没有分类枚举), 因此仍然**禁用 + 说明**,不编造筛选值域;等后端给出可枚举的改动类型再接。 @@ -885,6 +941,7 @@ export function GameLineagePage({ currentGameId={anchorId} trunkGameIds={trunkGameIds} selectedGameId={pinnedGameId} + relationByGameId={relationRoles} onSelect={(gameId) => setPinnedGameId((current) => current === gameId ? '' : gameId, @@ -950,6 +1007,9 @@ export function GameLineagePage({ isOnPath={Boolean(pathIds?.has(layoutNode.gameId))} isPinned={pinnedGameId === layoutNode.gameId} compact={compactTree} + relation={ + relationRoles.get(layoutNode.gameId) ?? 'side' + } onTogglePin={setPinnedGameId} onOpenDetail={onOpenDetail} onActivate={setActiveGameId} diff --git a/src/components/game-distribution/LineageNodeContent.tsx b/src/components/game-distribution/LineageNodeContent.tsx index 4d4a73fed..384a7a3b0 100644 --- a/src/components/game-distribution/LineageNodeContent.tsx +++ b/src/components/game-distribution/LineageNodeContent.tsx @@ -1,5 +1,6 @@ import type { GameDistributionGame } from '../../services/gameDistributionClient'; import { GameCover } from './GameCover'; +import type { LineageRelationRole } from './lineageTreeLayout'; import { generationAxisLabel } from './lineageTreeLayout'; /** @@ -21,6 +22,7 @@ export function LineageNodeContent({ isStub = false, isTrunk = false, compact = false, + relation = 'side', }: { title: string | null; authorName: string | null; @@ -32,6 +34,12 @@ export function LineageNodeContent({ isTrunk?: boolean; /** 画布卡片的紧凑态(窄屏图谱):省掉作者行;大纲/血缘链行始终保留作者名。 */ compact?: boolean; + /** + * 相对「当前作品」的角色:父代(祖先链)/ 子代(后代)/ 自己 / 旁支。 + * 只给**父代 / 子代**渲染角标(自己是「当前作品」角标、旁支不标注), + * 是可读文本而不是只靠颜色,因此可断言。 + */ + relation?: LineageRelationRole; }) { const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品'); const authorLabel = isStub @@ -65,6 +73,15 @@ export function LineageNodeContent({ {displayTitle} + {relation === 'ancestor' || relation === 'descendant' ? ( + // 「父代 / 子代」是可读文本角标(不是只靠描边颜色),所以能被断言、也能被读屏。 + + {relation === 'ancestor' ? '父代' : '子代'} + + ) : null} {/* 作者名在两种形态都渲染(画布卡片曾因重构一度漏掉,导致节点少了作者信息、 也踩到了 e2e 的作者名断言):只有窄屏图谱的紧凑卡片才省掉它。 diff --git a/src/components/game-distribution/LineageOutlineList.tsx b/src/components/game-distribution/LineageOutlineList.tsx index 01dbe8a44..40afc417f 100644 --- a/src/components/game-distribution/LineageOutlineList.tsx +++ b/src/components/game-distribution/LineageOutlineList.tsx @@ -1,4 +1,5 @@ import { LineageNodeContent } from './LineageNodeContent'; +import type { LineageRelationRole } from './lineageTreeLayout'; /** * 移动端**大纲式缩进树**(默认形态):全宽行 + 左缩进 + 肘形导线。 @@ -15,6 +16,7 @@ export function LineageOutlineList({ currentGameId, trunkGameIds, selectedGameId, + relationByGameId, onSelect, onOpenDetail, onOpenFork, @@ -31,6 +33,8 @@ export function LineageOutlineList({ currentGameId: string; trunkGameIds: ReadonlySet; selectedGameId: string; + /** 每个节点相对「当前作品」的角色(父代 / 子代 / 自己 / 旁支):与画布同一份口径。 */ + relationByGameId: ReadonlyMap; onSelect: (gameId: string) => void; onOpenDetail: (gameId: string) => void; onOpenFork: (gameId: string) => void; @@ -40,6 +44,7 @@ export function LineageOutlineList({ {nodes.map((node) => { const isCurrent = node.gameId === currentGameId; const isSelected = node.gameId === selectedGameId; + const relation = relationByGameId.get(node.gameId) ?? 'side'; return (
  • @@ -82,6 +88,7 @@ export function LineageOutlineList({ isAnchor={isCurrent} isStub={node.isStub} isTrunk={trunkGameIds.has(node.gameId)} + relation={relation} /> {node.isStub ? null : ( diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 2f4f9c93e..3c94a22a8 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -2434,6 +2434,45 @@ font-weight: 600; cursor: pointer; } + +/* 开关按下态:明确的可视反馈(不是只靠 aria-pressed 属性)。 */ +.game-lineage-filter__switch[aria-pressed='true'] { + border-color: var(--platform-accent-strong); + background: rgba(199, 101, 61, 0.12); + color: var(--platform-accent-strong); +} + +/* + * 「父代 / 子代」角标:**可读文本**(不是只靠颜色深浅),所以可断言、读屏也能读到; + * 自己是「当前作品」角标、旁支不标注。颜色只是辅助表达。 + */ +.game-lineage-node__relation { + align-self: flex-start; + padding: 0 0.35rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.62rem; + line-height: 1.6; + white-space: nowrap; +} +.game-lineage-node__relation--ancestor { + color: var(--platform-accent-strong); + background: rgba(199, 101, 61, 0.1); +} +.game-lineage-node__relation--descendant { + color: var(--platform-text-base); + background: var(--platform-surface-muted, rgba(0, 0, 0, 0.05)); +} +/* 画布卡片按角色加描边(辅助:主表达仍是上面的角标与 data-relation)。 */ +.game-lineage-node[data-relation='ancestor'] { + border-color: var(--platform-accent-strong); +} +.lineage-outline__row[data-relation='ancestor'] .lineage-outline__main { + border-left: 3px solid var(--platform-accent-strong); +} +.lineage-outline__row[data-relation='descendant'] .lineage-outline__main { + border-left: 3px solid var(--platform-surface-border); +} .game-lineage-filter__switch[aria-pressed='true'] { border-color: var(--platform-accent-strong); color: var(--platform-accent-strong); diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index 7a03c1200..fa15c806e 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -7,6 +7,8 @@ import { buildLineageTreeLayout, collectLineageAncestorChain, collectLineageHighlight, + collectLineageImmediateScope, + collectLineageRelationRoles, collectLineageSubtreeDepth, generationAxisLabel, LINEAGE_COLUMN_GAP, @@ -317,9 +319,9 @@ describe('横向朝向(移动端图谱视图)', () => { expect(hById.get('a')!.y).not.toBe(hById.get('b')!.y); expect(hById.get('a')!.x).toBe(hById.get('b')!.x); // 同代(同一列)节点在纵向上至少隔一个列间距,结构上不重叠。 - expect(Math.abs(hById.get('a')!.y - hById.get('b')!.y)).toBeGreaterThanOrEqual( - hById.get('a')!.height + 1, - ); + expect( + Math.abs(hById.get('a')!.y - hById.get('b')!.y), + ).toBeGreaterThanOrEqual(hById.get('a')!.height + 1); expect(vertical.nodes[0]!.y).toBeLessThan(vertical.nodes[1]!.y); }); @@ -435,3 +437,103 @@ describe('collectLineageHighlight', () => { expect([...highlighted].sort()).toEqual(['child', 'game-removed']); }); }); + +/** + * 血缘关系数据集:root → a → a1 → a1x,root → b(旁支),a → a2(第二个子代)。 + * 用来钉住「父代 / 子代 / 自己 / 旁支」四类角色与「只看父代与子代」的集合口径。 + */ +const relationTree = [ + node('root', 0, null), + node('a', 1, 'root'), + node('b', 1, 'root'), + node('a1', 2, 'a'), + node('a2', 2, 'a'), + node('a1x', 3, 'a1'), +]; + +describe('collectLineageRelationRoles', () => { + it('区分自己 / 父代(祖先链)/ 子代(整棵后代子树)/ 旁支', () => { + const roles = collectLineageRelationRoles(relationTree, 'a'); + expect(roles.get('a')).toBe('self'); + expect(roles.get('root')).toBe('ancestor'); + expect(roles.get('a1')).toBe('descendant'); + expect(roles.get('a2')).toBe('descendant'); + expect(roles.get('a1x')).toBe('descendant'); + // 兄弟分支是旁支:不冒充父代/子代。 + expect(roles.get('b')).toBe('side'); + }); + + it('根节点:自己没有父代,所有后代都是子代', () => { + const roles = collectLineageRelationRoles(relationTree, 'root'); + expect([...roles.entries()].filter(([, r]) => r === 'ancestor')).toEqual( + [], + ); + expect( + [...roles.entries()] + .filter(([, r]) => r === 'descendant') + .map(([id]) => id) + .sort(), + ).toEqual(['a', 'a1', 'a1x', 'a2', 'b']); + }); + + it('叶节点:只有父代链,没有子代', () => { + const roles = collectLineageRelationRoles(relationTree, 'a1x'); + expect(roles.get('a1x')).toBe('self'); + expect( + [...roles.entries()] + .filter(([, r]) => r === 'ancestor') + .map(([id]) => id) + .sort(), + ).toEqual(['a', 'a1', 'root']); + expect( + [...roles.values()].filter((role) => role === 'descendant'), + ).toHaveLength(0); + }); + + it('单节点树:自己是 self,没有父代/子代', () => { + const roles = collectLineageRelationRoles([node('solo', 0, null)], 'solo'); + expect([...roles.entries()]).toEqual([['solo', 'self']]); + }); + + it('锚点不在可见集合里:全部按旁支返回(不抛错、不臆造关系)', () => { + const roles = collectLineageRelationRoles(relationTree, 'game-missing'); + expect([...roles.values()].every((role) => role === 'side')).toBe(true); + }); + + it('自环数据不会死循环', () => { + const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')]; + const roles = collectLineageRelationRoles(cyclic, 'x'); + expect(roles.get('x')).toBe('self'); + expect(roles.get('y')).toBe('ancestor'); + }); +}); + +describe('collectLineageImmediateScope(只看父代与子代)', () => { + it('保留祖先链 + 自己 + **直接**子代(不含孙代)', () => { + const scope = collectLineageImmediateScope(relationTree, 'a'); + // a1x 是孙代(a 的子代的子代),不在「只看父子」的范围里。 + expect([...scope].sort()).toEqual(['a', 'a1', 'a2', 'root']); + expect(scope.has('a1x')).toBe(false); + expect(scope.has('b')).toBe(false); + }); + + it('根节点:自己 + 直接子代', () => { + expect( + [...collectLineageImmediateScope(relationTree, 'root')].sort(), + ).toEqual(['a', 'b', 'root']); + }); + + it('离树的锚点返回空集合(调用方据此不筛选)', () => { + expect( + collectLineageImmediateScope(relationTree, 'game-missing').size, + ).toBe(0); + }); + + it('父代不在可见集合里时链停在子节点上(父仍由布局层补占位)', () => { + const scope = collectLineageImmediateScope( + [node('child', 1, 'game-removed'), node('grandchild', 2, 'child')], + 'child', + ); + expect([...scope].sort()).toEqual(['child', 'grandchild']); + }); +}); diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index 936c03915..ce0aab374 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -308,8 +308,14 @@ export function buildLineageTreeLayout( const horizontal = orientation === 'horizontal'; // 竖版:父底中点 → 子顶中点;横版:父右边中点 → 子左边中点。 const from = horizontal - ? { x: roundTo(parent.x + parent.width), y: roundTo(parent.y + parent.height / 2) } - : { x: roundTo(parent.x + parent.width / 2), y: roundTo(parent.y + parent.height) }; + ? { + x: roundTo(parent.x + parent.width), + y: roundTo(parent.y + parent.height / 2), + } + : { + x: roundTo(parent.x + parent.width / 2), + y: roundTo(parent.y + parent.height), + }; const to = horizontal ? { x: roundTo(node.x), y: roundTo(node.y + node.height / 2) } : { x: roundTo(node.x + node.width / 2), y: roundTo(node.y) }; @@ -321,7 +327,10 @@ export function buildLineageTreeLayout( ); const mid = roundTo((horizontal ? from.x : from.y) + delta * 0.5); const path = (() => { - if (cornerRadius <= 0 || (horizontal ? from.y === to.y : from.x === to.x)) { + if ( + cornerRadius <= 0 || + (horizontal ? from.y === to.y : from.x === to.x) + ) { return horizontal ? `M ${from.x},${from.y} H ${to.x}` : `M ${from.x},${from.y} V ${to.y}`; @@ -448,6 +457,89 @@ export function collectLineageAncestorChain< return chain.reverse(); } +/** 节点相对「当前作品」的角色(文档口径的**父代 / 子代**)。 */ +export type LineageRelationRole = 'self' | 'ancestor' | 'descendant' | 'side'; + +/** + * 每个节点相对 `gameId` 的角色: + * + * - `self`:当前作品本体; + * - `ancestor`:沿 `parentGameId` 上溯到的**父代**(祖先链,不含自己); + * - `descendant`:沿 `parentGameId` **反向**走到的**子代**(整棵后代子树); + * - `side`:与当前作品没有直系血缘的旁支。 + * + * 纯函数、稳定(同输入同输出);渲染层据此打 `data-relation` 并显示「父代 / 子代」角标, + * 不用「颜色深浅」这类无法断言的表达。 + */ +export function collectLineageRelationRoles< + T extends { gameId: string; parentGameId?: string | null }, +>(nodes: readonly T[], gameId: string): Map { + const roles = new Map(); + for (const node of nodes) roles.set(node.gameId, 'side'); + const byId = new Map(nodes.map((node) => [node.gameId, node])); + if (!byId.has(gameId)) return roles; + + roles.set(gameId, 'self'); + // 父代:沿 parentGameId 上溯(防环)。 + const guard = new Set([gameId]); + let cursor = byId.get(gameId); + while (cursor?.parentGameId) { + const parentId = cursor.parentGameId; + if (guard.has(parentId) || !byId.has(parentId)) break; + guard.add(parentId); + roles.set(parentId, 'ancestor'); + cursor = byId.get(parentId); + } + // 子代:按 parentGameId 反向 BFS(整棵后代子树)。 + const childrenByParent = new Map(); + for (const node of nodes) { + const parentId = node.parentGameId ?? null; + if (!parentId) continue; + const bucket = childrenByParent.get(parentId); + if (bucket) bucket.push(node.gameId); + else childrenByParent.set(parentId, [node.gameId]); + } + const stack = [...(childrenByParent.get(gameId) ?? [])]; + while (stack.length > 0) { + const id = stack.pop() as string; + // 只给还是「旁支」的节点定子代:脏数据里出现环时(某人既是祖先又是后代), + // 先判定的**父代**优先,保证结果稳定、不来回翻。 + if (roles.get(id) !== 'side') continue; + roles.set(id, 'descendant'); + stack.push(...(childrenByParent.get(id) ?? [])); + } + return roles; +} + +/** + * 「只看父代与子代」要保留的节点集合:当前作品的**祖先链**(父代)+ **直接子代** + 自己。 + * + * 注意是**直接**子代(下一层),不是整棵后代子树——开关的语义是「只看这个作品的上一步与下一步」, + * 想看整棵树关掉开关即可。父代不在可见集合里时链停在那里(与「父作品不可用」口径一致, + * 布局层仍会给那个缺失的父补一个占位节点)。 + * + * 找不到 `gameId` 时返回空集合:调用方据此「不筛选」而不是渲染空图。 + */ +export function collectLineageImmediateScope< + T extends { gameId: string; parentGameId?: string | null }, +>(nodes: readonly T[], gameId: string): Set { + const scope = new Set(); + const byId = new Map(nodes.map((node) => [node.gameId, node])); + if (!byId.has(gameId)) return scope; + scope.add(gameId); + let cursor = byId.get(gameId); + while (cursor?.parentGameId) { + const parentId = cursor.parentGameId; + if (scope.has(parentId) || !byId.has(parentId)) break; + scope.add(parentId); + cursor = byId.get(parentId); + } + for (const node of nodes) { + if ((node.parentGameId ?? null) === gameId) scope.add(node.gameId); + } + return scope; +} + /** * hover / 聚焦一个节点时要高亮的集合:它自己 + 它的祖先路径;`includeSubtree` 为真时再加上 * 它整棵子树。其余节点在渲染层降透明度。