diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 7e05e07f7..ced248b50 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -494,12 +494,12 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的 | HTTP 端到端(BFF 路由与真实数据库连接) | `npm run dev` 整套拉起后:`/healthz` 200;`GET /api/game-distribution/games` 200(空列表,走通真实数据库查询与 envelope);`PUT …/fork-authorization` 未带 Bearer 返回 401 `UNAUTHORIZED` | ✅ 已验证 | | HTTP 端到端(真实登录态下的提升 / 声明血缘 / 详情页字段) | 未跑:需要真实登录态与一个已发布作品;本地开发库当前为空 | ⏳ 待补 | | 浏览器视觉与交互验证(详情页共创卡、我的作品授权入口、移动端布局) | 未跑:本地库无作品数据,登录态不可用,页面无法进入有效状态 | ⏳ 待补 | -| 权限与边界(非作者 403、降级 409、父作品下架后新声明被拒) | 领域层与 procedure 校验已实现并覆盖单测;真实栈边界用例待补 | ⏳ 待补 | +| 权限与边界(非作者 403、降级 409、父作品下架后新声明被拒、软删除边界) | 领域层与 procedure 校验已实现;**软删除×Fork 三条规则已提取为纯函数并补单测**——`can_serve_as_fork_source`(未删+已公开,四组合,含「已删但 visibility 仍 published」必须 false)、`counts_as_public_derivative`(同四组合)、`fork_lineage_visible_identity`(父可用透传;父不可用即使传入非空标题/作者名也必须返回 `(None, None)`;作者名三态)由 `cargo test -p module-game-distribution`(36 passed)覆盖。真实栈边界用例仍待补 | ⏳ 部分(单测已补,真实栈待补) | | **rebase 到含 #565 的新 master** | `git rebase origin/master`(新 master `6c9566058`)重放两个提交,9 个冲突文件按 §3.9.1 全部解决(两侧业务逻辑都保留),无 merge commit,未改动 master;提交信息沿用原中文提交 | ✅ 已验证 | | **生成物重生成** | `npm run spacetime:generate -- --rust-only` 后保留 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,rustfmt 版本差异带来的其它文件格式漂移已 `git checkout --` 还原 | ✅ 已验证 | | **软删除语义收敛 ①来源校验** | `resolve_game_distribution_fork_declaration_tx` 增加「来源 `deleted_at` 非空 → `FORK_SOURCE_NOT_AVAILABLE`(409)」,显式判定而不是只靠 `visibility` 复位 | ✅ 已验证(`cargo test -p api-server game_distribution`;领域错误码沿用 `FORK_*` 前缀) | | **软删除语义收敛 ②衍生作品数** | `game_distribution_public_fork_count` 只统计 `deleted_at.is_none() && visibility == published` 的子作品;作者删除子作品后父作品计数随之下降 | ✅ 已验证(同上) | -| **软删除语义收敛 ③溯源摘要** | `game_distribution_lineage_snapshot` 在父(或根)作品被软删除时把 `parent_title` / `root_title` 置空、父作者名置空,api-server 原样发 `null`,TS 契约改为 `string \| null`,详情页降级为「原作品已不可用」 | ✅ 已验证(DTO parity + 前端用例;单测待补) | +| **软删除语义收敛 ③溯源摘要** | `game_distribution_lineage_snapshot` 在父(或根)作品被软删除时把 `parent_title` / `root_title` 置空、父作者名置空,api-server 原样发 `null`,TS 契约改为 `string \| null`,详情页降级为「原作品已不可用」 | ✅ 已验证(DTO parity + 前端用例 + `fork_lineage_visible_identity` 单测,「父不可用即使传入标题也必须 `(None, None)`」已由 `cargo test -p module-game-distribution` 断言) | | **详情页结构对齐 #565** | 详情页改用共享 `GameDetailDisplay`,共创卡通过 `infoCards` 渲染;vitest 用例「展示共创授权、代际与溯源信息,且不提供改造入口」与「旧数据缺少授权与血缘字段时只按禁止共创渲染」通过 | ✅ 已验证 | --- diff --git a/server-rs/crates/module-game-distribution/src/domain.rs b/server-rs/crates/module-game-distribution/src/domain.rs index 8b6f399f8..5dce65a36 100644 --- a/server-rs/crates/module-game-distribution/src/domain.rs +++ b/server-rs/crates/module-game-distribution/src/domain.rs @@ -185,10 +185,149 @@ pub fn next_generation(parent_generation: u32) -> u32 { parent_generation.saturating_add(1) } +/// 某个作品行能否充当新的改编(Fork)来源。 +/// +/// 规则 =「未软删除」且「已公开」,两个条件缺一不可: +/// - **已软删除的作品一律不能被新改编**(#565 的 `deleted_at`)。删除虽然会把可见性复位成 +/// `unpublished`,但那是删除的副作用;这里显式排除删除行,一是对外说明「来源已不可用」, +/// 二是避免将来改可见性语义时把已删作品重新变成可改编来源。 +/// - **未公开的作品**(审核中 / 未公开 / 已下架 / 已封禁)没有对外承诺,也不该成为改编来源。 +/// +/// 只看这两个布尔、不读行本身:调用方负责把行状态折叠成布尔,于是这条规则可以在不起 +/// `ReducerContext` 的前提下被单测钉死。 +pub fn can_serve_as_fork_source(is_deleted: bool, is_published: bool) -> bool { + !is_deleted && is_published +} + +/// 某个直接子代是否计入父作品的「已被改编 N 次」。 +/// +/// 与来源可用性同样是「未软删除 + 已公开」,但动机不同,所以单独成函数而不是复用前者 +/// (两条规则将来可能各自演化,共用会让一处改动悄悄改掉另一处): +/// - **软删行必须排除**:删除只下线游戏行、保留血缘行;若把已删子作品也算进去,父作品的 +/// 计数会虚报改编热度,并对外泄露「这里曾经有个作品」。 +/// - **未公开的子作品不算**:它还不是公开可知的衍生,算进去同样是虚报。 +/// +/// 尤其注意「已删但 `visibility` 恰好仍是 `published`」这个组合:删除与可见性复位之间 +/// 没有原子保证,读路径完全可能看到它,此时必须判 `false`。 +pub fn counts_as_public_derivative(is_deleted: bool, is_published: bool) -> bool { + !is_deleted && is_published +} + +/// 血缘溯源卡对外可见的 `(标题, 作者名)`。 +/// +/// - `parent_available == false`(父作品已被软删除,或调用方已判定该行不可用)时**必须** +/// 返回 `(None, None)`:即使调用方把父行的标题与作者名都传进来,也一个都不外发。 +/// 公开目录/详情早已下线被删作品,若还能从别人的溯源卡里读到它,删除就等于没删; +/// 因此这里**不做**任何「拿不到标题就回退用行数据」的降级。 +/// - `parent_available == true` 时按原值透传:标题原样返回;作者名保持三态—— +/// `None` 仍是 `None`,空串也仍是空串(不 trim、不把空串归一成 `None`, +/// 行的原值是什么就发什么,展示层自己决定空署名是否渲染)。 +pub fn fork_lineage_visible_identity( + parent_available: bool, + parent_title: &str, + parent_author_name: Option<&str>, +) -> (Option, Option) { + if !parent_available { + return (None, None); + } + ( + Some(parent_title.to_string()), + parent_author_name.map(str::to_string), + ) +} + pub(crate) fn normalize_id(value: impl AsRef) -> Option { normalize_required_string(value) } +#[cfg(test)] +mod fork_soft_delete_tests { + use super::*; + + /// 来源可用性:四种组合逐一钉死。 + /// + /// 关键是「已软删除 + 可见性仍是 published」必须为 `false`——删除会复位可见性,但 + /// 两个字段之间没有原子保证,读路径完全可能看到这个组合,它正是 rebase 时最容易退化成 + /// 「只看 visibility」的地方。 + #[test] + fn fork_source_requires_unremoved_and_published() { + let cases = [ + (false, true, true, "未删 + 已公开"), + (true, true, false, "已软删除 + 可见性仍是 published"), + (false, false, false, "未删 + 未公开"), + (true, false, false, "已软删除 + 未公开"), + ]; + for (is_deleted, is_published, expected, label) in cases { + assert_eq!( + can_serve_as_fork_source(is_deleted, is_published), + expected, + "{label}(is_deleted={is_deleted}, is_published={is_published})" + ); + } + } + + /// 衍生计数纳入:同一条件、不同动机,四组组合同样逐一钉死。 + #[test] + fn derivative_count_excludes_removed_and_unpublished_children() { + let cases = [ + (false, true, true, "未删 + 已公开"), + (true, true, false, "已软删除 + 可见性仍是 published"), + (false, false, false, "未删 + 未公开"), + (true, false, false, "已软删除 + 未公开"), + ]; + for (is_deleted, is_published, expected, label) in cases { + assert_eq!( + counts_as_public_derivative(is_deleted, is_published), + expected, + "{label}(is_deleted={is_deleted}, is_published={is_published})" + ); + } + } + + /// 父作品可用:标题与作者名按原值透传。 + #[test] + fn lineage_identity_passes_through_when_parent_available() { + assert_eq!( + fork_lineage_visible_identity(true, "原初之光", Some("原作者")), + (Some("原初之光".to_string()), Some("原作者".to_string())) + ); + assert_eq!( + fork_lineage_visible_identity(true, "标题", None), + (Some("标题".to_string()), None) + ); + } + + /// 防泄露的关键断言:父作品不可用时,即使调用方把非空标题与作者名都传进来, + /// 也必须返回 `(None, None)`——不允许任何形式的回退。 + #[test] + fn lineage_identity_never_leaks_removed_parent_identity() { + assert_eq!( + fork_lineage_visible_identity(false, "原初之光", Some("原作者")), + (None, None), + "父不可用时标题与作者名都不得外发" + ); + assert_eq!( + fork_lineage_visible_identity(false, "原初之光", None), + (None, None) + ); + assert_eq!(fork_lineage_visible_identity(false, "", None), (None, None)); + } + + /// 作者名三态语义(按实现断言):`None` → `None`;空串 → 空串;只含空白的名字原样返回。 + /// 即「行的原值是什么就发什么」,不 trim、不把空串归一成 `None`(展示层按 falsy 处理)。 + #[test] + fn lineage_identity_keeps_author_name_tristate() { + assert_eq!( + fork_lineage_visible_identity(true, "标题", Some("")), + (Some("标题".to_string()), Some(String::new())) + ); + assert_eq!( + fork_lineage_visible_identity(true, "标题", Some(" ")), + (Some("标题".to_string()), Some(" ".to_string())) + ); + } +} + #[cfg(test)] mod fork_authorization_tests { use super::*; diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 00adca54b..28ecd0f59 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -22,7 +22,8 @@ pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, Revi pub use domain::{ FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL, ForkAuthorization, GameDistributionAction, GameSnapshot, GameVersionSnapshot, - GameVersionStatus, GameVisibility, compute_request_digest, generate_game_id, + GameVersionStatus, GameVisibility, can_serve_as_fork_source, compute_request_digest, + counts_as_public_derivative, fork_lineage_visible_identity, generate_game_id, generate_game_version_id, next_generation, resolve_version_number, }; pub use errors::{GameDistributionError, GameDistributionFieldError}; diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index a09d52f98..5b0bcf9a6 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1897,11 +1897,10 @@ pub fn get_owner_game_distribution_version_and_return( /// 失败关闭:来源必须存在且未软删除、已公开、授权档位非「禁止共创」、来源版本必须恰好是 /// 该作品当前公开版本(禁止指向历史版本或伪造)。 /// -/// 软删除(#565 的 `deleted_at`)单独判一次而不是只靠 `visibility`:删除会把可见性复位成 -/// `unpublished`,但那是删除的副作用;显式按 `deleted_at` 拒绝对外说明「已被作者删除」, -/// 也避免将来改可见性语义时把已删作品重新变成可改编来源。统一用 -/// `FORK_SOURCE_NOT_AVAILABLE`(api-server 映射 409),不额外区分「已删除」与「未公开」, -/// 免得用错误码把删除事实泄露给非作者。 +/// 「未软删除 + 已公开」这条规则本身放在 `module_game_distribution::can_serve_as_fork_source`, +/// 这里只负责把行状态折叠成两个布尔并统一映射成 `FORK_SOURCE_NOT_AVAILABLE` +/// (api-server 映射 409):不额外区分「已删除」与「未公开」,免得用错误码把删除事实泄露给 +/// 非作者。规则不在这里重写一遍,避免与领域层单测钉住的口径漂移。 fn resolve_game_distribution_fork_declaration_tx( ctx: &ReducerContext, forked_from_game_id: &str, @@ -1915,12 +1914,10 @@ fn resolve_game_distribution_fork_declaration_tx( .ok_or_else(|| { module_game_distribution::GameDistributionError::ForkSourceNotFound.to_string() })?; - if source.deleted_at.is_some() { - return Err( - module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string(), - ); - } - if source.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED { + if !module_game_distribution::can_serve_as_fork_source( + source.deleted_at.is_some(), + source.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + ) { return Err( module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string(), ); @@ -4173,9 +4170,9 @@ fn public_game_distribution_version( /// 直接改编该作品的**已公开且未软删除**作品数。按血缘索引实时统计,不维护物化计数, /// 避免与下架/封禁/删除状态产生第二份真相。 /// -/// 软删除的作品必须排除(#565 的 `deleted_at`):删除只下线游戏行、保留血缘行, -/// 若不判 `deleted_at`,作者删掉子作品后父作品的「已被改编 N 次」会把已删条目算进去, -/// 对外泄露「这里曾经有个作品」并虚报改编热度。 +/// 「该不该计入」的规则放在 `module_game_distribution::counts_as_public_derivative`:删除只 +/// 下线游戏行、保留血缘行,若不排除已删子作品,父作品的「已被改编 N 次」会虚报改编热度, +/// 并对外泄露「这里曾经有个作品」。这里只折叠行状态,不重写规则。 fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u64 { let derived_game_ids: Vec = ctx .db @@ -4192,8 +4189,10 @@ fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u .game_id() .find(derived_game_id) .map(|game| { - game.deleted_at.is_none() - && game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED + module_game_distribution::counts_as_public_derivative( + game.deleted_at.is_some(), + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + ) }) .unwrap_or(false) }) @@ -4208,6 +4207,10 @@ fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u /// 却能在别人的溯源卡里继续读到。这里把标题/作者名置空(api-server 原样发 `null`), /// 前端降级为「原作品已不可用」。根作品同理:它是更远的祖先,标题同样是已删作品的信息。 /// 只保留不透明的 ID 与代际,删掉的作品不因为血缘关系继续对外输出内容。 +/// +/// 「可用时才透传、不可用就 `(None, None)`」这条规则放在 +/// `module_game_distribution::fork_lineage_visible_identity`,这里只负责判定行是否可用并调用它, +/// 不在 module 里重写一遍(否则「拿不到标题就回退用行数据」这类降级会悄悄长回来)。 fn game_distribution_lineage_snapshot( ctx: &ReducerContext, game_id: &str, @@ -4223,22 +4226,36 @@ fn game_distribution_lineage_snapshot( .game_id() .find(&lineage.parent_game_id)?; let parent_available = parent.deleted_at.is_none(); + let (parent_title, parent_author_name) = + module_game_distribution::fork_lineage_visible_identity( + parent_available, + parent.title.as_str(), + parent.author_name.as_deref(), + ); + // 根作品与父作品同一口径:标题同样只在根行可用时外发;根行缺失(历史数据)退回父标题, + // 且同样受父的可用性约束。规则复用同一个纯函数,不在 module 里再写一遍。 let root_title = match ctx .db .game_distribution_game() .game_id() .find(&lineage.root_game_id) { - Some(root) if root.deleted_at.is_none() => Some(root.title), - // 根作品已软删除:与父作品同一口径,标题不外发。 - Some(_) => None, - // 根行缺失(历史数据):退回父标题,但同样受父作品的删除判定约束。 - None => parent_available.then(|| parent.title.clone()), - }; - let (parent_title, parent_author_name) = if parent_available { - (Some(parent.title.clone()), parent.author_name.clone()) - } else { - (None, None) + Some(root) => { + let (title, _) = module_game_distribution::fork_lineage_visible_identity( + root.deleted_at.is_none(), + root.title.as_str(), + None, + ); + title + } + None => { + let (title, _) = module_game_distribution::fork_lineage_visible_identity( + parent_available, + parent.title.as_str(), + None, + ); + title + } }; Some(GameDistributionLineageSnapshot { generation: lineage.generation,