修正 M3 族谱口径注释并抽出图装配纯函数(第二批)

- 修正 spacetime-module 里 get_game_distribution_lineage_and_return 的文档注释:锚点不存在/已软删除/未公开一律 found=false(api-server 映射 404),并写明理由——用空标题占位或空树代替 404 等于确认该 gameId 的存在、代际与血缘位置
- 修正 shared-contracts 里节点 status 的注释:当前只有已公开作品会成为节点(未公开节点被过滤、未公开锚点直接 404),线上取值实际恒为 published;保留该字段是为将来允许非公开节点进入树时不必再改契约
- 新增纯函数 assemble_lineage_tree_entries:家族归属校验(只保留 root_game_id 命中的血缘行)、根行以 0 代入集合、游戏行缺失的行安全跳过、重复行去重(根行优先);module 侧只负责取行与按根查索引
- 新增纯函数 resolve_lineage_author_name:账号昵称优先(trim 后非空)→ 退回发布时冻结的作者名 → 两者都空白则 None(不占位)
- 新增纯函数 lineage_entry_from_game_facts:族谱装配与衍生列表共用同一份字段映射
- module 删除 game_distribution_lineage_entry,衍生列表 tx 改用共用构造函数并复用同一份行事实折叠
- 新增 6 条单测:整族装配、旁支排除、缺行跳过、无根行回退、重复行去重、作者名归一三态
- 本批未改前端;package.json 与 lineage e2e 脚本属第三个 session,未触碰
This commit is contained in:
2026-10-05 01:47:49 +08:00
parent c6758e5e20
commit 775dea02ea
4 changed files with 313 additions and 50 deletions
@@ -30,8 +30,10 @@ pub use domain::{
pub use errors::{GameDistributionError, GameDistributionFieldError};
pub use events::GameDistributionEvent;
pub use lineage::{
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, LineageNodeList, LineageTree, LineageTreeEntry,
LineageTreeNode, build_derived_node_list, build_lineage_tree, lineage_anchor_readable,
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, LineageGameFacts, LineageLink, LineageNodeList,
LineageResolvedLink, LineageTree, LineageTreeEntry, LineageTreeNode,
assemble_lineage_tree_entries, build_derived_node_list, build_lineage_tree,
lineage_anchor_readable, lineage_entry_from_game_facts, resolve_lineage_author_name,
};
pub use package::{
MAX_COMPRESSION_RATIO, MAX_EXPANDED_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_PACKAGE_BYTES,
@@ -13,6 +13,8 @@
//! 展示层遇到「父不在可见集合里」就按 M1 已确立的口径标注「原作品已不可用」,
//! 不猜测、不回显父作品内容。
use std::collections::HashSet;
use crate::domain::{can_serve_as_fork_source, counts_as_public_derivative};
/// 单次响应最多返回的树节点数;超出时如实标注 `truncated`,不静默丢弃。
@@ -132,6 +134,116 @@ pub fn build_derived_node_list(entries: &[LineageTreeEntry], limit: usize) -> Li
LineageNodeList { nodes, truncated }
}
/// 游戏行的事实:调用方从游戏表取出后折叠成这些字段,不携带任何 `ReducerContext` 依赖。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineageGameFacts {
pub game_id: String,
pub title: String,
pub author_name: Option<String>,
pub play_count: u64,
pub is_deleted: bool,
pub is_published: bool,
pub status: String,
pub created_at_micros: i64,
}
/// 血缘行的事实:谁改编自谁、属于哪个根、第几代。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineageLink {
/// 子作品。
pub game_id: String,
pub parent_game_id: String,
pub root_game_id: String,
pub generation: u32,
}
/// 血缘行 + 它指向的游戏行事实;游戏行缺失(脏数据 / 历史遗留)时 `game` 为 `None`。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineageResolvedLink {
pub link: LineageLink,
pub game: Option<LineageGameFacts>,
}
/// 把「根作品行 + 以该根为祖先的血缘行」装配成族谱树输入行。
///
/// 这一层是树形状正确性的根,规则全部收在这里并脱离 SpacetimeDB 可单测:
/// - **家族归属**:只保留 `root_game_id` 命中的血缘行(调用方已按根索引取过一次,这里是显式
/// 不变量,避免将来换查询写法时把旁支混进树里);
/// - **根自身也在集合里**:根行以 0 代、无父进入;根行缺失时不占位(由展示层判「母版不可用」);
/// - **游戏行缺失就跳过**:不发只有 ID 的半残节点,也不 panic;
/// - **重复行去重**:同一作品只保留第一次出现,根行优先于血缘行。
pub fn assemble_lineage_tree_entries(
root_game_id: &str,
root: Option<LineageGameFacts>,
links: &[LineageResolvedLink],
) -> Vec<LineageTreeEntry> {
let mut entries = Vec::new();
if let Some(root) = root {
entries.push(lineage_entry_from_game_facts(root, 0, None));
}
for resolved in links {
if resolved.link.root_game_id != root_game_id {
continue;
}
let Some(game) = resolved.game.clone() else {
continue;
};
entries.push(lineage_entry_from_game_facts(
game,
resolved.link.generation,
Some(resolved.link.parent_game_id.clone()),
));
}
let mut seen = HashSet::new();
entries.retain(|entry| seen.insert(entry.game_id.clone()));
entries
}
/// 族谱节点的作者名:账号昵称优先(非空才用),否则退回发布时冻结的作者名;
/// 两者都是空串或纯空白时返回 `None`——节点上没有可渲染的名字就不占位。
///
/// 注意与 M1 的 `fork_lineage_visible_identity` 的差异:那个函数刻意保留作者名三态
/// (`Some("")` / `Some(" ")` 原样透传),因为溯源卡由展示层按 falsy 判断是否渲染
/// 「由 X 制作」,两种取值渲染结果等价;节点侧则不该把空串当成一个名字发出去。
pub fn resolve_lineage_author_name(
account_display_name: Option<&str>,
frozen_author_name: Option<&str>,
) -> Option<String> {
if let Some(display_name) = account_display_name {
let trimmed = display_name.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
frozen_author_name
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
}
/// 由行事实 + 血缘关系构造一条树输入行。
///
/// 公开给调用方复用:族谱树走 [`assemble_lineage_tree_entries`],衍生列表则由调用方按
/// `parent_game_id` 取直接子代后逐条调用本函数——两处的字段映射因此只有一份。
pub fn lineage_entry_from_game_facts(
facts: LineageGameFacts,
generation: u32,
parent_game_id: Option<String>,
) -> LineageTreeEntry {
LineageTreeEntry {
game_id: facts.game_id,
title: facts.title,
author_name: facts.author_name,
generation,
parent_game_id,
play_count: facts.play_count,
is_deleted: facts.is_deleted,
is_published: facts.is_published,
status: facts.status,
created_at_micros: facts.created_at_micros,
}
}
/// 稳定排序:代际升序 → 创建时间升序 → gameId 升序。
///
/// 最后一级是按 ID 的确定性兜底,避免同代同刻的作品在两次读之间换序(截断点会随之漂移)。
@@ -422,4 +534,143 @@ mod lineage_tree_tests {
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT
);
}
fn facts(game_id: &str, is_deleted: bool, is_published: bool) -> LineageGameFacts {
LineageGameFacts {
game_id: game_id.to_string(),
title: format!("作品 {game_id}"),
author_name: Some(format!("作者 {game_id}")),
play_count: 1,
is_deleted,
is_published,
status: if is_published {
"published"
} else {
"unpublished"
}
.to_string(),
created_at_micros: 10,
}
}
fn link(game_id: &str, parent: &str, root: &str, generation: u32) -> LineageResolvedLink {
LineageResolvedLink {
link: LineageLink {
game_id: game_id.to_string(),
parent_game_id: parent.to_string(),
root_game_id: root.to_string(),
generation,
},
game: Some(facts(game_id, false, true)),
}
}
fn assembled_ids(entries: &[LineageTreeEntry]) -> Vec<&str> {
entries.iter().map(|entry| entry.game_id.as_str()).collect()
}
/// 装配是树形状正确性的根:根行进集合(0 代、无父),同根的整族(各分支 + 深层后代)
/// 都进去,且后代保留自己的代际与父引用。
#[test]
fn assembly_keeps_root_and_whole_family_of_its_root() {
let entries = assemble_lineage_tree_entries(
"root",
Some(facts("root", false, true)),
&[
link("child-a", "root", "root", 1),
link("child-b", "root", "root", 1),
link("grandchild", "child-a", "root", 2),
],
);
assert_eq!(
assembled_ids(&entries),
vec!["root", "child-a", "child-b", "grandchild"]
);
assert_eq!(entries[0].generation, 0, "根自身是 0 代");
assert_eq!(entries[0].parent_game_id, None, "根自身没有父");
let grandchild = entries
.iter()
.find(|entry| entry.game_id == "grandchild")
.expect("深层后代应进入集合");
assert_eq!(grandchild.generation, 2);
assert_eq!(grandchild.parent_game_id.as_deref(), Some("child-a"));
}
/// 不属于该根的血缘行一律排除,避免把旁支混进树里。
#[test]
fn assembly_excludes_links_from_other_roots() {
let entries = assemble_lineage_tree_entries(
"root",
Some(facts("root", false, true)),
&[
link("child-a", "root", "root", 1),
link("outsider", "other-root", "other-root", 1),
],
);
assert_eq!(assembled_ids(&entries), vec!["root", "child-a"]);
}
/// 游戏行缺失(脏数据 / 历史遗留)时跳过该行:不 panic,也不产生只有 ID 的半残节点。
#[test]
fn assembly_skips_links_whose_game_row_is_missing() {
let mut broken = link("ghost", "root", "root", 1);
broken.game = None;
let entries = assemble_lineage_tree_entries(
"root",
Some(facts("root", false, true)),
&[broken, link("child-a", "root", "root", 1)],
);
assert_eq!(assembled_ids(&entries), vec!["root", "child-a"]);
assert!(entries.iter().all(|entry| entry.game_id != "ghost"));
}
/// 根行缺失(历史数据)时仍装配出后代:根不占位,由展示层按 `rootGameId` 判「母版不可用」。
#[test]
fn assembly_works_without_the_root_row() {
let entries =
assemble_lineage_tree_entries("root", None, &[link("child-a", "root", "root", 1)]);
assert_eq!(assembled_ids(&entries), vec!["child-a"]);
}
/// 重复行去重且保留第一次出现:同一作品既有根行又有血缘行时,根的身份(0 代、无父)优先。
#[test]
fn assembly_dedupes_keeping_the_first_occurrence() {
let entries = assemble_lineage_tree_entries(
"root",
Some(facts("root", false, true)),
&[
link("root", "someone", "root", 3),
link("root", "someone", "root", 3),
],
);
assert_eq!(assembled_ids(&entries), vec!["root"]);
assert_eq!(entries[0].generation, 0, "根行优先,不被血缘行改写");
assert_eq!(entries[0].parent_game_id, None);
}
/// 作者名归一:账号昵称优先(并 trim)→ 退回冻结名 → 两者都空白则 `None`(节点不占位)。
#[test]
fn author_name_prefers_account_then_frozen_then_none() {
assert_eq!(
resolve_lineage_author_name(Some("账号昵称"), Some("冻结名")),
Some("账号昵称".to_string())
);
assert_eq!(
resolve_lineage_author_name(Some(" 账号昵称 "), Some("冻结名")),
Some("账号昵称".to_string()),
"账号昵称去首尾空白后透传"
);
// 账号信息不可得(或昵称为空白)时退回发布时冻结的作者名。
assert_eq!(
resolve_lineage_author_name(None, Some("冻结名")),
Some("冻结名".to_string())
);
assert_eq!(
resolve_lineage_author_name(Some(" "), Some("冻结名")),
Some("冻结名".to_string())
);
// 两者都没有可渲染的名字 → 不占位。
assert_eq!(resolve_lineage_author_name(None, None), None);
assert_eq!(resolve_lineage_author_name(Some(""), Some(" ")), None);
}
}
@@ -270,7 +270,9 @@ pub struct GameDistributionLineageNode {
#[serde(default)]
pub parent_game_id: Option<String>,
pub play_count: u64,
/// 可见性;未公开的锚点节点原样回传 `unpublished` / `suspended`。
/// 可见性。当前**只有已公开作品会成为节点**(未公开节点被过滤、未公开锚点直接 404),
/// 所以线上取值实际恒为 `published`;保留该字段是为了将来允许非公开节点进入树时
/// 不必再改契约,届时展示层按此渲染状态。
pub status: GameDistributionVisibility,
}
@@ -363,6 +365,14 @@ pub struct GameDistributionCreateGameRequest {
pub device_support: GameDistributionDeviceSupport,
pub input_modes: Vec<GameDistributionInputMode>,
pub orientation: GameDistributionOrientation,
/// 上架时选择的共创授权档位;缺省按「禁止共创」解释(与表列默认一致,旧客户端不受影响)。
///
/// 写成非 `Option` + `#[serde(default)]` 而不是 `Option<..>`:省略与显式传 `forbidden`
/// 得到同一个值,因此 `create_game` 的幂等摘要(对整个请求体取摘要)也相同——省略与显式
/// 传默认不会变成两个不同的请求;未知取值由反序列化直接拒绝(api-server 映射 400),
/// 不会静默落成 `forbidden`。
#[serde(default)]
pub fork_authorization: GameDistributionForkAuthorization,
/// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。
#[serde(default)]
pub fork: Option<GameDistributionForkDeclaration>,
@@ -2013,8 +2013,11 @@ pub fn get_public_game_distribution_game_and_return(
/// 返回以该作品根为顶的整棵创作族谱树;公开只读,匿名可读。
///
/// 目标作品不存在或已软删除时 `found = false`(api-server 映射 404);未公开但未删除的作品
/// 仍可读(作者页入口),自身作为锚点保留、身份字段按可见性降级。
/// 锚点必须公开可读:不存在、已软删除或未公开(`visibility != published`)一律 `found = false`
/// (api-server 映射 404)。**不允许**用「标题降级为空的锚点节点」或空树代替 404——那等于对外
/// 确认该 gameId 存在、它是第几代、它在血缘里的位置,本身就是未公开作品的信息泄露。
/// 树内也只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与
/// `parent_game_id`,不补占位节点。
#[spacetimedb::procedure]
pub fn get_game_distribution_lineage_and_return(
ctx: &mut ProcedureContext,
@@ -4173,9 +4176,9 @@ fn game_distribution_derived_games_tx(
.game_distribution_game()
.game_id()
.find(&lineage.game_id)?;
Some(game_distribution_lineage_entry(
ctx,
&child,
// 字段映射只有一份:复用与族谱装配同一个构造函数,避免两条路径各写一套。
Some(module_game_distribution::lineage_entry_from_game_facts(
game_distribution_lineage_game_facts(ctx, &child),
lineage.generation,
Some(lineage.parent_game_id.clone()),
))
@@ -4234,57 +4237,52 @@ fn get_game_distribution_fork_source_tx(
/// 折叠族谱树的输入行:根作品自身 + 所有以该根为祖先的血缘行。
///
/// 只读行事实,不做任何可见性判断——「哪些节点能出现、身份字段要不要降级」全部由
/// `module_game_distribution::lineage` 的纯函数决定,避免同一口径长出第二套实现。
/// 这里只负责取行(根游戏行、按根索引命中的血缘行、以及每行引用的游戏行),
/// 「家族归属校验、根自身入集合、游戏行缺失跳过、重复行去重」全部交给
/// `module_game_distribution::assemble_lineage_tree_entries` 这个纯函数,
/// 既避免同一口径长出第二套实现,也让树形状能在不起 `ReducerContext` 的前提下被单测钉住。
fn game_distribution_lineage_entries(
ctx: &ReducerContext,
root_game_id: &str,
) -> Vec<module_game_distribution::LineageTreeEntry> {
let mut entries = Vec::new();
if let Some(root) = ctx
let root = ctx
.db
.game_distribution_game()
.game_id()
.find(&root_game_id.to_string())
{
entries.push(game_distribution_lineage_entry(ctx, &root, 0, None));
}
for lineage in ctx
.map(|game| game_distribution_lineage_game_facts(ctx, &game));
let resolved_links = ctx
.db
.game_distribution_lineage()
.by_game_distribution_lineage_root_game_id()
.filter(&root_game_id.to_string())
{
let Some(game) = ctx
.db
.game_distribution_game()
.game_id()
.find(&lineage.game_id)
else {
continue;
};
entries.push(game_distribution_lineage_entry(
ctx,
&game,
lineage.generation,
Some(lineage.parent_game_id.clone()),
));
}
entries
.map(|lineage| module_game_distribution::LineageResolvedLink {
link: module_game_distribution::LineageLink {
game_id: lineage.game_id.clone(),
parent_game_id: lineage.parent_game_id.clone(),
root_game_id: lineage.root_game_id.clone(),
generation: lineage.generation,
},
game: ctx
.db
.game_distribution_game()
.game_id()
.find(&lineage.game_id)
.map(|game| game_distribution_lineage_game_facts(ctx, &game)),
})
.collect::<Vec<_>>();
module_game_distribution::assemble_lineage_tree_entries(root_game_id, root, &resolved_links)
}
fn game_distribution_lineage_entry(
/// 把一个游戏行折叠成族谱装配用的行事实(含作者名的读时联账号)。
fn game_distribution_lineage_game_facts(
ctx: &ReducerContext,
game: &GameDistributionGame,
generation: u32,
parent_game_id: Option<String>,
) -> module_game_distribution::LineageTreeEntry {
module_game_distribution::LineageTreeEntry {
) -> module_game_distribution::LineageGameFacts {
module_game_distribution::LineageGameFacts {
game_id: game.game_id.clone(),
title: game.title.clone(),
author_name: game_distribution_lineage_author_name(ctx, game),
generation,
parent_game_id,
play_count: game.play_count,
is_deleted: game.deleted_at.is_some(),
is_published: game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED,
@@ -4296,20 +4294,22 @@ fn game_distribution_lineage_entry(
/// 族谱节点的作者名:与公开详情同样读时联账号表(账号改名/换头像立即跟随),
/// 账号信息不可得时退回发布时冻结的作者名。
///
/// 与公开投影的差别只有一处:空串与纯空白归一成 `None`(节点上没有可渲染的名字就不占位)。
/// 归一规则(账号昵称优先、两者都空白则 `None`)放在
/// `module_game_distribution::resolve_lineage_author_name`,这里只做账号查询。
fn game_distribution_lineage_author_name(
ctx: &ReducerContext,
game: &GameDistributionGame,
) -> Option<String> {
if let Some(account) = ctx.db.user_account().user_id().find(&game.owner_user_id) {
let display_name = account.display_name.trim().to_string();
if !display_name.is_empty() {
return Some(display_name);
}
}
game.author_name
.clone()
.filter(|name| !name.trim().is_empty())
let account_display_name = ctx
.db
.user_account()
.user_id()
.find(&game.owner_user_id)
.map(|account| account.display_name);
module_game_distribution::resolve_lineage_author_name(
account_display_name.as_deref(),
game.author_name.as_deref(),
)
}
fn game_distribution_lineage_node_snapshot(