fix(游戏共创): 血缘摘要的父作者名改为读时联账号表兜底(修真实库 parentAuthorName 为 null)
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

真实库 `GET /games/{A111}` 的血缘摘要里 `parentTitle` 有值而 `parentAuthorName` 为 `null`,详情页溯源条
「父作品 · 作者名」开天窗。根因核实:`game_distribution_lineage_snapshot` 直接用了发布时冻结的
`parent.author_name`,而老数据 / 程序化创建的作品上该列常为 `None`——公开投影与族谱节点早已改成**读时联
`user_account`**,只有血缘摘要没跟上(同一处语义没同步)。

- 血缘摘要改用 `game_distribution_lineage_author_name`(与族谱节点**同一实现**):账号显示名优先 →
  账号不可得/昵称为空白时退回冻结作者名 → 两者都为空白则 `None`(不占位、不回退空串、不编造),
  与公开详情同口径;父作品不可用时仍走原有的 `fork_lineage_visible_identity` 三态裁剪,不泄露已删作品身份。
- 新增结构回归测试 `lineage_summary_resolves_parent_author_name_at_read_time`:血缘快照必须调用账号兜底
  函数,并**不得**再直接出现 `parent.author_name`(宿主起不了真库,行为证据留给真实栈复验)。
- parity 新增登记 `lineage_payload`(构建器 17→**18**):把「builder 发出的键 == DTO 字段」钉住,并要求
  `parentAuthorName` **恒发该键**(值为 `null` 也要发——缺键与 `null` 在前端是两种渲染分支,不能用
  optional 表达)。
- 文档 §3.4:写明读时兜底的口径与优先级;并明确**不新增 `rootAuthorName`**——已只读核实前端
  `GameDetailPage` 只消费 `parentAuthorName`(`CoCreationLineageStrip` 用的是族谱节点的 `authorName`),
  根侧只发 `rootTitle`,不加字段以免契约膨胀。
- **无字段形状变更**(Rust DTO 与 TS 均未增删字段,故无 TS/绑定改动)。

门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p spacetime-module` **306 passed / 1 ignored**;
`cargo test -p module-game-distribution` 134 passed;`cargo test -p api-server game_distribution` 107 passed;
DTO parity 0(66 组 / **18** 构建器 / 15 手拼类型);`check:spacetime-schema` 0(98 表);
`check:encoding` 0(5546 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;
`git diff --check` 0。

未在真实库手动改任何数据(按要求);修完请另一个 session 用真实数据复验 `parentAuthorName`。
This commit is contained in:
2026-10-07 00:43:54 +08:00
parent 62a35d0313
commit a898e18a9f
3 changed files with 40 additions and 1 deletions
@@ -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`;除非将来确实要展示根作者,否则不加字段以免契约膨胀。
---
@@ -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) {
@@ -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}"
);
}
}