From a898e18a9fc37cddc8f62705f59f554dea005bc8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 00:43:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E8=A1=80=E7=BC=98=E6=91=98=E8=A6=81=E7=9A=84=E7=88=B6=E4=BD=9C?= =?UTF-8?q?=E8=80=85=E5=90=8D=E6=94=B9=E4=B8=BA=E8=AF=BB=E6=97=B6=E8=81=94?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E8=A1=A8=E5=85=9C=E5=BA=95=EF=BC=88=E4=BF=AE?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E5=BA=93=20parentAuthorName=20=E4=B8=BA=20nu?= =?UTF-8?q?ll=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实库 `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`。 --- ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 2 ++ .../check-game-distribution-dto-parity.mjs | 9 ++++++ .../spacetime-module/src/game_distribution.rs | 30 ++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) 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}" + ); + } }