test(游戏共创): 软删除×Fork 三条规则提取为纯函数并补单测

- domain.rs:新增三个不依赖 SpacetimeDB / ReducerContext 的纯函数
  · `can_serve_as_fork_source(is_deleted, is_published)`:来源可用性 = 未软删除 且 已公开
  · `counts_as_public_derivative(is_deleted, is_published)`:衍生计数纳入,同条件但动机不同(避免虚报热度、避免泄露「这里曾有个作品」),单独成函数以免两条规则将来互相牵连
  · `fork_lineage_visible_identity(parent_available, parent_title, parent_author_name)`:父可用才透传;父不可用必须返回 (None, None),不做任何「拿不到标题就回退用行数据」的降级
- lib.rs:在既有 `pub use domain::{...}` 列表中导出上述三个函数(一行)
- spacetime-module:三处调用点改为调用纯函数,行为完全不变——来源守卫合并为一个 `if`(仍统一映射 FORK_SOURCE_NOT_AVAILABLE)、衍生计数 filter 传入两个布尔、血缘快照的父身份与根标题共用同一函数;`visibility` 判定仍由 module 侧折叠成布尔
- domain.rs 测试:新增 `fork_soft_delete_tests` 5 条——来源可用性四组合(含「已删但 visibility 仍是 published」必须 false)、计数纳入四组合、父可用透传、父不可用即使传入非空标题/作者名也必须 (None, None)、作者名三态(None→None / 空串→空串 / 只含空白原样透传)
- 技术方案 §5.1:把「权限与边界」行的说明改为已由纯函数单测覆盖的三条规则(真实栈用例仍标注待补);「软删除语义收敛 ③溯源摘要」行补上 `fork_lineage_visible_identity` 单测证据
This commit is contained in:
2026-10-05 00:37:18 +08:00
parent d9c53a5b75
commit 036989d1cc
4 changed files with 186 additions and 29 deletions
@@ -494,12 +494,12 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
| HTTP 端到端(BFF 路由与真实数据库连接) | `npm run dev` 整套拉起后:`/healthz` 200;`GET /api/game-distribution/games` 200(空列表,走通真实数据库查询与 envelope);`PUT …/fork-authorization` 未带 Bearer 返回 401 `UNAUTHORIZED` | ✅ 已验证 |
| HTTP 端到端(真实登录态下的提升 / 声明血缘 / 详情页字段) | 未跑:需要真实登录态与一个已发布作品;本地开发库当前为空 | ⏳ 待补 |
| 浏览器视觉与交互验证(详情页共创卡、我的作品授权入口、移动端布局) | 未跑:本地库无作品数据,登录态不可用,页面无法进入有效状态 | ⏳ 待补 |
| 权限与边界(非作者 403、降级 409、父作品下架后新声明被拒) | 领域层与 procedure 校验已实现并覆盖单测;真实栈边界用例待补 | ⏳ 待补 |
| 权限与边界(非作者 403、降级 409、父作品下架后新声明被拒、软删除边界) | 领域层与 procedure 校验已实现;**软删除×Fork 三条规则已提取为纯函数并补单测**——`can_serve_as_fork_source`(未删+已公开,四组合,含「已删但 visibility 仍 published」必须 false)、`counts_as_public_derivative`(同四组合)、`fork_lineage_visible_identity`(父可用透传;父不可用即使传入非空标题/作者名也必须返回 `(None, None)`;作者名三态)由 `cargo test -p module-game-distribution`(36 passed)覆盖。真实栈边界用例仍待补 | ⏳ 部分(单测已补,真实栈待补) |
| **rebase 到含 #565 的新 master** | `git rebase origin/master`(新 master `6c9566058`)重放两个提交,9 个冲突文件按 §3.9.1 全部解决(两侧业务逻辑都保留),无 merge commit,未改动 master;提交信息沿用原中文提交 | ✅ 已验证 |
| **生成物重生成** | `npm run spacetime:generate -- --rust-only` 后保留 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,rustfmt 版本差异带来的其它文件格式漂移已 `git checkout --` 还原 | ✅ 已验证 |
| **软删除语义收敛 ①来源校验** | `resolve_game_distribution_fork_declaration_tx` 增加「来源 `deleted_at` 非空 → `FORK_SOURCE_NOT_AVAILABLE`(409)」,显式判定而不是只靠 `visibility` 复位 | ✅ 已验证(`cargo test -p api-server game_distribution`;领域错误码沿用 `FORK_*` 前缀) |
| **软删除语义收敛 ②衍生作品数** | `game_distribution_public_fork_count` 只统计 `deleted_at.is_none() && visibility == published` 的子作品;作者删除子作品后父作品计数随之下降 | ✅ 已验证(同上) |
| **软删除语义收敛 ③溯源摘要** | `game_distribution_lineage_snapshot` 在父(或根)作品被软删除时把 `parent_title` / `root_title` 置空、父作者名置空,api-server 原样发 `null`,TS 契约改为 `string \| null`,详情页降级为「原作品已不可用」 | ✅ 已验证(DTO parity + 前端用例;单测待补) |
| **软删除语义收敛 ③溯源摘要** | `game_distribution_lineage_snapshot` 在父(或根)作品被软删除时把 `parent_title` / `root_title` 置空、父作者名置空,api-server 原样发 `null`,TS 契约改为 `string \| null`,详情页降级为「原作品已不可用」 | ✅ 已验证(DTO parity + 前端用例 + `fork_lineage_visible_identity` 单测,「父不可用即使传入标题也必须 `(None, None)`」已由 `cargo test -p module-game-distribution` 断言) |
| **详情页结构对齐 #565** | 详情页改用共享 `GameDetailDisplay`,共创卡通过 `infoCards` 渲染;vitest 用例「展示共创授权、代际与溯源信息,且不提供改造入口」与「旧数据缺少授权与血缘字段时只按禁止共创渲染」通过 | ✅ 已验证 |
---
@@ -185,10 +185,149 @@ pub fn next_generation(parent_generation: u32) -> u32 {
parent_generation.saturating_add(1)
}
/// 某个作品行能否充当新的改编(Fork)来源。
///
/// 规则 =「未软删除」且「已公开」,两个条件缺一不可:
/// - **已软删除的作品一律不能被新改编**(#565 的 `deleted_at`)。删除虽然会把可见性复位成
/// `unpublished`,但那是删除的副作用;这里显式排除删除行,一是对外说明「来源已不可用」,
/// 二是避免将来改可见性语义时把已删作品重新变成可改编来源。
/// - **未公开的作品**(审核中 / 未公开 / 已下架 / 已封禁)没有对外承诺,也不该成为改编来源。
///
/// 只看这两个布尔、不读行本身:调用方负责把行状态折叠成布尔,于是这条规则可以在不起
/// `ReducerContext` 的前提下被单测钉死。
pub fn can_serve_as_fork_source(is_deleted: bool, is_published: bool) -> bool {
!is_deleted && is_published
}
/// 某个直接子代是否计入父作品的「已被改编 N 次」。
///
/// 与来源可用性同样是「未软删除 + 已公开」,但动机不同,所以单独成函数而不是复用前者
/// (两条规则将来可能各自演化,共用会让一处改动悄悄改掉另一处):
/// - **软删行必须排除**:删除只下线游戏行、保留血缘行;若把已删子作品也算进去,父作品的
/// 计数会虚报改编热度,并对外泄露「这里曾经有个作品」。
/// - **未公开的子作品不算**:它还不是公开可知的衍生,算进去同样是虚报。
///
/// 尤其注意「已删但 `visibility` 恰好仍是 `published`」这个组合:删除与可见性复位之间
/// 没有原子保证,读路径完全可能看到它,此时必须判 `false`。
pub fn counts_as_public_derivative(is_deleted: bool, is_published: bool) -> bool {
!is_deleted && is_published
}
/// 血缘溯源卡对外可见的 `(标题, 作者名)`。
///
/// - `parent_available == false`(父作品已被软删除,或调用方已判定该行不可用)时**必须**
/// 返回 `(None, None)`:即使调用方把父行的标题与作者名都传进来,也一个都不外发。
/// 公开目录/详情早已下线被删作品,若还能从别人的溯源卡里读到它,删除就等于没删;
/// 因此这里**不做**任何「拿不到标题就回退用行数据」的降级。
/// - `parent_available == true` 时按原值透传:标题原样返回;作者名保持三态——
/// `None` 仍是 `None`,空串也仍是空串(不 trim、不把空串归一成 `None`,
/// 行的原值是什么就发什么,展示层自己决定空署名是否渲染)。
pub fn fork_lineage_visible_identity(
parent_available: bool,
parent_title: &str,
parent_author_name: Option<&str>,
) -> (Option<String>, Option<String>) {
if !parent_available {
return (None, None);
}
(
Some(parent_title.to_string()),
parent_author_name.map(str::to_string),
)
}
pub(crate) fn normalize_id(value: impl AsRef<str>) -> Option<String> {
normalize_required_string(value)
}
#[cfg(test)]
mod fork_soft_delete_tests {
use super::*;
/// 来源可用性:四种组合逐一钉死。
///
/// 关键是「已软删除 + 可见性仍是 published」必须为 `false`——删除会复位可见性,但
/// 两个字段之间没有原子保证,读路径完全可能看到这个组合,它正是 rebase 时最容易退化成
/// 「只看 visibility」的地方。
#[test]
fn fork_source_requires_unremoved_and_published() {
let cases = [
(false, true, true, "未删 + 已公开"),
(true, true, false, "已软删除 + 可见性仍是 published"),
(false, false, false, "未删 + 未公开"),
(true, false, false, "已软删除 + 未公开"),
];
for (is_deleted, is_published, expected, label) in cases {
assert_eq!(
can_serve_as_fork_source(is_deleted, is_published),
expected,
"{label}(is_deleted={is_deleted}, is_published={is_published})"
);
}
}
/// 衍生计数纳入:同一条件、不同动机,四组组合同样逐一钉死。
#[test]
fn derivative_count_excludes_removed_and_unpublished_children() {
let cases = [
(false, true, true, "未删 + 已公开"),
(true, true, false, "已软删除 + 可见性仍是 published"),
(false, false, false, "未删 + 未公开"),
(true, false, false, "已软删除 + 未公开"),
];
for (is_deleted, is_published, expected, label) in cases {
assert_eq!(
counts_as_public_derivative(is_deleted, is_published),
expected,
"{label}(is_deleted={is_deleted}, is_published={is_published})"
);
}
}
/// 父作品可用:标题与作者名按原值透传。
#[test]
fn lineage_identity_passes_through_when_parent_available() {
assert_eq!(
fork_lineage_visible_identity(true, "原初之光", Some("原作者")),
(Some("原初之光".to_string()), Some("原作者".to_string()))
);
assert_eq!(
fork_lineage_visible_identity(true, "标题", None),
(Some("标题".to_string()), None)
);
}
/// 防泄露的关键断言:父作品不可用时,即使调用方把非空标题与作者名都传进来,
/// 也必须返回 `(None, None)`——不允许任何形式的回退。
#[test]
fn lineage_identity_never_leaks_removed_parent_identity() {
assert_eq!(
fork_lineage_visible_identity(false, "原初之光", Some("原作者")),
(None, None),
"父不可用时标题与作者名都不得外发"
);
assert_eq!(
fork_lineage_visible_identity(false, "原初之光", None),
(None, None)
);
assert_eq!(fork_lineage_visible_identity(false, "", None), (None, None));
}
/// 作者名三态语义(按实现断言):`None` → `None`;空串 → 空串;只含空白的名字原样返回。
/// 即「行的原值是什么就发什么」,不 trim、不把空串归一成 `None`(展示层按 falsy 处理)。
#[test]
fn lineage_identity_keeps_author_name_tristate() {
assert_eq!(
fork_lineage_visible_identity(true, "标题", Some("")),
(Some("标题".to_string()), Some(String::new()))
);
assert_eq!(
fork_lineage_visible_identity(true, "标题", Some(" ")),
(Some("标题".to_string()), Some(" ".to_string()))
);
}
}
#[cfg(test)]
mod fork_authorization_tests {
use super::*;
@@ -22,7 +22,8 @@ pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, Revi
pub use domain::{
FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL,
ForkAuthorization, GameDistributionAction, GameSnapshot, GameVersionSnapshot,
GameVersionStatus, GameVisibility, compute_request_digest, generate_game_id,
GameVersionStatus, GameVisibility, can_serve_as_fork_source, compute_request_digest,
counts_as_public_derivative, fork_lineage_visible_identity, generate_game_id,
generate_game_version_id, next_generation, resolve_version_number,
};
pub use errors::{GameDistributionError, GameDistributionFieldError};
@@ -1897,11 +1897,10 @@ pub fn get_owner_game_distribution_version_and_return(
/// 失败关闭:来源必须存在且未软删除、已公开、授权档位非「禁止共创」、来源版本必须恰好是
/// 该作品当前公开版本(禁止指向历史版本或伪造)。
///
/// 软删除(#565 的 `deleted_at`)单独判一次而不是只靠 `visibility`:删除会把可见性复位成
/// `unpublished`,但那是删除的副作用;显式按 `deleted_at` 拒绝对外说明「已被作者删除」,
/// 也避免将来改可见性语义时把已删作品重新变成可改编来源。统一用
/// `FORK_SOURCE_NOT_AVAILABLE`(api-server 映射 409),不额外区分「已删除」与「未公开」,
/// 免得用错误码把删除事实泄露给非作者。
/// 「未软删除 + 已公开」这条规则本身放在 `module_game_distribution::can_serve_as_fork_source`,
/// 这里只负责把行状态折叠成两个布尔并统一映射成 `FORK_SOURCE_NOT_AVAILABLE`
/// (api-server 映射 409):不额外区分「已删除」与「未公开」,免得用错误码把删除事实泄露给
/// 非作者。规则不在这里重写一遍,避免与领域层单测钉住的口径漂移。
fn resolve_game_distribution_fork_declaration_tx(
ctx: &ReducerContext,
forked_from_game_id: &str,
@@ -1915,12 +1914,10 @@ fn resolve_game_distribution_fork_declaration_tx(
.ok_or_else(|| {
module_game_distribution::GameDistributionError::ForkSourceNotFound.to_string()
})?;
if source.deleted_at.is_some() {
return Err(
module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string(),
);
}
if source.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED {
if !module_game_distribution::can_serve_as_fork_source(
source.deleted_at.is_some(),
source.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED,
) {
return Err(
module_game_distribution::GameDistributionError::ForkSourceNotAvailable.to_string(),
);
@@ -4173,9 +4170,9 @@ fn public_game_distribution_version(
/// 直接改编该作品的**已公开且未软删除**作品数。按血缘索引实时统计,不维护物化计数,
/// 避免与下架/封禁/删除状态产生第二份真相。
///
/// 软删除的作品必须排除(#565 的 `deleted_at`):删除只下线游戏行、保留血缘行,
/// 若不判 `deleted_at`,作者删掉子作品后父作品的「已被改编 N 次」会把已删条目算进去,
/// 对外泄露「这里曾经有个作品」并虚报改编热度。
/// 「该不该计入」的规则放在 `module_game_distribution::counts_as_public_derivative`:删除只
/// 下线游戏行、保留血缘行,若不排除已删子作品,父作品的「已被改编 N 次」会虚报改编热度,
/// 并对外泄露「这里曾经有个作品」。这里只折叠行状态,不重写规则。
fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u64 {
let derived_game_ids: Vec<String> = ctx
.db
@@ -4192,8 +4189,10 @@ fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u
.game_id()
.find(derived_game_id)
.map(|game| {
game.deleted_at.is_none()
&& game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED
module_game_distribution::counts_as_public_derivative(
game.deleted_at.is_some(),
game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED,
)
})
.unwrap_or(false)
})
@@ -4208,6 +4207,10 @@ fn game_distribution_public_fork_count(ctx: &ReducerContext, game_id: &str) -> u
/// 却能在别人的溯源卡里继续读到。这里把标题/作者名置空(api-server 原样发 `null`),
/// 前端降级为「原作品已不可用」。根作品同理:它是更远的祖先,标题同样是已删作品的信息。
/// 只保留不透明的 ID 与代际,删掉的作品不因为血缘关系继续对外输出内容。
///
/// 「可用时才透传、不可用就 `(None, None)`」这条规则放在
/// `module_game_distribution::fork_lineage_visible_identity`,这里只负责判定行是否可用并调用它,
/// 不在 module 里重写一遍(否则「拿不到标题就回退用行数据」这类降级会悄悄长回来)。
fn game_distribution_lineage_snapshot(
ctx: &ReducerContext,
game_id: &str,
@@ -4223,22 +4226,36 @@ fn game_distribution_lineage_snapshot(
.game_id()
.find(&lineage.parent_game_id)?;
let parent_available = parent.deleted_at.is_none();
let (parent_title, parent_author_name) =
module_game_distribution::fork_lineage_visible_identity(
parent_available,
parent.title.as_str(),
parent.author_name.as_deref(),
);
// 根作品与父作品同一口径:标题同样只在根行可用时外发;根行缺失(历史数据)退回父标题,
// 且同样受父的可用性约束。规则复用同一个纯函数,不在 module 里再写一遍。
let root_title = match ctx
.db
.game_distribution_game()
.game_id()
.find(&lineage.root_game_id)
{
Some(root) if root.deleted_at.is_none() => Some(root.title),
// 根作品已软删除:与父作品同一口径,标题不外发。
Some(_) => None,
// 根行缺失(历史数据):退回父标题,但同样受父作品的删除判定约束。
None => parent_available.then(|| parent.title.clone()),
};
let (parent_title, parent_author_name) = if parent_available {
(Some(parent.title.clone()), parent.author_name.clone())
} else {
(None, None)
Some(root) => {
let (title, _) = module_game_distribution::fork_lineage_visible_identity(
root.deleted_at.is_none(),
root.title.as_str(),
None,
);
title
}
None => {
let (title, _) = module_game_distribution::fork_lineage_visible_identity(
parent_available,
parent.title.as_str(),
None,
);
title
}
};
Some(GameDistributionLineageSnapshot {
generation: lineage.generation,