feat(游戏共创): 公开投影新增 coCreationCount(公开可见的全部后代数)
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

会议口径:共创页卡片要突出「共创次数(= 该作品有多少个子项目/衍生作品)」。前端已按 `coCreationCount`
接字段(TS 侧先声明 ⇒ 上轮 **dto parity 变红**:`GameDistributionGame 多出未登记字段/变体:coCreationCount`),
本提交把服务端补齐并让 parity 回绿。

- **字段**:公开投影新增 `coCreationCount`(Rust `co_creation_count: u64`)= **该作品子树里「公开可见的全部后代」
  数,不含自己**;沿血缘边 BFS 整棵子树,只数「未软删除 + 已公开」的后代(与 `forkCount` 同一批规则
  `module_game_distribution::counts_as_public_derivative`,只是深度不同)。已删/未公开的后代不进任何一层计数;
  **中间作品不可见也不断链**(它可见的后代仍计入祖先);血缘环/自引用按 visited 去重,自己永不计数。
- **与 `forkCount` 并存、互不推导**:`forkCount` = **直接**子代(「被改编 N」),`coCreationCount` = **全部**后代
  (「共创 N」);同卡上前者 ≤ 后者。
- **发出范围**:公开投影**恒发**(公开目录、公开详情、我的收藏、主题成员 `roots` 都走同一个 `public_game_payload`);
  **无后代时发 `0` 而不是缺键**;作者侧同形响应不发(与 `currentVersion`/`ratingSummary` 同类)⇒ 前端读取建议
  `coCreationCount ?? forkCount`。
- **算法与成本**:在 spacetime **只读事务内现算** —— 先建一次 `PublicDerivativeIndex`(父→直接子代边 + 可见性集合),
  再对本页每行做一次 BFS 子树计数(visited 去重);目录/详情/收藏/主题各自事务内建一次。
  **不新增表/列/索引、不落物化计数,api-server 只发键不遍历**;边际成本 O(作品数)(当前 51)。
- **契约/门禁**:parity 的 `public_game_payload` 构建器 `mustEmit` 把 `coCreationCount` 与 `forkCount` 一起钉住
  ⇒ **parity 由红转绿(EXIT 0)**;TS 契约里两字段注释改为最终口径。
- **文档**:主规范公开投影一行写明两字段口径、BFS 算法与成本。
- **测试**:+4(根带 3 直接子代、其中一个再带 1 孙代 ⇒ **`forkCount == 3`、`coCreationCount == 4`**;无后代两键恒发且为 0;
  不可见后代不计且**中间不可见不断链**;环/自引用安全)→ `module-game-distribution` **153 passed**。

门禁:`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **153 passed**;
`cargo test -p api-server game_distribution` 见下文;**`check-game-distribution-dto-parity` 0(66 组 / 19 构建器 /
15 手拼类型)**;`check:spacetime-schema` 0(98 表);`cargo fmt --all -- --check` 0;`git diff --check` 0。
This commit is contained in:
2026-10-07 18:02:13 +08:00
parent e349bc696a
commit ffd7510143
9 changed files with 411 additions and 9 deletions
@@ -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!` 已登记该表。
@@ -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`;匿名请求不加键,
@@ -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,
}
}
@@ -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,
};
@@ -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<String, Vec<String>>,
/// 通过 [`counts_as_public_derivative`] 的作品 ID(未软删除 + 已公开)。
visible: HashSet<String>,
}
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<Item = (String, String)>,
games: impl IntoIterator<Item = (String, bool, bool)>,
) -> Self {
let mut children: HashMap<String, Vec<String>> = 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");
}
}
@@ -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<GameDistributionLineage>,
@@ -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<GameDistributionLineageRecord>,
}
@@ -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),
}
}
@@ -16,6 +16,7 @@ pub struct GameDistributionPublicGameSnapshot {
pub average_score: Option<f64>,
pub rating_count: u64,
pub fork_count: u64,
pub co_creation_count: u64,
pub lineage: Option<GameDistributionLineageSnapshot>,
}
@@ -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<GameDistributionLineageSnapshot>,
}
@@ -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::<Vec<_>>();
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::<Vec<_>>();
// 信号由「真实可见数 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<GameDistributionPublicGameSnapshot> {
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() {