docs(游戏共创): 修正收藏幂等注释与实现不符之处(换用户不是 409)
Project CI / AI game creator shell Rust lane 1/2 (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 crates (pull_request) Has been cancelled
Project CI / Backend tests (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 web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (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 crates (pull_request) Has been cancelled
Project CI / Backend tests (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 web tests (pull_request) Has been cancelled
端到端实测(`scripts/check-game-distribution-collection-e2e.mjs`)发现注释与实现不一致,**行为本身无缺陷,改的是注释**: - `api-server/src/modules/game_distribution.rs:1389-1396`(`collection_request_digest` 文档注释):原写「同一个 key 撞到不同作品 / 不同用户」都被判成同键不同请求(409)。实际只有**换作品**是 409;**换用户**不会命中彼此收据(收据键 `(user_id, action, idempotency_key)` 本身含 `user_id`),两个用户各走各自的新请求、正常 200。已改写为「换作品 → 409;换用户 → 200 各自成功」,并注明这条注释曾写错。 - `api-server/src/modules/game_distribution.rs:1404-1408`(`collect_game` handler 幂等说明):同上(原文「同键不同请求(换作品 / 换用户)是 409」)。 - `api-server/src/modules/game_distribution.rs:7896-7897`(测试 `collection_request_digest_binds_user_and_game` 的描述):补上「换作品 → 409 / 换用户不会(收据按用户隔离)」;**断言未改**(它只断言摘要对两维度敏感,本来就与实现一致)。 - `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md:339`(`§3.4` 接口表同一句话):同一口径修正。 - `module-game-distribution/src/collection.rs:173`:把易被误读为「客户端 Idempotency-Key」的注释改为明确的「主键派生必须对两个维度都敏感」,并说明与幂等收据的键无关。 `spacetime-module/src/game_distribution.rs:4717-4719` 的注释本来就与实测一致(「不同用户 / 不同作品即使共用同一个 Idempotency-Key 也不会互相命中」),未改。 门禁:`cargo check -p api-server --all-targets` 0(仅既有 1 warning);`cargo test -p api-server collection` 7 passed;`cargo fmt --all -- --check` 0;`check:encoding` 0(5382 files);`git diff --check` 0。 遗留(非本人文件,未改):`scripts/check-game-distribution-collection-e2e.mjs:32-34,537` 的说明文字仍在描述「api-server 注释与实测不一致」,现已一致,需该脚本作者同步(归另一会话)。
This commit is contained in:
@@ -336,7 +336,7 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
|
||||
| 方法 / 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `PUT /games/{gameId}/collection` | 收藏。要求 `Idempotency-Key`;请求摘要绑定 `(userId, gameId)`。**幂等**:同键重放返回 `replayed: true`(本次没有新事实),同键不同请求(换作品 / 换用户)→ **409**。**防重靠结构**:主键是确定性构造的 `{userId}:{gameId}`,同一 (用户, 作品) 不可能出现第二行(重复收藏只留一行、仍算成功),不靠「事务里先查后写」,也不额外建唯一索引。错误码:作品不存在 → **404**;未公开 / 已软删除 / 没有当前公开版本 → **409**(失败关闭,文案 `作品状态不允许收藏(未公开或已软删除)`,不含「不存在」/「已被删除」,不用错误码泄露未公开作品的存在性)。响应 `{ collected: true, replayed: boolean }` |
|
||||
| `PUT /games/{gameId}/collection` | 收藏。要求 `Idempotency-Key`;请求摘要绑定 `(userId, gameId)`。**幂等**:同键重放返回 `replayed: true`(本次没有新事实);同键**换作品** → **409**(同一收据键、摘要不同);同键**换用户** → **200 各自成功**(收据键是 `(userId, action, idempotencyKey)`,本身含 `userId`,两个用户不会命中彼此的收据)。**防重靠结构**:主键是确定性构造的 `{userId}:{gameId}`,同一 (用户, 作品) 不可能出现第二行(重复收藏只留一行、仍算成功),不靠「事务里先查后写」,也不额外建唯一索引。错误码:作品不存在 → **404**;未公开 / 已软删除 / 没有当前公开版本 → **409**(失败关闭,文案 `作品状态不允许收藏(未公开或已软删除)`,不含「不存在」/「已被删除」,不用错误码泄露未公开作品的存在性)。响应 `{ collected: true, replayed: boolean }` |
|
||||
| `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` 唯一 ⇒ 全序,翻页不重不漏)。分页顺序定义为「**先按可见性过滤、再排序切页**」——游标位置落在已过滤序列上,否则每翻一页都会漏掉自己的若干条收藏。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 |
|
||||
|
||||
@@ -480,6 +480,199 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
|
||||
|
||||
---
|
||||
|
||||
### 3.10 共创主题(平台命名的作品树归组实体)
|
||||
|
||||
> **形态**:共创 Tab 里有多个「共创主题」,点进去是一棵(或一组)作品树;**主题由平台 / 运营命名,不由根作品决定**;作品在游戏 Tab 里仍作为独立作品展示,详情页有 Fork 入口,并能看到 / 跳到对应主题页。
|
||||
>
|
||||
> 本节把 §7 第 3 条(「是否引入共创主题实体」)从**待拍板**收敛为**已拍板**:采纳「平台命名主题 → 作品树」。归属关系的**曝光口径不变**——作品之间不互相挂靠,主题是运营侧的**额外归组维度**,不是作品的可读锚点。本轮只落设计(本节 + 里程碑《共创主题与作品树-2026-10-06》),**实现是下一轮**;后台 UI 亦不在本轮。
|
||||
>
|
||||
> 每小节末尾显式标注 **本轮实现**(下一轮按里程碑清单实施)或 **留白**(本轮明确不做,需另立需求)。
|
||||
|
||||
#### 3.10.1 实体:`game_distribution_theme` + `game_distribution_theme_member`
|
||||
|
||||
```rust
|
||||
#[spacetimedb::table(
|
||||
accessor = game_distribution_theme,
|
||||
index(
|
||||
accessor = by_game_distribution_theme_created_at,
|
||||
btree(columns = [created_at])
|
||||
),
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
pub struct GameDistributionTheme {
|
||||
/// 平台签发的主题 ID,形如 `theme-*`;由服务端生成,不接受客户端指定。
|
||||
#[primary_key]
|
||||
pub(crate) theme_id: String,
|
||||
/// 主题名(运营命名,不是根作品标题的派生)。
|
||||
pub(crate) name: String,
|
||||
pub(crate) summary: String,
|
||||
/// 角标短文本(本轮用它 + 名称承担「封面」的呈现职责,见 §3.10.9)。
|
||||
pub(crate) badge: String,
|
||||
/// 运营排序权重(成员排序使用;主题侧的用途见 §3.10.6 的说明)。
|
||||
pub(crate) sort_order: i64,
|
||||
/// `draft` / `published` / `archived`。
|
||||
pub(crate) status: String,
|
||||
/// 创建该主题的运营账号(= 后台会话主体),用于审计,不参与鉴权判定。
|
||||
pub(crate) created_by_user_id: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = game_distribution_theme_member,
|
||||
index(
|
||||
accessor = by_game_distribution_theme_member_theme_id,
|
||||
btree(columns = [theme_id])
|
||||
),
|
||||
index(
|
||||
accessor = by_game_distribution_theme_member_root_game_id,
|
||||
btree(columns = [root_game_id])
|
||||
),
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
pub struct GameDistributionThemeMember {
|
||||
/// 确定性主键 `"{theme_id}:{root_game_id}"`(同 `game_distribution_collection` 的写法)。
|
||||
#[primary_key]
|
||||
pub(crate) member_id: String,
|
||||
pub(crate) theme_id: String,
|
||||
/// 只允许**根作品**(见 §3.10.2)。
|
||||
pub(crate) root_game_id: String,
|
||||
pub(crate) sort_order: i64,
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
```
|
||||
|
||||
| 设计点 | 取值 | 理由 | 代价 |
|
||||
| --- | --- | --- | --- |
|
||||
| 独立表而不是在 `game` 上挂「主题」列 | 两张新表 | 归属是**多对多**关系(见 §3.10.3),`game` 上加单值列直接表达不了;且 `game` 主行是详情页热路径,不该再塞运营归组字段 | 多两张表与两条索引,迁移白名单与生成绑定都要跟着走 |
|
||||
| `theme_id` 由服务端生成、形如 `theme-*` | 与 `game-*` 同族命名 | 主题是平台实体,ID 不应由调用方决定(避免运营侧的碰撞与「抢 ID」);命名同族便于日志与排障一眼区分实体 | 运营无法用自选 ID 做外部引用;本轮不做 slug(§3.10.9) |
|
||||
| `member_id` 用**确定性**主键 `"{theme_id}:{root_game_id}"` | 与 `collection_id` 同款 | 去重由**主键结构**保证,而不是靠「事务里先查后写」:同一主题内同一根不可能出现第二行,因此**不需要**再建唯一索引 | 跨主题的同一根会有**多行**(每主题一行)——这是预期的,不是重复数据(§3.10.3) |
|
||||
| `theme_id` / `root_game_id` 各建**具名** btree 索引 | 两条具名索引 | 两条真实查询路径都需要它:① 主题页按主题取成员;② 作品详情按根反查所属主题(§3.10.7)。具名(`by_game_distribution_*`)与仓库既有命名一致,生成绑定与 schema 门禁都按具名索引核对 | 写入多两条索引维护成本;主题量级小,可忽略 |
|
||||
| `status ∈ draft|published|archived` | 三态 | 运营需要「未发布」「已发布」「已归档下架」三种处置;归档不是删除(成员行保留,见 §3.10.5),语义比 `deleted_at` 更弱也更可逆 | 每次新增一态都要在三处(可见性纯函数、后台列表过滤白名单、DTO)同步,靠测试与 DTO parity 钉住 |
|
||||
| `created_by_user_id` = 运营账号 | 审计字段 | 后台写接口是运营动作,出问题时必须能答「谁建的 / 谁改的」;与仓库现有 admin 操作留痕同口径 | 仅记录创建者,不记录后续每次改动者(本轮不建单独的主题修改日志) |
|
||||
|
||||
**本轮实现**:两表、两条具名索引、`migration.rs` 的 `migration_tables!` 白名单登记、生成绑定与 schema 门禁。
|
||||
|
||||
#### 3.10.2 成员只允许「根作品」
|
||||
|
||||
| 判断 | 结论 | 理由 | 代价 |
|
||||
| --- | --- | --- | --- |
|
||||
| 成员可以是任意代作品吗 | **不可以,只允许根**(根 = 该作品没有血缘行,即第 0 代) | 主题页要呈现的是**作品树**,而我们的树查询 `/games/{id}/lineage` 是**按根聚合**的(`by_game_distribution_lineage_root_game_id` 取整棵子树)。若允许任意代作品入主题,同一棵树会在主题页里出现**多次**(子作品自己一条成员、它的根又一条成员),去重与「高亮当前节点」都会打架 | 运营不能把某一代子作品单独挂进主题——**要挂就挂它所属的根**。这是产品口径上的约束,不是技术限制 |
|
||||
|
||||
实现口径:成员写入时按「该 `game_id` 是否存在血缘行」判定——`game_distribution_lineage().game_id().find(&game_id)` 命中(有行 ⇒ 非根)一律拒绝(`409 THEME_MEMBER_NOT_ROOT`);未命中即视为根。
|
||||
|
||||
**族谱页已有 `from` 高亮参数**,因此「从主题页直接跳到某一代节点」将来属于 **UI 层**能力,**不需要**模型支持「成员是第几代」这类字段。**留白**:主题内的「跳到某一代节点」高亮本轮不做(§3.10.9)。
|
||||
|
||||
#### 3.10.3 一个作品可以属于多个主题(跨主题多归属)
|
||||
|
||||
| 判断 | 结论 | 理由 | 代价 |
|
||||
| --- | --- | --- | --- |
|
||||
| 同一根能否同时属于多个主题 | **可以**,这是预期行为 | 主题是**运营叙事**而不是分类学唯一归属:「平台精选」「双人合作」完全可能同时收录同一个根。`member_id` 的确定性主键只保证「**同一主题内**同一根不重复」,跨主题重复是设计的一部分 | ① 运营改作品时要意识到它可能出现在多个主题里(本轮不做「该作品属于 N 个主题」的运营侧提示);② 作品详情必须返回**多值** `themes`(§3.10.7),不是一个可选单值 |
|
||||
|
||||
**本轮实现**:多归属由 `(theme_id, root_game_id)` 的复合语义自然承载,不需要额外结构。
|
||||
|
||||
#### 3.10.4 与血缘树的关系:直接复用 `/games/{id}/lineage`
|
||||
|
||||
| 判断 | 结论 | 理由 | 代价 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主题页怎么拿树 | 主题页呈现**多棵树**(一个主题可含多个根),树数据**直接复用既有 `/games/{id}/lineage`** | 该端点已落地且已收敛全部口径(锚点 404、只出现公开未删节点、稳定排序、上限截断,见 §3.4 与 `lineage.rs`)。**不在主题侧重建任何树查询或树整形**,就没有第二套可见性/排序规则可漂移 | 前端要按成员根清单**逐个**请求树(N 次),存在 N+1 |
|
||||
| 主题详情返回什么 | **只返回成员根清单**(按 `sort_order` 稳定排序 + 每个根的既有公开摘要) | 服务端职责边界清晰:主题管「有哪些根、什么顺序」,树是 lineage 的职责。两个端点各自可独立演进(例如将来 lineage 加上限/分页,不需要动主题) | 响应里没有整棵树,客户端组装;**留白**:将来如确有必要,再提供一个**批量树端点**(§3.10.9) |
|
||||
|
||||
**本轮实现**:主题详情返回 `roots`(逐条为既有 `public_game_payload` 投影)。**留白**:批量树端点。
|
||||
|
||||
#### 3.10.5 可见性口径(与现有一致)
|
||||
|
||||
判定写成**纯函数**(`module-game-distribution`,不碰 `ReducerContext`),与 `game_distribution_collection_visible` / `lineage_anchor_readable` 同一层,理由:可见性是最容易漂移的规则,必须能脱离真库单测。
|
||||
|
||||
| 对象 | 可见条件 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 主题 | `status == published` | 公开侧不出现草稿与归档;运营在后台仍可见全量 |
|
||||
| 成员(根) | 作品**公开未删**且**有当前公开版本** | 与公开目录 / 收藏同一份口径:未公开、已软删除、没有当前公开版本的作品不得出现在任何公开投影里 |
|
||||
|
||||
| 情形 | 响应 | 理由 | 代价 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主题不存在 | **404** | 与「锚点必须公开可读」同一条纪律:不用空壳回应,避免对外确认「这个 `theme_id` 存在」 | 客户端无法区分「主题不存在」与「主题存在但未发布」——这正是目的 |
|
||||
| 主题未发布(`draft` / `archived`) | **404**(同上) | 同上;不区分「存在但未发布」 | 运营预览需要后台接口(本轮后台 UI 不做) |
|
||||
| 主题已发布、但可见成员为空 | **200 + 空成员列表** | 主题是**运营实体**,不是「不可读锚点」:它的存在本身由运营发布行为对外确认,且不指向任何具体作品,空树不构成泄露。用 404 反而会让「刚建好还没挂作品」的正常运营状态被误判为故障 | 前端要显式写空态(不能把「空」当「错」) |
|
||||
| 成员作品下架 / 软删除 | 该成员在**投影里跳过**,**行不删除** | 与收藏同口径:重新公开后**自动回来**,不需要运营重新挂 | 主题页的成员数会随作品状态变化(这是真实投影,不是计数漂移);行与投影的差异必须靠测试钉住 |
|
||||
|
||||
**本轮实现**:`theme` 可见性纯函数、`member` 可见性纯函数、以及「投影跳过但不删行」的事务级断言。**留白**:主题级联下架 / 归档时的成员清理(不做——归档本身就是「不再公开」,成员行按同一口径保留)。
|
||||
|
||||
#### 3.10.6 公开接口(匿名可读、`Cache-Control: no-store`)
|
||||
|
||||
| 方法 / 路径 | 响应 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/game-distribution/themes?limit=&cursor=` | `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }` | 公开主题列表(只含 `published`)。**分页沿用刚落地的那套游标惯例**:默认 20、上限 50、超界截断;游标 `"{createdAtMicros}:{themeId}"`(`"{micros}:{id}"` 同一套惯例,解析只切第一个冒号);非法游标 **400**;`nextCursor` 为真实值,**末页为 `null`** |
|
||||
| `GET /api/game-distribution/themes/{theme_id}` | `{ themeId, name, summary, badge, memberCount, roots: [<既有公开作品投影>...] }` | 主题详情 + 成员根清单。`roots` 逐条用公开目录同一份 `public_game_payload` 投影;排序 `sort_order` 升序 + `member_id` 升序兜底(`sort_order` 允许重复,兜底键保证全序、翻页/渲染不重不漏)。顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。不存在 / 未发布 → 404(§3.10.5) |
|
||||
|
||||
补充口径:
|
||||
|
||||
- **`memberCount` = 当前公开可见成员数**(与 `roots` 长度一致,同一可见性纯函数)。理由:若回报「全部成员行数」,运营就能通过计数变化探测「存在草稿或被下架成员」,且它会与 `roots` 长度**对不上**,客户端无法解释两个数为什么不一致。代价:计数是逐主题现算的(不新增物化计数字段,与仓库「实时算、不加物化计数」的既有取舍一致)。
|
||||
- 排序与切页顺序定义为「**先按可见性过滤、再排序切页**」,游标位置落在已过滤序列上——否则每翻一页都会漏掉自己的若干条(与 `/my-collections` 同一条纪律)。
|
||||
- 全路径 `no-store`,挂在既有 `add_no_store_response_headers` 上;不新增第二套响应头中间件。
|
||||
|
||||
**本轮实现**:上述两条公开路由 + 可见性过滤 + 游标分页。
|
||||
|
||||
#### 3.10.7 作品详情增量:`themes: [{ themeId, name, badge }]`
|
||||
|
||||
`GET /games/{game_id}`(公开详情)在既有字段之上追加 `themes`:该作品所属的**公开**主题列表。
|
||||
|
||||
**实现口径(必须先取根,再按根反查)**:
|
||||
|
||||
1. 先取该作品的**根**——`game_distribution_lineage().game_id().find(&game_id).map(|l| l.root_game_id)`,**无血缘行则为自身**(与 `game_distribution_lineage_entries` 的根解析同一写法,复用既有函数而不是重写一遍);
|
||||
2. 再用 `by_game_distribution_theme_member_root_game_id` 按根取出成员行,联主题行,只保留 `status == published` 的主题。
|
||||
|
||||
| 决策 | 理由 | 代价 |
|
||||
| --- | --- | --- |
|
||||
| 按**根**反查而不是按作品自身 | 成员只允许根(§3.10.2)。若按 `game_id` 自身查成员,**第 N 代作品永远查不到任何主题**,而产品要求「作品详情能看到并跳到对应主题页」——第 N 代作品恰恰是最需要跳转的那一批 | 详情页多一次血缘点查 + 一次成员索引查(都在本进程的 SpacetimeDB 上,不走网络) |
|
||||
| 多值数组,不是可选单值 | 跨主题多归属(§3.10.3) | DTO 是数组,前端要按数组渲染 |
|
||||
| 排序用与公开列表**同一份**排序纯函数(创建时间倒序 + `theme_id` 升序兜底) | 同一份比较器复用,避免「列表一种顺序、详情另一种顺序」的第二套语义 | 数组顺序不承载运营意图(运营侧顺序是成员的 `sort_order`,不是主题之间的顺序) |
|
||||
|
||||
**本轮实现**:详情 payload 追加 `themes`;DTO parity 登记该构建器(证明这条路径确实会发出该键)。**留白**:主题入口在详情页的 UI 呈现(属前端里程碑)。
|
||||
|
||||
#### 3.10.8 后台接口与鉴权(后台 UI 不在本轮)
|
||||
|
||||
鉴权**照仓库既有 admin 体系**,不自造:路由挂进 `game_distribution::router` 的 `admin` 子路由族,统一 `route_layer(middleware::from_fn_with_state(state, require_admin_auth))`,handler 取 `Extension<AuthenticatedAdmin>`(`server-rs/crates/api-server/src/admin.rs:130`、`:2042`),路径前缀 `/admin/api/game-distribution/*`。写接口要求 `Idempotency-Key`(上限 128 字符,与既有常量同口径)。
|
||||
|
||||
| 方法 / 路径 | 语义 | 幂等 / 并发 | 错误 |
|
||||
| --- | --- | --- | --- |
|
||||
| `POST /admin/api/game-distribution/themes` | 创建主题(`name` 必填、非空;`summary` / `badge` / `sortOrder` / `status` 可选,缺省 `draft`) | 要求 `Idempotency-Key`;同键重放返回 `replayed: true`,同键不同请求 → 409 | 非法 `status` / 空 `name` → 400;未带 admin 会话 → 401 |
|
||||
| `PUT /admin/api/game-distribution/themes/{theme_id}` | 改名 / 简介 / 角标 / 排序 / 状态(整体覆盖这几个字段) | 要求 `Idempotency-Key`;`updated_at` 每次生效即刷新 | `theme_id` 不存在 → 404 `THEME_NOT_FOUND`;非法 `status` → 400 |
|
||||
| `GET /admin/api/game-distribution/themes?limit=&status=` | 后台列表,**含 `draft` / `archived`**;`status` 缺省 = 全量 | 只读 | 非法 `status` 过滤值 → 400(白名单 `all` / `draft` / `published` / `archived`,与后台作品列表同一处理方式) |
|
||||
| `PUT /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 增 / 改成员(幂等 upsert,body 可带 `sortOrder`) | 幂等由**确定性主键**保证(同主题同根只有一行),重复调用不产生第二行、结果相同 | 主题不存在 → 404 `THEME_NOT_FOUND`;作品不存在 → 404 `THEME_MEMBER_GAME_NOT_FOUND`;作品**非根**(有血缘行)→ 409 `THEME_MEMBER_NOT_ROOT` |
|
||||
| `DELETE /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 移除成员 | **不要求** `Idempotency-Key`:按确定性主键删除,重复调用结果相同(不存在也算成功),没有「重放 vs 新意图」需要区分(与 `DELETE …/collection` 同一取舍) | 主题不存在 → 404;成员不存在 → 200(幂等成功) |
|
||||
|
||||
说明与理由:
|
||||
|
||||
- **成员增删「都幂等」**:`PUT` 靠确定性主键、`DELETE` 靠「不存在也算成功」,两条都不需要「先查后写」的事务假设。
|
||||
- **不做「成员必须是公开作品」的前置校验**:运营完全可以先把主题和成员备好(作品还是草稿),等作品公开后成员**自动**出现在公开投影里;这与 §3.10.5「投影跳过但不删行」是同一条规则的正面用法。代价:运营不能靠 `PUT` 的返回判断成员当前是否对外可见——后台列表需要按可见性口径显示「当前不可见」标记(**本轮后台 UI 不做**)。
|
||||
- **`sort_order` 的定位**:本轮的**公开主题列表排序由游标惯例决定(`created_at` 倒序 + `theme_id` 升序兜底)**,`sort_order` 用于**主题内成员排序**与后台列表展示顺序。理由:游标格式的主键是 micros,若公开列表改按 `sort_order` 排,就需要 `(sort_order, theme_id)` 双键游标,与「沿用刚落地的那套游标惯例」冲突。若产品要求共创 Tab 按运营序展示,需要显式改游标格式(见 §7 与本轮里程碑的待确认项)。**留白**:主题级排序的运营拖拽 UI。
|
||||
- **错误码命名**:沿用 `FORK_*` 那套「`模块前缀_原因` 字符串 → 状态码」的映射写法(`theme_*` 纯函数产出,api-server 集中映射),不落到 axum 默认的 422 纯文本。
|
||||
|
||||
**本轮实现**:五条后台路由 + 权限校验 + 幂等 + 错误码映射 + 后台列表(含 `draft` / `archived`)。**留白**:后台管理页面。
|
||||
|
||||
#### 3.10.9 本轮留白(显式列出,不静默省略)
|
||||
|
||||
| 留白项 | 为什么不做 | 将来怎么接 |
|
||||
| --- | --- | --- |
|
||||
| 主题**封面图** | 要接平台素材链路(上传 / 存储 / 生命周期)与公开授权口径,是一份**独立小需求**,塞进本轮会把「主题模型」和「素材链路」两件事绑在一起 | 本轮用 `badge` + `name` 呈现;封面落地时在 `game_distribution_theme` 表尾追加对象键列(兼容追加),复用既有素材上传链路 |
|
||||
| **slug / URL 别名** | 公开 URL 直接用 `theme_id` 就够(`theme-*` 已经是稳定、可读的标识);引入 slug 就要处理唯一性约束、改名后的重定向与「谁是第二套身份」的问题 | 本轮不做 slug 与唯一性约束;如将来要做,slug 只是 `theme_id` 的**别名**,不得成为第二个主键 |
|
||||
| **埋点 / 统计** | 主题曝光、点击、成员转化的口径必须等 UI 上线后按真实数据决定,先埋一套随后要改的字段是负收益(与 §3.7 的取舍一致) | 等共创 Tab 上线,按数据决定事件字段;本轮只有 `tracing` 日志 |
|
||||
| 主题内「**跳到某一代节点**」高亮 | 属 UI 层能力,族谱页已有 `from` 参数(§3.10.2),后端不需要新字段;在没有主题页 UI 之前做它没有消费方 | 主题页 UI 落地时直接复用 `from` 参数 |
|
||||
| **批量树端点** | 本轮接受前端 N 次 `/games/{id}/lineage`(N = 成员数);主题成员量级小,且「不在主题侧重建树查询」的价值高于省这几次请求 | 若主题成员规模上升或出现首屏超时,再提供一个批量端点(服务端内部仍是同一份 `build_lineage_tree`,不新造规则) |
|
||||
|
||||
#### 3.10.10 迁移、契约与门禁
|
||||
|
||||
| 项 | 口径 |
|
||||
| --- | --- |
|
||||
| 迁移 | 两张**新表**,初始为空,无回填;`migration.rs` 的 `migration_tables!` 白名单登记新表,并在注释中写明「主题与成员是运营业务事实,随迁移导出/导入」 |
|
||||
| 生成绑定 | 必须重跑 `npm run spacetime:generate`,并跑 `npm run check:generated-bindings`、`npm run check:spacetime-schema` |
|
||||
| DTO | Rust 侧 `server-rs/crates/shared-contracts/src/game_distribution.rs`、TS 侧 `packages/shared/src/contracts/gameDistribution.ts`;新响应构建器登记进 `check:game-distribution-dto-parity` |
|
||||
| 数据契约表 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` **随表落地时**再补(`check:spacetime-schema` 会在表存在后要求) |
|
||||
| 不改的东西 | 不动 `/api/external/v1`(不新增 External OpenAPI 条目);不新增数据库访问通道;不新增第二套作品系统;游戏 Tab / 广场的公开目录**不变**(主题是额外维度,不改变作品的独立展示) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 为什么是 `game` 而不是 `version`
|
||||
|
||||
| 判断 | 结论 | 理由 |
|
||||
@@ -567,9 +760,9 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
|
||||
| [M2a 参考式改编(只试玩 + 素材 + 血缘,不含重新发布)](./project-memory/plans/【里程碑】成品包改造闭环-2026-10-03.md) | 复用已存在的成品包做受鉴权内容下发、AGC 下载/本地副本/素材提取、血缘写入与溯源展示、发布面板授权选择 | 真实 AGC 上完成「看别人的作品 → 本地试玩并参考 → 在**自建合规工程**里重做 → 发布 → 溯源正确」;**不承诺**成品包可直接重新发布(§3.5.1)。**成立前提**:产品接受对外口径从「复刻工程」改为「参考改编 / 素材复用」(§3.5.4 路线 A) |
|
||||
| [M2b 工程源包与源码级复刻](./project-memory/plans/【里程碑】作品工程源包与一键改造-2026-10-03.md) | version 追加工程包字段、AGC 打包/上传、`fork-source` 优先返回工程包、源码形态建项、**新增工程包下载/授权接口**(当前不存在,§3.5.1) | 真实 AGC 上完成「拿到源码 → 改核心逻辑 → 发布 → 溯源正确」;这是「一键复刻完整工程」的**唯一正解路径**(§3.5.4 路线 B) |
|
||||
| [M3 族谱与衍生列表](./project-memory/plans/【里程碑】创作族谱与衍生列表-2026-10-03.md) | `/games/lineage` 树页、`/games/{id}/lineage` 与 `/derived` 接口、`/games/mine` 被改编列表 | 族谱树可浏览、可跳转、父作品下架有降级展示 |
|
||||
| M4(可选,需产品决策) | 共创主题(平台命名的归组实体)与广场共创 Tab | 仅当采纳「平台命名主题」方案时才做 |
|
||||
| [M4 共创主题与作品树](./project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md) | 两张主题表(主题 + 成员,成员只允许根)、公开主题列表 / 详情接口、作品详情 `themes` 增量、后台主题写接口(后台 UI 不在本轮) | 共创 Tab 能按运营命名列出主题并进入多棵作品树;作品详情能看到并跳到所属主题;方案见 §3.10。**已拍板采纳「平台命名主题」方案**(§7 第 3 条),设计已定、实现排在下一轮 |
|
||||
|
||||
依赖:M2 依赖 M1 的数据模型;M3 依赖 M1;M4 依赖 M2 的实际使用数据。
|
||||
依赖:M2 依赖 M1 的数据模型;M3 依赖 M1;M4 依赖 M1(按根归组)与 M3(树查询复用),且设计上依赖 M2 的实际使用数据来验证共创意愿。
|
||||
|
||||
---
|
||||
|
||||
@@ -579,7 +772,7 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
|
||||
|
||||
1. **成品包路径的事实边界已改写**(原条目「成品包路径默认要做……建议:做」的前提已被否证):成品包路径能试玩、能提供素材,但**不能发布**(§3.5.1)。因此要拍板的不再是「要不要顺带做工程源包」,而是「是否接受对外口径从『一键复刻完整工程』改成『参考改编 / 素材复用』,并把工程源包作为唯一源码级路径」。建议:接受并改口径(§3.5.4 路线 A 先行、B 排后续)。
|
||||
2. **授权默认值**:本文档按需求描述取「默认禁止」;飞书文档评论中包仲航建议改为「默认允许 + 发布前合同勾选」。两者会改变默认曝光面与合规口径,需产品拍板(默认允许对生态更友好,但要处理存量作品的合法性回溯)。(M1 已按「默认禁止」实现,改动需连带迁移口径。)
|
||||
3. **是否引入「共创主题」实体**:包仲航建议「平台命名主题 → 作品树」,并明确作品之间不存在曝光挂靠。本文档的 M1–M3 按「作品间独立展示 + 详情页溯源 + 族谱按根归组」实现,不引入新实体;若采纳主题方案,需要额外的运营命名流程与表。
|
||||
3. **「共创主题」实体已拍板采纳(2026-10-06,原为待拍板)**:采纳包仲航提出的「平台命名主题 → 作品树」,且明确作品之间不存在曝光挂靠。设计见 §3.10,实施清单见 `docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md`(M4)。本轮只落设计(不写业务代码);**仍待产品确认的是主题级排序口径**——§3.10.8 按「沿用既有 micros 游标」把公开列表排在 `created_at` 上,`sort_order` 只用于主题内成员排序;若要求共创 Tab 按运营序展示,需要改游标格式(`(sort_order, theme_id)`)并同步前端。
|
||||
4. **收益分成**:需求文档要求「每一代均享有权益(署名 / 流量回馈 / 版权分成)」。署名本期做,流量回馈与分成本期不做(无账本、无算力成本口径,`docs/【技术方案】外部产品支付服务接入-2026-10-03.md:200` 明确人工结算)。
|
||||
5. **相似度反洗稿校验**:需求文档要求「低改动度复刻判定」。本期不做;本方案只保证来源声明真实、不可伪造。若要做,只能基于工程源包做结构化比对,属于独立议题。
|
||||
6. **「永久链上溯源」表述**:实现为平台持久化的不可变父子链,不上链。需确认该措辞是否可以调整。
|
||||
|
||||
@@ -1387,8 +1387,13 @@ fn public_game_detail_payload(
|
||||
/// 收藏(收录)的请求摘要:只绑定 `(user_id, game_id)`。
|
||||
///
|
||||
/// 服务端的收据键是 `(user_id, action, idempotency_key)`,而 `Idempotency-Key` 由客户端生成、
|
||||
/// 可能在不同作品之间复用。摘要里带上这对组合,才让「同一个 key 撞到不同作品 / 不同用户」被
|
||||
/// 判成同键不同请求(409),而不是把别人的收藏结果重放给当前请求者。
|
||||
/// 可能在不同作品之间复用。摘要里带上这对组合,才让「同一个 key 撞到**不同作品**」被判成同键
|
||||
/// 不同请求(409),而不是把另一个作品的收藏结果重放给当前请求者。
|
||||
///
|
||||
/// **同键换用户不会是 409、也不会互相命中**:收据键本身含 `user_id`,两个用户各带相同
|
||||
/// `Idempotency-Key` 时读到的是各自(不存在)的收据,各自按新请求处理并 200 成功——端到端
|
||||
/// 实测如此(`spacetime-module` 侧同步写明「不同用户 / 不同作品即使共用同一个
|
||||
/// `Idempotency-Key` 也不会互相命中」);本条注释曾把「换用户」一并错写成 409。
|
||||
fn collection_request_digest(user_id: &str, game_id: &str) -> Result<String, AppError> {
|
||||
Ok(compute_request_digest(
|
||||
&serde_json::to_vec(&(user_id, game_id)).map_err(|error| internal(error.to_string()))?,
|
||||
@@ -1398,7 +1403,9 @@ fn collection_request_digest(user_id: &str, game_id: &str) -> Result<String, App
|
||||
/// 收藏(收录)某作品:`PUT /games/{gameId}/collection`。
|
||||
///
|
||||
/// 幂等语义:必须带 `Idempotency-Key`。同键重放返回同一结果并带 `replayed = true`;
|
||||
/// 同键不同请求(换作品 / 换用户)是 409;重复收藏(不同键)不会产生第二行,仍然成功。
|
||||
/// 同键**换作品**是 409(摘要绑定 `(user_id, game_id)`);同键**换用户**是 200 各自成功
|
||||
/// (收据键 `(user_id, action, idempotency_key)` 按用户隔离,两个用户不会命中彼此的收据);
|
||||
/// 重复收藏(不同键)不会产生第二行,仍然成功。
|
||||
async fn collect_game(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
@@ -7886,7 +7893,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 幂等摘要必须绑定 `(user_id, game_id)`:不同作品 / 不同用户互不干扰。
|
||||
/// 幂等摘要必须绑定 `(user_id, game_id)`:不同作品 / 不同用户得到不同摘要——换作品会被
|
||||
/// 判成同键不同请求(409),换用户不会(收据键含 `user_id`,两个用户各走各自的新请求)。
|
||||
#[test]
|
||||
fn collection_request_digest_binds_user_and_game() {
|
||||
let baseline = collection_request_digest("usr_1", "game_a").expect("摘要可算");
|
||||
|
||||
@@ -170,7 +170,8 @@ mod tests {
|
||||
game_distribution_collection_id("usr_1", "game_a"),
|
||||
game_distribution_collection_id("usr_1", "game_a")
|
||||
);
|
||||
// 换用户或换作品必须换键,否则不同用户/作品的收藏会互相覆盖。
|
||||
// 主键派生必须对「用户」「作品」两个维度都敏感:漏掉任一维度,不同用户 / 不同作品的
|
||||
// 收藏就会共用同一行而互相覆盖(这是结构防重的前提,与幂等收据的键无关)。
|
||||
assert_ne!(
|
||||
game_distribution_collection_id("usr_1", "game_a"),
|
||||
game_distribution_collection_id("usr_2", "game_a")
|
||||
|
||||
Reference in New Issue
Block a user