diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 5b14fb0e7..7ea3d5e1d 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -14,7 +14,7 @@ - **工程源包 Fork(真·复刻工程,也是唯一能直接发布的路径)**:成品包是 Vite 构建产物——模板工程 `vite.config.js` 只配了 `build: { outDir: 'dist' }`,**未关压缩也未开 sourcemap**,且平台发行校验明确拒收 `*.map`(`server-rs/crates/module-game-distribution/src/package.rs:171-175`)。所以 AI 在成品包上改核心逻辑不可靠;要让「改造完成后可发布」成立,必须有版本级可选资产**工程源包**。 - 两者共用同一套血缘模型;成品包路径是工程源包缺失时(网页端发布、作者不愿公开源码)的天然**降级路径,降级到「试玩 + 素材」,不含发布**。 4. **工程源包有现成规范与现成客户端链路可复用**:排除规则见 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`;下载→校验 SHA-256→解压→建项全链路已存在于 `apps/ai-game-creator-shell/src-tauri/src/template_library.rs`(`create_project_from_installed_template_at` `:816`)。 -5. **本期不做**:创作者收益分成(仓库无收益/分成/结算表,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算)、相似度反洗稿校验、上链。 +5. **本期只做贡献归集与归因计算(§3.11),不含资金 / 分成结算**(口径留待产品决定;仓库无收益 / 分成 / 结算表,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算);**反洗稿(相似度校验)产品已决定搁置(2026-10-06,见 §2.2 与 §7)**;**不上链**。 6. **顺带必修的相关缺口(已在 M1 修复)**:公开 `author.name` 长期落到兜底文案(创建游戏时 `author_name` 写死 `None`,`api-server/src/modules/game_distribution.rs:1001-1003`;公开 payload 兜底「创作者」)。修法:公开快照改为读时联 `user_account`,账号改名/换头像立即跟随,与后台游戏管理页同口径。 --- @@ -78,8 +78,8 @@ ### 2.2 非目标 -- 不做收益分成、版税结算、算力成本核算(无账本与口径,属独立议题)。 -- 不做相似度反洗稿校验(无判定标准;本方案只保证**来源声明不可伪造**)。 +- **本期只做贡献归集与归因计算**(§3.11:递归「子代所有的都算父代的」+ 按代际 / 直接子代归因分解),**不做**资金 / 分成 / 版税结算与算力成本核算——无账本、无结算周期、无金额口径,属独立议题;资金与分成口径留待产品决定。 +- **反洗稿(相似度校验)产品已决定搁置(2026-10-06)**:不是没想到,是明确不做——无判定标准,且判定只能基于工程源包做结构化比对,属独立议题;本方案只保证**来源声明不可伪造**(声明只能来自真实取件,见 §3.5.3)。 - 不做上链;「永久溯源」实现为**不可变的父子链 + 平台持久化事实**。 - 不做跨作品类型 Fork(现役只有一种作品:游戏分发)。 - 不做关注 / 粉丝。收藏(收录)原先也在这个「不做」列表里,但**已纳入并落地**(2026-10-06,见 §2.4 / §3.4):它是服务端真实用户态投影(`game_distribution_collection`),不是前端本地状态,因此仍满足「不要虚构收藏状态」这条既有发行合同。 @@ -132,7 +132,7 @@ stateDiagram-v2 | --- | --- | | `/games/mine` 每张作品卡 | 新增「共创授权」三态设置(仅允许提升,终态 `full` 时只读);新增「被改编 N」入口,弹层列出直接子代(标题 / 作者 / 代际 / 状态);提升授权后若当前公开版本没有工程源包,行内提示「上传工程源码以支持源码级改造」(上传在桌面端客户端完成,网页端只做引导) | | `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」**且档位由服务端继承父作品当时的档位**——该场景下客户端所选档位不生效(不报错,见 §2.3) | -| AGC 客户端 | ① 详情页「改造这个作品」唤起 AGC(唤起方式**待拍板**,候选见 §7 第 11 条;`genarrative://fork?gameId=` 的 deep link 目前**未注册**,只是候选之一);② AGC 首页/项目入口提供「从平台作品开始创作」(输入 gameId 或从平台跳转) | +| AGC 客户端 | ① 详情页「改造这个作品」唤起 AGC(唤起方式**待拍板**,候选见 §7 第 10 条;`genarrative://fork?gameId=` 的 deep link 目前**未注册**,只是候选之一);② AGC 首页/项目入口提供「从平台作品开始创作」(输入 gameId 或从平台跳转) | | `/games/detail` 详情页 | 新增「收藏」按钮(收藏 / 已收藏两态)。初始态取自公开详情的 `collected`(登录才有该字段);点击后 `PUT` / `DELETE /api/game-distribution/games/{gameId}/collection`,按钮态改用**响应里的权威投影值**(不写本地乐观状态——前端本地状态正是本功能要消灭的东西)。未公开 / 已软删除的作品返回 409,按钮按失败态提示 | | `/games/mine`(或「我的」入口)| 新增「我的收藏(收录)」列表:`GET /api/game-distribution/my-collections`,形状与广场一致(封面 / 标题 / 作者 / 游玩数),点击进详情。列表只含**当前公开可读**的作品;已下架作品的收藏行保留,作品重新公开后自动回来(不需要用户重新收藏) | @@ -191,7 +191,7 @@ sequenceDiagram API->>API: 校验授权/来源版本/计算 generation 与 root → 写 lineage ``` -> 说明:本图是**目标形态**。唤起方式尚未拍板(§7 第 11 条);`GET …/fork-source` **M2a 已实现**(受鉴权元数据 + 取件本体,实际字段与状态码见 §3.4「登录用户」表),但本图里的响应字段名是 M2b 的目标形状,定稿一律以 §3.4 表格为准。 +> 说明:本图是**目标形态**。唤起方式尚未拍板(§7 第 10 条);`GET …/fork-source` **M2a 已实现**(受鉴权元数据 + 取件本体,实际字段与状态码见 §3.4「登录用户」表),但本图里的响应字段名是 M2b 的目标形状,定稿一律以 §3.4 表格为准。 **C. 溯源与命名** @@ -347,6 +347,14 @@ pub(crate) project_bundle_sha256: Option, | `DELETE /games/{gameId}/collection` | 取消收藏。**不要求 `Idempotency-Key`**:按确定性主键删除,重复调用结果完全相同(不存在也算成功),没有「重放 vs 新意图」需要区分。**也不要求作品仍公开 / 未被删除**:下架后拒绝取消只会给用户留下清理不掉的脏行。响应 `{ collected: false }`(**不带** `replayed`) | | `GET /my-collections?limit=&cursor=`(2026-10-06 补真分页) | 「我的收藏(收录)」列表:逐条用公开目录同一份 `public_game_payload` 投影,形状与公开目录一致 `{ games, nextCursor }`。**真游标分页**:`limit` 缺省 **20**(网格一屏)、上限 **50**(约束单响应体积;与后台列表的 200 口径**不必相等**),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{collectionId}"`(与后台列表同一套 `"{micros}:{id}"` 惯例,`collectionId` = `{userId}:{gameId}` 自带冒号,解析只切第一个冒号);**格式非法 → 400**;`nextCursor` 为真实值,**最后一页为 `null`**。排序:**收藏时间倒序,同值用 `collectionId` 升序兜底**(`collectionId` 唯一 ⇒ 全序,翻页不重不漏)。分页顺序定义为「**先按可见性过滤、再排序切页**」——游标位置落在已过滤序列上,否则每翻一页都会漏掉自己的若干条收藏。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 | +**贡献归集与归因(作者视角只读,2026-10-06 新增,Bearer,`no-store`)** + +口径与不变量见 §3.11;这里是路由层的合同摘要。 + +| 方法 / 路径 | 说明 | +| --- | --- | +| `GET /games/{gameId}/contribution` | 作者读取自己作品的贡献归集:`{ gameId, own, inherited, total, byGeneration, directChildren, nodeCount, truncated, truncatedReason }`(指标集合当前只有 `playCount`)。**只要求 Bearer、不叠加发布灰度**;未登录 / 失效 → **401**,作品不存在或已软删除 → **404**,非作者 → **403**(沿用既有 owner-mismatch 惯例,理由与代价见 §3.11.5)。子树遍历只用血缘表既有父索引,**不新增表 / 索引**;受节点上限 **500** / 深度上限 **32** 约束,超限时 `truncated: true` + `truncatedReason` ∈ `node_limit` / `depth_limit`,已计入部分的数字仍自洽。**本期只做计算**:响应里没有资金 / 分成 / 结算字段 | + #### 后台(admin) | 方法 / 路径 | 说明 | @@ -697,6 +705,114 @@ pub struct GameDistributionThemeMember { | 数据契约表 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` **已随表落地补齐**(`2fa201e0d`,两张表小节;`check:spacetime-schema` 现为 **96** tables) | | 不改的东西 | 不动 `/api/external/v1`(不新增 External OpenAPI 条目);不新增数据库访问通道;不新增第二套作品系统;游戏 Tab / 广场的公开目录**不变**(主题是额外维度,不改变作品的独立展示) | +### 3.11 贡献归集与权益归因(2026-10-06) + +> **口径原话**:「子代所有的都算父代的」,并且要能回答「能溯源多少是子代给的、多少是自己的」。 +> **本期只做计算**:只做贡献归集(rollup)与归因(attribution)**计算**——没有账本、没有结算周期、 +> 没有金额字段,**不含资金 / 分成 / 结算**,资金与分成口径留待产品决定(见 §2.2 与 §7)。 +> 唯一实现在 `server-rs/crates/module-game-distribution/src/contribution.rs`(纯函数,无 +> `ReducerContext`)。 + +#### 3.11.1 递归定义 + +```text +inherited(W) = Σ_{c ∈ 直接子代(W)} total(c) +total(W) = own(W) + inherited(W) +``` + +- `own(W)`:作品 W 自己的指标(本期只有 `playCount`)。 +- `inherited(W)`:W 的**全部**直接子代按 `total` 汇总;子代的子代已经含在子代的 `total` 里, + 因此「子代所有的都算父代的」,而**每个后代在它的每个祖先里只计一次**(不重复、不漏)。 +- 等价恒等式(三条不变量里最有用的一条):`inherited(W) == Σ_{d ∈ 后代(W)} own(d)`。 +- 指标集合做成结构体 `ContributionTotals { play_count }`(`Default` + `AddAssign` 风格的**单一 + 合并点**):**将来加点赞 / 收藏 / 收入只需加字段**并改那一处合并,递归定义、归因分解与上限 / + 截断语义都不需要动。 + +#### 3.11.2 归因分解(根节点的明细) + +| 字段 | 语义 | +| --- | --- | +| `own` / `inherited` / `total` | 根自身的三个数,`total == own + inherited` | +| `byGeneration[]` | **每一代贡献多少**:只含**后代**(不含根自身),`generation` 用**绝对代际**(与族谱一致),按代际**升序** | +| `directChildren[]` | **每个直接子代带来多少**:每项是该直接子代的 `total`(含它自己的整棵子树),按 `gameId` 升序 | + +`byGeneration` 每条的 `total` 是「这一代对祖先 `inherited` 的贡献」,也就是**该代节点自身的量**; +`inherited` 恒为 `0`——该代从更深代际继承到的量已计入更深代际那一条。若把后代也滚进本代, +同一个后代会在多层分解里被重复计入,各代之和就会**大于**祖先的 `inherited`(这正是本功能要避免的事)。 + +不变量(**在截断后的子树上依然成立**,纯函数单测逐条钉住): + +- `inherited == Σ byGeneration.total == Σ directChildren.total`; +- `total == own + inherited`; +- `nodeCount == Σ byGeneration.gameCount`(`nodeCount` = 参与计算的**后代**节点数,**不含根**)。 + +#### 3.11.3 接口:作者视角只读 + +`GET /api/game-distribution/games/{gameId}/contribution` + +```json +{ + "gameId": "game_x", + "own": { "playCount": 120 }, + "inherited": { "playCount": 340 }, + "total": { "playCount": 460 }, + "byGeneration": [ { "generation": 1, "gameCount": 2, "own": {"playCount":90}, "inherited": {"playCount":0}, "total": {"playCount":90} } ], + "directChildren": [ { "gameId": "game_b", "generation": 1, "own": {"playCount":70}, "inherited": {"playCount":40}, "total": {"playCount":110} } ], + "nodeCount": 5, + "truncated": false, + "truncatedReason": null +} +``` + +口径: + +- **遍历只用血缘表既有索引**(`by_game_distribution_lineage_parent_game_id`)逐层下钻; + **不新增表或索引**,也不新建第二套血缘查询。 +- **不做公开可见性过滤**:这是作者自己血缘子树的账目,口径就是「血缘上属于我的后代」, + 因此**已下架 / 已软删除的后代照常计入**(血缘事实仍在)。入参与纯函数里都没有可见性维度—— + 要改口径必须显式加字段,不会悄悄漂移。 +- 子孙的游戏行缺失(脏数据 / 历史遗留)按不存在处理:跳过该节点,也不拿它当父继续下钻 + (与族谱装配同一口径)。 +- 缺失 / 悬空行(父不在集合里、自环、重复行)由纯函数安全跳过,不 panic、不发半残节点。 +- 除「作品不存在」外,本接口**不把任何异常抛成 404 / 409**:截断与数据问题都走 + `truncated` / `truncatedReason` 或 5xx 映射,不用状态码掩盖。 + +#### 3.11.4 规模上限与截断语义 + +| 常量 | 值 | 语义 | +| --- | --- | --- | +| `GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT` | **500** | 单次最多计入多少个**后代**(根不计入) | +| `GAME_DISTRIBUTION_CONTRIBUTION_DEPTH_LIMIT` | **32** | 相对深度上限(根 = 0),更深的节点不再计入 | + +- 超限时**如实标注**:`truncated: true` + `truncatedReason` ∈ `node_limit` / `depth_limit` + (未截断为 `null`)——与族谱接口 `truncated` 的既有约定一致,**不静默给半个数**。 +- 两种上限同时拦住同一批节点时按 **`node_limit`** 报:调用方该去收敛范围, + 而不是去怀疑血缘真有 32 层。 +- **已计入部分仍自洽**:三条不变量在截断后的子树上依旧成立(数字只覆盖被计入的那部分子树)。 +- 事务侧多收**一条**后代、多收**一层**深度:只收满上限时「刚好满」与「还有更多」不可分辨, + 截断会被静默吞掉。同层按 `gameId` 升序下钻,截断点因此确定,两次读不会换一批节点。 + +#### 3.11.5 鉴权口径与理由 + +| 情形 | 状态码 | 落点 | +| --- | --- | --- | +| 未登录 / 令牌失效 | **401** | 路由层 `require_bearer_auth`(不进入业务;**不叠加**发布灰度——它不是发布动作) | +| 作品不存在或**根自身**已软删除 | **404** | 事务 `Ok(None)` → 客户端折成 `None` → handler 404 | +| 作品存在但不属于当前主体 | **403** | 事务报 `游戏 owner 不匹配`,命中 `map_spacetime_error` 既有的 owner-mismatch → FORBIDDEN 分支 | + +**为什么非作者是 403 而不是「也折 404」**(代价一并写明): + +1. 沿用仓库既有 owner-mismatch 惯例——`map_spacetime_error` 早已把这类文案映射成 FORBIDDEN, + 作者侧写路由同源;在这里另立一套 404 会多出第二种「不属于你」的语义。 +2. 这是作者账目页:客户端必须能分辨「我没有权限看」(停手 / 换账号 / 回我的作品)与 + 「作品没了」(回列表刷新),两者折成同一个 404 只能靠再猜一次。 +3. 代价明确且被接受:登录用户可用 403 / 404 之分辨别某个 `gameId` 是否存在。已公开作品本就能从 + 公开目录枚举;未公开作品会因此多出一个存在性预言机,但本接口只服务作者本人,故接受并显式记录。 + +响应整组 `Cache-Control: no-store`(per-author 的商业敏感数据,不给任何共享缓存留缝); +procedure 另要求**受信服务身份**(归属判定靠 `owner_user_id`,而 SpacetimeDB 客户端身份本身不携带 +作品归属),与其它作者侧 procedure 同口径。 + --- ## 4. 为什么是 `game` 而不是 `version` @@ -793,6 +909,22 @@ pub struct GameDistributionThemeMember { --- +### 5.4 贡献归集与权益归因(2026-10-06)实施证据 + +| 项 | 证据 | 状态 | +| --- | --- | --- | +| 纯函数(递归 + 归因分解 + 上限 / 截断) | 新增 `server-rs/crates/module-game-distribution/src/contribution.rs`:`build_contribution_breakdown`(无 `ReducerContext`)+ `ContributionTotals { play_count }`(`Default` + `AddAssign` 的**单一合并点**,将来加指标只动它);常量 `GAME_DISTRIBUTION_CONTRIBUTION_NODE_LIMIT = 500` / `_DEPTH_LIMIT = 32` 在单测里按**合同值**钉住 | ✅ 已验证(**12 条纯函数单测**:A→B→C 三层链路 `A.inherited = B.own + C.own` / `B.inherited = C.own` / `C.inherited = 0`、根带 3 子 + 1 孙的多分支、无子代、悬空 / 缺失 / 自环 / 重复行安全跳过、绝对代际、两条分解之和 == `inherited`、`inherited == Σ 后代 own`、`node_limit` / `depth_limit` 截断且数字自洽;`cargo test -p module-game-distribution` → **131 passed**) | +| 事务与 procedure | `game_distribution_contribution_tx`(行不存在 / 已软删除 → `Ok(None)`;owner 不匹配 → `Err("游戏 owner 不匹配")`;正常 → 快照)+ `collect_game_distribution_contribution_nodes`(只用 `by_game_distribution_lineage_parent_game_id` 逐层下钻,同层按 `gameId` 排序,**多收一条 / 多收一层**作为截断判据)+ `get_game_distribution_contribution_and_return`(受信服务身份 + 单一 `try_with_tx`,失败路径显式给空) | ✅ 已验证(3 条结构性单测;`cargo test -p spacetime-module` → **304 passed / 1 ignored**) | +| 接口(作者视角只读) | `GET /api/game-distribution/games/{gameId}/contribution` 挂独立 Bearer + `no-store` 路由组(**不叠加发布灰度**);未登录 / 失效 → 401,不存在 / 已软删除 → 404,非作者 → 403(owner-mismatch → FORBIDDEN);响应经 DTO 逐字段搬运,不在 api-server 重算任何数字 | ✅ 已验证(3 条新用例:① 401 + `no-store`;② 403 / 404 映射可分辨;③ 用纯函数构造的节点喂进 `contribution_payload`,逐字段断言 `own` / `inherited` / `total` / `nodeCount` / `byGeneration` / `directChildren` 与纯函数一致,且 `truncated` + `truncatedReason = node_limit` 透传、截断后两条分解之和仍等于 `inherited`;`cargo test -p api-server game_distribution` → **107 passed**) | +| 契约与 parity | Rust DTO `GameDistributionContributionTotals` / `...Generation` / `...Child` / `...Response`(`server-rs/crates/shared-contracts/src/game_distribution.rs`)+ TS 镜像(`packages/shared/src/contracts/gameDistribution.ts`)+ `check-game-distribution-dto-parity` 登记 4 组(`truncatedReason` 的可空性双向比对) | ✅ 已验证(`check-game-distribution-dto-parity` → **66 组** Rust/TS 类型一致) | +| 生成绑定 | `npm run spacetime:generate -- --rust-only`:新增 6 个类型文件 + 1 个 procedure 文件;`module_bindings.rs` 只多出这 7 条登记,**无其它漂移** | ✅ 已验证(`git status` 核对:`module_bindings/` 只新增上述 7 个文件) | +| 迁移与 schema | **不新增表 / 索引 / 列**(只加 procedure 入参 / 结果类型),因此无迁移、schema 基线比对通过 | ✅ 已验证(`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 check:spacetime-schema` → 98 tables 通过) | +| 口径取舍:`byGeneration[i].total` | 取「**这一代对祖先 `inherited` 的贡献**」(= 该代节点自身量),`inherited` 恒为 `0`;把后代也滚进本代会让同一个后代在多层分解里被重复计入,各代之和就会**大于**祖先的 `inherited`,与 §3.11.2 的不变量冲突。`directChildren[i].total` 仍是「含整棵子树」的子代总量 | ✅ 已定(单测与接口用例都断言 `Σ byGeneration.total == inherited`;§3.11.2 已写明语义) | +| 门禁 | wasm build 0;`cargo check --all-targets` 0(仅既有 `TEST_AGC_MODEL_DEFAULT_ID` dead_code 警告);`cargo test -p module-game-distribution` 0(131 passed);`cargo test -p api-server game_distribution` 0(107 passed);`cargo test -p spacetime-module` 0(304 passed / 1 ignored);`cargo test -p spacetime-client` 0(43 passed);`check-game-distribution-dto-parity` 0;`check-project-bundle-policy-parity` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 check:spacetime-schema` 0;`check:encoding` 0;`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0 | ✅ 已验证 | +| 尚未覆盖 | 真实 dev 栈 / 浏览器端到端未跑(需本地库数据与登录态);前台作者账目页 UI 不在本次工作树权限内 | ⏳ 待补 | + +--- + ## 6. 里程碑拆分(评审后逐个开实施计划) | 里程碑 | 范围 | 交付判据 | @@ -809,18 +941,21 @@ pub struct GameDistributionThemeMember { ## 7. 未决问题(需要拍板) -> 第 1、8–12 条来自 2026-10-04 的只读侦察(`local://m2a-design.md`),其中 8–12 条是**第二阶段(M2a/M2b)落地前必须拍板**的五项;未拍板前不得据 §3.5.4 的推荐实现。 +> 第 1、7–11 条来自 2026-10-04 的只读侦察(`local://m2a-design.md`),其中 7–11 条是**第二阶段(M2a/M2b)落地前必须拍板**的五项;未拍板前不得据 §3.5.4 的推荐实现。 +> +> **已移出待拍板并定稿(2026-10-06)**: +> ① **相似度反洗稿校验**——**产品已决定搁置**。这不是「没想到」,而是**明确不做**:无判定标准,且判定只能基于工程源包做结构化比对,属独立议题;本方案只保证来源声明真实、不可伪造。口径落在 §2.2。 +> ② **结算**——统一为「**本期只做贡献归集与归因计算**(§3.11),**资金 / 分成不在范围内**(口径留待产品决定)」。原先「做不做分成」的问法作废:本期连账本与金额口径都没有,不预留半成品字段。 1. **成品包路径的事实边界已改写**(原条目「成品包路径默认要做……建议:做」的前提已被否证):成品包路径能试玩、能提供素材,但**不能发布**(§3.5.1)。因此要拍板的不再是「要不要顺带做工程源包」,而是「是否接受对外口径从『一键复刻完整工程』改成『参考改编 / 素材复用』,并把工程源包作为唯一源码级路径」。建议:接受并改口径(§3.5.4 路线 A 先行、B 排后续)。 2. **授权默认值已定口径(2026-10-06,原为待拍板)**:**母版**默认仍取「禁止共创」(`forbidden`,飞书评论里「默认允许 + 发布前合同勾选」的建议未采纳);**衍生作品**改为**创建时继承父作品当时的档位**(不再落 `forbidden`、也不接受客户端另选或收窄),见 §2.3 / §3.9 补充。存量作品不受影响:既有子作品的档位保持原值(继承只在创建时发生,不回填)。 3. **「共创主题」已拍板采纳且服务端已实现(2026-10-06,原为待拍板)**:采纳包仲航提出的「平台命名主题 → 作品树」,且明确作品之间不存在曝光挂靠。设计见 §3.10,实施清单见 `docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md`(M4)。**已落地**:设计定稿 `063c04a1b`、数据模型与领域纯函数 `2fa201e0d`、公开读路径 `742723a58`、后台写路径 `033e3aa79`(前台共创 Tab / 主题页与后台管理 UI **仍未做**)。**仍待产品拍板的是两点**:① **主题级排序口径**——公开列表当前按「沿用既有 micros 游标」排在 `created_at` 倒序 + `themeId` 升序兜底上,`sort_order` 只用于主题内成员排序;若要求共创 Tab 按运营序展示,需要把游标改成 `(sort_order, theme_id)` 双键并同步前端;② **主题详情 `roots` 的 50 上限**——可见成员 > 50 时静默截断且无「还有更多」标志(`memberCount == roots.len()`),三个备选(去上限 / 给成员加独立游标 / `memberCount` 报真实可见数)见 §3.10.6 与 §3.10.9,**本轮不改契约**。 -4. **收益分成**:需求文档要求「每一代均享有权益(署名 / 流量回馈 / 版权分成)」。署名本期做,流量回馈与分成本期不做(无账本、无算力成本口径,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算)。 -5. **相似度反洗稿校验**:需求文档要求「低改动度复刻判定」。本期不做;本方案只保证来源声明真实、不可伪造。若要做,只能基于工程源包做结构化比对,属于独立议题。 -6. **「永久链上溯源」表述**:实现为平台持久化的不可变父子链,不上链。需确认该措辞是否可以调整。 -7. **`play_count` 死字段**:它被前端展示为「N 次游玩」但永不自增。本方案不依赖它;是否顺带修复(新增游玩上报)需单独立项。 -8. **对外表述**:第二阶段是否接受「只能试玩 + 素材复用,不能一键复刻工程」?建议接受并同步改文案(AGC 侧注释 `export.rs:407-408` 已把无 `package.json` 的静态项目排除在发布合同外)。 -9. **血缘承载位**:血缘只作为客户端本地注释(项目内独立文件,如 `.agent/fork.json`),还是要平台可查询(服务端新增字段)或随工程包传输(manifest 升 v2,旧客户端读到即失败)?建议先本地,等工程源包(M2b)落地再上平台字段。 -10. **是否立刻做工程源包(M2b)**:它需要**新增工程包下载/授权接口**(当前不存在)并定义包内禁项(服务端已拒绝 `node_modules`/`.git`/`.env*`/`*.map`/`*.pem`/`*.key` 与嵌套 zip,`module-game-distribution/src/package.rs:135-142`、`:170-176`),成本最大。建议先做 A 验证需求。 -11. **从平台唤起 AGC 的方式**:AGC 内手工输入 gameId / 作品链接,还是平台发取件码在客户端兑换,还是注册 deep link(`tauri.conf.json` 未注册 scheme、无 `tauri-plugin-deep-link`、无单实例插件,需动安装器与升级链路)?建议手工输入起步。 -12. **是否允许「伪工程」(C 路线,补声明 phaser 4/vite 的 `package.json` 让成品包过检)用于存量作品兼容**:产出不可再构建,建议不允许;若确要做,需产品书面接受「不可再构建」的语义并限定为一次性兼容。 -13. **收藏(收录)已纳入并落地**(2026-10-06):原先它在 §2.2「非目标」里,现已实现为真实用户态投影(表 `game_distribution_collection` + `PUT`/`DELETE /games/{gameId}/collection` + `GET /my-collections` + 公开详情 `collected`),见 §2.4 / §3.4 与 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_collection` 小节。仍**不在**范围内的是「收藏数公开计数」「收藏动态流」「关注 / 粉丝」——它们各自需要独立的口径与表。 +4. **结算口径(2026-10-06 统一)**:需求文档要求「每一代均享有权益(署名 / 流量回馈 / 版权分成)」。署名本期已做;**本期只做贡献归集与归因计算**(§3.11),**资金 / 分成 / 版税结算与流量回馈不在范围内**,口径留待产品决定(无账本、无算力成本口径,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算)。贡献数是**计算事实**,不是可结算金额,也没有预留任何金额字段。 +5. **「永久链上溯源」表述**:实现为平台持久化的不可变父子链,不上链。需确认该措辞是否可以调整。 +6. **`play_count` 死字段**:它被前端展示为「N 次游玩」但永不自增。本方案不依赖它;是否顺带修复(新增游玩上报)需单独立项。 +7. **对外表述**:第二阶段是否接受「只能试玩 + 素材复用,不能一键复刻工程」?建议接受并同步改文案(AGC 侧注释 `export.rs:407-408` 已把无 `package.json` 的静态项目排除在发布合同外)。 +8. **血缘承载位**:血缘只作为客户端本地注释(项目内独立文件,如 `.agent/fork.json`),还是要平台可查询(服务端新增字段)或随工程包传输(manifest 升 v2,旧客户端读到即失败)?建议先本地,等工程源包(M2b)落地再上平台字段。 +9. **是否立刻做工程源包(M2b)**:它需要**新增工程包下载/授权接口**(当前不存在)并定义包内禁项(服务端已拒绝 `node_modules`/`.git`/`.env*`/`*.map`/`*.pem`/`*.key` 与嵌套 zip,`module-game-distribution/src/package.rs:135-142`、`:170-176`),成本最大。建议先做 A 验证需求。 +10. **从平台唤起 AGC 的方式**:AGC 内手工输入 gameId / 作品链接,还是平台发取件码在客户端兑换,还是注册 deep link(`tauri.conf.json` 未注册 scheme、无 `tauri-plugin-deep-link`、无单实例插件,需动安装器与升级链路)?建议手工输入起步。 +11. **是否允许「伪工程」(C 路线,补声明 phaser 4/vite 的 `package.json` 让成品包过检)用于存量作品兼容**:产出不可再构建,建议不允许;若确要做,需产品书面接受「不可再构建」的语义并限定为一次性兼容。 +12. **收藏(收录)已纳入并落地**(2026-10-06):原先它在 §2.2「非目标」里,现已实现为真实用户态投影(表 `game_distribution_collection` + `PUT`/`DELETE /games/{gameId}/collection` + `GET /my-collections` + 公开详情 `collected`),见 §2.4 / §3.4 与 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_collection` 小节。仍**不在**范围内的是「收藏数公开计数」「收藏动态流」「关注 / 粉丝」——它们各自需要独立的口径与表。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index e71695429..e0f5d5296 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -240,6 +240,66 @@ export type GameDistributionDerivedResponse = { truncated: boolean; }; +/** + * 归集的指标集合(当前只有游玩数)。 + * + * 单独成结构体而不是把字段摊进响应里:将来加点赞 / 收藏 / 收入只需加字段, + * 递归定义(`inherited(W) = Σ 直接子代 total`、`total = own + inherited`)与投影形状都不动。 + */ +export type GameDistributionContributionTotals = { + playCount: number; +}; + +/** + * 按代际分解的一条:**只含后代**(不含根自身),`generation` 为绝对代际(与族谱一致)。 + * + * 语义:这一条是祖先 `inherited` 里**由这一代自己产生**的那一份,`total` 即该代贡献; + * `inherited` 恒为 `0`——该代从更深代际继承到的量已计入更深代际那一条,重复计入会让 + * 「各代之和」大于祖先的 `inherited`(同一个后代会在多层分解里被重复计数)。 + */ +export type GameDistributionContributionGeneration = { + generation: number; + /** 这一代计入分解的后代节点数。 */ + gameCount: number; + own: GameDistributionContributionTotals; + inherited: GameDistributionContributionTotals; + total: GameDistributionContributionTotals; +}; + +/** 直接子代明细的一条:`total` 是该直接子代**含自己整棵子树**的值。 */ +export type GameDistributionContributionChild = { + gameId: string; + generation: number; + own: GameDistributionContributionTotals; + inherited: GameDistributionContributionTotals; + 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` 同约定:超限如实标注,不静默给半个数)。 + * **本期只做计算**:响应里没有资金 / 分成 / 结算字段。 + */ +export type GameDistributionContributionResponse = { + gameId: string; + own: GameDistributionContributionTotals; + inherited: GameDistributionContributionTotals; + total: GameDistributionContributionTotals; + byGeneration: GameDistributionContributionGeneration[]; + directChildren: GameDistributionContributionChild[]; + /** 参与计算的后代节点数(不含根)。 */ + nodeCount: number; + truncated: boolean; + truncatedReason: string | null; +}; + /** * Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在 * 两者时优先 `project`。 diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index a8e8a57a1..58b0084d9 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -70,6 +70,18 @@ const PAIRS = [ ['GameDistributionLineageNode', 'GameDistributionLineageNode'], ['GameDistributionLineageResponse', 'GameDistributionLineageResponse'], ['GameDistributionDerivedResponse', 'GameDistributionDerivedResponse'], + // 贡献归集与归因(作者视角只读,2026-10-06):指标集合、按代际分解、直接子代明细与响应体 + // 逐字段对齐。`truncatedReason` 在 Rust 是 `Option`(未截断为 `null`),TS 侧必须可空。 + ['GameDistributionContributionTotals', 'GameDistributionContributionTotals'], + [ + 'GameDistributionContributionGeneration', + 'GameDistributionContributionGeneration', + ], + ['GameDistributionContributionChild', 'GameDistributionContributionChild'], + [ + 'GameDistributionContributionResponse', + 'GameDistributionContributionResponse', + ], // M2a 取件通道:元数据响应与内容形态同样逐字段对齐(不含对象键是契约的一部分)。 ['GameDistributionForkSourceKind', 'GameDistributionForkSourceKind'], ['GameDistributionForkSource', 'GameDistributionForkSource'], diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 6607c4c9e..f562d623e 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -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 { 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 { .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, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(game_id): Path, +) -> Result, 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(value: Option) -> Result { + 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::(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![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 + ); + } } diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 0a50ee057..ca3807d1c 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -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, + pub direct_children: Vec, + /// 参与计算的后代节点数(不含根)。 + pub node_count: u64, + pub truncated: bool, + #[serde(default)] + pub truncated_reason: Option, +} + /// Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在 /// 两者时优先 `project`。 #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index af661ffa2..641f1c8f6 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 201437086..2ec95007d 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -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, + pub direct_children: Vec, + pub node_count: u64, + pub truncated: bool, + /// `node_limit` / `depth_limit`;未截断为 `None`。 + pub truncated_reason: Option, +} + /// 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, 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( diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 993857677..e9f8c8eab 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -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, 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, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index d12b64078..25372c220 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_child_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_child_snapshot_type.rs new file mode 100644 index 000000000..b04775847 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_child_snapshot_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_generation_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_generation_snapshot_type.rs new file mode 100644 index 000000000..41fcaf2c8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_generation_snapshot_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_input_type.rs new file mode 100644 index 000000000..ffd7f84cc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_result_type.rs new file mode 100644 index 000000000..73e257ffc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionContributionResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_snapshot_type.rs new file mode 100644 index 000000000..9bfd5266d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_snapshot_type.rs @@ -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, + pub direct_children: Vec, + pub node_count: u64, + pub truncated: bool, + pub truncated_reason: Option, +} + +impl __sdk::InModule for GameDistributionContributionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_totals_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_totals_snapshot_type.rs new file mode 100644 index 000000000..7fe83e108 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_contribution_totals_snapshot_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_contribution_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_contribution_and_return_procedure.rs new file mode 100644 index 000000000..907fe030d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_contribution_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionContributionResult>( + "get_game_distribution_contribution_and_return", + GetGameDistributionContributionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 34ec99291..d5b835332 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -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, } +/// 归集的指标集合(当前只有游玩数)。 +/// +/// 单独成结构体而不是把字段摊进结果里:将来加点赞 / 收藏 / 收入只需在此加字段, +/// 递归定义、归因分解与投影形状都不用动。 +#[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, + pub direct_children: Vec, + /// 参与计算的后代节点数(不含根)。 + pub node_count: u64, + pub truncated: bool, + /// `node_limit` / `depth_limit`;未截断为 None(不发空串)。 + pub truncated_reason: Option, +} + +/// 贡献归集读取结果。 +/// +/// - `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, + pub error_message: Option, +} + /// 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 { + 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::>(); + 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, 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")); + } }