From 512bbb7a62e133287f1f3955388a5e242e6fc234 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 17:39:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E8=A1=8D=E7=94=9F=E4=BD=9C=E5=93=81=E5=88=9B=E5=BB=BA=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=BB=A7=E6=89=BF=E7=88=B6=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=A1=A3=E4=BD=8D=EF=BC=88=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=B8=8D=E5=8F=AF=E5=8F=A6=E9=80=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐需求文档 revision 1554「授权设置区:自动继承父作品授权」(被划掉的那句「可收窄、不可放宽」不做)。 - **口径**:衍生作品创建时 `fork_authorization` **直接取父作品当时的档位**(创建时快照),客户端传值一律 **忽略且不报错**(旧客户端会惯常带默认值);母版不变(按请求值,缺省 `forbidden`,未知档位仍失败关闭); 收窄入口不做;**PUT 提升接口语义未改**(见下)。 - **落点**:`resolve_game_distribution_fork_declaration_tx` 原本就在父行上判「存在/未软删/已公开/非禁止/来源版本 等于父当前公开版本」,现在把父档位一并回传(`(root, generation, ForkAuthorization)`);新增纯函数 `resolve_game_distribution_creation_fork_authorization(parent, requested)`:有父 → 父档位,无父 → 解析请求值 (未知 → `FORK_AUTHORIZATION_UNKNOWN`);创建事务里把血缘解析挪到授权裁决之前,新行写裁决结果。 - **无 schema / 无契约形状变化**:`GameDistributionCreateGameRequest.forkAuthorization` 保留(非 Option + `serde(default)` 未动 ⇒ 幂等摘要不变);表列、client record、快照均无字段增删。 - **文档**:技术方案 §2.3(授权模式 + 时序图两条继承边)/§2.4/§3.2.1/§3.4 `POST /games` 行/§3.9 补充/新增 §5.3 实施证据/§7 第 2 条(待拍板 → 已定口径并注明收窄不做);数据契约 `game_distribution_game` 授权条;共创授权与 血缘里程碑(依赖决策/验收第 1 条/实施记录);decision-log 新增 2026-10-06 决策条。 - **测试**:spacetime-module +7(继承矩阵:父 full/nonCommercial × 请求 forbid/nonCommercial/full/空串 一律落 父档位;母版按请求值;未知档位失败关闭;以及「血缘解析返回父档位 + 裁决排在解析之后且写库之前」的结构断言)。 Rust 侧原本**没有**断言「衍生默认 forbidden」的用例(已 grep 核对),因此没有为了让测试过而弱化任何断言。 门禁:`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema` 0(98 表);`cargo check --all-targets` 0; `cargo test -p module-game-distribution` 119 passed;`cargo test -p spacetime-module` **301 passed / 1 ignored**; `cargo test -p api-server game_distribution` 104 passed;DTO parity 0(62 组 / 17 构建器 / 15 手拼类型); `check:encoding` 0(5535 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。 **待你拍板(只报不改)**:继承是「创建时快照 + 等值」,而 PUT 只限制方向不限制对象 ⇒ 衍生作品作者仍可把继承来的 档位再往上提,得到比祖先更宽的子作品,「不可放宽」会被绕过。选项:① 保持现状(PUT 对衍生作品也生效); ② 禁止对衍生作品 PUT 提升(档位只由继承决定;需新增「该 game 无血缘行」判定 + 稳定错误码,并同步文档与前端入口)。 **需其它 owner 同步**(我未改,属其文件):`scripts/check-game-distribution-lineage-e2e.mjs`(richB1/richC1/richD 从 `forbidden` 提升的前置假设已不成立)、`scripts/capture-game-lineage-visual.mjs`(固定 `expectedForkAuthorization: 'forbidden'` 的造树与断言)。 --- ...【里程碑】游戏共创授权与血缘-2026-10-03.md | 5 +- .../shared-memory/decision-log.md | 11 ++ ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 37 +++- .../shared/src/contracts/gameDistribution.ts | 4 + .../src/modules/game_distribution.rs | 2 + .../shared-contracts/src/game_distribution.rs | 4 + .../spacetime-client/src/game_distribution.rs | 2 + .../spacetime-module/src/game_distribution.rs | 174 ++++++++++++++++-- 9 files changed, 215 insertions(+), 26 deletions(-) diff --git a/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md index 6d1903433..ad3bc7890 100644 --- a/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md +++ b/docs/project-memory/plans/【里程碑】游戏共创授权与血缘-2026-10-03.md @@ -32,12 +32,12 @@ ## 依赖与前置条件 - 主规范 §3 的数据模型与状态机已评审通过。 -- 决策:授权默认值取「禁止共创」还是「允许」需产品拍板;本里程碑的实现按拍板结果设定默认值,其余合同不变。 +- 决策(2026-10-06 已拍板,取代原待拍板项):授权默认值——**母版**创建缺省仍为「禁止共创」;**衍生作品**在创建时**继承父作品当时的档位**(不接受客户端另选,也不做收窄)。其余合同不变,见技术方案 §2.3 / §3.9 补充。 - 决策:是否引入共创主题实体需产品拍板;本里程碑不依赖该实体。 ## 验收标准 -- [x] 新建作品默认授权为拍板结果;迁移前已存在的作品在升级后同样按该默认解释,无需人工回填。 +- [x] 新建作品的初始授权为拍板结果:**母版**取请求值(缺省「禁止共创」)、**衍生作品**继承父作品当时的档位(创建时快照);迁移前已存在的作品在升级后仍按其行上的原值解释,无需人工回填。 - [x] 同一次授权变更里,合法提升(含跳级)成功、任何降级被拒绝且不写库。 - [x] 非作品作者不能变更授权;基于旧值的并发请求按期望值冲突拒绝。 - [x] 重复提交同一授权变更请求不产生第二次副作用,如实回报为幂等重放。 @@ -59,3 +59,4 @@ ## 实施记录 - 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 的遗留项。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1ed47f754..935df8555 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-10-06 衍生作品的共创授权改为「创建时继承父作品当时的档位」 + +- 背景:需求文档(rev 1554「发布设置页 → 授权设置区」)要求「**自动继承父作品授权**」,并划掉了「可选择收窄授权,不可放宽」——连收窄也不做。改前的实现是衍生作品创建时落默认 `forbidden`、再由作者自己 PUT 提升,与文档不一致;旧客户端在创建衍生作品时惯常带自己的默认档位。 +- 决策:创建游戏事务的新建分支里,带 `forked_from_game_id` / `forked_from_version_id` 时新行的 `fork_authorization` 取**父行当时的档位**(血缘解析 `resolve_game_distribution_fork_declaration_tx` 现在把父行档位一并返回,它的既有校验「父必须允许共创」不变,因此继承到的档位只会是 `nonCommercial` / `full`);客户端在请求里传的值**一律被忽略且不报错**。母版维持按请求值(缺省 `forbidden`,未知档位失败关闭)。裁决抽成纯函数 `resolve_game_distribution_creation_fork_authorization(parent, requested)`,让口径可被单测直接钉住(事务需要 `ReducerContext`,起不了真库)。 +- 语义边界:继承是**创建时快照**——父作品之后提升档位不会回溯改写既有子作品的行;因此它与「只升不降」作用在同一行数据上不冲突(提升只改被提升的那一行)。**不做收窄入口**(产品决定 2026-10-06)。 +- 契约与形状:`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,未改。 +- 验证方式:新增/改写 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 纵深防御不变 - 背景:付费游戏的可玩入口是创建播放会话后拿到的 `/api/game-distribution/play-sessions//`(sandbox iframe 的 `src`,包内相对资源沿同一前缀解析)。该前缀落在 `/api/*` 上,边缘通用 `/api` location 必须转发 Cookie(`/api/auth/*` 需要 refresh cookie),而 `api-server` 播放网关对带可解析平台 refresh Cookie 的请求返回 403,导致真实浏览器里 iframe 与每个包内资源都 403、付费游戏实际不可玩。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index c41fa0f49..a2c22aa3b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -501,7 +501,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。 - 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、发行网关素材授权(`game_distribution_asset_has_public_read_grant`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。 - 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。**资料编辑不得直接改公开价格**:调价必须走新版本审核。 -- 共创授权:游戏行末尾追加 `fork_authorization: String` 并设置 `#[default("forbidden")]`,取值 `forbidden` / `nonCommercial` / `full`(2026-10-04)。它表达作者对「这部作品能否被改编」的策略,与血缘正交:只能单向提升(`forbidden → nonCommercial → full`),降级与未知档位失败关闭;旧行按 `forbidden` 解释,旧客户端缺字段同样按禁止共创兜底。写入只在创建游戏(`create_game_distribution_game_and_return` 的 `GameDistributionCreateGameInput`)与提升 procedure `set_game_distribution_fork_authorization_and_return`(输入 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`)发生:只允许 owner 本人,`expected_fork_authorization` 不符返回 409,同 key 重放走 `game_distribution_idempotency_receipt`(action = `set_fork_authorization`)。`api-server` 把实现值映射为 HTTP 码(`FORK_AUTHORIZATION_UNKNOWN` 400、降级 409、非本人 403、未登录 401),路由 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 受发布灰度开关约束(收紧时 503,读接口不受影响)。 +- 共创授权:游戏行末尾追加 `fork_authorization: String` 并设置 `#[default("forbidden")]`,取值 `forbidden` / `nonCommercial` / `full`(2026-10-04)。它表达作者对「这部作品能否被改编」的策略,与血缘正交:只能单向提升(`forbidden → nonCommercial → full`),降级与未知档位失败关闭;旧行按 `forbidden` 解释,旧客户端缺字段同样按禁止共创兜底。**创建时的档位来源分两种(2026-10-06)**:母版按请求值(缺省 `forbidden`);带 `forked_from_game_id` / `forked_from_version_id` 的**衍生作品继承父作品当时的档位**(创建时快照,父作品之后提升不会回溯改写既有子作品的行),请求里的 `forkAuthorization` 一律被忽略且不报错(旧客户端惯常带默认值),父作品为禁止共创时在血缘解析里就以 `FORK_NOT_AUTHORIZED` 失败,因此继承到的档位只会是 `nonCommercial` / `full`;「收窄授权」不实现(产品决定 2026-10-06)。写入只发生在创建游戏(`create_game_distribution_game_and_return` 的 `GameDistributionCreateGameInput`)与提升 procedure `set_game_distribution_fork_authorization_and_return`(输入 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`)发生:只允许 owner 本人,`expected_fork_authorization` 不符返回 409,同 key 重放走 `game_distribution_idempotency_receipt`(action = `set_fork_authorization`)。`api-server` 把实现值映射为 HTTP 码(`FORK_AUTHORIZATION_UNKNOWN` 400、降级 409、非本人 403、未登录 401),路由 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 受发布灰度开关约束(收紧时 503,读接口不受影响)。 - 共创授权与软删除的交叉口径:已软删除的作品(`deleted_at` 非空)不再是可用的改编来源——`resolve_game_distribution_fork_declaration_tx` 先判 `deleted_at`,命中即按 `FORK_SOURCE_NOT_AVAILABLE` 失败(HTTP 409,与「未公开」同一错误码,不用错误码区分删除事实);既有子作品与血缘行不受影响,父作品的删除不会连带下线子作品。衍生计数(`game_distribution_public_fork_count`)只统计 `deleted_at` 为空且 `visibility = published` 的子作品,作者删除子作品后父作品的「已被改编 N 次」随之下降。 - 买断制定价(2026-10-05):游戏行末尾追加 `price_mud_points: u64` 并设置 `#[default(0u64)]`;`0` 表示免费,上限 `1_000_000`(复用 `module-game-distribution::normalize_game_price_mud_points` 校验)。价格是版本冻结资料的一部分:作者在 `GameDistributionCreateVersionRequest.priceMudPoints` 提交,写入版本冻结 `metadata_json.priceMudPoints`,只有 `approve_game_distribution_version_and_return` 通过审核时才随资料整体生效到本行;未通过审核或资料编辑都不会改变当前公开价格。公开投影(`get_public_game_distribution_game_and_return` 等)在游戏快照上带出 `priceMudPoints`。 - 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且活动版本存在、状态为 `published`(有效 `active_version_id`)的投影。 diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index def270d8a..5b14fb0e7 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -86,11 +86,11 @@ ### 2.3 授权模式(作者侧,作品级) -作者在上架时可选择,之后**只能单向提升开放度**: +作者在**母版**上架时可选择,之后**只能单向提升开放度**;**衍生作品没有选择权**——它在创建时直接继承父作品当时的档位,也不能收窄(见下方约束): | 值 | 用户可见文案 | 含义 | | --- | --- | --- | -| `forbidden`(默认) | 禁止共创 | 仅可游玩,不可复刻改编 | +| `forbidden`(母版默认) | 禁止共创 | 仅可游玩,不可复刻改编 | | `nonCommercial` | 允许非商用共创 | 可二次开发,禁止盈利,仅可公开分享 | | `full` | 允许全开放共创 | 可改编、可商用、可引流、可发布新版本 | @@ -98,7 +98,9 @@ ```mermaid stateDiagram-v2 - [*] --> forbidden : 创建作品(默认) + [*] --> forbidden : 母版创建(请求缺省) + [*] --> nonCommercial : 衍生作品继承父档位 + [*] --> full : 衍生作品继承父档位 forbidden --> nonCommercial : 作者提升 forbidden --> full : 作者跳级提升 nonCommercial --> full : 作者提升 @@ -106,6 +108,8 @@ stateDiagram-v2 ``` 约束: +- **衍生作品的档位 = 创建时快照的父作品档位**(2026-10-06 产品决定):创建请求里的 `forkAuthorization` 对衍生作品**一律被忽略且不报错**(旧客户端会惯常带自己的默认档位);父作品之后提升档位**不会回溯**改写既有子作品的行。因此「自动继承」与「只升不降」作用在同一行数据上不冲突:提升只改被提升的那一行。父作品档位为 `forbidden` 时根本不能建立血缘(`403 FORK_NOT_AUTHORIZED`),所以继承到的档位只可能是 `nonCommercial` 或 `full`。 +- **不做收窄入口**:衍生作品不能选择比父作品更窄的授权(产品决定 2026-10-06,「可选择收窄授权」已从需求中划掉)——「收窄」这条路径整体不实现。 - 只有 `owner_user_id` 可以变更;请求必须带 `expectedForkAuthorization`(CAS),不匹配返回 `409`。 - 任何降级请求一律 `409 FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED`,不写库。 - 已按旧授权完成的 Fork **不受后续提升影响**(授权在建立血缘时已兑现)。 @@ -127,7 +131,7 @@ stateDiagram-v2 | 位置 | 内容 | | --- | --- | | `/games/mine` 每张作品卡 | 新增「共创授权」三态设置(仅允许提升,终态 `full` 时只读);新增「被改编 N」入口,弹层列出直接子代(标题 / 作者 / 代际 / 状态);提升授权后若当前公开版本没有工程源包,行内提示「上传工程源码以支持源码级改造」(上传在桌面端客户端完成,网页端只做引导) | -| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」 | +| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」**且档位由服务端继承父作品当时的档位**——该场景下客户端所选档位不生效(不报错,见 §2.3) | | AGC 客户端 | ① 详情页「改造这个作品」唤起 AGC(唤起方式**待拍板**,候选见 §7 第 11 条;`genarrative://fork?gameId=` 的 deep link 目前**未注册**,只是候选之一);② AGC 首页/项目入口提供「从平台作品开始创作」(输入 gameId 或从平台跳转) | | `/games/detail` 详情页 | 新增「收藏」按钮(收藏 / 已收藏两态)。初始态取自公开详情的 `collected`(登录才有该字段);点击后 `PUT` / `DELETE /api/game-distribution/games/{gameId}/collection`,按钮态改用**响应里的权威投影值**(不写本地乐观状态——前端本地状态正是本功能要消灭的东西)。未公开 / 已软删除的作品返回 409,按钮按失败态提示 | | `/games/mine`(或「我的」入口)| 新增「我的收藏(收录)」列表:`GET /api/game-distribution/my-collections`,形状与广场一致(封面 / 标题 / 作者 / 游玩数),点击进详情。列表只含**当前公开可读**的作品;已下架作品的收藏行保留,作品重新公开后自动回来(不需要用户重新收藏) | @@ -161,7 +165,7 @@ sequenceDiagram participant A as 作者(AGC/网页) participant API as api-server A->>API: 发布游戏/版本,携带 forkAuthorization - API->>API: 校验三态合法;写入 game.fork_authorization + API->>API: 母版:校验三态合法并写入 game.fork_authorization;衍生作品:忽略该值,继承父作品当时的档位 A->>API: (AGC 且授权非 forbidden)上传工程源包 API->>API: 校验 zip(复用模板包门禁)→ OSS → version.project_bundle_* Note over A,API: 审核通过后作品公开,且可被改造 @@ -225,7 +229,7 @@ shared-contracts Rust DTO ←→ packages/shared TS DTO pub(crate) fork_authorization: String, ``` -旧行反序列化自动补 `"forbidden"`,与「默认禁止共创」语义一致。 +旧行反序列化自动补 `"forbidden"`,与「默认禁止共创」语义一致。写入有两个来源:**母版**按创建请求的档位(缺省 `forbidden`);**衍生作品**在创建时继承父作品当时的档位(创建时快照,见 §2.3)。 #### 3.2.2 新增 `game_distribution_lineage`(父子血缘,1:1) @@ -320,7 +324,7 @@ pub(crate) project_bundle_sha256: Option, | --- | --- | | `PUT /games/{gameId}/fork-authorization`(新) | body `{ expectedForkAuthorization, forkAuthorization }` + `Idempotency-Key`;只允许提升;返回最新 `forkAuthorization` 与 `replayed` | | `PUT /versions/{versionId}/project-bundle`(**M2b 已实现**) | `application/octet-stream` 整包一次上传(≤ 200 MiB);另有分片族 `GET …/project-bundle/upload-state`、`PUT …/project-bundle/chunk`(偏移头 `x-genarrative-upload-offset`)、`POST …/project-bundle/complete`(服务端独立跑工程包 zip 门禁 + 算摘要 + 确认)、`POST …/project-bundle/reset`(丢弃未确认的暂存对象)。前置:调用者是该版本作者;版本处于 `awaiting_upload` / `upload_failed`;该版本**尚无**已确认的工程包(换内容 → 409)。对象键由服务端派生(`…/{version_id}.project.zip`),不接受客户端指定。**两个按实现为准的细节(端到端实测,A5/A5b/A6)**:① **非作者上传返回 `404` 而不是 `403`**——api-server 用 `load_owner_version_or_404` 把 owner 不匹配按「版本不存在」处理,与发行包上行族同口径,既不会泄露「这个版本存在但不属于你」,响应里也不含对象键;② **阶段门先判「已存在」再判版本档位**——`ensure_project_bundle_uploadable` 先看 `project_bundle_bytes > 0`(→ 409 `PROJECT_BUNDLE_ALREADY_EXISTS`,因为确认工程包不驱动版本状态机,已确认的版本可能仍停在 `awaiting_upload`),再看 `status`(→ 409 `PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED`);因此「已公开**且已有**工程包」返回的是 `ALREADY_EXISTS`,只有「已公开**但还没有**工程包」才落到 `UPLOAD_NOT_ALLOWED` | -| `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId`;再追加可选 `forkAuthorization`(`forbidden` / `nonCommercial` / `full`,**缺省禁止共创**,非法取值整请求 400 + 平台信封),使作者**上架时**即可选择授权档位,不必事后提升 | +| `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId`;再追加可选 `forkAuthorization`(`forbidden` / `nonCommercial` / `full`,**母版缺省禁止共创**,非法取值整请求 400 + 平台信封),使作者**上架时**即可选择授权档位,不必事后提升。**该字段只对母版生效**:带 `fork` 声明的衍生作品在创建时**继承父作品当时的档位**(创建时快照),请求里传的值一律被忽略且不报错(旧客户端惯常带默认值);「收窄授权」不实现(产品决定 2026-10-06)。 | | `POST /games/{gameId}/versions`(**既有,请求增量**,2026-10-06「衍生作品发布必填核心改动说明」) | 追加可选 `changeSummary`:「本次核心改动说明」,发布设置页的「二创信息区」随包提交。**谁必填**:**衍生作品**(该 `gameId` **有血缘行**,即带过 `fork` 声明、代际 ≥ 1)**必须**提供;**0 代母版忽略该字段**——不校验、不落库(写 `NULL`)而不是「可选」,否则一个与血缘无关的字段会在母版上分叉出第二套语义。判定取**血缘行**本身、不接受客户端自称派生(`fork` 声明只在创建作品时生效一次),后续每一版都按血缘行判。**长度口径**:先 `trim` 再按**字符**数计(`chars().count()`,不是字节数——中文一字 3 字节,按字节算会让中文作者只能写 1/3 的内容),区间 **20–500**(常量 `GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS` / `_MAX_CHARS` 单点持有)。下限 20 的理由:这一项要回答「这一版相对来源作品改了什么」,比作品标题(40)短、比主题角标(16)长,是一句话能说清的最小规模;只写「改了」这类敷衍串等于没有说明,而展示层要把它当「这一代的差异」读。上限 500 的理由:它是发布流程里的一个文本框、不是正文(作品详细介绍 2000 才是正文),500 个字符足够写清「加了什么玩法、换了什么美术、修了什么」,同时保证详情页 / 族谱里相邻代际不会被挤出首屏。**失败关闭**且两条失败各有稳定码(都 400,客户端可只补说明或改短重试,不必按 409 去刷新后重放):缺失 / trim 后为空 → **`FORK_CHANGE_SUMMARY_REQUIRED`**;越界 → **`FORK_CHANGE_SUMMARY_INVALID`**。**存储**:`game_distribution_version` 表尾追加可空列 `change_summary`——存**版本级**(同一作品的不同代各说各的改动),随版本冻结不可改。**投影**:公开版本摘要发 `changeSummary`(衍生必有、母版 `null`),见上方公开详情行。**幂等**:该字段属于请求体,因此参与请求摘要——同 `Idempotency-Key` 换说明会被按「同键不同请求」拒绝(409);`serde` 上缺省**不序列化 `null`**,省略该键的旧客户端请求与升级前逐字节一致,同键重放不会被误判成新请求 | #### 登录用户(Bearer,**不叠加**发布灰度) @@ -454,6 +458,8 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的 补充(2026-10-05):作者**上架时即可指定档位**——创建作品的请求增量里新增可选 `forkAuthorization`(缺省 `forbidden`,非法取值 400 + 平台信封,见 §3.4),不再是「先落 `forbidden`、再靠 PUT 提升」;事后提升(`PUT …/fork-authorization`)仍然保留,用于把已上架作品单向提升。该增量对 #565 侧无影响:创建请求与游戏行默认值都保持向后兼容。 +补充(2026-10-06,授权继承口径,产品决定):**衍生作品创建时直接继承父作品当时的档位**,不再落 `forbidden`、也不接受客户端另选档位——带 `fork` 声明的创建请求里 `forkAuthorization` **一律被忽略且不报错**(旧客户端会惯常带自己的默认档位),新行的 `fork_authorization` 取父行的档位;母版维持原样(按请求值,缺省 `forbidden`)。继承是**创建时快照**:父作品之后提升档位不会回溯改写既有子作品的行,因此与「只升不降」在同一行数据上不冲突(提升只改被提升的那一行)。父作品为 `forbidden` 时本来就不能建立血缘(`403 FORK_NOT_AUTHORIZED`),所以继承到的档位必然是 `nonCommercial` 或 `full`。**收窄入口不做**(需求里「可选择收窄授权,不可放宽」已划掉)。落点:`spacetime-module/src/game_distribution.rs` 的血缘解析返回父行档位、创建事务经 `resolve_game_distribution_creation_fork_authorization` 裁决(衍生忽略请求值 / 母版按请求值)。`PUT …/fork-authorization` 的语义**未改**(仍只升不降、仍服务提升),本补充不改变它的合同。 + 同一批重叠文件(24 个)里多数是**机械冲突**(同一函数/同一 `json!` 块/同一枚举块各加一段),唯一需要重新生成的是 `spacetime-client/src/module_bindings/**`:合并后必须重跑 `npm run spacetime:generate`,不得手工合并生成物。 合并顺序:**先合 #565 到 master,再把本分支 rebase 到新 master**,然后重跑 §5.1 的全部门禁与本地发布 smoke。 @@ -772,6 +778,21 @@ pub struct GameDistributionThemeMember { --- +### 5.3 授权继承口径(2026-10-06)实施证据 + +| 项 | 证据 | 状态 | +| --- | --- | --- | +| 落点(事务内取父档位的那一步) | `server-rs/crates/spacetime-module/src/game_distribution.rs`:血缘解析 `resolve_game_distribution_fork_declaration_tx` 在既有校验(父存在 / 未软删 / 已公开 / 允许共创 / 来源版本等于父当前公开版本)之后把父行档位一并返回(`(root_game_id, generation, parent_authorization)`);`create_game_distribution_game_tx` 把血缘解析**排在授权裁决之前**,再调纯函数 `resolve_game_distribution_creation_fork_authorization(parent, input.fork_authorization)`,写行仍用裁决结果 | ✅ 已验证 | +| 衍生作品继承父档位(`full` / `nonCommercial` / `forbidden` 三条) | 行为矩阵 `creation_authorization_inherits_the_parent_tier_and_ignores_the_request`(父 `full` / `nonCommercial` 各请求组合都落父档位;父 `forbidden` 由结构性用例 `create_game_tx_takes_fork_authorization_from_the_parent_row` 钉住「在血缘解析里就以 `FORK_NOT_AUTHORIZED` 拒绝,继承函数拿不到它」) | ✅ 已验证(`cargo test -p spacetime-module` → 301 passed / 1 ignored) | +| 衍生作品传了别的档位也被忽略 | 同一矩阵覆盖:父 `full` 传 `forbidden` / `nonCommercial` / `full` / `allowed`(未知)、父 `nonCommercial` 传 `forbidden` / `full` / 空串,结果都等于父档位(不报错) | ✅ 已验证(同上) | +| 母版仍按请求值 / 缺省 `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`):改口径后衍生节点的创建档位已等于父档位,这些「再从 `forbidden` 提升」的 PUT 会因 CAS 不符回 409,需改成「按新继承档位断言 / 不再提升」。两脚本不属本次工作树权限,未改 | ⏳ 待改(需 `scripts/**` owner) | +| 客户端 UI 的衍生场景 | AGC 发布面板在衍生作品上仍可点选档位,而服务端已忽略该值(`src/**`、`apps/**` 不属本次工作树权限):建议后续把「从父作品 Fork 而来」的档位控件改为只读展示继承结果 | ⏳ 待接 | + +--- + ## 6. 里程碑拆分(评审后逐个开实施计划) | 里程碑 | 范围 | 交付判据 | @@ -791,7 +812,7 @@ pub struct GameDistributionThemeMember { > 第 1、8–12 条来自 2026-10-04 的只读侦察(`local://m2a-design.md`),其中 8–12 条是**第二阶段(M2a/M2b)落地前必须拍板**的五项;未拍板前不得据 §3.5.4 的推荐实现。 1. **成品包路径的事实边界已改写**(原条目「成品包路径默认要做……建议:做」的前提已被否证):成品包路径能试玩、能提供素材,但**不能发布**(§3.5.1)。因此要拍板的不再是「要不要顺带做工程源包」,而是「是否接受对外口径从『一键复刻完整工程』改成『参考改编 / 素材复用』,并把工程源包作为唯一源码级路径」。建议:接受并改口径(§3.5.4 路线 A 先行、B 排后续)。 -2. **授权默认值**:本文档按需求描述取「默认禁止」;飞书文档评论中包仲航建议改为「默认允许 + 发布前合同勾选」。两者会改变默认曝光面与合规口径,需产品拍板(默认允许对生态更友好,但要处理存量作品的合法性回溯)。(M1 已按「默认禁止」实现,改动需连带迁移口径。) +2. **授权默认值已定口径(2026-10-06,原为待拍板)**:**母版**默认仍取「禁止共创」(`forbidden`,飞书评论里「默认允许 + 发布前合同勾选」的建议未采纳);**衍生作品**改为**创建时继承父作品当时的档位**(不再落 `forbidden`、也不接受客户端另选或收窄),见 §2.3 / §3.9 补充。存量作品不受影响:既有子作品的档位保持原值(继承只在创建时发生,不回填)。 3. **「共创主题」已拍板采纳且服务端已实现(2026-10-06,原为待拍板)**:采纳包仲航提出的「平台命名主题 → 作品树」,且明确作品之间不存在曝光挂靠。设计见 §3.10,实施清单见 `docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md`(M4)。**已落地**:设计定稿 `063c04a1b`、数据模型与领域纯函数 `2fa201e0d`、公开读路径 `742723a58`、后台写路径 `033e3aa79`(前台共创 Tab / 主题页与后台管理 UI **仍未做**)。**仍待产品拍板的是两点**:① **主题级排序口径**——公开列表当前按「沿用既有 micros 游标」排在 `created_at` 倒序 + `themeId` 升序兜底上,`sort_order` 只用于主题内成员排序;若要求共创 Tab 按运营序展示,需要把游标改成 `(sort_order, theme_id)` 双键并同步前端;② **主题详情 `roots` 的 50 上限**——可见成员 > 50 时静默截断且无「还有更多」标志(`memberCount == roots.len()`),三个备选(去上限 / 给成员加独立游标 / `memberCount` 报真实可见数)见 §3.10.6 与 §3.10.9,**本轮不改契约**。 4. **收益分成**:需求文档要求「每一代均享有权益(署名 / 流量回馈 / 版权分成)」。署名本期做,流量回馈与分成本期不做(无账本、无算力成本口径,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算)。 5. **相似度反洗稿校验**:需求文档要求「低改动度复刻判定」。本期不做;本方案只保证来源声明真实、不可伪造。若要做,只能基于工程源包做结构化比对,属于独立议题。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 1f1e31ab5..e71695429 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -593,6 +593,10 @@ export type GameDistributionCreateGameRequest = { * * 缺省按「禁止共创」解释(与库表默认、与旧客户端行为一致);非法取值整请求 400,不会静默 * 落成 `forbidden`。上架之后只能通过 `PUT …/fork-authorization` 单向提升。 + * + * **只对母版(0 代作品)生效**:带 `fork` 声明时,服务端在新作品创建时**继承父作品当时的 + * 档位**,这里传的值被忽略且不报错(旧客户端惯常带默认值);服务端不接受「收窄」。 + * 字段形状刻意保持不变——改成 `Option` 之类的形状会让创建请求的幂等摘要漂移。 */ forkAuthorization?: GameDistributionForkAuthorization; /** 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 */ diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index bb901bf99..6607c4c9e 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -2863,6 +2863,8 @@ async fn create_game( input_modes_json: serde_json::to_string(&payload.input_modes) .map_err(|error| internal(error.to_string()))?, orientation: orientation_wire_value(payload.orientation)?, + // 只对**母版**生效:带 `fork` 声明时,新作品的档位由事务从父作品行继承(创建时快照), + // 这里原样透传的客户端取值会被忽略且不报错——旧客户端在衍生作品上惯常带默认档位。 fork_authorization: fork_authorization_value(payload.fork_authorization), idempotency_key, request_digest, diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 919c40eac..0a50ee057 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -585,6 +585,10 @@ pub struct GameDistributionCreateGameRequest { pub orientation: GameDistributionOrientation, /// 上架时选择的共创授权档位;缺省按「禁止共创」解释(与表列默认一致,旧客户端不受影响)。 /// + /// **只对母版(0 代作品)生效**:带 `fork` 声明时,新作品的档位在创建时**继承父作品当时的 + /// 档位**,本字段一律被忽略且不报错(旧客户端会惯常带默认值),服务端也不接受「收窄」。 + /// 字段形状刻意保持不变(非 `Option` + `#[serde(default)]`):改了会让幂等摘要漂移。 + /// /// 写成非 `Option` + `#[serde(default)]` 而不是 `Option<..>`:省略与显式传 `forbidden` /// 得到同一个值,因此 `create_game` 的幂等摘要(对整个请求体取摘要)也相同——省略与显式 /// 传默认不会变成两个不同的请求;未知取值由反序列化直接拒绝(api-server 映射 400), diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 15fa89aad..993857677 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -85,6 +85,8 @@ pub struct GameDistributionCreateGameRecordInput { pub input_modes_json: String, pub orientation: String, /// 上架时选择的共创授权档位(`forbidden` / `nonCommercial` / `full`)。 + /// 只对母版生效:带血缘声明时,事务在创建时继承父作品当时的档位并忽略本值(见 + /// `spacetime-module` 的 `resolve_game_distribution_creation_fork_authorization`)。 pub fork_authorization: String, pub idempotency_key: String, pub request_digest: String, diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 66725aca1..34ec99291 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -776,6 +776,10 @@ pub struct GameDistributionGame { /// 作品级共创(Fork)授权档位:`forbidden` / `nonCommercial` / `full`。 /// 只允许单向提升开放度;旧行按默认「禁止共创」解释。 /// + /// 写入有两种来源:母版按创建请求的档位(缺省 `forbidden`);**衍生作品在创建时继承 + /// 父作品当时的档位**(创建时快照,父作品之后提升不会回溯改写既有子作品的行)。 + /// 两种口径互不冲突:提升只改被提升的那一行。 + /// /// 本分支较 master 后追加,因此留在表尾(见上一列的注释)。 #[default("forbidden")] pub(crate) fork_authorization: String, @@ -1148,7 +1152,11 @@ pub struct GameDistributionCreateGameInput { pub input_modes_json: String, pub orientation: String, /// 上架时选择的共创授权档位(`forbidden` / `nonCommercial` / `full`); - /// 由 api-server 归一后传入,未知取值在事务里失败关闭。 + /// 由 api-server 归一后传入。 + /// + /// **只对母版有效**:带改编声明(`forked_from_game_id` / `forked_from_version_id`)时, + /// 新作品的档位在创建时**继承父作品当时的档位**,这里的值一律被忽略(不报错——旧客户端 + /// 会惯常带上自己的默认档位)。母版则按本字段落库:未知取值失败关闭。 pub fork_authorization: String, pub idempotency_key: String, pub request_digest: String, @@ -3246,9 +3254,13 @@ pub fn get_owner_game_distribution_version_and_return( } } -/// 校验改编(Fork)声明并返回(根作品 ID、子作品代际)。 +/// 校验改编(Fork)声明并返回(根作品 ID、子作品代际、**父作品当时的共创授权档位**)。 /// -/// 失败关闭:来源必须存在且未软删除、已公开、授权档位非「禁止共创」、来源版本必须恰好是 +/// 第三个返回值就是衍生作品在创建时要继承的档位:**创建时快照**——父作品之后提升档位不会 +/// 回溯改写既有子作品的行,因此它与「只升不降」(作用在被提升的那一行自身)不冲突。 +/// +/// 失败关闭:来源必须存在且未软删除、已公开、授权档位非「禁止共创」(父档位为禁止时这里就 +/// 失败,因此继承到的档位必然是 `nonCommercial` 或 `full`)、来源版本必须恰好是 /// 该作品当前公开版本(禁止指向历史版本或伪造)。 /// /// 「未软删除 + 已公开」这条规则本身放在 `module_game_distribution::can_serve_as_fork_source`, @@ -3259,7 +3271,7 @@ fn resolve_game_distribution_fork_declaration_tx( ctx: &ReducerContext, forked_from_game_id: &str, forked_from_version_id: &str, -) -> Result<(String, u32), String> { +) -> Result<(String, u32, module_game_distribution::ForkAuthorization), String> { let source = ctx .db .game_distribution_game() @@ -3313,9 +3325,34 @@ fn resolve_game_distribution_fork_declaration_tx( Ok(( root_game_id, module_game_distribution::next_generation(parent_generation), + authorization, )) } +/// 决定新作品行要写入的共创授权档位。 +/// +/// - **衍生作品**(有改编声明,`parent` 为 `Some`):直接取父作品**当时**的档位快照, +/// **忽略**客户端在 `fork_authorization` 里传的值(旧客户端惯常带自己的默认档位,这里不报错)。 +/// - **母版**(`parent` 为 `None`):按请求值落库;未知取值失败关闭——**绝不**静默落成 +/// `forbidden`(否则作者会以为自己设成功了)。 +/// +/// 抽成纯函数是为了让「继承」这条口径能被单测直接钉住:事务本身需要 `ReducerContext`, +/// 在 host 测试里起不了真库。继承是**创建时快照**,与「只升不降」作用在同一行数据上不冲突。 +fn resolve_game_distribution_creation_fork_authorization( + parent: Option, + requested: &str, +) -> Result { + match parent { + Some(authorization) => Ok(authorization), + None => module_game_distribution::ForkAuthorization::parse(requested).ok_or_else(|| { + module_game_distribution::GameDistributionError::ForkAuthorizationUnknown { + value: requested.to_string(), + } + .to_string() + }), + } +} + /// 买断制购买:同一事务内校验可见性/价格/未购买,走 runtime profile 钱包扣费并写购买行。 /// /// 幂等由 `game_distribution_idempotency_receipt`(action = `purchase`,owner = 购买者) @@ -3376,16 +3413,6 @@ fn create_game_distribution_game_tx( let idempotency_key = required_game_distribution_text(input.idempotency_key, "idempotency_key")?; let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?; - // 上架时的共创授权档位:api-server 已按 DTO 枚举挡掉未知取值,这里再判一次是为了让非 HTTP - // 调用方同样失败关闭——**绝不**静默落成 `forbidden`(否则作者会以为自己设成功了)。 - let fork_authorization = - module_game_distribution::ForkAuthorization::parse(input.fork_authorization.as_str()) - .ok_or_else(|| { - module_game_distribution::GameDistributionError::ForkAuthorizationUnknown { - value: input.fork_authorization.clone(), - } - .to_string() - })?; let receipt_id = game_distribution_receipt_id( owner_user_id.as_str(), GAME_DISTRIBUTION_ACTION_CREATE_GAME, @@ -3413,6 +3440,17 @@ fn create_game_distribution_game_tx( ), _ => None, }; + // 共创授权档位:衍生作品**在创建时继承父作品当时的档位**(父行已在上面校验过,档位必然 + // 是允许共创的一档),客户端在请求里带的值一律被忽略——旧客户端惯常带自己的默认档位, + // 这里不报错;母版仍按请求值,未知取值失败关闭(见 + // `resolve_game_distribution_creation_fork_authorization`)。授权裁决必须排在血缘解析 + // **之后**:没有父行就没有可继承的档位。 + let fork_authorization = resolve_game_distribution_creation_fork_authorization( + fork_resolution + .as_ref() + .map(|(_, _, parent_authorization)| *parent_authorization), + input.fork_authorization.as_str(), + )?; if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; @@ -3516,7 +3554,7 @@ fn create_game_distribution_game_tx( price_mud_points: 0, }); // 血缘与游戏行同事务写入:只有全新作品才会走到这里,复用身份已在上面被拒绝。 - if let (Some(parent_game_id), Some(parent_version_id), Some((root_game_id, generation))) = ( + if let (Some(parent_game_id), Some(parent_version_id), Some((root_game_id, generation, _))) = ( forked_from_game_id.as_deref(), forked_from_version_id.as_deref(), fork_resolution, @@ -7514,6 +7552,112 @@ mod tests { ); } + /// 创建作品事务:授权档位必须来自**父作品行**(衍生作品继承)或请求值(母版)。 + /// + /// 三条父档位分支的证据分两处: + /// - `full` / `nonCommercial`:父行必然允许共创,档位由血缘解析原样返回并在创建时落进新行 + /// (行为矩阵见下一个用例)。 + /// - `forbidden`:父行**在血缘解析里就被拒**(`allows_fork()` → `FORK_NOT_AUTHORIZED`), + /// 继承函数拿不到它,所以不可能落出一个「继承到禁止共创」的衍生作品。 + #[test] + fn create_game_tx_takes_fork_authorization_from_the_parent_row() { + let source = include_str!("game_distribution.rs"); + let resolver = function_body(source, "fn resolve_game_distribution_fork_declaration_tx("); + for required in [ + "ForkAuthorization::parse(source.fork_authorization.as_str())", + "authorization.allows_fork()", + "GameDistributionError::ForkNotAuthorized", + "next_generation(parent_generation)", + " authorization,", + ] { + assert!(resolver.contains(required), "血缘解析缺少:{required}"); + } + + let body = function_body(source, "fn create_game_distribution_game_tx("); + assert!( + body.contains("resolve_game_distribution_creation_fork_authorization("), + "创建事务必须经继承函数裁决授权档位" + ); + assert!( + !body.contains("ForkAuthorization::parse(input.fork_authorization"), + "创建事务不得直接解析请求档位:那会让衍生作品按客户端传值落库" + ); + let resolve_at = body + .find("resolve_game_distribution_fork_declaration_tx(") + .expect("必须先解析血缘"); + let inherit_at = body + .find("resolve_game_distribution_creation_fork_authorization(") + .expect("必须继承父档位"); + let insert_at = body + .find("fork_authorization: fork_authorization.as_str().to_string(),") + .expect("必须把裁决结果写进游戏行"); + assert!( + resolve_at < inherit_at, + "继承必须排在父行解析之后:没有父行就没有可继承的档位" + ); + assert!(inherit_at < insert_at, "继承必须在写库之前"); + } + + /// 继承矩阵:父档位逐一走一遍,客户端传值一律被忽略且不报错。 + /// + /// - 父 `full` / `nonCommercial`:请求里带另一个合法档位、带未知取值、带空串,落库的都是 + /// **父档位**(旧客户端会惯常带自己的默认档位,因此这条不能报错)。 + /// - 父 `forbidden`:创建在血缘解析里就被 `FORK_NOT_AUTHORIZED` 拒掉(结构性用例钉住), + /// 继承函数永远拿不到 forbidden;这里用「`forbidden` 不允许共创」把这条前提钉住。 + #[test] + fn creation_authorization_inherits_the_parent_tier_and_ignores_the_request() { + use module_game_distribution::ForkAuthorization::{Forbidden, Full, NonCommercial}; + + assert!( + !Forbidden.allows_fork(), + "父 forbidden 必须在上游被拒:继承到的档位只可能是允许共创的两档" + ); + + for (parent, requested) in [ + (Full, "forbidden"), + (Full, "nonCommercial"), + (Full, "full"), + (NonCommercial, "forbidden"), + (NonCommercial, "full"), + // 未知取值同样被忽略:HTTP 面由 DTO 枚举先挡成 400,这里证明非 HTTP 调用方也安全。 + (Full, "allowed"), + (NonCommercial, ""), + ] { + assert_eq!( + resolve_game_distribution_creation_fork_authorization(Some(parent), requested), + Ok(parent), + "衍生作品必须继承父档位 parent={parent:?} requested={requested:?}" + ); + } + } + + /// 母版:按请求值落库(缺省值由 DTO 归一成 `forbidden`,到事务里就是 `"forbidden"`); + /// 未知取值失败关闭——**绝不**静默落成 `forbidden`(否则作者会以为自己设成功了)。 + #[test] + fn creation_authorization_uses_the_request_for_masters_and_fails_closed_on_unknown() { + use module_game_distribution::ForkAuthorization::{Forbidden, Full, NonCommercial}; + + for (requested, expected) in [ + ("forbidden", Forbidden), + ("nonCommercial", NonCommercial), + ("full", Full), + ] { + assert_eq!( + resolve_game_distribution_creation_fork_authorization(None, requested), + Ok(expected), + "母版必须按请求值落库:requested={requested:?}" + ); + } + for requested in ["", "allowed", "FULL", "non_commercial"] { + let error = resolve_game_distribution_creation_fork_authorization(None, requested) + .expect_err("母版未知档位必须失败关闭"); + assert!( + error.starts_with("FORK_AUTHORIZATION_UNKNOWN:"), + "value={requested:?} got={error}" + ); + } + } + /// 说明校验助手只做「违规码 → 领域错误文案」的映射,规则本身仍由模块纯函数单点持有: /// 两个码分别映射到两个不同变体,HTTP 面才分得清「没填」与「超限」。 #[test]