feat(游戏共创): 衍生作品授权档位终态——禁止 PUT 修改(新可达码 FORK_AUTHORIZATION_INHERITED → 409)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
漏洞:继承是「创建时快照等值」,而 PUT 只限方向不限对象 ⇒ 作者可把继承来的 `nonCommercial` 再提成 `full`,
得到比祖先更宽的子作品,把父作品仅在非商用授权下公开的工程内容变成可商用。裁定:衍生作品档位是终态。
- **服务端**:`set_game_distribution_fork_authorization_tx` 在**幂等重放之后、CAS 之前**先查血缘行
(`game_distribution_lineage().game_id().find(...)`),衍生作品(有血缘行)**任何** PUT 都拒绝——含「传同值」
这种幂等重试,客户端不会遇到「有时成功有时失败」的随机性;裁决集中到新纯函数
`resolve_fork_authorization_promotion(has_lineage_row, current, expected, target)`(规则唯一实现,事务不再自己比
CAS / 不再出现 `can_promote_to`)。
- **新稳定错误码**:`FORK_AUTHORIZATION_INHERITED` → **409**;模块 `Display` 产出 `"{CODE}: 衍生作品的共创授权由
父作品继承,不能自行修改"`;api-server `FORK_` 映射登记;纳入 `fork_error_codes_are_reachable_from_module_messages`
枚举(状态码 + 稳定码),并保留「未登记码退化成 FORK_ERROR」的反证。
- **母版不变**:只升不降(可跳级)+ CAS + 幂等重放 + 未知档位 400 + 非 owner 403 全部照旧。
- **无 schema/契约形状变更**(无列/表/索引/DTO 新字段)。
- **测试**:新增 5 组衍生矩阵(同值 / 再提一级 / 终态再传同值 / 期望值过期 / 期望值与当前都不符 → 全 409 + 稳定码)、
母版提升与 CAS 语义回归、码前缀与可达性枚举扩展、以及「血缘行判定早于 CAS、事务不再自比 CAS」的结构断言。
**Rust 侧原本没有任何「衍生 PUT 成功」的断言可改**(已 grep 核对:该路径此前只有 401/400 信封用例),未删除或
弱化任何既有断言;断言旧语义的是 `scripts/check-game-distribution-lineage-e2e.mjs` 与
`scripts/capture-game-lineage-visual.mjs`(属其它 owner,本轮未改,已在报告里点名需其把衍生提升步骤改成「必被拒」)。
- **文档**:技术方案 §2.3(母版 vs 衍生两档 + 状态机只发生在母版行)、§2.6 时序图、§3.3 状态表、§3.4(PUT 行、
POST /games 行、lineage 行)、§3.9 新增「授权终态」补充与 §5.5 实施证据;并写明三件口径:**累计世代数由前端自算**
(子树最大代际 − 本作品代际,根有 3 层后代则为 3;服务端不加字段)、**changeSummary 已在版本摘要 payload 上**
(族谱面板只对选中节点查一次公开详情,不做 N+1、不给节点贴字段)、**衍生作品不会继承到 `forbidden`**
(父为 forbidden 时不允许共创,根本产生不了衍生作品)。decision-log 追加 2026-10-06 决策条。
门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **134 passed**;
`cargo test -p spacetime-module` **305 passed / 1 ignored**;`cargo test -p spacetime-client` 43 passed;
`cargo test -p api-server game_distribution` 107 passed;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`
0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:encoding` 0(5544 files);
`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。
This commit is contained in:
@@ -59,4 +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/check-game-distribution-lineage-e2e.mjs` 富树段、`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` 同日条目。
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-06 衍生作品的共创授权档位是终态:禁止 PUT 修改(新码 `FORK_AUTHORIZATION_INHERITED` → 409)
|
||||
|
||||
- 背景(真漏洞):上一轮「创建时继承父作品档位」只堵住了创建,`PUT /api/game-distribution/games/{gameId}/fork-authorization` 仍是「任意作品、只限方向不限对象」。继承是「创建时快照等值」,于是一个继承到 `nonCommercial` 的衍生作品可以被作者再提成 `full`——得到一个**比祖先更宽**的子作品,把父作品在非商用授权下公开的工程内容变成可用商用。
|
||||
- 决策:**衍生作品的档位是终态**,只由继承决定。判定 = 「该作品**存在血缘行**」(有父即衍生,不接受客户端自称)→ 直接拒绝,稳定错误码 **`FORK_AUTHORIZATION_INHERITED`** → **409 CONFLICT**(与既有 `FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED` 同为 409,码不同),文案「衍生作品的共创授权由父作品继承,不能自行修改」。
|
||||
- 口径(照此实现):**衍生作品的任何 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`(不属本工作树权限,见「遗留」)。
|
||||
- 契约与形状:**无列 / 表 / 索引 / 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`),根本产生不出衍生作品。
|
||||
- 遗留(不在本次工作树权限内):两个 e2e 脚本里对衍生作品的 PUT 现在**一律**回 409 `FORK_AUTHORIZATION_INHERITED`(不再只是 CAS 不符),需改成「按新继承档位断言 / 只对母版提升」——由 `scripts/**` owner 并行改写。前端侧已接:`MyGamesPage` 对衍生作品隐藏提升入口、改为只读展示(`4f888d8c9`),AGC 发布面板同轮改为只读展示「授权继承自父作品」;`/lineage` 世代数按子树深度自算、面板读版本摘要层(`bd73acfcc`)。
|
||||
- 验证方式:`.worktrees/feat/game-fork` 上自跑 12 条门禁全部 exit 0(wasm build、`cargo check --all-targets`、`cargo test -p module-game-distribution`、`cargo test -p api-server game_distribution`、`cargo test -p spacetime-module`、`cargo test -p spacetime-client`、`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 见技术方案 §5.5。
|
||||
|
||||
## 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、幂等重放)原样保留;它继续服务「提升」。
|
||||
- 未改(后被同日「档位终态」决策收紧,见上一条;「原样保留」现在只对**母版**成立):`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 脚本)。
|
||||
|
||||
@@ -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` 解释,旧客户端缺字段同样按禁止共创兜底。**创建时的档位来源分两种(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,读接口不受影响)。
|
||||
- 共创授权:游戏行末尾追加 `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)。**衍生作品的档位是终态(2026-10-06)**:存在血缘行时任何 PUT 都被拒——**409 `FORK_AUTHORIZATION_INHERITED`**(与降级同为 409、码不同),含「传同值」这种幂等重试;判定取血缘行且**先于 CAS**(同一衍生作品不因载荷不同而回不同的码),母版的只升不降 + CAS 语义不变。写入只发生在创建游戏(`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`)的投影。
|
||||
|
||||
@@ -86,7 +86,10 @@
|
||||
|
||||
### 2.3 授权模式(作者侧,作品级)
|
||||
|
||||
作者在**母版**上架时可选择,之后**只能单向提升开放度**;**衍生作品没有选择权**——它在创建时直接继承父作品当时的档位,也不能收窄(见下方约束):
|
||||
档位**只写在作品行上**,两种作品的规则不同:
|
||||
|
||||
- **母版(0 代作品,没有血缘行)**:作者上架时可选,之后**只能单向提升开放度**(可跳级),请求必须带 `expectedForkAuthorization`(CAS);降级一律拒绝。
|
||||
- **衍生作品(有血缘行)**:档位在创建时**继承父作品当时的档位**,是**终态**——作者既不能收窄(入口整体不做),也不能提升:任何 `PUT …/fork-authorization`(含传同值这种幂等重试)一律 **409 `FORK_AUTHORIZATION_INHERITED`**。若放行,作者就能把继承来的 `nonCommercial` 提成 `full`,得到一个**比祖先更宽**的子作品,父作品在非商用授权下公开的工程内容会因此变成可用商用。
|
||||
|
||||
| 值 | 用户可见文案 | 含义 |
|
||||
| --- | --- | --- |
|
||||
@@ -94,24 +97,26 @@
|
||||
| `nonCommercial` | 允许非商用共创 | 可二次开发,禁止盈利,仅可公开分享 |
|
||||
| `full` | 允许全开放共创 | 可改编、可商用、可引流、可发布新版本 |
|
||||
|
||||
状态机(唯一合法迁移):
|
||||
状态机(唯一合法迁移;**提升只发生在母版行上**,衍生作品行一旦由创建写入即不再变化):
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> forbidden : 母版创建(请求缺省)
|
||||
[*] --> nonCommercial : 衍生作品继承父档位
|
||||
[*] --> full : 衍生作品继承父档位
|
||||
forbidden --> nonCommercial : 作者提升
|
||||
forbidden --> full : 作者跳级提升
|
||||
nonCommercial --> full : 作者提升
|
||||
[*] --> nonCommercial : 衍生作品继承父档位(终态)
|
||||
[*] --> full : 衍生作品继承父档位(终态)
|
||||
forbidden --> nonCommercial : 母版作者提升
|
||||
forbidden --> full : 母版作者跳级提升
|
||||
nonCommercial --> full : 母版作者提升
|
||||
full --> [*] : 终态(不可降级)
|
||||
```
|
||||
|
||||
约束:
|
||||
- **衍生作品的档位 = 创建时快照的父作品档位**(2026-10-06 产品决定):创建请求里的 `forkAuthorization` 对衍生作品**一律被忽略且不报错**(旧客户端会惯常带自己的默认档位);父作品之后提升档位**不会回溯**改写既有子作品的行。因此「自动继承」与「只升不降」作用在同一行数据上不冲突:提升只改被提升的那一行。父作品档位为 `forbidden` 时根本不能建立血缘(`403 FORK_NOT_AUTHORIZED`),所以继承到的档位只可能是 `nonCommercial` 或 `full`。
|
||||
- **衍生作品的档位 = 创建时快照的父作品档位**(2026-10-06 产品决定):创建请求里的 `forkAuthorization` 对衍生作品**一律被忽略且不报错**(旧客户端会惯常带自己的默认档位);父作品之后提升档位**不会回溯**改写既有子作品的行(提升只改被提升的那一行)。
|
||||
- **衍生作品之下没有第二条迁移**(2026-10-06 产品决定,取代「继承的行上还能再提升」的读法):档位是终态,`PUT` 一律 409 `FORK_AUTHORIZATION_INHERITED`;判定取**血缘行**(有父即衍生),不接受客户端自称,且**先于 CAS**——同一个衍生作品不会因为载荷不同(传同值 / 传新值 / 期望值过期)而回不同的码。母版(无血缘行)的语义完全不变。
|
||||
- **衍生作品不会继承到 `forbidden`**:父作品为 `forbidden` 时根本不允许共创(`403 FORK_NOT_AUTHORIZED`),建立不了血缘,因此**产生不出**衍生作品;继承到的档位只可能是 `nonCommercial` 或 `full`。
|
||||
- **不做收窄入口**:衍生作品不能选择比父作品更窄的授权(产品决定 2026-10-06,「可选择收窄授权」已从需求中划掉)——「收窄」这条路径整体不实现。
|
||||
- 只有 `owner_user_id` 可以变更;请求必须带 `expectedForkAuthorization`(CAS),不匹配返回 `409`。
|
||||
- 任何降级请求一律 `409 FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED`,不写库。
|
||||
- 只有 `owner_user_id` 可以变更(非作者 403);**母版**的提升请求必须带 `expectedForkAuthorization`(CAS),不匹配返回 `409`。
|
||||
- **母版**的任何降级请求一律 `409 FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED`,不写库。
|
||||
- 已按旧授权完成的 Fork **不受后续提升影响**(授权在建立血缘时已兑现)。
|
||||
- **提升与源码的时序**:工程源包只能在版本**尚未公开**时随版本上传(阶段门与发行包确认一致);作品公开后**不接受**补传(本期不做 backfill),因此「先禁止共创、后提升授权」的作品在其当前公开版本上不会被源码级改造,只能走「试玩 + 素材参考」。提升界面对此如实提示,不作为提升的前置条件。
|
||||
|
||||
@@ -124,14 +129,14 @@ stateDiagram-v2
|
||||
| `/games` 广场卡片 | `forkedFrom` 存在时显示「改编」角标;不做父子聚合,卡片仍独立展示 |
|
||||
| `/games/detail` 详情页 | ① 授权徽章(禁止共创 / 允许非商用共创 / 允许全开放共创);② 有父作品时显示溯源卡「改编自《X》· 由 Y 制作」+ 可点进父作品;③ 显示「第 N 代作品」与「N 个衍生作品」;④ `forkAuthorization != forbidden` 时显示主行动作「改造这个作品」(未登录点击走既有登录门禁) |
|
||||
| `/games/detail` 族谱入口 | 「查看创作族谱」→ `/games/lineage?id=<rootGameId>` |
|
||||
| `/games/lineage`(新页面) | 以根作品为顶的树:根节点、各代分支、每代作品卡(封面 / 标题 / 作者 / 第 N 代 / 游玩数),点击进入详情;父作品已下架时该节点显示「原作品已下架」但仍可点;空态与加载失败按现有平台错误组件 |
|
||||
| `/games/lineage`(新页面) | 以根作品为顶的树:根节点、各代分支、每代作品卡(封面 / 标题 / 作者 / 第 N 代 / 游玩数),点击进入详情;父作品已下架时该节点显示「原作品已下架」但仍可点;空态与加载失败按现有平台错误组件。**累计世代数(「这棵树往下还有几代」)由前端自算**:本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),服务端**不提供**该字段,前端从同一次 `/lineage` 响应自算,不额外发请求。右侧面板展示选中节点的「本次核心改动说明」时,**只对选中节点请求一次公开详情**取 `currentVersion.changeSummary`(衍生作品为字符串、母版为 `null`)——不做 N+1、不给族谱节点贴该字段(详见 §3.4 的 `/lineage` 行)。两条前端口径均已落地(`bd73acfcc`:世代数按子树深度计算、面板读版本摘要层) |
|
||||
|
||||
**作者 / 已登录**
|
||||
|
||||
| 位置 | 内容 |
|
||||
| --- | --- |
|
||||
| `/games/mine` 每张作品卡 | 新增「共创授权」三态设置(仅允许提升,终态 `full` 时只读);新增「被改编 N」入口,弹层列出直接子代(标题 / 作者 / 代际 / 状态);提升授权后若当前公开版本没有工程源包,行内提示「上传工程源码以支持源码级改造」(上传在桌面端客户端完成,网页端只做引导) |
|
||||
| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」**且档位由服务端继承父作品当时的档位**——该场景下客户端所选档位不生效(不报错,见 §2.3) |
|
||||
| `/games/mine` 每张作品卡 | 新增「共创授权」三态设置(**只有母版**有该入口:仅允许提升,终态 `full` 时只读;**衍生作品的档位由父作品继承、是终态**,不提供该入口——入口隐藏已落地(`4f888d8c9`:网页端改为只读展示「继承自父作品」),服务端即使收到 `PUT` 也一律 409 `FORK_AUTHORIZATION_INHERITED`);新增「被改编 N」入口,弹层列出直接子代(标题 / 作者 / 代际 / 状态);提升授权后若当前公开版本没有工程源包,行内提示「上传工程源码以支持源码级改造」(上传在桌面端客户端完成,网页端只做引导) |
|
||||
| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」**且档位由服务端继承父作品当时的档位**——该场景下客户端所选档位不生效(不报错,见 §2.3),且**该作品从此没有「修改授权」入口**(终态);AGC 发布面板同样只读展示「授权继承自父作品」(同轮改动) |
|
||||
| AGC 客户端 | ① 详情页「改造这个作品」唤起 AGC(唤起方式**待拍板**,候选见 §7 第 10 条;`genarrative://fork?gameId=<id>` 的 deep link 目前**未注册**,只是候选之一);② AGC 首页/项目入口提供「从平台作品开始创作」(输入 gameId 或从平台跳转) |
|
||||
| `/games/detail` 详情页 | 新增「收藏」按钮(收藏 / 已收藏两态)。初始态取自公开详情的 `collected`(登录才有该字段);点击后 `PUT` / `DELETE /api/game-distribution/games/{gameId}/collection`,按钮态改用**响应里的权威投影值**(不写本地乐观状态——前端本地状态正是本功能要消灭的东西)。未公开 / 已软删除的作品返回 409,按钮按失败态提示 |
|
||||
| `/games/mine`(或「我的」入口)| 新增「我的收藏(收录)」列表:`GET /api/game-distribution/my-collections`,形状与广场一致(封面 / 标题 / 作者 / 游玩数),点击进详情。列表只含**当前公开可读**的作品;已下架作品的收藏行保留,作品重新公开后自动回来(不需要用户重新收藏) |
|
||||
@@ -169,7 +174,7 @@ sequenceDiagram
|
||||
A->>API: (AGC 且授权非 forbidden)上传工程源包
|
||||
API->>API: 校验 zip(复用模板包门禁)→ OSS → version.project_bundle_*
|
||||
Note over A,API: 审核通过后作品公开,且可被改造
|
||||
A->>API: 后续提升授权(PUT /games/{gameId}/fork-authorization,仅升)
|
||||
A->>API: 后续修改授权(PUT /games/{gameId}/fork-authorization,仅母版、只升不降;衍生作品一律 409)
|
||||
```
|
||||
|
||||
**B. 用户一键改造(目标形态;成品包分支只到「试玩 + 素材」)**
|
||||
@@ -291,7 +296,7 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
|
||||
| 对象 | 字段 | 取值 | 合法迁移 | 触发方 | 并发控制 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| game | `fork_authorization` | `forbidden` / `nonCommercial` / `full` | 只升不降(可跳级) | owner | `expectedForkAuthorization` 值 CAS |
|
||||
| game | `fork_authorization` | `forbidden` / `nonCommercial` / `full` | **母版**:只升不降(可跳级);**衍生作品(有血缘行)**:终态,任何 `PUT` 都拒(409 `FORK_AUTHORIZATION_INHERITED`,含传同值) | owner | `expectedForkAuthorization` 值 CAS(衍生作品的拒绝先于 CAS,因此码不随载荷变化) |
|
||||
| lineage | 全字段 | 创建即不可变 | 无 | 创建 game 时 | 主键冲突 → 409 |
|
||||
| version | `project_bundle_*` | 有 / 无 | 只在 `awaiting_upload` / `upload_failed` 时可写一次(未公开前);写入后不可再改,换内容 → 409 | owner | 与 `confirm_package` 同一幂等键族 |
|
||||
|
||||
@@ -313,7 +318,7 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
| 方法 / 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `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 占位节点 |
|
||||
| `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 |
|
||||
| `GET /api/game-distribution/themes?limit=&cursor=`(新,2026-10-06) | 公开共创主题列表。**匿名可读 + `no-store`**,只含 `status == published` 的主题。响应 `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`;`memberCount` = 该主题**真实可见成员数**(不截断、不套响应体积上限,也不是成员行总数)。它与详情 `roots` 的长度在可见成员超过 50 时**故意不相等**——「展示上限」不该污染「这个主题有多少棵树」,判定口径仍是同一份。分页沿用 `/my-collections` 那套游标惯例:`limit` 缺省 **20**、上限 **50**、超界**截断**(客户端拿到一个完整页,而不是需要重试的错误);`cursor` 形如 `"{createdAtMicros}:{themeId}"`(解析只切第一个冒号);**非法游标 → 400 `THEME_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页);`nextCursor` 为真实值,**末页为 `null`**。排序 `created_at` 倒序 + `themeId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」 |
|
||||
| `GET /api/game-distribution/themes/{themeId}`(新,2026-10-06) | 公开主题详情。**匿名可读 + `no-store`**;顶层**扁平** `{ themeId, name, summary, badge, memberCount, roots, rootsTruncated }`(与 `GET /games/{gameId}` 同形,不引入第二套包装)。`roots` 逐条是公开目录**同一份** `public_game_payload` 投影,按 `sort_order` 升序 + `member_id` 升序稳定排序(不泄露对象键、不泄露未公开作品)。主题**不存在 / `draft` / `archived` 一律 404**(模块侧回同一句「主题不存在」,由既有映射落 404;**不返回空壳**、公开侧不发 `THEME_*` 码);**已发布但可见成员为空 = 200 + 空 `roots`**(空态而不是错误)。**成员数口径已定(2026-10-06)**:`roots` 受响应体积上限 **50** 约束(排序后取前 50 条),`memberCount` 报**真实可见成员数**(不截断),`rootsTruncated` 仅当 `roots` 少于 `memberCount` 时为 `true`——即「下面这份 `roots` 被上限截断」的显式信号,与族谱响应的 `truncated` 同约定(详见 §3.10.6 与 §3.10.9) |
|
||||
@@ -322,9 +327,9 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
|
||||
| 方法 / 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `PUT /games/{gameId}/fork-authorization`(新) | body `{ expectedForkAuthorization, forkAuthorization }` + `Idempotency-Key`;只允许提升;返回最新 `forkAuthorization` 与 `replayed` |
|
||||
| `PUT /games/{gameId}/fork-authorization`(新) | body `{ expectedForkAuthorization, forkAuthorization }` + `Idempotency-Key`;**只有母版可用**——母版只允许提升(降级与未知档位拒绝),返回最新 `forkAuthorization` 与 `replayed`;**衍生作品(存在血缘行)任何请求都 409 `FORK_AUTHORIZATION_INHERITED`**(与降级同为 409、码不同),包括传同值的幂等重试。判定取**血缘行**且**先于 CAS**,所以同一个衍生作品不会因载荷不同(同值 / 新值 / 期望值过期)而回不同的码;失败一律不写库 |
|
||||
| `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 + 平台信封),使作者**上架时**即可选择授权档位,不必事后提升。**该字段只对母版生效**:带 `fork` 声明的衍生作品在创建时**继承父作品当时的档位**(创建时快照),请求里传的值一律被忽略且不报错(旧客户端惯常带默认值);「收窄授权」不实现(产品决定 2026-10-06)。 |
|
||||
| `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId`;再追加可选 `forkAuthorization`(`forbidden` / `nonCommercial` / `full`,**母版缺省禁止共创**,非法取值整请求 400 + 平台信封),使作者**上架时**即可选择授权档位,不必事后提升。**该字段只对母版生效**:带 `fork` 声明的衍生作品在创建时**继承父作品当时的档位**(创建时快照),请求里传的值一律被忽略且不报错(旧客户端惯常带默认值);「收窄授权」不实现(产品决定 2026-10-06)。继承来的档位是**终态**:该作品之后再调 `PUT …/fork-authorization` 一律 409 `FORK_AUTHORIZATION_INHERITED`(母版才走只升不降);父作品为 `forbidden` 时建立不了血缘,因此衍生作品**不会继承到 `forbidden`**。 |
|
||||
| `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,**不叠加**发布灰度)
|
||||
@@ -466,7 +471,9 @@ 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` 的语义**未改**(仍只升不降、仍服务提升),本补充不改变它的合同。
|
||||
补充(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` 的**母版**合同未改(仍只升不降、仍服务提升);但衍生作品的提升随后被关掉,见下一条补充。
|
||||
|
||||
补充(2026-10-06,授权**终态**口径,产品决定):**衍生作品的档位是终态**,不再允许沿 `PUT …/fork-authorization` 提升。原口径「继承只影响创建、提升照旧」留下一个真漏洞:继承是「创建时快照等值」,而 `PUT` 只限方向不限对象——作者可把继承来的 `nonCommercial` 再提成 `full`,得到一个**比祖先更宽**的子作品,把父作品在非商用授权下公开的工程内容变成可用商用。现在的判定:该作品**存在血缘行(即衍生作品)→ 直接拒绝**,稳定错误码 **`FORK_AUTHORIZATION_INHERITED`(409)**,文案「衍生作品的共创授权由父作品继承,不能自行修改」。口径是**任何** `PUT` 都拒(含传同值这种幂等重试),因为档位是终态、只由继承决定,这样客户端不会遇到「有时成功有时失败」的随机性;母版(无血缘行)保持可提升(只升不降 + CAS 语义一律不变)。判定落点:`spacetime-module/src/game_distribution.rs` 的 `set_game_distribution_fork_authorization_tx`——**幂等重放分支之后、CAS 之前**查血缘行(`game_distribution_lineage().game_id().find(...)`),把 `has_lineage_row` 连同 `current` / `expectedForkAuthorization` / `target` 交给纯函数 `module_game_distribution::resolve_fork_authorization_promotion` 一次裁决(衍生终态 → CAS → 只升不降;判据只有这一份实现)。api-server 的 `FORK_` 映射表登记同一码(409),并纳入「模块真实文案可达」枚举测试(未登记的码会退化成 `FORK_ERROR`/409,该反证保持)。
|
||||
|
||||
同一批重叠文件(24 个)里多数是**机械冲突**(同一函数/同一 `json!` 块/同一枚举块各加一段),唯一需要重新生成的是 `spacetime-client/src/module_bindings/**`:合并后必须重跑 `npm run spacetime:generate`,不得手工合并生成物。
|
||||
|
||||
@@ -904,8 +911,8 @@ 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`):改口径后衍生节点的创建档位已等于父档位,这些「再从 `forbidden` 提升」的 PUT 会因 CAS 不符回 409,需改成「按新继承档位断言 / 不再提升」。两脚本不属本次工作树权限,未改 | ⏳ 待改(需 `scripts/**` owner) |
|
||||
| 客户端 UI 的衍生场景 | AGC 发布面板在衍生作品上仍可点选档位,而服务端已忽略该值(`src/**`、`apps/**` 不属本次工作树权限):建议后续把「从父作品 Fork 而来」的档位控件改为只读展示继承结果 | ⏳ 待接 |
|
||||
| 遗留:两个 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 不符),需改成「按新继承档位断言 / 不再提升」。两脚本不属本次工作树权限,未改(工作树里已见 `scripts/**` owner 并行改写的改动) | ⏳ 待改(需 `scripts/**` owner) |
|
||||
| 客户端 UI 的衍生场景 | 已落地(后续提交):`src/components/game-distribution/MyGamesPage.tsx` 对衍生作品隐藏提升入口、改为只读展示「继承自父作品」(`4f888d8c9`,含用例「衍生作品的授权入口改为只读展示(授权继承父作品,不可单独提升)」);AGC 发布面板 `GameDistributionPublishPanel.tsx` 同轮改为只读展示「授权继承自父作品」 | ✅ 已接(由 `src/**`、`apps/**` owner 落地) |
|
||||
|
||||
---
|
||||
|
||||
@@ -925,6 +932,22 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,
|
||||
|
||||
---
|
||||
|
||||
### 5.5 衍生作品授权终态(2026-10-06)实施证据
|
||||
|
||||
| 项 | 证据 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 错误码与状态码 | `FORK_AUTHORIZATION_INHERITED` → **409 CONFLICT**(与 `FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED` 同状态码、不同码)。模块常量 `module_game_distribution::GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE`,`Display` 插值它产出 `"{CODE}: 衍生作品的共创授权由父作品继承,不能自行修改"`;api-server 的 `FORK_` 映射表逐条登记该码 | ✅ 已验证(module 的 `inherited_authorization_message_starts_with_its_code` + api-server 的 `fork_error_codes_are_reachable_from_module_messages`) |
|
||||
| 判定落点(哪个事务的哪一步) | `set_game_distribution_fork_authorization_tx`:**幂等重放分支之后、CAS 之前**查 `game_distribution_lineage().game_id().find(&game_id)` 得 `has_lineage_row`,再交给纯函数 `resolve_fork_authorization_promotion(has_lineage_row, current, expected, target)` 裁决(顺序:衍生终态 → CAS → 只升不降);失败不写库,事务里不再出现 `can_promote_to`、也不自己比 CAS 期望值 | ✅ 已验证(结构性用例 `set_fork_authorization_tx_rejects_derivatives_before_cas`:血缘行先于裁决、裁决先于 `.update`) |
|
||||
| 衍生作品任何 PUT 都被拒(含传同值) | 单测矩阵:同值 / 想再提一级 / 终态再传同值 / 期望值已过期 / 期望值与当前值都不符 —— 五种都回同一码(判定先于 CAS,码不随载荷变化) | ✅ 已验证(`derivative_put_is_rejected_even_with_the_same_or_stale_value`) |
|
||||
| 母版语义未变 | `forbidden → nonCommercial` / `forbidden → full` / `nonCommercial → full` 照常放行;同级与降级仍回 `FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED:`;CAS 不符仍是纯文案(含「不匹配」,不带 `FORK_` 前缀) | ✅ 已验证(`master_put_still_promotes_with_unchanged_cas_semantics`) |
|
||||
| 契约与形状 | **无列 / 表 / 索引 / DTO 形状变更**(只加一个错误码常量与错误变体);`GameDistributionSetForkAuthorizationRequest` serde 形状未动,幂等摘要不变 | ✅ 已验证(`SPACETIME_SCHEMA_BASE_REF=9f4c7d76`:98 表通过;`check-game-distribution-dto-parity`:66 组类型一致) |
|
||||
| 文档 | 技术方案 §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`,需按新语义改写(按新继承档位断言 / 只对母版提升)。两脚本不属本次工作树权限,未改(工作树里已见 `scripts/**` owner 并行的改动)。前端入口与只读展示已落地(见 §5.3 对应的两行) | ⏳ 待接(`scripts/**` owner) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 里程碑拆分(评审后逐个开实施计划)
|
||||
|
||||
| 里程碑 | 范围 | 交付判据 |
|
||||
@@ -948,7 +971,7 @@ procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,
|
||||
> ② **结算**——统一为「**本期只做贡献归集与归因计算**(§3.11),**资金 / 分成不在范围内**(口径留待产品决定)」。原先「做不做分成」的问法作废:本期连账本与金额口径都没有,不预留半成品字段。
|
||||
|
||||
1. **成品包路径的事实边界已改写**(原条目「成品包路径默认要做……建议:做」的前提已被否证):成品包路径能试玩、能提供素材,但**不能发布**(§3.5.1)。因此要拍板的不再是「要不要顺带做工程源包」,而是「是否接受对外口径从『一键复刻完整工程』改成『参考改编 / 素材复用』,并把工程源包作为唯一源码级路径」。建议:接受并改口径(§3.5.4 路线 A 先行、B 排后续)。
|
||||
2. **授权默认值已定口径(2026-10-06,原为待拍板)**:**母版**默认仍取「禁止共创」(`forbidden`,飞书评论里「默认允许 + 发布前合同勾选」的建议未采纳);**衍生作品**改为**创建时继承父作品当时的档位**(不再落 `forbidden`、也不接受客户端另选或收窄),见 §2.3 / §3.9 补充。存量作品不受影响:既有子作品的档位保持原值(继承只在创建时发生,不回填)。
|
||||
2. **授权默认值与终态已定口径(2026-10-06,原为待拍板)**:**母版**默认仍取「禁止共创」(`forbidden`,飞书评论里「默认允许 + 发布前合同勾选」的建议未采纳);**衍生作品**改为**创建时继承父作品当时的档位**(不再落 `forbidden`、也不接受客户端另选或收窄),并且继承来的档位是**终态**——衍生作品任何 `PUT …/fork-authorization` 都回 409 `FORK_AUTHORIZATION_INHERITED`(含传同值的幂等重试),只有母版能单向提升。见 §2.3 / §3.4 / §3.9 两条补充。存量作品不受影响:既有子作品的档位保持原值(继承只在创建时发生,不回填;本改动只堵住「再提升」,不回溯改写任何行)。**仍待接的是前端**:AGC 发布面板 / `/games/mine` 在衍生作品上不该再给出可点的档位控件(服务端已一律拒绝),见 §5.3 遗留。
|
||||
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. **结算口径(2026-10-06 统一)**:需求文档要求「每一代均享有权益(署名 / 流量回馈 / 版权分成)」。署名本期已做;**本期只做贡献归集与归因计算**(§3.11),**资金 / 分成 / 版税结算与流量回馈不在范围内**,口径留待产品决定(无账本、无算力成本口径,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算)。贡献数是**计算事实**,不是可结算金额,也没有预留任何金额字段。
|
||||
5. **「永久链上溯源」表述**:实现为平台持久化的不可变父子链,不上链。需确认该措辞是否可以调整。
|
||||
|
||||
@@ -357,7 +357,12 @@ export type GameDistributionGame = {
|
||||
currentVersion?: GameDistributionVersionSummary | null;
|
||||
/** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */
|
||||
ratingSummary?: GameDistributionRatingSummary;
|
||||
/** 共创授权三态;旧数据或字段缺省时读作 `forbidden`。 */
|
||||
/**
|
||||
* 共创授权三态;旧数据或字段缺省时读作 `forbidden`。
|
||||
*
|
||||
* 衍生作品(有血缘行)的档位由创建时继承父作品决定、是**终态**(不能靠 `PUT` 提升);
|
||||
* 只有母版能单向提升,因此 `forbidden` 只可能出现在母版上。
|
||||
*/
|
||||
forkAuthorization?: GameDistributionForkAuthorization;
|
||||
/** 被改编次数(直接子代数量);旧数据或字段缺省时读作 0。 */
|
||||
forkCount?: number;
|
||||
@@ -652,10 +657,13 @@ export type GameDistributionCreateGameRequest = {
|
||||
* 上架时选择的共创授权档位:`forbidden` / `nonCommercial` / `full`。
|
||||
*
|
||||
* 缺省按「禁止共创」解释(与库表默认、与旧客户端行为一致);非法取值整请求 400,不会静默
|
||||
* 落成 `forbidden`。上架之后只能通过 `PUT …/fork-authorization` 单向提升。
|
||||
* 落成 `forbidden`。上架之后**母版**只能通过 `PUT …/fork-authorization` 单向提升。
|
||||
*
|
||||
* **只对母版(0 代作品)生效**:带 `fork` 声明时,服务端在新作品创建时**继承父作品当时的
|
||||
* 档位**,这里传的值被忽略且不报错(旧客户端惯常带默认值);服务端不接受「收窄」。
|
||||
* 档位**,这里传的值被忽略且不报错(旧客户端惯常带默认值);服务端不接受「收窄」。继承来的
|
||||
* 档位是**终态**——衍生作品再调 `PUT …/fork-authorization` 一律被拒(409
|
||||
* `FORK_AUTHORIZATION_INHERITED`);父作品为 `forbidden` 时建立不了血缘,因此衍生作品
|
||||
* 不会继承到 `forbidden`。
|
||||
* 字段形状刻意保持不变——改成 `Option` 之类的形状会让创建请求的幂等摘要漂移。
|
||||
*/
|
||||
forkAuthorization?: GameDistributionForkAuthorization;
|
||||
@@ -796,7 +804,12 @@ export type GameDistributionCancelVersionResponse = {
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
/** 提升共创授权;`expectedForkAuthorization` 是 CAS 期望值,只允许升级。 */
|
||||
/**
|
||||
* 修改共创授权;`expectedForkAuthorization` 是 CAS 期望值。
|
||||
*
|
||||
* **只有母版**能提升(只升不降)。衍生作品(存在血缘行)的档位由创建时继承父作品决定、是终态,
|
||||
* 任何请求都返回 409 `FORK_AUTHORIZATION_INHERITED`——包括传同值的幂等重试。
|
||||
*/
|
||||
export type GameDistributionSetForkAuthorizationRequest = {
|
||||
expectedForkAuthorization: GameDistributionForkAuthorization;
|
||||
forkAuthorization: GameDistributionForkAuthorization;
|
||||
|
||||
@@ -4409,7 +4409,9 @@ async fn unpublish_game(
|
||||
))
|
||||
}
|
||||
|
||||
/// 作者提升作品的共创授权档位:只升不降,降级与未知档位由领域层拒绝。
|
||||
/// 作者修改作品的共创授权档位。**只有母版**可提升:只升不降,降级与未知档位由领域层拒绝;
|
||||
/// 衍生作品(存在血缘行)的档位由创建时继承父作品决定,是终态,一律 409
|
||||
/// `FORK_AUTHORIZATION_INHERITED`(含传同值的幂等重试)。
|
||||
async fn set_fork_authorization(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
@@ -6078,6 +6080,12 @@ fn map_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
StatusCode::CONFLICT,
|
||||
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED",
|
||||
),
|
||||
// 衍生作品的档位是终态(创建时继承父作品),任何 PUT 都拒:与「只升不降」同为 409,
|
||||
// 但语义不同——客户端要按「这个入口对衍生作品不存在」处理,而不是刷新后重试。
|
||||
module_game_distribution::GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE => (
|
||||
StatusCode::CONFLICT,
|
||||
module_game_distribution::GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE,
|
||||
),
|
||||
"FORK_AUTHORIZATION_UNKNOWN" => {
|
||||
(StatusCode::BAD_REQUEST, "FORK_AUTHORIZATION_UNKNOWN")
|
||||
}
|
||||
@@ -9911,7 +9919,8 @@ mod tests {
|
||||
///
|
||||
/// 这一条是 2026-10-06「衍生作品发布必填核心改动说明」补的:新增的
|
||||
/// `FORK_CHANGE_SUMMARY_REQUIRED` / `_INVALID` 若只写进映射表而没写进模块真正产出的文案
|
||||
/// (或反过来),都会在这里红。用的是**模块真实产出**的 `Display` 文案,而不是
|
||||
/// (或反过来),都会在这里红。后续新增的 `FORK_AUTHORIZATION_INHERITED`(衍生作品授权终态)
|
||||
/// 同样纳入本枚举,理由相同。用的是**模块真实产出**的 `Display` 文案,而不是
|
||||
/// `format!("{code}: 具体原因")` 这类合成串——合成串只能证明「映射表里有这一行」,证明不了
|
||||
/// 「模块真的会产出这个前缀」,上一轮 `THEME_INVALID_CURSOR` 就是这么变成死代码的。
|
||||
///
|
||||
@@ -9937,6 +9946,13 @@ mod tests {
|
||||
StatusCode::CONFLICT,
|
||||
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED",
|
||||
),
|
||||
// 衍生作品的终端授权:任何 PUT 都拒,与降级同为 409 但码不同(客户端据此区分
|
||||
// 「刷新后重试」与「这个入口对衍生作品不存在」)。
|
||||
(
|
||||
DomainError::ForkAuthorizationInherited,
|
||||
StatusCode::CONFLICT,
|
||||
module_game_distribution::GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE,
|
||||
),
|
||||
(
|
||||
DomainError::ForkNotAuthorized,
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -9998,6 +10014,10 @@ mod tests {
|
||||
module_game_distribution::GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE,
|
||||
"FORK_CHANGE_SUMMARY_INVALID"
|
||||
);
|
||||
assert_eq!(
|
||||
module_game_distribution::GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE,
|
||||
"FORK_AUTHORIZATION_INHERITED"
|
||||
);
|
||||
|
||||
// 未登记的 Fork 码会落到兜底分支(409 `FORK_ERROR`):这正是上面那条断言有意义的原因。
|
||||
let unknown = map_spacetime_error(SpacetimeClientError::Procedure(
|
||||
|
||||
@@ -263,6 +263,50 @@ impl ForkAuthorization {
|
||||
}
|
||||
}
|
||||
|
||||
/// 改变作品共创授权档位(`PUT …/fork-authorization`)的裁决——**唯一判据来源**。
|
||||
///
|
||||
/// 写入事务只负责取数(血缘行是否存在、当前档位)与落库,规则本身写在这里,因此
|
||||
/// 「HTTP 挡住的」与「落库挡住的」永远是同一套判据,也才能在不起 `ReducerContext` 的宿主测试里
|
||||
/// 被钉死。返回新的档位(= `target`),或调用方应**原样透出**的失败文案——
|
||||
/// 文案有两种形态,都由 api-server 的字符串映射识别:以稳定码开头
|
||||
/// (`FORK_AUTHORIZATION_INHERITED:` / `FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED:`),
|
||||
/// 或 CAS 期望值不符的纯文案(含「不匹配」,映射成通用 409)。
|
||||
///
|
||||
/// 判定顺序即语义,三步都不写库:
|
||||
/// 1. **衍生作品是终态**:`has_lineage_row`(该作品存在血缘行 = 衍生作品)时档位由创建时**继承**
|
||||
/// 父作品决定,之后不得再改,因此这里**先于 CAS 拒绝**——衍生作品的任何 PUT 都回
|
||||
/// `FORK_AUTHORIZATION_INHERITED`,连「传同值」这种幂等重试也一样。判定放在 CAS 之前是为了
|
||||
/// 确定性:同一个衍生作品不会因为载荷不同(传同值走 CAS / 传新值走降级)而回不同的码。
|
||||
/// 若放行,作者就能把继承来的 `nonCommercial` 再提成 `full`,得到一个**比祖先更宽**的子作品,
|
||||
/// 把父作品在非商用授权下公开的工程内容变成可用商用。
|
||||
/// 2. **CAS**:期望值与当前值不符(调用方基于旧状态)→ 拒绝,刷新后重试即可。
|
||||
/// 3. **只升不降**:`target` 不严格高于 `current`(含同级)→ 降级码拒绝。
|
||||
///
|
||||
/// 母版(无血缘行)的语义**未变**:仍只升不降 + CAS。
|
||||
pub fn resolve_fork_authorization_promotion(
|
||||
has_lineage_row: bool,
|
||||
current: ForkAuthorization,
|
||||
expected_fork_authorization: &str,
|
||||
target: ForkAuthorization,
|
||||
) -> Result<ForkAuthorization, String> {
|
||||
if has_lineage_row {
|
||||
return Err(GameDistributionError::ForkAuthorizationInherited.to_string());
|
||||
}
|
||||
if current.as_str() != expected_fork_authorization.trim() {
|
||||
return Err("共创授权期望值与当前状态不匹配".to_string());
|
||||
}
|
||||
if !current.can_promote_to(target) {
|
||||
return Err(
|
||||
GameDistributionError::ForkAuthorizationDowngradeNotAllowed {
|
||||
current: current.as_str().to_string(),
|
||||
requested: target.as_str().to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// 改编代际:根作品为 0,直接改编根作品为 1,逐级递增。
|
||||
pub fn next_generation(parent_generation: u32) -> u32 {
|
||||
parent_generation.saturating_add(1)
|
||||
@@ -464,7 +508,7 @@ mod fork_soft_delete_tests {
|
||||
#[cfg(test)]
|
||||
mod fork_authorization_tests {
|
||||
use super::*;
|
||||
use crate::GameDistributionError;
|
||||
use crate::{GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE, GameDistributionError};
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_every_declared_ladder_value() {
|
||||
@@ -510,6 +554,79 @@ mod fork_authorization_tests {
|
||||
assert!(!NonCommercial.can_promote_to(Forbidden));
|
||||
}
|
||||
|
||||
/// **衍生作品的档位是终态**:存在血缘行时任何 PUT 都被拒(`FORK_AUTHORIZATION_INHERITED`),
|
||||
/// 包括「传同值」这种幂等重试,也包括期望值已经过期的请求——因为判定先于 CAS,同一载荷
|
||||
/// 不会因重试时机不同而回不同的码。
|
||||
#[test]
|
||||
fn derivative_put_is_rejected_even_with_the_same_or_stale_value() {
|
||||
use ForkAuthorization::{Full, NonCommercial};
|
||||
|
||||
let cases = [
|
||||
// (当前档位, 期望值, 目标档位, 标签)
|
||||
(NonCommercial, "nonCommercial", NonCommercial, "传同值"),
|
||||
(NonCommercial, "nonCommercial", Full, "想再提一级"),
|
||||
(Full, "full", Full, "终态再传同值"),
|
||||
(NonCommercial, "forbidden", Full, "期望值已过期"),
|
||||
(Full, "forbidden", Full, "期望值与当前值都不符"),
|
||||
];
|
||||
for (current, expected, target, label) in cases {
|
||||
let error = resolve_fork_authorization_promotion(true, current, expected, target)
|
||||
.expect_err("衍生作品的 PUT 必须被拒");
|
||||
assert!(
|
||||
error.starts_with(GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE),
|
||||
"{label}:必须回稳定码前缀,got {error}"
|
||||
);
|
||||
assert!(
|
||||
error.starts_with(&format!(
|
||||
"{GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE}:"
|
||||
)),
|
||||
"{label}:码后必须跟冒号(缺了会把整句当码,落到 FORK_ERROR),got {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("继承"),
|
||||
"{label}:文案要讲清「由父作品继承」,got {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 母版语义**未变**:仍只升不降(可跳级)、同级拒绝、CAS 不符回通用 409 文案(不带 `FORK_` 码)。
|
||||
#[test]
|
||||
fn master_put_still_promotes_with_unchanged_cas_semantics() {
|
||||
use ForkAuthorization::{Forbidden, Full, NonCommercial};
|
||||
|
||||
for (current, expected, target) in [
|
||||
(Forbidden, "forbidden", NonCommercial),
|
||||
(Forbidden, "forbidden", Full),
|
||||
(NonCommercial, "nonCommercial", Full),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_fork_authorization_promotion(false, current, expected, target),
|
||||
Ok(target),
|
||||
"母版提升必须照常放行(current={current:?}, target={target:?})"
|
||||
);
|
||||
}
|
||||
|
||||
for (current, expected, target, label) in [
|
||||
(NonCommercial, "nonCommercial", NonCommercial, "同级"),
|
||||
(Full, "full", NonCommercial, "降级"),
|
||||
(Full, "full", Forbidden, "降到禁止共创"),
|
||||
] {
|
||||
let error = resolve_fork_authorization_promotion(false, current, expected, target)
|
||||
.expect_err("同级与降级都必须被拒");
|
||||
assert!(
|
||||
error.starts_with("FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED:"),
|
||||
"{label}:must be the downgrade code, got {error}"
|
||||
);
|
||||
}
|
||||
|
||||
let error = resolve_fork_authorization_promotion(false, Full, "nonCommercial", Full)
|
||||
.expect_err("CAS 不符必须被拒");
|
||||
assert!(
|
||||
error.contains("不匹配") && !error.contains("FORK_"),
|
||||
"CAS 不符仍是纯文案(api-server 按「不匹配」子串落 409),got {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_forbidden_blocks_forks() {
|
||||
assert!(!ForkAuthorization::Forbidden.allows_fork());
|
||||
@@ -541,6 +658,10 @@ mod fork_authorization_tests {
|
||||
},
|
||||
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED",
|
||||
),
|
||||
(
|
||||
GameDistributionError::ForkAuthorizationInherited,
|
||||
crate::GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE,
|
||||
),
|
||||
(
|
||||
GameDistributionError::ForkNotAuthorized,
|
||||
"FORK_NOT_AUTHORIZED",
|
||||
|
||||
@@ -37,6 +37,12 @@ pub const GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE: &str =
|
||||
"FORK_CHANGE_SUMMARY_REQUIRED";
|
||||
/// 共创(Fork)发布:「本次核心改动说明」长度不在允许区间内。
|
||||
pub const GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE: &str = "FORK_CHANGE_SUMMARY_INVALID";
|
||||
/// 共创(Fork)授权:**衍生作品的档位是终态**——由创建时继承父作品决定,不能自行修改。
|
||||
///
|
||||
/// 只有衍生作品(存在血缘行)可达;母版仍走 `FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED`。
|
||||
/// 与 `FORK_*` 同族,因此同样必须先登记进 api-server 的 `FORK_` 映射表(409)。
|
||||
pub const GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE: &str =
|
||||
"FORK_AUTHORIZATION_INHERITED";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GameDistributionFieldError {
|
||||
@@ -115,6 +121,12 @@ pub enum GameDistributionError {
|
||||
current: String,
|
||||
requested: String,
|
||||
},
|
||||
/// 共创授权:衍生作品的档位是终态(创建时继承父作品),不能自行修改。
|
||||
///
|
||||
/// 与「只升不降」是同一条策略的两面:母版只升不降,衍生作品连升都不允许。否则作者能把继承来的
|
||||
/// `nonCommercial` 提成 `full`,得到一个比祖先更宽的子作品,把父作品在非商用授权下公开的
|
||||
/// 工程内容变成可用商用。
|
||||
ForkAuthorizationInherited,
|
||||
/// 共创授权:来源作品未开放共创。
|
||||
ForkNotAuthorized,
|
||||
/// 共创授权:改编来源作品不存在。
|
||||
@@ -203,6 +215,11 @@ impl fmt::Display for GameDistributionError {
|
||||
formatter,
|
||||
"FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED: 共创授权只能提升,当前 {current},请求 {requested}"
|
||||
),
|
||||
Self::ForkAuthorizationInherited => write!(
|
||||
formatter,
|
||||
"{}: 衍生作品的共创授权由父作品继承,不能自行修改",
|
||||
GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE
|
||||
),
|
||||
Self::ForkNotAuthorized => {
|
||||
formatter.write_str("FORK_NOT_AUTHORIZED: 该作品尚未开放共创授权")
|
||||
}
|
||||
@@ -320,4 +337,19 @@ mod tests {
|
||||
);
|
||||
assert_eq!(GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS, 500);
|
||||
}
|
||||
|
||||
/// 「衍生作品授权终态」的文案同样以常量前缀 + 冒号开头:api-server 用 `split(':').next()`
|
||||
/// 取码,缺了冒号会把整句当成码,落到未登记分支退化成 `FORK_ERROR`(通用 409)。
|
||||
#[test]
|
||||
fn inherited_authorization_message_starts_with_its_code() {
|
||||
let message = GameDistributionError::ForkAuthorizationInherited.to_string();
|
||||
assert!(
|
||||
message.starts_with(&format!(
|
||||
"{}: ",
|
||||
GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE
|
||||
)),
|
||||
"got {message}"
|
||||
);
|
||||
assert!(message.contains("继承"), "got {message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,10 @@ pub use domain::{
|
||||
compute_request_digest, counts_as_public_derivative, fork_lineage_visible_identity,
|
||||
game_distribution_change_summary_violation, game_purchase_id, generate_game_id,
|
||||
generate_game_version_id, next_generation, normalize_game_price_mud_points,
|
||||
resolve_game_purchase_decision, resolve_version_number,
|
||||
resolve_fork_authorization_promotion, resolve_game_purchase_decision, resolve_version_number,
|
||||
};
|
||||
pub use errors::{
|
||||
GAME_DISTRIBUTION_FORK_AUTHORIZATION_INHERITED_CODE,
|
||||
GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE,
|
||||
GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE, GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE,
|
||||
GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE, GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
|
||||
|
||||
@@ -303,7 +303,10 @@ pub struct GameDistributionForkDeclaration {
|
||||
pub parent_version_id: String,
|
||||
}
|
||||
|
||||
/// 作者提升作品共创授权:只升不降,`expected` 用于并发校验。
|
||||
/// 修改作品共创授权:**只有母版**能提升(只升不降),`expected` 用于并发校验。
|
||||
///
|
||||
/// 衍生作品(存在血缘行)的档位由创建时继承父作品决定、是终态,任何 `PUT` 都返回 409
|
||||
/// `FORK_AUTHORIZATION_INHERITED`(含传同值的幂等重试)。
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionSetForkAuthorizationRequest {
|
||||
@@ -650,6 +653,9 @@ pub struct GameDistributionCreateGameRequest {
|
||||
///
|
||||
/// **只对母版(0 代作品)生效**:带 `fork` 声明时,新作品的档位在创建时**继承父作品当时的
|
||||
/// 档位**,本字段一律被忽略且不报错(旧客户端会惯常带默认值),服务端也不接受「收窄」。
|
||||
/// 继承来的档位是**终态**:衍生作品之后再调 `PUT …/fork-authorization` 一律被拒(409
|
||||
/// `FORK_AUTHORIZATION_INHERITED`),母版才走「只升不降」。父作品为 `forbidden` 时根本建立
|
||||
/// 不了血缘,所以衍生作品不会继承到 `forbidden`。
|
||||
/// 字段形状刻意保持不变(非 `Option` + `#[serde(default)]`):改了会让幂等摘要漂移。
|
||||
///
|
||||
/// 写成非 `Option` + `#[serde(default)]` 而不是 `Option<..>`:省略与显式传 `forbidden`
|
||||
|
||||
@@ -99,7 +99,8 @@ pub struct GameDistributionCreateGameRecordInput {
|
||||
pub forked_from_version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 作者提升作品共创授权档位;只升不降,并带上期望的当前档位做 CAS。
|
||||
/// 修改作品共创授权档位;**只有母版**可用(只升不降),衍生作品的档位由创建时继承父作品决定。
|
||||
/// 两者都带上期望的当前档位做 CAS。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GameDistributionSetForkAuthorizationRecordInput {
|
||||
pub game_id: String,
|
||||
|
||||
@@ -780,6 +780,10 @@ pub struct GameDistributionGame {
|
||||
/// 父作品当时的档位**(创建时快照,父作品之后提升不会回溯改写既有子作品的行)。
|
||||
/// 两种口径互不冲突:提升只改被提升的那一行。
|
||||
///
|
||||
/// **提升只对母版开放**:存在血缘行的作品(衍生作品)不允许再改档位——`forbidden` 之上父作品
|
||||
/// 给的是什么就是什么(终态),否则作者能把继承来的 `nonCommercial` 提成 `full`,得到比祖先
|
||||
/// 更宽的授权。判定与码见 `set_game_distribution_fork_authorization_tx`。
|
||||
///
|
||||
/// 本分支较 master 后追加,因此留在表尾(见上一列的注释)。
|
||||
#[default("forbidden")]
|
||||
pub(crate) fork_authorization: String,
|
||||
@@ -1169,7 +1173,8 @@ pub struct GameDistributionCreateGameInput {
|
||||
pub forked_from_version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 作者提升作品共创授权:只允许单向提升,并要求调用方给出期望的当前档位。
|
||||
/// 作者修改作品共创授权:**只有母版可用**——母版只允许单向提升;衍生作品存在血缘行时一律拒绝
|
||||
/// (档位由创建时继承父作品决定)。两种情形都要求调用方给出期望的当前档位。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct GameDistributionSetForkAuthorizationInput {
|
||||
pub game_id: String,
|
||||
@@ -2502,7 +2507,9 @@ pub fn create_game_distribution_version_and_return(
|
||||
}
|
||||
}
|
||||
|
||||
/// 提升作品的共创(Fork)授权档位。幂等、只升不降;降级与未知档位一律拒绝且不写库。
|
||||
/// 提升作品的共创(Fork)授权档位。幂等;**母版**只升不降,**衍生作品**(有血缘行)连升都不允许
|
||||
/// (档位由创建时继承父作品决定,是终态,回 `FORK_AUTHORIZATION_INHERITED`);降级与未知档位
|
||||
/// 一律拒绝且不写库。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn set_game_distribution_fork_authorization_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -4730,8 +4737,14 @@ fn unpublish_game_distribution_game_tx(
|
||||
|
||||
/// 提升作品的共创授权档位,要求调用方给出期望的当前档位。
|
||||
///
|
||||
/// 三重失败关闭:期望值不匹配(调用方基于旧状态)→ 冲突;目标档位不高于当前 → 拒绝降级;
|
||||
/// 档位字符串不在白名单 → 拒绝未知档位。三者都不写库。
|
||||
/// 裁决(衍生作品终态 / CAS / 只升不降)只有一份实现,在
|
||||
/// `module_game_distribution::resolve_fork_authorization_promotion`;这里只做取数与落库。
|
||||
///
|
||||
/// 关键一步是**在 CAS 之前**按**血缘行**判定该作品是不是衍生作品(有血缘行即衍生,不接受客户端
|
||||
/// 自称):衍生作品的档位由创建时继承父作品决定,是**终态**,任何 PUT 一律拒绝(连「传同值」的
|
||||
/// 幂等重试也拒绝,客户端因此不会遇到「有时成功有时失败」)。母版(无血缘行)照旧只升不降。
|
||||
///
|
||||
/// 失败一律不写库;幂等重放分支在这之前,只有曾成功提升过的母版才会有收据,衍生作品永远走不到它。
|
||||
fn set_game_distribution_fork_authorization_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: GameDistributionSetForkAuthorizationInput,
|
||||
@@ -4776,18 +4789,21 @@ fn set_game_distribution_fork_authorization_tx(
|
||||
let current =
|
||||
module_game_distribution::ForkAuthorization::parse(game.fork_authorization.as_str())
|
||||
.unwrap_or(module_game_distribution::ForkAuthorization::Forbidden);
|
||||
if current.as_str() != input.expected_fork_authorization.trim() {
|
||||
return Err("共创授权期望值与当前状态不匹配".to_string());
|
||||
}
|
||||
if !current.can_promote_to(target) {
|
||||
return Err(
|
||||
module_game_distribution::GameDistributionError::ForkAuthorizationDowngradeNotAllowed {
|
||||
current: current.as_str().to_string(),
|
||||
requested: target.as_str().to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// 衍生作品判定取**血缘行**本身(有父即衍生),不接受客户端自称;必须在 CAS 之前,
|
||||
// 否则同一个衍生作品会按载荷不同回不同的码:传同值/CAS 相等会走到「只升不降」,
|
||||
// 传旧期望值会走到 CAS 不符。终态口径是「一律拒绝」,码也必须是同一个。
|
||||
let has_lineage_row = ctx
|
||||
.db
|
||||
.game_distribution_lineage()
|
||||
.game_id()
|
||||
.find(&game_id)
|
||||
.is_some();
|
||||
let target = module_game_distribution::resolve_fork_authorization_promotion(
|
||||
has_lineage_row,
|
||||
current,
|
||||
input.expected_fork_authorization.as_str(),
|
||||
target,
|
||||
)?;
|
||||
let now = Timestamp::from_micros_since_unix_epoch(input.now_micros);
|
||||
game.fork_authorization = target.as_str().to_string();
|
||||
game.updated_at = now;
|
||||
@@ -7840,6 +7856,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 提升事务:衍生的判定取**血缘行**,且必须**先于 CAS**;规则本身不在事务里重写。
|
||||
///
|
||||
/// 顺序是不变量:幂等重放先返回(重放不该被新规则改写)→ 取血缘行 → 裁决(衍生终态 / CAS /
|
||||
/// 只升不降)→ 写库。错位会让衍生作品按载荷不同回不同的码,或让「传同值」被误放行。
|
||||
#[test]
|
||||
fn set_fork_authorization_tx_rejects_derivatives_before_cas() {
|
||||
let source = include_str!("game_distribution.rs");
|
||||
let body = function_body(source, "fn set_game_distribution_fork_authorization_tx(");
|
||||
for required in [
|
||||
"resolve_fork_authorization_promotion(",
|
||||
"game_distribution_lineage()",
|
||||
"has_lineage_row",
|
||||
] {
|
||||
assert!(body.contains(required), "提升事务缺少:{required}");
|
||||
}
|
||||
assert!(
|
||||
!body.contains("can_promote_to"),
|
||||
"只升不降只能有一处实现(module 的裁决函数),事务里不得再写一遍"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("input.expected_fork_authorization.trim()"),
|
||||
"CAS 判定也必须走裁决函数,避免两套判据漂移"
|
||||
);
|
||||
let receipt_at = body
|
||||
.find("find_game_distribution_receipt")
|
||||
.expect("幂等收据必须存在");
|
||||
let lineage_at = body
|
||||
.find("game_distribution_lineage()")
|
||||
.expect("必须按血缘行判定是否衍生");
|
||||
let resolve_at = body
|
||||
.find("resolve_fork_authorization_promotion(")
|
||||
.expect("必须调用裁决函数");
|
||||
let update_at = body.find(".update(game.clone())").expect("必须写库");
|
||||
assert!(
|
||||
receipt_at < lineage_at,
|
||||
"幂等重放必须先返回:重放不该被新的衍生判定改写"
|
||||
);
|
||||
assert!(
|
||||
lineage_at < resolve_at,
|
||||
"先取血缘行再裁决:衍生判定先于 CAS 就靠这一步"
|
||||
);
|
||||
assert!(resolve_at < update_at, "裁决失败必须不写库");
|
||||
}
|
||||
|
||||
/// 创建作品事务:授权档位必须来自**父作品行**(衍生作品继承)或请求值(母版)。
|
||||
///
|
||||
/// 三条父档位分支的证据分两处:
|
||||
|
||||
Reference in New Issue
Block a user