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
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:
@@ -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;
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+62
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user