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
@@ -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=<id>` 的 deep link 目前**未注册**,只是候选之一);② AGC 首页/项目入口提供「从平台作品开始创作」(输入 gameId 或从平台跳转) |
| AGC 客户端 | ① 详情页「改造这个作品」唤起 AGC(唤起方式**待拍板**,候选见 §7 第 10 条;`genarrative://fork?gameId=<id>` 的 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<String>,
| `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` 小节。仍**不在**范围内的是「收藏数公开计数」「收藏动态流」「关注 / 粉丝」——它们各自需要独立的口径与表。
@@ -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`。
@@ -70,6 +70,18 @@ const PAIRS = [
['GameDistributionLineageNode', 'GameDistributionLineageNode'],
['GameDistributionLineageResponse', 'GameDistributionLineageResponse'],
['GameDistributionDerivedResponse', 'GameDistributionDerivedResponse'],
// 贡献归集与归因(作者视角只读,2026-10-06):指标集合、按代际分解、直接子代明细与响应体
// 逐字段对齐。`truncatedReason` 在 Rust 是 `Option<String>`(未截断为 `null`),TS 侧必须可空。
['GameDistributionContributionTotals', 'GameDistributionContributionTotals'],
[
'GameDistributionContributionGeneration',
'GameDistributionContributionGeneration',
],
['GameDistributionContributionChild', 'GameDistributionContributionChild'],
[
'GameDistributionContributionResponse',
'GameDistributionContributionResponse',
],
// M2a 取件通道:元数据响应与内容形态同样逐字段对齐(不含对象键是契约的一部分)。
['GameDistributionForkSourceKind', 'GameDistributionForkSourceKind'],
['GameDistributionForkSource', 'GameDistributionForkSource'],
@@ -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"));
}
}