From f40050afb18d8a9c2d9310eed3b43c570427f3d2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 04:40:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=20M3=20=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E9=81=97=E7=95=99=EF=BC=9A=E6=96=87=E6=A1=A3=E5=A6=82=E5=AE=9E?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E3=80=81fork-source=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E3=80=81forkAuthorization=20=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E5=AE=88=E5=8D=AB=E3=80=81e2e=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=8E=BB=E8=A1=8C=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技术方案 §3.4:删掉「GET /games/{gameId} 追加 forkSourceAvailable」——实现里不存在该字段;改为如实口径:改造入口显隐只依据 forkAuthorization,真实可复刻形态由 /fork-source 的 source 回答,若 M2b 要区分「可源码级改造 / 只能参考」届时再加 - 实施计划(M1)§5:同步去掉 forkSourceAvailable 并注明「未实现、也不需要」,保留其余字段清单不动 - 技术方案 §2.6 时序图:说明字句从「GET …/fork-source 是待实现接口」改为「M2a 已实现(形状见 §3.4 登录用户表)」,并在图内加一行 Note 标注「图中字段名是 M2b 目标形状」;图属目标形态的免责声明保留 - parity 脚本:game_payload 增加 mustEmit ['forkAuthorization']——该字段 TS 侧可选(旧响应可省),但公开与作者响应必须发出,否则详情页授权徽章与改造入口会静默退化成「禁止共创」;public_game_payload 以 game_payload 为 base,因此一并受保护 - e2e 脚本头部:把全部行号引用(含已过期 2–4 行的那些)改为「文件 + 符号」引用(router()/lineage_read_or_not_found()/GameDistributionLineageNode/lineage.rs 常量与函数等),并删除「消费链路在未提交的 M2a WIP 里」这类会过期的措辞,改为「已在 M2a 落地」;只改注释,断言逻辑未动 --- .../src-tauri/src/game_fork.rs | 25 ++++----- ...�实施计划】游戏共创授权与血缘-2026-10-04.md | 2 +- ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 5 +- .../check-game-distribution-dto-parity.mjs | 5 ++ .../check-game-distribution-lineage-e2e.mjs | 53 +++++++++++-------- 5 files changed, 50 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs index 3503743a6..7744b1935 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs @@ -52,7 +52,8 @@ fn sha256_hex(bytes: &[u8]) -> String { /// 参考副本相对项目的路径:`reference//`。 /// /// 作品 ID 来自平台响应,只接受路径安全取值;拼接复用归档条目门禁(拒绝 `..`、盘符与 -/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。 +/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。这是参考目录的**唯一** +/// 推导处:调用方把它 join 到项目根,不再另起第二份拼接规则。 pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result { let game_id = game_id.trim(); // 空标识必须显式拒绝:`safe_archive_relative_path` 会把空段丢掉,`reference/` 会被 @@ -71,14 +72,6 @@ pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result/reference//`。 -pub(crate) fn fork_reference_directory( - project_root: &Path, - game_id: &str, -) -> Result { - Ok(project_root.join(fork_reference_relative_path(game_id)?)) -} - /// 用平台作品的成品包建一个新项目。 /// /// 先按标准初始化生成合规脚手架,再把参考副本铺进 `/reference//`, @@ -215,16 +208,20 @@ mod tests { } #[test] - fn fork_reference_directory_stays_inside_the_project() { - let root = Path::new("C:/work/project"); + fn fork_reference_relative_path_stays_inside_the_project() { + // 生产路径就是「把这唯一的相对路径 join 到项目根」,因此这里直接钉相对路径本身。 assert_eq!( - fork_reference_directory(root, "game_1").expect("safe id"), - root.join("reference").join("game_1") + fork_reference_relative_path("game_1").expect("safe id"), + Path::new("reference").join("game_1") + ); + assert_eq!( + Path::new("C:/work/project").join(fork_reference_relative_path("game_1").unwrap()), + Path::new("C:/work/project/reference/game_1") ); // 路径不安全的标识一律失败关闭,不拼出逃出项目根的路径。 for unsafe_id in ["", "..", "../escape", "a/b", "a\\b", "C:game", " "] { assert!( - fork_reference_directory(root, unsafe_id).is_err(), + fork_reference_relative_path(unsafe_id).is_err(), "should reject {unsafe_id:?}" ); } diff --git a/docs/project-memory/plans/【实施计划】游戏共创授权与血缘-2026-10-04.md b/docs/project-memory/plans/【实施计划】游戏共创授权与血缘-2026-10-04.md index aaa2fbb84..a196a3bea 100644 --- a/docs/project-memory/plans/【实施计划】游戏共创授权与血缘-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】游戏共创授权与血缘-2026-10-04.md @@ -46,7 +46,7 @@ - `GameDistributionCreateGameRequest` 追加 `fork: Option`(`parentGameId` / `parentVersionId`)。 - 新增 `GameDistributionSetForkAuthorizationRequest { expectedForkAuthorization, forkAuthorization }`。 -- 游戏响应加 `forkAuthorization`,公开响应加 `forkCount` / `lineage` / `forkSourceAvailable`。 +- 游戏响应加 `forkAuthorization`,公开响应加 `forkCount` / `lineage`。(原计划的 `forkSourceAvailable` 未实现,也不需要:改造入口的显隐依据 `forkAuthorization`,可复刻形态由 `/fork-source` 回答;若 M2b 要在详情页区分「可源码级改造 / 只能参考」,届时再加。) ### 6. `server-rs/crates/api-server/src/modules/game_distribution.rs` diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 3abbdabfe..2c812cabb 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -177,6 +177,7 @@ sequenceDiagram U->>AGC: 唤起客户端(方式待拍板:手工输入 / 取件码 / deep link) AGC->>API: GET /api/game-distribution/games/X/fork-source (Bearer) API-->>AGC: {versionId, bundleSha256, bundleBytes, downloadPath} + Note over API: 图中的字段名是 M2b 目标形状;M2a 已实现的形状见 §3.4「登录用户」表 AGC->>AGC: 下载 → 校验 SHA-256 → 解压(同模板安装门禁)→ 新建项目 AGC->>AGC: manifest 写入 forkedFrom{gameId, versionId, rootGameId, generation} Note over AGC: 工程源包可改源码/重跑构建/发布;只有成品包则只能试玩与提取素材(§3.5.1) @@ -184,7 +185,7 @@ sequenceDiagram API->>API: 校验授权/来源版本/计算 generation 与 root → 写 lineage ``` -> 说明:本图是**目标形态**。唤起方式尚未拍板(§7 第 11 条),`GET …/fork-source` 是**待实现**接口(§3.5.1),`fork-source` 的响应字段最终以定稿合同为准。 +> 说明:本图是**目标形态**。唤起方式尚未拍板(§7 第 11 条);`GET …/fork-source` **M2a 已实现**(受鉴权元数据 + 取件本体,实际字段与状态码见 §3.4「登录用户」表),但本图里的响应字段名是 M2b 的目标形状,定稿一律以 §3.4 表格为准。 **C. 溯源与命名** @@ -302,7 +303,7 @@ pub(crate) project_bundle_sha256: Option, | 方法 / 路径 | 说明 | | --- | --- | -| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)、`forkSourceAvailable`(布尔,仅表达"能否真复刻",不泄露对象键) | +| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段 | | `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status }`,按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点 | | `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 | diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 9145dbfb8..a527a85e1 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -105,6 +105,11 @@ const RESPONSE_BUILDERS = [ { fn: 'game_payload', ts: 'GameDistributionGame', + // `forkAuthorization` 在 TS 里是可选的(旧响应可省略),但**公开与作者响应都必须发出**它: + // 详情页的授权徽章与「改造这个作品」入口都读它,缺了会静默退化成「禁止共创」。 + // mustEmit 是唯一能表达「可选字段在这条路径上必出」的机制,所以钉在这里; + // public_game_payload / owner_game_entry_payload 都以它为 base,因此一并受保护。 + mustEmit: ['forkAuthorization'], nested: [ { key: 'author', ts: 'GameDistributionAuthor' }, { key: 'deviceSupport', ts: 'GameDistributionDeviceSupport' }, diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs index 6633b72c1..1a5f742bd 100644 --- a/scripts/check-game-distribution-lineage-e2e.mjs +++ b/scripts/check-game-distribution-lineage-e2e.mjs @@ -10,30 +10,37 @@ // E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器) // E2E_LINEAGE_LABEL_* 覆盖前端文案(默认对齐实现里的字符串) // -// 参考的仓库既有实现与脚本(本脚本按同一风格写成,契约形状全部来自代码而不是猜测): -// [1] 路由挂载(公开、匿名、no-store):server-rs/crates/api-server/src/modules/game_distribution.rs:419-426(public_games) -// [2] 「不可读即 404」映射:api-server/src/modules/game_distribution.rs:1090-1092(lineage_read_or_not_found) -// [3] 两个 handler:api-server/src/modules/game_distribution.rs:1096(get_game_lineage)/ :1114(get_game_derived_games) -// [4] 节点 DTO 的 7 个键:server-rs/crates/shared-contracts/src/game_distribution.rs:262-281 -// 响应::283-293(LineageResponse)/ :295-301(DerivedResponse) -// [5] 上限 200 / 锚点可读性 / 稳定排序:server-rs/crates/module-game-distribution/src/lineage.rs:22 / :28-30 / :135-143 -// [6] 作者软删除:api-server/src/modules/game_distribution.rs:1518-1550;事务语义(撤销当前公开版本 + deleted_at) -// server-rs/crates/spacetime-module/src/game_distribution.rs:3599-3660 -// [7] 改编声明 DTO:shared-contracts/src/game_distribution.rs:241-245 + :367-368; -// 消费点 api-server/...:1611-1615;事务校验 spacetime-module/...:2155-2160 + :2250-2254 -// [8] 发布链路(建版本 → 传包 → 送审 → 管理员通过)照抄 scripts/check-game-distribution-media-e2e.mjs:391-400,431-450,455-470,479-495,523-527,561-573 -// [9] 封面直传 + 确认:scripts/check-game-distribution-owner-isolation.mjs:97-150 -// [10] 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27 -// [11] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159 -// [12] 前端契约:详情页入口 GameDetailPage.tsx:409「查看创作族谱」;族谱页 GameLineagePage.tsx:65-70(节点卡片类名)、 -// :93「原作品已不可用」、:163-164 标题;路由 /games/lineage:src/routing/activeAppPageRoutes.ts:18 -// [13] 节点作者名读时联账号表:spacetime-module/src/game_distribution.rs:4294-4308(所以脚本会先给作者改名,让「删除后不外发作者名」这条断言有意义) +// 参考的仓库既有实现(按**符号**引用,不写行号,避免随改动腐化): +// [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 WIP** 里 -// (工作树 api-server/...:1611-1615 已接,但提交基线 4207d24f0 尚未包含)。在 M2a 落地前: -// - A 段(404 规则)与 E 段的「未公开锚点 404」可以照跑; -// - B/C 段(真实树、软删除降级)会因为创建 B 时 fork 声明不生效而失败 —— 那是依赖未就绪,不是脚本问题。 +// 依赖提示:改编声明 `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)。