diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8f5697926..4fe8a4c1e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -514,7 +514,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 用途:作品之间的改编(Fork)血缘。`game_id` 为主键(子作品),因此「一个作品最多有一个父」由主键本身保证;`owner_user_id`(子作品作者,冗余用于「我改编过的作品」查询,不参与授权判定)、`parent_game_id`、`parent_version_id`(建立血缘时父作品的当前公开版本,不可变事实)、`root_game_id`(0 代母版;根作品自身不写行)、`generation: u32`(直接改编母版为 1)、`created_at`。 - 写入时机:只在创建游戏时(`GameDistributionCreateGameInput` 携带 `forked_from_game_id` / `forked_from_version_id`,`api-server` 由创作入口的 `fork` 声明映射)与游戏行同事务写入,一次落定后不可变更;同一作品的第二次血缘声明失败关闭(`FORK_DECLARATION_ON_EXISTING_GAME` 409),`local_project_id` 复用既有身份的场景拒绝携带血缘。来源校验全部失败关闭:来源不存在 → `FORK_SOURCE_NOT_FOUND`(404),来源已软删除 / 未公开 / 无有效公开版本 → `FORK_SOURCE_NOT_AVAILABLE`(409),来源授权为禁止共创 → `FORK_NOT_AUTHORIZED`(403),来源版本不等于来源作品当前公开版本 → `FORK_SOURCE_VERSION_MISMATCH`(409)。 - 索引:`by_game_distribution_lineage_parent_game_id`(衍生计数与今后的衍生列表)、`by_game_distribution_lineage_root_game_id`、`by_game_distribution_lineage_owner_user_id`。 -- 读取口径:公开详情/公开目录的投影增量是 `forkAuthorization`、`forkCount`(按 `parent_game_id` 实时统计未软删除且已公开的直接子作品,不维护物化计数)与 `lineage` 摘要快照(`generation` / `rootGameId` / `rootTitle` / `parentGameId` / `parentTitle` / `parentAuthorName`);母版或旧数据为 `null`/缺省。父作品下架或封禁只影响新的血缘声明,既有摘要照常返回。 +- 读取口径:公开详情/公开目录的投影增量是 `forkAuthorization`、`forkCount`(按 `parent_game_id` 实时统计未软删除且已公开的**直接**子作品,不维护物化计数)、`coCreationCount`(2026-10-07 新增:**公开可见的全部后代数**,不含自己——同一条可见性规则 `counts_as_public_derivative`,深度不同;在只读事务里**建一次** `module_game_distribution::PublicDerivativeIndex`(父 → 直接子代血缘边 + 可见性集合)后对本页每行做 BFS 子树计数,事务内不落表 / 不新增索引,api-server 只发键)与 `lineage` 摘要快照(`generation` / `rootGameId` / `rootTitle` / `parentGameId` / `parentTitle` / `parentAuthorName`);母版或旧数据为 `null`/缺省。父作品下架或封禁只影响新的血缘声明,既有摘要照常返回。 - 溯源摘要与软删除:父(或根)作品被软删除时不再输出它的标题与父作者名——`game_distribution_lineage_snapshot` 把 `parent_title` / `root_title` 置为 `None`、`parent_author_name` 一并清空,`api-server` 原样发 `null`,前端降级为「原作品已不可用」;只保留不透明的 ID 与代际。理由:公开目录/详情既已下线被删作品,若还能从别人的溯源卡里读到它的标题与作者,删除语义就被血缘绕过。表结构同 `migration.rs` 的迁移导入导出白名单(血缘是业务事实,随迁移导入导出)。 - 迁移:新表初始为空,不需要回填;根作品的「0 代」由「无血缘行」表达。`migration_tables!` 已登记该表。 diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index a64c554a2..4d7ac9fd7 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -179,7 +179,15 @@ const RESPONSE_BUILDERS = [ fn: 'public_game_payload', ts: 'GameDistributionGame', base: 'game_payload', - mustEmit: ['currentVersion', 'ratingSummary', 'forkCount', 'lineage'], + // `coCreationCount`(公开可见的**全部**后代数)与 `forkCount`(**直接**子代)一样只属于公开投影: + // TS 为了同时描述作者侧形状把它标成可选,但公开目录 / 公开详情必须真的发出,故显式钉住。 + mustEmit: [ + 'currentVersion', + 'ratingSummary', + 'forkCount', + 'lineage', + 'coCreationCount', + ], }, { // 公开详情在 `public_game_payload` 之上按**可选登录态**追加 `collected`;匿名请求不加键, 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 ba5b62421..fad19ac47 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -5626,6 +5626,10 @@ fn public_game_payload( json!(rating_summary_payload(game.rating_summary)), ); object.insert("forkCount".to_string(), json!(game.fork_count)); + // 「共创次数」= 该作品子树里公开可见的**全部后代**数(不含自己),与 `forkCount` + // (只数直接子代)刻意并存:网页端共创页卡片优先读这个键、缺省时回退 `forkCount`。 + // 计数由 spacetime 事务在同一份公开投影里现算,api-server 只发键、不遍历。 + object.insert("coCreationCount".to_string(), json!(game.co_creation_count)); object.insert( "lineage".to_string(), game.lineage @@ -7198,12 +7202,27 @@ mod tests { rating_count, }, fork_count: 0, + co_creation_count: 0, lineage: None, }, &GameViewerContext::default(), ); + // 「无子代」也必须**恒发**两个键(键在、值为 0),不能靠缺键表达 0:展示层把缺键 + // 当成旧响应而回退,把 0 当成真实计数,两者语义不同。 + assert_eq!( + payload["forkCount"], + Value::Number(0.into()), + "forkCount 恒发" + ); + assert_eq!( + payload["coCreationCount"], + Value::Number(0.into()), + "coCreationCount 恒发(与 forkCount 并存)" + ); let summary: shared_contracts::game_distribution::GameDistributionGameSummary = serde_json::from_value(payload).expect("公开游戏响应应可解析"); + assert_eq!(summary.fork_count, 0); + assert_eq!(summary.co_creation_count, 0); assert_eq!( summary.rating_summary, Some(GameDistributionRatingSummary { @@ -7214,6 +7233,22 @@ mod tests { } } + /// 公开 payload 里两个计数**并存且口径不同**:`forkCount` 只数直接子代(3), + /// `coCreationCount` 数整棵子树的可见后代(4)。两者都必须真实发出、能被契约结构体解析。 + #[test] + fn public_payload_emits_co_creation_count_alongside_fork_count() { + let mut record = public_game_record_fixture(); + record.fork_count = 3; + record.co_creation_count = 4; + let payload = public_game_payload(record, &GameViewerContext::default()); + assert_eq!(payload["forkCount"], Value::Number(3.into())); + assert_eq!(payload["coCreationCount"], Value::Number(4.into())); + let summary: shared_contracts::game_distribution::GameDistributionGameSummary = + serde_json::from_value(payload).expect("公开游戏响应应可解析"); + assert_eq!(summary.fork_count, 3, "forkCount = 直接子代数"); + assert_eq!(summary.co_creation_count, 4, "coCreationCount = 全部后代数"); + } + #[test] fn version_detail_payload_exposes_frozen_metadata_to_owner() { let game = GameDistributionGameRecord { @@ -8927,6 +8962,7 @@ mod tests { rating_count: 0, }, fork_count: 0, + co_creation_count: 0, lineage: None, }), "index.html", @@ -10076,6 +10112,7 @@ mod tests { rating_count: 0, }, fork_count: 0, + co_creation_count: 0, lineage: None, } } @@ -10159,6 +10196,7 @@ mod tests { rating_count: 0, }, fork_count: 0, + co_creation_count: 0, lineage: None, } } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index deab8afd4..1003a254c 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -70,7 +70,7 @@ pub use errors::{ pub use events::GameDistributionEvent; pub use lineage::{ GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, LineageGameFacts, LineageLink, LineageNodeList, - LineageResolvedLink, LineageTree, LineageTreeEntry, LineageTreeNode, + LineageResolvedLink, LineageTree, LineageTreeEntry, LineageTreeNode, PublicDerivativeIndex, assemble_lineage_tree_entries, build_derived_node_list, build_lineage_tree, lineage_anchor_readable, lineage_entry_from_game_facts, resolve_lineage_author_name, }; diff --git a/server-rs/crates/module-game-distribution/src/lineage.rs b/server-rs/crates/module-game-distribution/src/lineage.rs index a46027926..4191e1bbd 100644 --- a/server-rs/crates/module-game-distribution/src/lineage.rs +++ b/server-rs/crates/module-game-distribution/src/lineage.rs @@ -13,7 +13,7 @@ //! 展示层遇到「父不在可见集合里」就按 M1 已确立的口径标注「原作品已不可用」, //! 不猜测、不回显父作品内容。 -use std::collections::HashSet; +use std::collections::{HashMap, HashSet, VecDeque}; use crate::domain::{can_serve_as_fork_source, counts_as_public_derivative}; @@ -140,6 +140,77 @@ pub fn build_derived_node_list(entries: &[LineageTreeEntry], limit: usize) -> Li LineageNodeList { nodes, truncated } } +/// 「公开可见后代数」的输入索引:血缘边(父 → 直接子代)+ 公开可见作品集合。 +/// +/// 事务侧**一次建索引、多次数子树**:目录一页最多 100 行,每行都要数一次子树,但数的时候 +/// 只在内存里走边、不再回表,也不新增表 / 列 / 索引、不维护物化计数(物化计数会和下架 / +/// 封禁 / 删除状态产生第二份真相)。本类型不碰 `ReducerContext`,因此计数口径可以脱离 +/// SpacetimeDB 直接单测。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PublicDerivativeIndex { + /// `parent_game_id` → 直接子代 `game_id`(按输入顺序,不去重;计数时用 `visited` 去重)。 + children: HashMap>, + /// 通过 [`counts_as_public_derivative`] 的作品 ID(未软删除 + 已公开)。 + visible: HashSet, +} + +impl PublicDerivativeIndex { + /// `edges`:血缘边 `(parent_game_id, game_id)`,只折叠行事实、不在这里过滤可见性; + /// `games`:`(game_id, is_deleted, is_published)`,可见性判定复用 + /// [`counts_as_public_derivative`](与公开详情 `forkCount` 同一条规则),调用方只负责把行状态 + /// 折成布尔,不重写规则。 + pub fn new( + edges: impl IntoIterator, + games: impl IntoIterator, + ) -> Self { + let mut children: HashMap> = HashMap::new(); + for (parent_game_id, game_id) in edges { + children.entry(parent_game_id).or_default().push(game_id); + } + let visible = games + .into_iter() + .filter(|(_, is_deleted, is_published)| { + counts_as_public_derivative(*is_deleted, *is_published) + }) + .map(|(game_id, _, _)| game_id) + .collect(); + Self { children, visible } + } + + /// 公开可见的**全部后代**数(不含自己)——公开目录 / 公开详情 `coCreationCount` 的口径。 + /// + /// 与 `forkCount`(只数**直接**子代、`build_derived_node_list` 的长度)刻意不同: + /// 这里是整棵子树的可见后代数。 + /// + /// - **遍历**:从 `game_id` 沿血缘边 BFS;`visited` 起始就含自己,因此自引用与血缘环 + /// 既不会死循环,也不会把自己数进去(同一作品最多被数一次)。 + /// - **计数**:只有可见集合命中的后代才 `+1`;已软删除 / 未公开的后代不进任何一代的计数。 + /// - **不在不可见处断链**:父作品已删 / 未公开时,它**可见的**后代仍算在祖先的后代数里 + /// (与族谱「父/祖辈被排除时孩子照常出现」同一条口径,不补占位节点)。 + pub fn public_derivative_count(&self, game_id: &str) -> u64 { + let mut visited: HashSet<&str> = HashSet::new(); + visited.insert(game_id); + let mut queue: VecDeque<&str> = VecDeque::new(); + queue.push_back(game_id); + let mut count = 0_u64; + while let Some(current) = queue.pop_front() { + let Some(children) = self.children.get(current) else { + continue; + }; + for child in children { + if !visited.insert(child.as_str()) { + continue; + } + if self.visible.contains(child.as_str()) { + count += 1; + } + queue.push_back(child.as_str()); + } + } + count + } +} + /// 游戏行的事实:调用方从游戏表取出后折叠成这些字段,不携带任何 `ReducerContext` 依赖。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct LineageGameFacts { @@ -743,3 +814,152 @@ mod lineage_tree_tests { ); } } + +#[cfg(test)] +mod public_derivative_count_tests { + use super::*; + + /// `(game_id, is_deleted, is_published)`。 + type GameFacts = (&'static str, bool, bool); + + fn build_index(edges: &[(&str, &str)], games: &[GameFacts]) -> PublicDerivativeIndex { + PublicDerivativeIndex::new( + edges + .iter() + .map(|(parent, child)| (parent.to_string(), child.to_string())), + games.iter().map(|(game_id, is_deleted, is_published)| { + (game_id.to_string(), *is_deleted, *is_published) + }), + ) + } + + fn visible(game_id: &'static str) -> GameFacts { + (game_id, false, true) + } + + /// 只用于 `forkCount` 一侧的对照:与 `lineage_tree_tests` 里同名 helper 同一形状(那个 helper + /// 是另一个测试模块私有的,这里不跨模块借用)。 + fn published(game_id: &str, generation: u32, created_at_micros: i64) -> LineageTreeEntry { + LineageTreeEntry { + game_id: game_id.to_string(), + title: format!("作品 {game_id}"), + author_name: Some(format!("作者 {game_id}")), + generation, + parent_game_id: (generation > 0).then(|| format!("game_gen{}", generation - 1)), + play_count: generation as u64 * 10, + is_deleted: false, + is_published: true, + status: "published".to_string(), + created_at_micros, + cover_object_key: None, + } + } + + /// 核心口径:根 + 3 个直接子代 + 其中 1 个再带 1 个孙代 ⇒ `forkCount == 3`、`coCreationCount == 4`。 + /// + /// `forkCount` 一侧直接用同一批规则(`counts_as_public_derivative`)的衍生列表长度钉死: + /// 两个数字来自同一条可见性规则,只是深度不同(直接子代 vs 全部后代)。 + #[test] + fn co_creation_counts_the_whole_subtree_while_fork_count_stays_direct() { + // `forkCount` 一侧:调用方按 `parent_game_id` 取**直接**子代后交给 `build_derived_node_list` + // (与模块里 `game_distribution_public_fork_count` 同一条可见性规则、同一层深度)。 + let direct = build_derived_node_list( + &[ + published("child-a", 1, 2), + published("child-b", 1, 3), + published("child-c", 1, 4), + ], + GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, + ); + assert_eq!(direct.nodes.len(), 3, "forkCount 口径 = 直接子代数"); + + let index = build_index( + &[ + ("root", "child-a"), + ("root", "child-b"), + ("root", "child-c"), + ("child-a", "grandchild-a"), + ], + &[ + visible("root"), + visible("child-a"), + visible("child-b"), + visible("child-c"), + visible("grandchild-a"), + ], + ); + assert_eq!( + index.public_derivative_count("root"), + 4, + "coCreationCount 口径 = 公开可见的全部后代(不含自己)" + ); + assert_eq!( + index.public_derivative_count("child-a"), + 1, + "孙代算进子代的子树" + ); + assert_eq!(index.public_derivative_count("child-b"), 0); + assert_eq!(index.public_derivative_count("child-c"), 0); + assert_eq!(index.public_derivative_count("grandchild-a"), 0); + } + + /// 无子代(或索引里根本没有这个作品)恒为 0:调用方据此发 `coCreationCount: 0`,而不是缺键。 + #[test] + fn childless_games_count_zero() { + let index = build_index(&[], &[visible("lonely")]); + assert_eq!(index.public_derivative_count("lonely"), 0); + assert_eq!( + index.public_derivative_count("never-heard-of-it"), + 0, + "未知作品按 0 处理,不 panic" + ); + } + + /// 不可见后代(软删 / 未公开)不进任何一代的计数;但**不在不可见处断链**——已删 / 未公开 + /// 中间作品**可见的**后代仍算在祖先的后代数里(与族谱「父被排除时孩子照常出现」同一口径)。 + #[test] + fn invisible_descendants_never_count_but_do_not_cut_the_subtree() { + let index = build_index( + &[ + ("root", "deleted-child"), + ("root", "unpublished-child"), + ("deleted-child", "visible-grandchild"), + ("unpublished-child", "also-hidden"), + ], + &[ + visible("root"), + ("deleted-child", true, true), + ("unpublished-child", false, false), + visible("visible-grandchild"), + ("also-hidden", true, true), + ], + ); + // 1 = visible-grandchild;已删的直接子代自己、未公开的直接子代、以及它下面同样不可见的 + // 后代都不计数。 + assert_eq!(index.public_derivative_count("root"), 1); + // 中间作品自己的子树:它可见的后代照常算进它的后代数。 + assert_eq!(index.public_derivative_count("deleted-child"), 1); + assert_eq!(index.public_derivative_count("unpublished-child"), 0); + // 「已软删除 + 可见性仍是 published」这类中间态同样不计数(读路径可能看到它)。 + let stale = build_index( + &[("root", "stale")], + &[visible("root"), ("stale", true, true)], + ); + assert_eq!(stale.public_derivative_count("root"), 0); + } + + /// 自己永远不计数:自引用边与血缘环都不会把根算成自己的后代,也不会死循环; + /// 重复的血缘边只数一次。 + #[test] + fn self_loops_and_cycles_never_count_the_root_twice() { + let self_loop = build_index(&[("root", "root")], &[visible("root")]); + assert_eq!(self_loop.public_derivative_count("root"), 0); + + let cycle = build_index( + &[("root", "a"), ("a", "b"), ("b", "root"), ("root", "a")], + &[visible("root"), visible("a"), visible("b")], + ); + assert_eq!(cycle.public_derivative_count("root"), 2, "环上只数 a、b"); + assert_eq!(cycle.public_derivative_count("a"), 2, "环上只数 b、root"); + } +} diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 413cb5cc9..1fc416bca 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -247,6 +247,14 @@ pub struct GameDistributionGameSummary { /// 直接改编该作品的已公开作品数。 #[serde(default)] pub fork_count: u64, + /// 该作品子树里**公开可见的全部后代**数(不含自己)——网页端「共创次数」。 + /// + /// 与 `fork_count`(只看**直接**子代)刻意不同:本字段数整棵子树的可见后代。口径 = 未软删除 + /// + 已公开,与 `fork_count` 共用同一批可见性规则,只是深度不同。**公开投影** + /// (公开目录 / 公开详情 / 我的收藏 / 主题成员)恒发;作者侧同形响应可省略(旧响应也缺省), + /// 读取方按 `0` 兜底。 + #[serde(default)] + pub co_creation_count: u64, /// 该作品的改编来源;母版为空。 #[serde(default, skip_serializing_if = "Option::is_none")] pub lineage: Option, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index e0a895378..01a729723 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -299,6 +299,8 @@ pub struct GameDistributionPublicGameRecord { pub rating_summary: GameDistributionRatingSummaryRecord, /// 直接改编该作品的已公开作品数。 pub fork_count: u64, + /// 该作品子树里公开可见的全部后代数(不含自己);口径与 `fork_count` 只差深度。 + pub co_creation_count: u64, /// 该作品的改编来源;母版为 None。 pub lineage: Option, } @@ -818,6 +820,7 @@ fn map_public_game( rating_count: value.rating_count, }, fork_count: value.fork_count, + co_creation_count: value.co_creation_count, lineage: map_lineage(value.lineage), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs index aafc6cf36..85a59a615 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs @@ -16,6 +16,7 @@ pub struct GameDistributionPublicGameSnapshot { pub average_score: Option, pub rating_count: u64, pub fork_count: u64, + pub co_creation_count: u64, pub lineage: Option, } diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 068a04412..bdde93ca1 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1746,6 +1746,14 @@ pub struct GameDistributionPublicGameSnapshot { pub rating_count: u64, /// 直接改编该作品的已公开作品数。 pub fork_count: u64, + /// 该作品子树里**公开可见的全部后代**数(不含自己)。 + /// + /// 与 `fork_count` 刻意不同:`fork_count` 只数**直接**子代(网页端「被改编 N」), + /// 本字段数**整棵子树**的可见后代(网页端「共创次数 / 子项目总数」)。口径 = 未软删除 + /// + 已公开(`counts_as_public_derivative`),已删 / 未公开的后代不进任何一代的计数。 + /// 由调用方在事务内用同一份 `module_game_distribution::PublicDerivativeIndex` 现算, + /// 不落表、不新增索引。 + pub co_creation_count: u64, /// 该作品的改编来源;母版为 None。 pub lineage: Option, } @@ -5641,9 +5649,11 @@ fn list_public_game_distribution_games_tx( cursor, input.limit, ); + // 索引只在事务里建一次:一页最多 100 行,每行的 `coCreationCount` 都在内存里数子树。 + let co_creation_index = game_distribution_public_derivative_index(ctx); Ok(( page.into_iter() - .filter_map(|game| public_game_distribution_snapshot(ctx, &game)) + .filter_map(|game| public_game_distribution_snapshot(ctx, &game, &co_creation_index)) .collect(), next_cursor, )) @@ -5663,7 +5673,13 @@ fn get_public_game_distribution_game_tx( { return Ok(None); } - Ok(public_game_distribution_snapshot(ctx, &game)) + // 详情一次只投影一行,但索引仍按「事务内建一次」的统一形状传给投影,避免两条路径两套写法。 + let co_creation_index = game_distribution_public_derivative_index(ctx); + Ok(public_game_distribution_snapshot( + ctx, + &game, + &co_creation_index, + )) } /// 族谱树读取的领域结果(procedure 只做 ok/error 包装)。 @@ -6172,9 +6188,11 @@ fn list_game_distribution_collections_tx( .collect::>(); let (page, next_cursor) = module_game_distribution::game_distribution_collection_page(visible, cursor, input.limit); + // 与公开目录同一份投影、同一份计数:索引同样在事务里建一次。 + let co_creation_index = game_distribution_public_derivative_index(ctx); Ok(( page.iter() - .filter_map(|game| public_game_distribution_snapshot(ctx, game)) + .filter_map(|game| public_game_distribution_snapshot(ctx, game, &co_creation_index)) .collect(), next_cursor, )) @@ -6340,12 +6358,14 @@ fn get_game_distribution_theme_detail_tx( // 计数与切页读的是**同一份**可见成员清单:`member_count` 取它的全长(不截断),`roots` 取排序 // 后的前 50 条。顺序不能反:先切页再计数就会把「这个主题有多少棵树」变成「这次回了多少条」。 let member_count = game_distribution_theme_member_count(ctx, theme.theme_id.as_str()); + // `roots` 逐条是公开目录同一份投影:计数索引同样在事务里建一次(不是每条建一次)。 + let co_creation_index = game_distribution_public_derivative_index(ctx); let roots = module_game_distribution::page_theme_members( game_distribution_theme_visible_members(ctx, theme.theme_id.as_str()), module_game_distribution::GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX as usize, ) .iter() - .filter_map(|game| public_game_distribution_snapshot(ctx, game)) + .filter_map(|game| public_game_distribution_snapshot(ctx, game, &co_creation_index)) .collect::>(); // 信号由「真实可见数 vs 实际回传条数」现算,不写死「超过 50 就 true」:上限一旦调整、 // 或某个成员的公开投影此刻不可得(`filter_map` 少一条),信号都跟着走,不需要改第二处。 @@ -7054,7 +7074,8 @@ fn get_game_distribution_game_for_owner_or_public_tx( } return Ok(Some(game_distribution_game_snapshot(&game))); } - Ok(public_game_distribution_snapshot(ctx, &game).map(|item| item.game)) + let co_creation_index = game_distribution_public_derivative_index(ctx); + Ok(public_game_distribution_snapshot(ctx, &game, &co_creation_index).map(|item| item.game)) } fn get_game_distribution_version_tx( @@ -7642,6 +7663,32 @@ fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u .count() as u64 } +/// 公开后代计数索引(血缘边 + 公开可见集合):**同一事务内建一次**,供该事务里的多个作品复用。 +/// +/// 为什么允许在事务侧全表扫:目录事务本来就要遍历 `game_distribution_game` 过滤 / 排序 / 切页, +/// 这里只多折叠一次「父 → 直接子代」的血缘边与一次可见性集合,边际成本 O(作品数);之后每行数 +/// 子树都在内存里走边。表规模 = 作品数(当前 51),且**不新增表 / 列 / 索引**,也不在 api-server +/// 里遍历。若将来作品量级真的上来,再换成物化计数或按根索引,读路径形状不变。 +fn game_distribution_public_derivative_index( + ctx: &ReducerContext, +) -> module_game_distribution::PublicDerivativeIndex { + module_game_distribution::PublicDerivativeIndex::new( + ctx.db + .game_distribution_lineage() + .iter() + .map(|lineage| (lineage.parent_game_id.clone(), lineage.game_id.clone())), + // 可见性只折叠行状态;「该不该计入」的规则仍由纯函数 `counts_as_public_derivative` 判, + // 这里不重写一遍(与 `game_distribution_public_fork_count` 同一口径来源)。 + ctx.db.game_distribution_game().iter().map(|game| { + ( + game.game_id.clone(), + game.deleted_at.is_some(), + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + ) + }), + ) +} + /// 公开血缘摘要。父作品被下架或封禁时仍然返回(作品行不会被删除), /// 只有父行真的缺失时才整体降级为 None。 /// @@ -7720,10 +7767,14 @@ fn game_distribution_lineage_snapshot( fn public_game_distribution_snapshot( ctx: &ReducerContext, game: &GameDistributionGame, + co_creation_index: &module_game_distribution::PublicDerivativeIndex, ) -> Option { let version = public_game_distribution_version(ctx, game)?; let (average_score, rating_count) = game_distribution_user_rating_summary(ctx, &game.game_id); let fork_count = game_distribution_public_fork_count(ctx, game.game_id.as_str()); + // 共创次数(全部后代)与 forkCount(直接子代)来自同一份索引 / 同一批可见性规则,只是深度 + // 不同;索引由调用方在**同一事务里建一次**,这里只做内存里的子树计数。 + let co_creation_count = co_creation_index.public_derivative_count(game.game_id.as_str()); let lineage = game_distribution_lineage_snapshot(ctx, game.game_id.as_str()); let mut game_snapshot = game_distribution_game_snapshot(game); // 作者名/头像读时联账号表:发布时冻结的资料可能是空的(历史数据),而公开页展示的是 @@ -7743,6 +7794,7 @@ fn public_game_distribution_snapshot( average_score, rating_count, fork_count, + co_creation_count, lineage, }) } @@ -8488,6 +8540,78 @@ mod tests { } } + /// 公开投影的 `coCreationCount`:事务里**建一次**索引、逐行数子树;与 `forkCount` 并存但口径不同。 + /// + /// 结构性不变量用源码文本钉住(行为证据在 `module-game-distribution` 的纯函数测试与 + /// api-server 的 payload 测试里): + /// - 索引每个事务只建一次,且**在循环之外**(否则一页 100 行就扫 100 次血缘表); + /// - 四条复用公开投影的事务都必须把索引传进去(漏一处 = 该路径静默少一个键); + /// - `forkCount` 的旧口径(只数直接子代)不得被改写成全后代计数。 + #[test] + fn public_projection_counts_co_creation_from_one_transaction_index() { + let source = include_str!("game_distribution.rs"); + let catalog = function_body(source, "fn list_public_game_distribution_games_tx("); + assert_eq!( + catalog + .matches("game_distribution_public_derivative_index(ctx)") + .count(), + 1, + "目录事务必须只建一次索引(页内多行复用同一份)" + ); + let index_at = catalog + .find("let co_creation_index = game_distribution_public_derivative_index(ctx);") + .expect("目录事务必须在切页之后建一次索引"); + let page_at = catalog + .find("module_game_distribution::page_public_game_distribution_catalog(") + .expect("切页必须在事务内"); + assert!( + index_at > page_at, + "索引必须建在切页之后:切页前的过滤链不该为被滤掉的行做计数" + ); + assert!( + catalog.contains("public_game_distribution_snapshot(ctx, &game, &co_creation_index)"), + "目录每一行都必须把同一份索引传给公开投影" + ); + // 复用同一份公开投影的其余事务:各自建一次、各自传进去。 + for name in [ + "fn get_public_game_distribution_game_tx(", + "fn list_game_distribution_collections_tx(", + "fn get_game_distribution_theme_detail_tx(", + "fn get_game_distribution_game_for_owner_or_public_tx(", + ] { + let body = function_body(source, name); + assert!( + body.contains("game_distribution_public_derivative_index(ctx)"), + "{name} 必须自建索引(事务内一次),否则该路径没有 coCreationCount" + ); + assert!( + body.contains("&co_creation_index"), + "{name} 必须把索引传进公开投影" + ); + } + // 索引折叠的是「血缘边 + 作品行可见性」,不新增表 / 列 / 索引,也不在 api-server 里遍历。 + let index = function_body(source, "fn game_distribution_public_derivative_index("); + assert!(index.contains(".game_distribution_lineage()")); + assert!(index.contains(".game_distribution_game()")); + assert!(index.contains("module_game_distribution::PublicDerivativeIndex::new(")); + // 投影:两个计数并存,深口径来自索引、直接子代口径仍走原函数。 + let snapshot = function_body(source, "fn public_game_distribution_snapshot("); + assert!( + snapshot.contains("co_creation_index.public_derivative_count(game.game_id.as_str())") + ); + assert!( + snapshot.contains("game_distribution_public_fork_count(ctx, game.game_id.as_str())") + ); + assert!(snapshot.contains("co_creation_count,")); + // forkCount 不得被改成全后代计数:它仍然按 parent 索引取一次直接子代。 + let fork = function_body(source, "fn game_distribution_public_fork_count("); + assert!(fork.contains("by_game_distribution_lineage_parent_game_id()")); + assert!( + !fork.contains("public_derivative_count"), + "forkCount 口径 = 直接子代,不得改成全部后代" + ); + } + /// 主题详情:不存在 / 未发布用同一句 404 文案;已发布空成员是正常结果;roots 复用公开投影。 #[test] fn public_theme_detail_maps_missing_to_not_found_and_empty_members_to_success() {