diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 8000d50f7..512ac4bdd 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -507,6 +507,8 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的 - 共创信息(授权徽章 + 代际 + 溯源 + 已被改编次数)通过它的 `infoCards` 槽位渲染,落在资料侧栏,等价于原设计里 `.game-info-card.game-fork-card` 的位置; - 共享组件会把 `value` 再包一层内联 flex 的 `span`,卡内样式统一用 `.game-detail-info-card .game-fork-*` 前缀保证优先级; - 父作品被软删除时后端 `parentTitle` 为 `null`,卡片降级为「第 N 代作品 · 原作品已不可用」,不猜测、不回显已删内容。 +- **`lineage.parentAuthorName` 是读时联账号表的结果(2026-10-06 修)**:账号显示名优先,账号不可得或昵称为空白时退回发布时冻结的作者名,两者都为空则 `null`(不占位、不伪造)。**不得**只读游戏行的冻结列 `author_name`——老数据与程序化创建的作品上它常为 `None`,真实库里就出现过「`parentTitle` 有值而 `parentAuthorName` 为 `null`」,导致溯源条「父作品 · 作者名」开天窗;口径与公开投影 / 族谱节点共用 `resolve_lineage_author_name`。 +- **不提供 `rootAuthorName`**:前端溯源条只展示父作者(`GameDetailPage` 只消费 `parentAuthorName`),根侧只发 `rootTitle`;除非将来确实要展示根作者,否则不加字段以免契约膨胀。 --- diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 58b0084d9..564a3d189 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -312,6 +312,15 @@ const RESPONSE_BUILDERS = [ 'visibility', ], }, + { + // 公开详情里的血缘摘要(`GET /games/{gameId}` 的 `lineage`)。TS 里 `parentAuthorName` 是可选的 + // (表达「历史响应可能缺键」),但这条路径**必须恒发该键**(值为 `null` 也要发):它是溯源条 + // 「父作品 · 作者名」的唯一来源,「缺键」与「null」在前端是两种渲染分支,不能靠 optional 表达。 + // 这条登记同时把「builder 发出的键 == DTO 字段」钉住,防止再出现「加了字段忘了发」。 + fn: 'lineage_payload', + ts: 'GameDistributionLineage', + mustEmit: ['parentAuthorName'], + }, ]; function camelCase(value) { diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 55ce5181a..710550512 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -7606,11 +7606,18 @@ fn game_distribution_lineage_snapshot( .game_id() .find(&lineage.parent_game_id)?; let parent_available = parent.deleted_at.is_none(); + // 父作品作者名与公开投影/族谱节点同一口径:**账号显示名优先**,账号不可得或昵称为空时退回 + // 发布时冻结的 `author_name`,两者都为空白则 `None`(不占位)。 + // + // 这里必须**读时联 `user_account`**:冻结值在老数据与程序化创建的作品上常常是 `None`(真实库里 + // 就出现过 `parentTitle` 有值而 `parentAuthorName` 为 `null`),而溯源条要展示「父作品 · 作者名」。 + // 规则复用 `game_distribution_lineage_author_name`(与族谱节点同一实现),不在本函数里再写一遍。 + let parent_author_fallback = game_distribution_lineage_author_name(ctx, &parent); let (parent_title, parent_author_name) = module_game_distribution::fork_lineage_visible_identity( parent_available, parent.title.as_str(), - parent.author_name.as_deref(), + parent_author_fallback.as_deref(), ); // 根作品与父作品同一口径:标题同样只在根行可用时外发;根行缺失(历史数据)退回父标题, // 且同样受父的可用性约束。规则复用同一个纯函数,不在 module 里再写一遍。 @@ -9037,4 +9044,25 @@ mod tests { ); assert!(totals.contains("pub play_count: u64")); } + + /// 公开详情的**血缘摘要**必须读时联账号表取父作者名。 + /// + /// 真实库缺陷:`parentTitle` 有值而 `parentAuthorName` 为 `null`(详情页溯源条要展示 + /// 「父作品 · 作者名」,于是开天窗)。根因是这里直接用了发布时冻结的 `parent.author_name`, + /// 而老数据 / 程序化创建的作品上该列常为 `None`——公开投影与族谱节点早已改成读时联 + /// `user_account`(账号昵称优先 → 退回冻结值 → 空白为 `None`),只有血缘摘要没跟上。 + /// 归一规则复用族谱节点那一份实现(`game_distribution_lineage_author_name`),不在这里重写。 + #[test] + fn lineage_summary_resolves_parent_author_name_at_read_time() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn game_distribution_lineage_snapshot("); + assert!( + body.contains("game_distribution_lineage_author_name(ctx, &parent)"), + "血缘摘要必须用「账号兜底」的作者名:{body}" + ); + assert!( + !body.contains("parent.author_name"), + "不得再直接读冻结列 `parent.author_name`(老数据常为 None,会重现空作者名缺陷):{body}" + ); + } }