feat(游戏共创): 贡献归集与归因(接口 + 契约 + 文档;反洗稿改判搁置)
Project CI / AI game creator shell Rust lane 1/2 (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 Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

产品口径:「子代所有的都算父代的」,并要能溯源「多少是子代给的、多少是自己的」。本期**只做计算**,
不含资金/分成结算。

- 递归定义:`inherited(W) = Σ_{c∈直接子代} total(c)`,`total(W) = own(W) + inherited(W)`;每个后代在它的每个
  祖先里只计一次。
- 接口:`GET /api/game-distribution/games/{game_id}/contribution`(Bearer + `no-store`)——**仅该作品作者**:
  未登录/失效 401(中间件)、非作者 403(复用既有 owner-mismatch → FORBIDDEN,不新增第二套鉴权)、
  作品不存在或根自身已软删 404。响应:`{ gameId, own, inherited, total, byGeneration[], directChildren[],
  nodeCount, truncated, truncatedReason }`;指标结构 `ContributionTotals{playCount}` 可扩展(将来加点赞/收藏/收入)。
- 不变量(测试钉住):`inherited == Σ byGeneration.total == Σ directChildren.total`、`total == own + inherited`、
  `nodeCount == Σ byGeneration.gameCount`;`byGeneration` 只含后代(绝对代际,升序)。
- 上限与截断:节点 500 / 深度 32;超限如实标 `truncated: true` + `truncatedReason ∈ {node_limit, depth_limit}`,
  已计入部分仍自洽(与族谱 `truncated` 同约定,不静默给半个数);遍历只用血缘表既有索引,**不新增表/索引**。
- 契约:shared-contracts 4 个 DTO + TS 镜像 + parity(+4 组 → 66 组);7 个生成绑定(含 procedure)。
- 文档:技术方案新增 `§3.11 贡献归集与权益归因`(定义/归因分解/接口/上限与截断/鉴权口径与理由)+ §3.4 路由行 +
  §5.4 实施证据;**任务 B**:反洗稿(相似度校验)从「待拍板」改为「**产品已决定搁置(2026-10-06)**」并从待拍板
  清单移除,结算口径统一为「本期只做贡献归集与归因计算」。
- 测试:module-game-distribution **131 passed**(contribution 12 条,含 A→B→C 三层链路不变量);spacetime-module
  **304 passed / 1 ignored**(+3 结构断言);spacetime-client 43 passed;api-server game_distribution **107 passed**(+3)。

门禁:wasm build 0;`cargo check --all-targets` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`
0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:project-bundle-policy-parity` OK;
`check:encoding` 0(5543 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。

注:本块未提交 `scripts/*.mjs`(`check-game-distribution-lineage-e2e.mjs` / `-theme-e2e.mjs` /
`capture-game-lineage-visual.mjs`)——属其它 owner,工作区保留未提交。
This commit is contained in:
2026-10-06 18:05:49 +08:00
parent 9ab3d5e687
commit 6aa2f97410
17 changed files with 1351 additions and 26 deletions
@@ -50,7 +50,9 @@ use shared_contracts::game_distribution::{
GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT, GAME_DISTRIBUTION_THEME_INVALID_CURSOR,
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT,
GAME_DISTRIBUTION_THEME_NOT_FOUND, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT,
GameDistributionAuthor, GameDistributionCollectionState, GameDistributionCreateGameRequest,
GameDistributionAuthor, GameDistributionCollectionState, GameDistributionContributionChild,
GameDistributionContributionGeneration, GameDistributionContributionResponse,
GameDistributionContributionTotals, GameDistributionCreateGameRequest,
GameDistributionCreateThemeRequest, GameDistributionCreateVersionRequest,
GameDistributionDerivedResponse, GameDistributionForkAuthorization, GameDistributionForkSource,
GameDistributionForkSourceKind, GameDistributionForkSourceResponse, GameDistributionInputMode,
@@ -397,6 +399,19 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
))
.route_layer(middleware::from_fn(add_no_store_response_headers));
// 贡献归集(作者视角只读):要求 Bearer(未登录 / 失效 → 401),但**不叠加发布灰度**
// ——它不是发布动作,只是作者查看自己血缘子树的账目。响应是 per-author 的商业敏感数据,
// 整组 `no-store`;归属判定在事务里(非作者 → 403 / 不存在 → 404,见 handler 注释)。
let contribution = Router::new()
.route(
"/api/game-distribution/games/{game_id}/contribution",
get(get_game_contribution),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
))
.route_layer(middleware::from_fn(add_no_store_response_headers));
// 收藏(收录):登录用户的真实用户态投影,绝不虚构收藏状态。
// - PUT 需要 `Idempotency-Key`(重放与「重复收藏」要靠收据区分);
// - DELETE 按确定性主键 `{userId}:{gameId}` 删除,天然幂等,所以**不要求**幂等键;
@@ -642,6 +657,7 @@ pub fn router(state: AppState) -> Router<AppState> {
.merge(play_sessions)
.merge(user_reviews)
.merge(fork_sources)
.merge(contribution)
.merge(collections)
.merge(admin_user_reviews)
.merge(admin)
@@ -2417,6 +2433,95 @@ fn derived_games_payload(
}
}
fn contribution_totals_payload(
totals: spacetime_client::GameDistributionContributionTotalsRecord,
) -> GameDistributionContributionTotals {
GameDistributionContributionTotals {
play_count: totals.play_count,
}
}
/// 贡献归集响应的字段映射(契约形状由 DTO parity 门禁逐字段比对)。
///
/// 逐字段搬运、不做任何重算:数字的唯一来源是模块侧纯函数
/// `module_game_distribution::build_contribution_breakdown`,这一层只负责把它折成对外 DTO。
fn contribution_payload(
record: spacetime_client::GameDistributionContributionRecord,
) -> GameDistributionContributionResponse {
GameDistributionContributionResponse {
game_id: record.game_id,
own: contribution_totals_payload(record.own),
inherited: contribution_totals_payload(record.inherited),
total: contribution_totals_payload(record.total),
by_generation: record
.by_generation
.into_iter()
.map(|entry| GameDistributionContributionGeneration {
generation: entry.generation,
game_count: entry.game_count,
own: contribution_totals_payload(entry.own),
inherited: contribution_totals_payload(entry.inherited),
total: contribution_totals_payload(entry.total),
})
.collect(),
direct_children: record
.direct_children
.into_iter()
.map(|entry| GameDistributionContributionChild {
game_id: entry.game_id,
generation: entry.generation,
own: contribution_totals_payload(entry.own),
inherited: contribution_totals_payload(entry.inherited),
total: contribution_totals_payload(entry.total),
})
.collect(),
node_count: record.node_count,
truncated: record.truncated,
truncated_reason: record.truncated_reason,
}
}
/// 作者视角读取自己作品的贡献归集与归因(子代所有的都算父代的)。
///
/// 鉴权与状态码口径(**只有作者本人**;与 `get_owner_game` 那类「不存在与不是你的都 404」
/// 的做法**刻意不同**):
/// - 未登录 / 令牌失效 → **401**(`require_bearer_auth` 在路由层挡下,不进入业务);
/// - 作品不存在或已软删除 → **404**(`found = false`);
/// - 作品存在但不属于当前主体 → **403**(事务报 `游戏 owner 不匹配`,命中 `map_spacetime_error`
/// 既有的 owner-mismatch → FORBIDDEN 分支)。
///
/// 选 403 而不是「非作者折 404」的理由(代价也一并写下):① 沿用仓库既有 owner-mismatch 惯例
/// ——`map_spacetime_error` 早就把这类文案映射成 FORBIDDEN,作者侧写路由与 owner 读路径同源;
/// ② 这是作者账目页:客户端必须能分辨「我没有权限看」(停手 / 换账号 / 回我的作品)与
/// 「作品没了」(回列表刷新),两者折成同一个 404 只能靠再猜一次;
/// ③ 代价明确且被接受:登录用户可用 403/404 之分辨别某个 gameId 是否存在——已公开作品本就能
/// 从公开目录枚举,未公开作品则多出一个存在性预言机;本接口只服务作者本人,
/// 这里显式记录该取舍,不假装它不存在。
async fn get_game_contribution(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Path(game_id): Path<String>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = auth.claims().user_id().to_string();
let record = contribution_read_or_not_found(
state
.spacetime_client()
.get_game_distribution_contribution(game_id, owner_user_id)
.await
.map_err(map_spacetime_error)?,
)?;
Ok(json_success_body(Some(&ctx), contribution_payload(record)))
}
/// 贡献归集读不到(`found = false`:作品不存在或已软删除)→ 404。
///
/// 抽成函数是为了让这条映射可被单测钉住(不可读 → 404),而不是把 `ok_or_else` 散在 handler 里;
/// 与族谱读路径的 `lineage_read_or_not_found` 同一种折叠方式。
fn contribution_read_or_not_found<T>(value: Option<T>) -> Result<T, AppError> {
value.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))
}
/// 一次游玩上报的请求体;只有匿名身份需要 `clientId`,登录身份由 bearer 决定。
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -11180,4 +11285,225 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
}
/// 贡献归集路由:未登录必须在进入业务前被挡成 401,且整组 `no-store`。
#[tokio::test]
async fn contribution_route_requires_bearer_and_is_no_store() {
use axum::{body::Body, http::Request};
use tower::ServiceExt;
let app =
crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap());
let response = app
.oneshot(
Request::builder()
.uri("/api/game-distribution/games/game_1/contribution")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
}
/// 贡献归集的两条读口径必须可分辨:非作者 403(沿用 owner-mismatch 惯例)、读不到 404。
#[test]
fn contribution_read_maps_owner_mismatch_to_forbidden_and_missing_to_not_found() {
// 事务报的 owner 不匹配文案必须原样命中既有映射,不在这里另写一套 403。
assert_eq!(
map_spacetime_error(SpacetimeClientError::Procedure(
"游戏 owner 不匹配".to_string()
))
.status_code(),
StatusCode::FORBIDDEN
);
assert_eq!(
contribution_read_or_not_found::<u8>(None)
.expect_err("读不到必须 404")
.status_code(),
StatusCode::NOT_FOUND
);
assert_eq!(
contribution_read_or_not_found(Some(7)).expect("可读透传"),
7
);
}
fn contribution_node(
game_id: &str,
parent_game_id: Option<&str>,
generation: u32,
play_count: u64,
) -> module_game_distribution::ContributionNode {
module_game_distribution::ContributionNode {
game_id: game_id.to_string(),
parent_game_id: parent_game_id.map(str::to_string),
generation,
totals: module_game_distribution::ContributionTotals::from_play_count(play_count),
}
}
/// 把纯函数结果**原样搬运**成客户端记录(不重算任何数字),用于证明
/// 「响应里的数字 == 纯函数的数字」打的是完整链路,而不是两份实现互相印证。
fn contribution_record(
breakdown: module_game_distribution::ContributionBreakdown,
) -> spacetime_client::GameDistributionContributionRecord {
use spacetime_client::{
GameDistributionContributionChildRecord, GameDistributionContributionGenerationRecord,
GameDistributionContributionRecord, GameDistributionContributionTotalsRecord,
};
fn totals(
value: module_game_distribution::ContributionTotals,
) -> GameDistributionContributionTotalsRecord {
GameDistributionContributionTotalsRecord {
play_count: value.play_count,
}
}
GameDistributionContributionRecord {
game_id: breakdown.game_id,
own: totals(breakdown.own),
inherited: totals(breakdown.inherited),
total: totals(breakdown.total),
by_generation: breakdown
.by_generation
.into_iter()
.map(|entry| GameDistributionContributionGenerationRecord {
generation: entry.generation,
game_count: entry.game_count,
own: totals(entry.own),
inherited: totals(entry.inherited),
total: totals(entry.total),
})
.collect(),
direct_children: breakdown
.direct_children
.into_iter()
.map(|entry| GameDistributionContributionChildRecord {
game_id: entry.game_id,
generation: entry.generation,
own: totals(entry.own),
inherited: totals(entry.inherited),
total: totals(entry.total),
})
.collect(),
node_count: breakdown.node_count,
truncated: breakdown.truncated,
truncated_reason: breakdown
.truncated_reason
.map(|reason| reason.as_str().to_string()),
}
}
fn contribution_total_of(entries: &[Value]) -> u64 {
entries
.iter()
.map(|entry| entry["total"]["playCount"].as_u64().expect("数字"))
.sum()
}
/// 贡献归集:响应数字必须与纯函数逐字段一致,两条分解之和都等于 `inherited`,
/// 截断时 `truncated` / `truncatedReason` 如实透传且已计入部分仍自洽。
#[test]
fn contribution_payload_matches_pure_function_and_keeps_truncation_reason() {
use module_game_distribution::{
GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT, GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT,
build_contribution_breakdown,
};
// 三层链路 + 一个并列分支:root → game_b → game_c,root → game_d。
let nodes = [
contribution_node("root", None, 0, 120),
contribution_node("game_b", Some("root"), 1, 70),
contribution_node("game_c", Some("game_b"), 2, 40),
contribution_node("game_d", Some("root"), 1, 230),
];
let breakdown = build_contribution_breakdown(
&nodes,
"root",
GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT,
GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT,
)
.expect("根必须存在");
// 递归定义:inherited(root) = total(game_b) + total(game_d) = (70 + 40) + 230。
assert_eq!(breakdown.own.play_count, 120);
assert_eq!(breakdown.inherited.play_count, 340);
assert_eq!(breakdown.total.play_count, 460);
assert_eq!(breakdown.node_count, 3);
assert!(!breakdown.truncated);
let json = serde_json::to_value(contribution_payload(contribution_record(breakdown)))
.expect("负载应可序列化");
assert_eq!(json["gameId"], Value::String("root".to_string()));
assert_eq!(json["own"]["playCount"], 120);
assert_eq!(json["inherited"]["playCount"], 340);
assert_eq!(json["total"]["playCount"], 460);
assert_eq!(json["nodeCount"], 3);
assert_eq!(json["truncated"], Value::Bool(false));
assert_eq!(json["truncatedReason"], Value::Null);
let by_generation = json["byGeneration"].as_array().expect("按代际分解").clone();
assert_eq!(
by_generation
.iter()
.map(|entry| entry["generation"].as_u64().expect("代际"))
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(by_generation[0]["gameCount"], 2);
assert_eq!(by_generation[0]["own"]["playCount"], 300);
assert_eq!(by_generation[1]["gameCount"], 1);
assert_eq!(by_generation[1]["own"]["playCount"], 40);
assert_eq!(contribution_total_of(&by_generation), 340);
let direct_children = json["directChildren"]
.as_array()
.expect("直接子代明细")
.clone();
assert_eq!(direct_children.len(), 2);
assert_eq!(direct_children[0]["gameId"], "game_b");
assert_eq!(direct_children[0]["generation"], 1);
assert_eq!(direct_children[0]["own"]["playCount"], 70);
assert_eq!(direct_children[0]["inherited"]["playCount"], 40);
assert_eq!(direct_children[0]["total"]["playCount"], 110);
assert_eq!(direct_children[1]["gameId"], "game_d");
assert_eq!(direct_children[1]["total"]["playCount"], 230);
assert_eq!(contribution_total_of(&direct_children), 340);
// 截断:标志与原因原样透传,且已计入部分的数字仍自洽(三条不变量在截断子树上成立)。
let truncated = build_contribution_breakdown(&nodes, "root", 1, 32).expect("根必须存在");
assert!(truncated.truncated);
let covered = truncated.inherited.play_count;
let truncated_json =
serde_json::to_value(contribution_payload(contribution_record(truncated)))
.expect("截断负载应可序列化");
assert_eq!(truncated_json["truncated"], Value::Bool(true));
assert_eq!(
truncated_json["truncatedReason"],
Value::String("node_limit".to_string())
);
assert_eq!(truncated_json["inherited"]["playCount"], covered);
assert_eq!(
contribution_total_of(
truncated_json["byGeneration"]
.as_array()
.expect("按代际分解")
),
covered
);
assert_eq!(
contribution_total_of(
truncated_json["directChildren"]
.as_array()
.expect("直接子代明细")
),
covered
);
assert_eq!(
truncated_json["total"]["playCount"].as_u64().expect("总量"),
truncated_json["own"]["playCount"].as_u64().expect("自身") + covered
);
}
}
@@ -367,6 +367,69 @@ pub struct GameDistributionDerivedResponse {
pub truncated: bool,
}
/// 归集的指标集合(当前只有游玩数)。
///
/// 单独成结构体而不是把字段摊进响应里:将来加点赞 / 收藏 / 收入只需加字段,
/// 递归定义(`inherited(W) = Σ 直接子代 total`、`total = own + inherited`)与投影形状都不动。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionTotals {
pub play_count: u64,
}
/// 按代际分解的一条:**只含后代**(不含根自身),`generation` 为绝对代际(与族谱一致)。
///
/// 语义:这一条是祖先 `inherited` 里**由这一代自己产生**的那一份,`total` 即该代贡献;
/// `inherited` 恒为 `0`——该代从更深代际继承到的量已计入更深代际那一条,重复计入会让
/// 「各代之和」大于祖先的 `inherited`(同一个后代会在多层分解里被重复计数)。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionGeneration {
pub generation: u32,
/// 这一代计入分解的后代节点数。
pub game_count: u64,
pub own: GameDistributionContributionTotals,
pub inherited: GameDistributionContributionTotals,
pub total: GameDistributionContributionTotals,
}
/// 直接子代明细的一条:`total` 是该直接子代**含自己整棵子树**的值。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionChild {
pub game_id: String,
pub generation: u32,
pub own: GameDistributionContributionTotals,
pub inherited: GameDistributionContributionTotals,
pub total: GameDistributionContributionTotals,
}
/// 作者视角的贡献归集与归因响应(`GET /api/game-distribution/games/{gameId}/contribution`)。
///
/// 不变量(在**截断后的子树**上依然成立):
/// - `inherited == Σ byGeneration.total == Σ directChildren.total`;
/// - `total == own + inherited`;
/// - `nodeCount == Σ byGeneration.gameCount`(= 参与计算的**后代**节点数,不含根)。
///
/// `truncated` 为真时 `truncatedReason` 取 `node_limit` / `depth_limit`,未截断为 `null`
/// (与族谱接口 `truncated` 同约定:超限如实标注,不静默给半个数)。
/// **本期只做计算**:响应里没有资金 / 分成 / 结算字段。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionResponse {
pub game_id: String,
pub own: GameDistributionContributionTotals,
pub inherited: GameDistributionContributionTotals,
pub total: GameDistributionContributionTotals,
pub by_generation: Vec<GameDistributionContributionGeneration>,
pub direct_children: Vec<GameDistributionContributionChild>,
/// 参与计算的后代节点数(不含根)。
pub node_count: u64,
pub truncated: bool,
#[serde(default)]
pub truncated_reason: Option<String>,
}
/// Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在
/// 两者时优先 `project`。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -101,7 +101,9 @@ pub use self::game_distribution::{
GameDistributionAdminThemeMutationRecord, GameDistributionAdminThemeRecord,
GameDistributionAdminUserReviewDetailRecord, GameDistributionAdminUserReviewListRecord,
GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord,
GameDistributionCollectionMutationRecord, GameDistributionDerivedGamesRecord,
GameDistributionCollectionMutationRecord, GameDistributionContributionChildRecord,
GameDistributionContributionGenerationRecord, GameDistributionContributionRecord,
GameDistributionContributionTotalsRecord, GameDistributionDerivedGamesRecord,
GameDistributionForkSourceRecord, GameDistributionGameRecord,
GameDistributionLineageNodeRecord, GameDistributionLineageRecord,
GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord,
@@ -182,14 +184,14 @@ pub(crate) use self::game_distribution::{
map_game_distribution_admin_theme_member_upsert_result,
map_game_distribution_admin_theme_mutation_result,
map_game_distribution_collection_list_result, map_game_distribution_collection_mutation_result,
map_game_distribution_collection_state_result, map_game_distribution_derived_games_result,
map_game_distribution_fork_source_result, map_game_distribution_game_result,
map_game_distribution_lineage_result, map_game_distribution_owner_game_list_result,
map_game_distribution_public_game_list_result, map_game_distribution_public_game_result,
map_game_distribution_purchase_result, map_game_distribution_review_list_result,
map_game_distribution_theme_detail_result, map_game_distribution_theme_list_result,
map_game_distribution_theme_refs_result, map_game_distribution_version_result,
map_review_moderation_operation, map_user_review,
map_game_distribution_collection_state_result, map_game_distribution_contribution_result,
map_game_distribution_derived_games_result, map_game_distribution_fork_source_result,
map_game_distribution_game_result, map_game_distribution_lineage_result,
map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result,
map_game_distribution_public_game_result, map_game_distribution_purchase_result,
map_game_distribution_review_list_result, map_game_distribution_theme_detail_result,
map_game_distribution_theme_list_result, map_game_distribution_theme_refs_result,
map_game_distribution_version_result, map_review_moderation_operation, map_user_review,
};
pub(crate) use self::payment::{
map_payment_api_key_list_result, map_payment_api_key_result, map_payment_app_list_result,
@@ -337,6 +337,56 @@ pub struct GameDistributionDerivedGamesRecord {
pub truncated: bool,
}
/// 贡献归集 / 归因的指标集合(当前只有游玩数)。
///
/// 单独成结构体:将来加点赞 / 收藏 / 收入只需加字段,递归与归因形状都不动。
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionContributionTotalsRecord {
pub play_count: u64,
}
/// 按代际分解的一条(**只含后代**,`generation` 为绝对代际)。
///
/// `total` 是这一代对祖先 `inherited` 的贡献(即该代节点自身的量),`inherited` 恒为 0:
/// 更深代际的量归它们自己那一条,重复计入会让各代之和大于祖先的 `inherited`。
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionContributionGenerationRecord {
pub generation: u32,
pub game_count: u64,
pub own: GameDistributionContributionTotalsRecord,
pub inherited: GameDistributionContributionTotalsRecord,
pub total: GameDistributionContributionTotalsRecord,
}
/// 直接子代明细的一条:`total` 含该子代整棵子树。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionContributionChildRecord {
pub game_id: String,
pub generation: u32,
pub own: GameDistributionContributionTotalsRecord,
pub inherited: GameDistributionContributionTotalsRecord,
pub total: GameDistributionContributionTotalsRecord,
}
/// 作者视角的贡献归集与归因结果。
///
/// 不变量:`inherited == Σ by_generation.total == Σ direct_children.total`、
/// `total == own + inherited`、`node_count == Σ by_generation.game_count`(截断后同样成立)。
/// **本期只做计算**:没有任何资金 / 分成字段。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionContributionRecord {
pub game_id: String,
pub own: GameDistributionContributionTotalsRecord,
pub inherited: GameDistributionContributionTotalsRecord,
pub total: GameDistributionContributionTotalsRecord,
pub by_generation: Vec<GameDistributionContributionGenerationRecord>,
pub direct_children: Vec<GameDistributionContributionChildRecord>,
pub node_count: u64,
pub truncated: bool,
/// `node_limit` / `depth_limit`;未截断为 `None`。
pub truncated_reason: Option<String>,
}
/// Fork 取件信息(受鉴权内容下发通道的元数据)。
///
/// `found == false` 表示游戏行不存在(api-server → 404);`available == false` 表示行存在但
@@ -639,6 +689,64 @@ pub(crate) fn map_game_distribution_derived_games_result(
}))
}
fn map_contribution_totals(
value: crate::module_bindings::GameDistributionContributionTotalsSnapshot,
) -> GameDistributionContributionTotalsRecord {
GameDistributionContributionTotalsRecord {
play_count: value.play_count,
}
}
/// 贡献归集:`ok == false` 是服务端失败(含 `游戏 owner 不匹配` → api-server 403);
/// `found == false` 表示作品不存在 / 已软删除,折成 `None` 由 api-server 映射 404。
pub(crate) fn map_game_distribution_contribution_result(
result: crate::module_bindings::GameDistributionContributionResult,
) -> Result<Option<GameDistributionContributionRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
if !result.found {
return Ok(None);
}
let Some(contribution) = result.contribution else {
// `found == true` 却没有负载是不可能状态:不折成 404(那会把契约破裂说成「作品不存在」)。
return Err(SpacetimeClientError::procedure_failed(Some(
"贡献归集缺失:found 为真但没有结果负载".to_string(),
)));
};
Ok(Some(GameDistributionContributionRecord {
game_id: contribution.game_id,
own: map_contribution_totals(contribution.own),
inherited: map_contribution_totals(contribution.inherited),
total: map_contribution_totals(contribution.total),
by_generation: contribution
.by_generation
.into_iter()
.map(|entry| GameDistributionContributionGenerationRecord {
generation: entry.generation,
game_count: entry.game_count,
own: map_contribution_totals(entry.own),
inherited: map_contribution_totals(entry.inherited),
total: map_contribution_totals(entry.total),
})
.collect(),
direct_children: contribution
.direct_children
.into_iter()
.map(|entry| GameDistributionContributionChildRecord {
game_id: entry.game_id,
generation: entry.generation,
own: map_contribution_totals(entry.own),
inherited: map_contribution_totals(entry.inherited),
total: map_contribution_totals(entry.total),
})
.collect(),
node_count: contribution.node_count,
truncated: contribution.truncated,
truncated_reason: contribution.truncated_reason,
}))
}
/// Fork 取件信息:`ok == false` 是服务端失败(走既有错误映射);`found` / `available` 原样透传,
/// HTTP 语义(404 / 409 / 403)由 api-server 决定。
pub(crate) fn map_game_distribution_fork_source_result(
@@ -769,6 +769,35 @@ impl SpacetimeClient {
.await
}
/// 读取作者自己作品的贡献归集与归因(递归:子代所有的都算父代的)。
///
/// `found == false`(作品不存在 / 已软删除)折成 `None`,由 api-server 映射 404;
/// 非作者由 procedure 报 `游戏 owner 不匹配`(api-server → 403)。本期只做计算。
pub async fn get_game_distribution_contribution(
&self,
game_id: String,
owner_user_id: String,
) -> Result<Option<GameDistributionContributionRecord>, SpacetimeClientError> {
let input = crate::module_bindings::GameDistributionContributionInput {
game_id,
owner_user_id,
};
self.call_after_connect(
"get_game_distribution_contribution",
move |connection, sender| {
connection
.procedures()
.get_game_distribution_contribution_and_return_then(input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_contribution_result);
send_once(&sender, mapped);
});
},
)
.await
}
/// 读取某作品的直接衍生作品(「被改编」列表);锚点不可公开读取时返回 `None`。
pub async fn list_game_distribution_derived_games(
&self,
@@ -452,6 +452,12 @@ pub mod game_distribution_collection_table;
pub mod game_distribution_collection_type;
pub mod game_distribution_confirm_package_input_type;
pub mod game_distribution_confirm_project_bundle_input_type;
pub mod game_distribution_contribution_child_snapshot_type;
pub mod game_distribution_contribution_generation_snapshot_type;
pub mod game_distribution_contribution_input_type;
pub mod game_distribution_contribution_result_type;
pub mod game_distribution_contribution_snapshot_type;
pub mod game_distribution_contribution_totals_snapshot_type;
pub mod game_distribution_create_game_input_type;
pub mod game_distribution_create_theme_input_type;
pub mod game_distribution_create_version_input_type;
@@ -554,6 +560,7 @@ pub mod get_external_generation_job_result_and_return_procedure;
pub mod get_external_generation_job_summary_and_return_procedure;
pub mod get_external_generation_queue_stats_and_return_procedure;
pub mod get_feature_gate_config_procedure;
pub mod get_game_distribution_contribution_and_return_procedure;
pub mod get_game_distribution_fork_source_and_return_procedure;
pub mod get_game_distribution_game_and_return_procedure;
pub mod get_game_distribution_lineage_and_return_procedure;
@@ -1431,6 +1438,12 @@ pub use game_distribution_collection_table::*;
pub use game_distribution_collection_type::GameDistributionCollection;
pub use game_distribution_confirm_package_input_type::GameDistributionConfirmPackageInput;
pub use game_distribution_confirm_project_bundle_input_type::GameDistributionConfirmProjectBundleInput;
pub use game_distribution_contribution_child_snapshot_type::GameDistributionContributionChildSnapshot;
pub use game_distribution_contribution_generation_snapshot_type::GameDistributionContributionGenerationSnapshot;
pub use game_distribution_contribution_input_type::GameDistributionContributionInput;
pub use game_distribution_contribution_result_type::GameDistributionContributionResult;
pub use game_distribution_contribution_snapshot_type::GameDistributionContributionSnapshot;
pub use game_distribution_contribution_totals_snapshot_type::GameDistributionContributionTotalsSnapshot;
pub use game_distribution_create_game_input_type::GameDistributionCreateGameInput;
pub use game_distribution_create_theme_input_type::GameDistributionCreateThemeInput;
pub use game_distribution_create_version_input_type::GameDistributionCreateVersionInput;
@@ -1533,6 +1546,7 @@ pub use get_external_generation_job_result_and_return_procedure::get_external_ge
pub use get_external_generation_job_summary_and_return_procedure::get_external_generation_job_summary_and_return;
pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return;
pub use get_feature_gate_config_procedure::get_feature_gate_config;
pub use get_game_distribution_contribution_and_return_procedure::get_game_distribution_contribution_and_return;
pub use get_game_distribution_fork_source_and_return_procedure::get_game_distribution_fork_source_and_return;
pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return;
pub use get_game_distribution_lineage_and_return_procedure::get_game_distribution_lineage_and_return;
@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_contribution_totals_snapshot_type::GameDistributionContributionTotalsSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionContributionChildSnapshot {
pub game_id: String,
pub generation: u32,
pub own: GameDistributionContributionTotalsSnapshot,
pub inherited: GameDistributionContributionTotalsSnapshot,
pub total: GameDistributionContributionTotalsSnapshot,
}
impl __sdk::InModule for GameDistributionContributionChildSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_contribution_totals_snapshot_type::GameDistributionContributionTotalsSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionContributionGenerationSnapshot {
pub generation: u32,
pub game_count: u64,
pub own: GameDistributionContributionTotalsSnapshot,
pub inherited: GameDistributionContributionTotalsSnapshot,
pub total: GameDistributionContributionTotalsSnapshot,
}
impl __sdk::InModule for GameDistributionContributionGenerationSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionContributionInput {
pub game_id: String,
pub owner_user_id: String,
}
impl __sdk::InModule for GameDistributionContributionInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_contribution_snapshot_type::GameDistributionContributionSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionContributionResult {
pub ok: bool,
pub found: bool,
pub contribution: Option<GameDistributionContributionSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for GameDistributionContributionResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,27 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_contribution_child_snapshot_type::GameDistributionContributionChildSnapshot;
use super::game_distribution_contribution_generation_snapshot_type::GameDistributionContributionGenerationSnapshot;
use super::game_distribution_contribution_totals_snapshot_type::GameDistributionContributionTotalsSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionContributionSnapshot {
pub game_id: String,
pub own: GameDistributionContributionTotalsSnapshot,
pub inherited: GameDistributionContributionTotalsSnapshot,
pub total: GameDistributionContributionTotalsSnapshot,
pub by_generation: Vec<GameDistributionContributionGenerationSnapshot>,
pub direct_children: Vec<GameDistributionContributionChildSnapshot>,
pub node_count: u64,
pub truncated: bool,
pub truncated_reason: Option<String>,
}
impl __sdk::InModule for GameDistributionContributionSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionContributionTotalsSnapshot {
pub play_count: u64,
}
impl __sdk::InModule for GameDistributionContributionTotalsSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_contribution_input_type::GameDistributionContributionInput;
use super::game_distribution_contribution_result_type::GameDistributionContributionResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetGameDistributionContributionAndReturnArgs {
pub input: GameDistributionContributionInput,
}
impl __sdk::InModule for GetGameDistributionContributionAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_game_distribution_contribution_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_game_distribution_contribution_and_return {
fn get_game_distribution_contribution_and_return(
&self,
input: GameDistributionContributionInput,
) {
self.get_game_distribution_contribution_and_return_then(input, |_, _| {});
}
fn get_game_distribution_contribution_and_return_then(
&self,
input: GameDistributionContributionInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionContributionResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_game_distribution_contribution_and_return for super::RemoteProcedures {
fn get_game_distribution_contribution_and_return_then(
&self,
input: GameDistributionContributionInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionContributionResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionContributionResult>(
"get_game_distribution_contribution_and_return",
GetGameDistributionContributionAndReturnArgs { input },
__callback,
);
}
}
@@ -1409,6 +1409,17 @@ pub struct GameDistributionDerivedGamesInput {
pub game_id: String,
}
/// 贡献归集(作者视角只读)的输入。
///
/// `owner_user_id` 由受信 BFF 按登录会话填入;事务内按行归属校验,不匹配即报
/// `游戏 owner 不匹配`(api-server 映射 403)。与其它作者侧 procedure 同口径:
/// procedure 另要求受信服务身份,SpacetimeDB 客户端身份本身不携带作品归属。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionContributionInput {
pub game_id: String,
pub owner_user_id: String,
}
/// 读取某作品的 Fork 取件信息(受鉴权内容下发通道的元数据);只按作品 ID 查询。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionForkSourceInput {
@@ -1766,6 +1777,70 @@ pub struct GameDistributionDerivedGamesResult {
pub error_message: Option<String>,
}
/// 归集的指标集合(当前只有游玩数)。
///
/// 单独成结构体而不是把字段摊进结果里:将来加点赞 / 收藏 / 收入只需在此加字段,
/// 递归定义、归因分解与投影形状都不用动。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionContributionTotalsSnapshot {
pub play_count: u64,
}
/// 按代际分解的一条:只含后代,`generation` 是**绝对**代际(与族谱一致)。
///
/// `total` 是这一代对祖先 `inherited` 的贡献(即该代节点自身的量),`inherited` 恒为 `0`
/// ——更深代际的量归它们自己那一条,重复计入会让分解之和大于祖先的 `inherited`。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionContributionGenerationSnapshot {
pub generation: u32,
pub game_count: u64,
pub own: GameDistributionContributionTotalsSnapshot,
pub inherited: GameDistributionContributionTotalsSnapshot,
pub total: GameDistributionContributionTotalsSnapshot,
}
/// 直接子代明细的一条:`total` 是该子代**含自己整棵子树**的值。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionContributionChildSnapshot {
pub game_id: String,
pub generation: u32,
pub own: GameDistributionContributionTotalsSnapshot,
pub inherited: GameDistributionContributionTotalsSnapshot,
pub total: GameDistributionContributionTotalsSnapshot,
}
/// 作者视角的贡献归集与归因结果(**只做计算**,不含资金 / 分成结算字段)。
///
/// 不变量(截断后依然成立):`inherited == Σ by_generation.total == Σ direct_children.total`、
/// `total == own + inherited`、`node_count == Σ by_generation.game_count`。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionContributionSnapshot {
pub game_id: String,
pub own: GameDistributionContributionTotalsSnapshot,
pub inherited: GameDistributionContributionTotalsSnapshot,
pub total: GameDistributionContributionTotalsSnapshot,
pub by_generation: Vec<GameDistributionContributionGenerationSnapshot>,
pub direct_children: Vec<GameDistributionContributionChildSnapshot>,
/// 参与计算的后代节点数(不含根)。
pub node_count: u64,
pub truncated: bool,
/// `node_limit` / `depth_limit`;未截断为 None(不发空串)。
pub truncated_reason: Option<String>,
}
/// 贡献归集读取结果。
///
/// - `ok == false`:服务端拒绝(含 `游戏 owner 不匹配` → api-server 403);
/// - `found == false`:作品不存在或已软删除(api-server 404);
/// - `contribution` 在 `found == true` 时必然存在,扁平字段无法表达这个不可能状态。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionContributionResult {
pub ok: bool,
pub found: bool,
pub contribution: Option<GameDistributionContributionSnapshot>,
pub error_message: Option<String>,
}
/// Fork 取件的可判定状态。
///
/// 单独成类型而不是让 api-server 自己读游戏行:`get_game_distribution_game_for_owner_or_public_tx`
@@ -2209,6 +2284,27 @@ impl GameDistributionDerivedGamesResult {
}
}
impl GameDistributionContributionResult {
/// 作品不存在或已软删除:`ok` 仍为 true,由 api-server 映射成 404。
fn not_found() -> Self {
Self {
ok: true,
found: false,
contribution: None,
error_message: None,
}
}
fn failed(error: String) -> Self {
Self {
ok: false,
found: false,
contribution: None,
error_message: Some(error),
}
}
}
/// 作者自有游戏的聚合快照:游戏身份 + 最近若干版本,供作者管理页展示状态与驳回理由。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionOwnerGameSnapshot {
@@ -2834,6 +2930,34 @@ pub fn get_game_distribution_lineage_and_return(
}
}
/// 作者视角读取自己作品的贡献归集与归因(递归:子代所有的都算父代的)。
///
/// 要求受信服务身份(与其它作者侧 procedure 同口径):归属判定靠 `owner_user_id`,而
/// SpacetimeDB 客户端身份本身不携带作品归属,所以这一层只允许 api-server 调。
/// HTTP 语义(未登录 401、非作者 403、作品不存在 404)全部由 api-server 映射:
/// `found = false` → 404,`ok = false`(含 `游戏 owner 不匹配`)→ 403。
/// **本期只做计算**:结果里没有资金 / 分成 / 结算字段。
#[spacetimedb::procedure]
pub fn get_game_distribution_contribution_and_return(
ctx: &mut ProcedureContext,
input: GameDistributionContributionInput,
) -> GameDistributionContributionResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_editor_generation_runtime_service_identity(tx, caller)?;
game_distribution_contribution_tx(tx, input.clone())
}) {
Ok(Some(contribution)) => GameDistributionContributionResult {
ok: true,
found: true,
contribution: Some(contribution),
error_message: None,
},
Ok(None) => GameDistributionContributionResult::not_found(),
Err(error) => GameDistributionContributionResult::failed(error),
}
}
/// 返回某作品的直接衍生作品(「被改编」列表);公开只读,匿名可读。
///
/// 与公开详情 `forkCount` 同口径:只列未软删除且已公开的直接子代。父作品被软删除时按
@@ -5562,6 +5686,170 @@ fn game_distribution_derived_games_tx(
}))
}
/// 贡献归集的节点集合:根 + 按血缘索引逐层下钻得到的后代。
///
/// 只用血缘表既有的 `by_game_distribution_lineage_parent_game_id` 索引,不新增表 / 索引。
/// 两点刻意的「多收一点」,都是为了让纯函数能**如实**判定截断:
/// - 后代多收一条(`NODE_LIMIT + 1`):只收满 `NODE_LIMIT` 条时「刚好满」与「还有更多」不可分辨,
/// 截断会被静默吞掉;
/// - 相对深度多收一层(`DEPTH_LIMIT + 1`):纯函数要看见更深一层确实存在,才能标注 `depth_limit`。
///
/// 同层按 `gameId` 升序下钻,保证两次读收集到同一批节点(截断点不漂移)。子孙的游戏行缺失
/// (脏数据 / 历史遗留)按不存在处理:跳过该节点,也不拿它当父继续下钻——与族谱装配同一口径。
/// 不查可见性:这是作者自己血缘子树的账目,入参与纯函数里都没有可见性维度。
fn collect_game_distribution_contribution_nodes(
ctx: &ReducerContext,
root: &GameDistributionGame,
) -> Vec<module_game_distribution::ContributionNode> {
let limit = module_game_distribution::GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT + 1;
let depth_limit = module_game_distribution::GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT;
let root_game_id = root.game_id.clone();
// 根自身:父引用与代际取自它自己的血缘行(没有血缘行即 0 代母版);根不参与按代际分解。
let root_lineage = ctx
.db
.game_distribution_lineage()
.game_id()
.find(&root_game_id);
let mut nodes = vec![module_game_distribution::ContributionNode {
game_id: root_game_id.clone(),
parent_game_id: root_lineage
.as_ref()
.map(|lineage| lineage.parent_game_id.clone()),
generation: root_lineage
.as_ref()
.map(|lineage| lineage.generation)
.unwrap_or(0),
totals: module_game_distribution::ContributionTotals::from_play_count(root.play_count),
}];
let mut seen = std::collections::HashSet::new();
seen.insert(root_game_id.clone());
let mut frontier = vec![root_game_id];
let mut depth = 0u32;
while !frontier.is_empty() && depth < depth_limit + 1 && nodes.len() < limit {
let mut next = Vec::new();
for parent_game_id in &frontier {
let mut children = ctx
.db
.game_distribution_lineage()
.by_game_distribution_lineage_parent_game_id()
.filter(parent_game_id)
.collect::<Vec<_>>();
children.sort_by(|left, right| left.game_id.cmp(&right.game_id));
for lineage in children {
if !seen.insert(lineage.game_id.clone()) {
continue;
}
let Some(child) = ctx
.db
.game_distribution_game()
.game_id()
.find(&lineage.game_id)
else {
continue;
};
nodes.push(module_game_distribution::ContributionNode {
game_id: child.game_id.clone(),
parent_game_id: Some(lineage.parent_game_id.clone()),
generation: lineage.generation,
totals: module_game_distribution::ContributionTotals::from_play_count(
child.play_count,
),
});
next.push(lineage.game_id.clone());
if nodes.len() >= limit {
return nodes;
}
}
}
frontier = next;
depth += 1;
}
nodes
}
fn game_distribution_contribution_totals(
totals: module_game_distribution::ContributionTotals,
) -> GameDistributionContributionTotalsSnapshot {
GameDistributionContributionTotalsSnapshot {
play_count: totals.play_count,
}
}
/// 把纯函数的归集结果折成 procedure 快照;字段映射只有这一份。
fn game_distribution_contribution_snapshot(
breakdown: module_game_distribution::ContributionBreakdown,
) -> GameDistributionContributionSnapshot {
GameDistributionContributionSnapshot {
game_id: breakdown.game_id,
own: game_distribution_contribution_totals(breakdown.own),
inherited: game_distribution_contribution_totals(breakdown.inherited),
total: game_distribution_contribution_totals(breakdown.total),
by_generation: breakdown
.by_generation
.into_iter()
.map(|entry| GameDistributionContributionGenerationSnapshot {
generation: entry.generation,
game_count: entry.game_count,
own: game_distribution_contribution_totals(entry.own),
inherited: game_distribution_contribution_totals(entry.inherited),
total: game_distribution_contribution_totals(entry.total),
})
.collect(),
direct_children: breakdown
.direct_children
.into_iter()
.map(|entry| GameDistributionContributionChildSnapshot {
game_id: entry.game_id,
generation: entry.generation,
own: game_distribution_contribution_totals(entry.own),
inherited: game_distribution_contribution_totals(entry.inherited),
total: game_distribution_contribution_totals(entry.total),
})
.collect(),
node_count: breakdown.node_count,
truncated: breakdown.truncated,
truncated_reason: breakdown
.truncated_reason
.map(|reason| reason.as_str().to_string()),
}
}
/// 读取作者自己作品的贡献归集与归因:`inherited(W) = Σ 直接子代 total`、`total = own + inherited`。
///
/// 与族谱 / 衍生列表不同,这里把三件事分开回传(都发生在同一事务里):
/// - 行不存在或已软删除 → `Ok(None)`(api-server 404);
/// - 行存在但归属不是调用者 → `Err("游戏 owner 不匹配")`(api-server 403,沿用仓库既有
/// owner-mismatch → FORBIDDEN 惯例;这样作者页能分辨「没有权限」与「作品已不存在」);
/// - 其余 → `Ok(Some(快照))`,其中 `truncated` / `truncated_reason` 如实标注上限截断。
fn game_distribution_contribution_tx(
ctx: &ReducerContext,
input: GameDistributionContributionInput,
) -> Result<Option<GameDistributionContributionSnapshot>, String> {
let game_id = required_game_distribution_text(input.game_id, "game_id")?;
let owner_user_id = required_game_distribution_text(input.owner_user_id, "owner_user_id")?;
let Some(game) = ctx.db.game_distribution_game().game_id().find(&game_id) else {
return Ok(None);
};
if game.owner_user_id != owner_user_id {
return Err("游戏 owner 不匹配".to_string());
}
if game.deleted_at.is_some() {
return Ok(None);
}
let nodes = collect_game_distribution_contribution_nodes(ctx, &game);
let Some(breakdown) = module_game_distribution::build_contribution_breakdown(
&nodes,
game_id.as_str(),
module_game_distribution::GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT,
module_game_distribution::GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT,
) else {
// 根行刚读过、也刚放进集合,这个分支不可达。真到了这里说明构造逻辑坏了:
// 返回 404 会把缺陷说成「作品不存在」,所以失败关闭(api-server 落通用 400)。
return Err(format!("归集计算失败:根节点不在集合里({game_id})"));
};
Ok(Some(game_distribution_contribution_snapshot(breakdown)))
}
/// 读取某作品的 Fork 取件信息(受鉴权内容下发通道的元数据)。
///
/// 与族谱 / 衍生列表不同,这里**必须**区分「行不存在」与「行存在但不可用」:HTTP 合同要求
@@ -8583,4 +8871,110 @@ mod tests {
apply_game_distribution_frozen_metadata(&mut game, &priced);
assert_eq!(game.price_mud_points, 100);
}
/// 贡献归集事务:归属 / 软删除口径、只用血缘表既有父索引、多收一点的截断信号。
///
/// 行为证据(真实读数与截断标志)由纯函数单测 + api-server 断言承担——事务需要
/// `ReducerContext`,host 测试起不了真库,这里只钉住结构性不变量。
#[test]
fn contribution_tx_reads_by_parent_index_and_stays_owner_scoped() {
let source = include_str!("game_distribution.rs");
let tx = function_body(source, "fn game_distribution_contribution_tx(");
for required in [
// 非作者按既有 owner-mismatch 文案拒绝(api-server 映射 403),与其它作者侧入口同一句。
"\"游戏 owner 不匹配\"",
// 软删除按不存在处理(api-server 404),不用错误码区分「已删除」与「不存在」。
"deleted_at.is_some()",
// 计算只有一份实现:事务不自己算递归。
"module_game_distribution::build_contribution_breakdown",
"module_game_distribution::GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT",
"module_game_distribution::GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT",
] {
assert!(tx.contains(required), "贡献归集事务缺少:{required}");
}
// 本期只做计算:事务里不得出现资金 / 分成 / 结算语义。
for forbidden in ["settlement", "revenue", "money", "资金"] {
assert!(!tx.contains(forbidden), "贡献归集事务不得引入 {forbidden}");
}
// 遍历只用血缘表既有索引,不新增表 / 索引,也不得扫描游戏表。
let collect = function_body(source, "fn collect_game_distribution_contribution_nodes(");
assert!(
collect.contains("by_game_distribution_lineage_parent_game_id()"),
"必须按既有父索引逐层下钻"
);
assert!(!collect.contains(".iter()"), "不得扫全表:游戏行只按主键取");
// 多收一条 / 多收一层:纯函数要看见「还有更多」才能如实标注截断,而不是静默给半个数。
assert!(
collect.contains("CONTRIBUTION_NODE_LIMIT + 1"),
"必须多收一条后代作为 node_limit 的判据"
);
assert!(
collect.contains("depth < depth_limit + 1"),
"必须多收一层作为 depth_limit 的判据"
);
// 同层按 gameId 排序后下钻:截断点确定,两次读不会换一批节点。
assert!(collect.contains("sort_by"), "同层必须排序,保证截断点确定");
// 根不在集合里的分支在事务里失败关闭(不回 404、不 panic)。
assert!(tx.contains("归集计算失败"));
}
/// 贡献归集 procedure:受信服务身份 + 单一事务 + 失败路径显式给空。
#[test]
fn contribution_procedure_requires_service_identity_and_fails_closed() {
let source = include_str!("game_distribution.rs");
let body = function_body(
source,
"pub fn get_game_distribution_contribution_and_return(",
);
assert!(
body.contains("require_editor_generation_runtime_service_identity(tx, caller)?"),
"作者侧只读 procedure 同样必须要求受信服务身份"
);
assert!(body.contains("ctx.try_with_tx("));
assert!(body.contains("Ok(None) => GameDistributionContributionResult::not_found()"));
let start = source
.find("impl GameDistributionContributionResult {")
.expect("贡献归集失败路径必须存在");
let tail = &source[start..];
let end = tail.find("\nimpl ").unwrap_or(tail.len());
assert!(
tail[..end].contains("contribution: None"),
"失败 / 不存在路径必须显式给出空结果"
);
assert!(tail[..end].contains("found: false"));
}
/// 贡献归集快照是合同形状:只有「指标 + 归因 + 截断」三类字段,没有资金 / 分成的位置。
#[test]
fn contribution_snapshot_shape_matches_the_contract() {
let source = include_str!("game_distribution.rs");
let body = function_body(source, "pub struct GameDistributionContributionSnapshot {");
for field in [
"pub game_id:",
"pub own:",
"pub inherited:",
"pub total:",
"pub by_generation:",
"pub direct_children:",
"pub node_count:",
"pub truncated:",
"pub truncated_reason:",
] {
assert!(body.contains(field), "贡献归集快照缺少合同字段:{field}");
}
for forbidden in ["money", "revenue", "settlement", "share"] {
assert!(
!body.contains(forbidden),
"本期只做计算,快照不得出现 {forbidden}"
);
}
// 指标集合是独立结构体:将来加指标只动它,不动递归与归因形状。
let totals = function_body(
source,
"pub struct GameDistributionContributionTotalsSnapshot {",
);
assert!(totals.contains("pub play_count: u64"));
}
}