feat(游戏共创): 收录(收藏)契约与文档同步(块 3/3)
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend 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 crates (pull_request) Has been cancelled
Project CI / Backend 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 2/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 / AI game creator shell Rust lane 1/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 / 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 2/2 (pull_request) Has been cancelled
- `shared-contracts`:新增 `GameDistributionCollectionState { collected, replayed: Option<bool> }` 承载 PUT/DELETE 共用形状(PUT 才发 `replayed`);既有 `GameDistributionGameSummary` 增 `collected: Option<bool>`(公开详情登录时才带)——该类型只被 api-server 测试与 `GameDistributionListResponse` 引用,加字段不影响任何现有发送方
- `packages/shared` TS 镜像同步(`{ collected: boolean; replayed?: boolean }` 与 `GameDistributionGame.collected?: boolean`),并按既有约定登记进 `scripts/check-game-distribution-dto-parity.mjs`(PAIRS + 手拼响应构建器);注意 `json!` 字面量里不插注释行(parity 脚本抓顶层键时不剥注释,这个坑今天踩过一次)
- 技术方案:`§2.4` 入口矩阵补「收藏按钮 / 我的收藏(登录)与我的作品页同行风格」;`§3.4` 接口表补三条路由(含 404/409 语义、PUT 要求幂等键而 DELETE 不要求及理由、`my-collections` 只含公开作品且不删行)与公开详情 `collected` 的可见性口径;`§7` 把「收录/收藏」从范围外移除并标注已纳入落地
- 数据契约表目录:新增 `game_distribution_collection` 小节(主键确定性、两个索引、为什么不删行、为什么不建唯一索引),供 `check:spacetime-schema` 守卫通过(94 tables)
- 门禁:DTO parity 0(**49 组** Rust/TS 一致,较本轮前 +7);`check:spacetime-schema` 0(94 tables,对照新 merge-base `51cb05f4`);`check:encoding` 0;`git diff --check` 0
This commit is contained in:
@@ -507,6 +507,16 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 溯源摘要与软删除:父(或根)作品被软删除时不再输出它的标题与父作者名——`game_distribution_lineage_snapshot` 把 `parent_title` / `root_title` 置为 `None`、`parent_author_name` 一并清空,`api-server` 原样发 `null`,前端降级为「原作品已不可用」;只保留不透明的 ID 与代际。理由:公开目录/详情既已下线被删作品,若还能从别人的溯源卡里读到它的标题与作者,删除语义就被血缘绕过。表结构同 `migration.rs` 的迁移导入导出白名单(血缘是业务事实,随迁移导入导出)。
|
||||
- 迁移:新表初始为空,不需要回填;根作品的「0 代」由「无血缘行」表达。`migration_tables!` 已登记该表。
|
||||
|
||||
### `game_distribution_collection`
|
||||
|
||||
- Rust 结构体:`GameDistributionCollection`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-06 新增。私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供用户态投影。
|
||||
- 用途:登录用户对公开作品的**收藏(收录)**事实——真实服务端投影,不是前端本地状态(既有发行合同禁止虚构收藏状态)。字段:`collection_id: String`(主键)、`user_id`、`game_id`、`created_at: Timestamp`。
|
||||
- 防重:主键是确定性构造的 `{user_id}:{game_id}`(`module_game_distribution::game_distribution_collection_id`,与 `profile_save_archive.archive_id` 同一写法),同一 (用户, 作品) 在结构上不可能出现第二行——去重由主键约束本身承担,而不是「事务里先查后写」(那种写法只在单写者假设下成立,分片 / 并发时两个事务都可能先查到「不存在」再各写一行)。因此**不需要**额外的 `(user_id, game_id)` 唯一索引。
|
||||
- 索引:`by_game_distribution_collection_user_id`(「我的收藏」按用户读取)、`by_game_distribution_collection_game_id`(按作品维度读取)。
|
||||
- 写入时机:`set_game_distribution_collection_and_return`(`PUT /api/game-distribution/games/{gameId}/collection`,要求 `Idempotency-Key`,命中同键收据回 `replayed = true`;主键已存在则跳过写入但仍算成功)与 `unset_game_distribution_collection_and_return`(`DELETE` 同路径,按确定性主键删除,不存在也算成功,天然幂等因此不要求幂等键)。收藏的前置失败关闭:作品不存在 → 404,未公开 / 已软删除 / 没有当前公开版本 → 409(文案 `作品状态不允许收藏(未公开或已软删除)`,`shared_contracts::game_distribution::GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT` 与 api-server 的 `状态` 子串映射共用同一常量)。取消**不**要求作品仍公开(否则下架后会留下用户清理不掉的脏行)。
|
||||
- 读取口径:`list_game_distribution_collections_and_return` 按 `user_id` 索引取行、按 `created_at` 倒序(同刻按 `game_id` 升序)稳定排序,逐条用**既有** `public_game_distribution_snapshot` 投影,只返回当前公开可读的作品(`module_game_distribution::game_distribution_collection_visible`)。下架 / 软删除的行在**投影时被跳过但不删除**,作品重新公开后自动回到列表。公开详情的 `collected` 由 `is_game_distribution_collected_and_return` 单独查询,不改动既有公开快照契约(`api-server` 在登录时把它并入公开详情负载,匿名不发该键)。
|
||||
- 迁移:随迁移导入导出(用户态事实),作品下架不删除行。`migration_tables!` 已登记该表。
|
||||
|
||||
### `game_distribution_review`
|
||||
|
||||
- Rust 结构体:`GameDistributionReview`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供评价投影。
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
| fork 计数 | **不存在**(`remixCount` 是 `#[cfg(any())]` 退役契约里的死字段,零消费方) | `shared-contracts/src/runtime.rs:922,950` |
|
||||
| 作者署名 | 字段存在但创建时写死 `None`,公开页兜底「创作者」 | `api-server/.../game_distribution.rs:1001-1003`、`:2390` |
|
||||
| 作者主页 / 关注 / 粉丝 | **不存在**(无任何社交关系表) | `migration.rs:149-200` 无相关表 |
|
||||
| 收藏 / 收录 | **不存在**,且文档明确禁止虚构收藏状态 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md:74` |
|
||||
| 收藏 / 收录 | **已落地**(2026-10-06):`game_distribution_collection` 表 + `PUT/DELETE /api/game-distribution/games/{gameId}/collection`、`GET /api/game-distribution/my-collections`、公开详情 `collected` 字段,是**服务端真实投影**而非前端本地状态(`server-rs/crates/spacetime-module/src/game_distribution.rs`、`server-rs/crates/api-server/src/modules/game_distribution.rs`) | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md:74` 的「不要虚构收藏状态」条款照旧成立 |
|
||||
| 播放次数 | 本分支上 `play_count` 仍**从无递增路径**却被前端展示;PR #565 已新增游玩计数上报 procedure,合并后以 master 结果为准 | `game_distribution.rs:1759`(仅初值);`GameDetailPage.tsx:292` |
|
||||
| 收益 / 分成 | **不存在**;`PuzzleAuthorIncentiveClaim` 有枚举无写入方 | `module-runtime/src/domain.rs:1147` |
|
||||
| 从包建项(客户端) | **存在且完整**:下载→SHA-256 校验→解压(拒绝绝对路径/`..`/盘符/反斜杠/符号链接,条目 ≤4096、单文件 ≤256 MiB)→建项→失败删半成品 | `template_library.rs`、`:815-850`、`:240-342` |
|
||||
@@ -82,7 +82,7 @@
|
||||
- 不做相似度反洗稿校验(无判定标准;本方案只保证**来源声明不可伪造**)。
|
||||
- 不做上链;「永久溯源」实现为**不可变的父子链 + 平台持久化事实**。
|
||||
- 不做跨作品类型 Fork(现役只有一种作品:游戏分发)。
|
||||
- 不做收藏 / 关注 / 粉丝。
|
||||
- 不做关注 / 粉丝。收藏(收录)原先也在这个「不做」列表里,但**已纳入并落地**(2026-10-06,见 §2.4 / §3.4):它是服务端真实用户态投影(`game_distribution_collection`),不是前端本地状态,因此仍满足「不要虚构收藏状态」这条既有发行合同。
|
||||
|
||||
### 2.3 授权模式(作者侧,作品级)
|
||||
|
||||
@@ -129,6 +129,8 @@ stateDiagram-v2
|
||||
| `/games/mine` 每张作品卡 | 新增「共创授权」三态设置(仅允许提升,终态 `full` 时只读);新增「被改编 N」入口,弹层列出直接子代(标题 / 作者 / 代际 / 状态);提升授权后若当前公开版本没有工程源包,行内提示「上传工程源码以支持源码级改造」(上传在桌面端客户端完成,网页端只做引导) |
|
||||
| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」 |
|
||||
| AGC 客户端 | ① 详情页「改造这个作品」唤起 AGC(唤起方式**待拍板**,候选见 §7 第 11 条;`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`,形状与广场一致(封面 / 标题 / 作者 / 游玩数),点击进详情。列表只含**当前公开可读**的作品;已下架作品的收藏行保留,作品重新公开后自动回来(不需要用户重新收藏) |
|
||||
|
||||
**后台**
|
||||
|
||||
@@ -306,7 +308,7 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
|
||||
| 方法 / 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段 |
|
||||
| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段。**另追加 `collected`(仅登录用户,2026-10-06)**:登录已认证时返回 `true` / `false`(真实投影,值来自 `is_game_distribution_collected_and_return`);**匿名请求不返回该字段**(也不发 `false`——`false` 会把「未登录」说成「没收藏」)。该字段随请求者变化,所以这条路径必须 `no-store`、不得有任何共享缓存 |
|
||||
| `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status }`,按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点 |
|
||||
| `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 |
|
||||
|
||||
@@ -320,7 +322,7 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
|
||||
#### 登录用户(Bearer,**不叠加**发布灰度)
|
||||
|
||||
灰度只针对「发布」,改编不应当被发布开关挡住:任何已登录用户都能改编已授权公开的作品。三个接口共用同一条校验(同一个函数,规则不分叉),错误码沿用 `FORK_*`:
|
||||
灰度只针对「发布」,改编不应当被发布开关挡住:任何已登录用户都能改编已授权公开的作品。改编的三个取件接口共用同一条校验(同一个函数,规则不分叉),错误码沿用 `FORK_*`。本章末尾的收藏(收录)三条路由同样只要求 Bearer,但它们是**用户态**接口,与改编取件无关,不走 `FORK_*` 错误码:
|
||||
|
||||
| 方法 / 路径 | 说明 |
|
||||
| --- | --- |
|
||||
@@ -328,6 +330,16 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
| `GET /games/{gameId}/fork-source/project`(**M2b 已实现**) | 取件本体(源码级):与 `/fork-source/package` 同一套鉴权与校验(同一个函数),仅当该作品当前公开版本**确有工程包**时才服务,否则 `409 FORK_SOURCE_NOT_AVAILABLE`(**失败关闭**,绝不悄悄回落成品包)。响应头与成品包同形,文件名为 `{gameId}-{versionId}-project.zip`;不做引用归一化 / `RELEASE_STORAGE_BOOTSTRAP` 注入(下发原始源码包),读取走同一条 OSS 读路径但**缓存键含资产维度**(对象键不同,两份资产不串味) |
|
||||
| `GET /games/{gameId}/fork-source/package`(**M2a 已实现**) | 取件本体:直接回该版本发行包 ZIP 的字节,头为 `Content-Type: application/zip`、`Content-Length`、`Content-Disposition: attachment; filename="{gameId}-{versionId}.zip"`、`Cache-Control: no-store`。数据复用现役发行网关的读包路径(整包进内存 + 进程内缓存,上限 4 条 / 256 MiB),**不做**网关那套引用归一化与 `RELEASE_STORAGE_BOOTSTRAP` 注入——下发的是原始构建产物,客户端要按摘要校验后离线解压,任何改写都会让摘要对不上 |
|
||||
|
||||
**收藏(收录)三条路由(2026-10-06 新增,Bearer,`no-store`)**
|
||||
|
||||
收藏是被收藏作品的**服务端真实投影**(表 `game_distribution_collection`),不是前端本地状态;`PUT` / `DELETE` 共用同一个响应形状 `{ collected }`,客户端不按动词推断结果。
|
||||
|
||||
| 方法 / 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `PUT /games/{gameId}/collection` | 收藏。要求 `Idempotency-Key`;请求摘要绑定 `(userId, gameId)`。**幂等**:同键重放返回 `replayed: true`(本次没有新事实),同键不同请求(换作品 / 换用户)→ **409**。**防重靠结构**:主键是确定性构造的 `{userId}:{gameId}`,同一 (用户, 作品) 不可能出现第二行(重复收藏只留一行、仍算成功),不靠「事务里先查后写」,也不额外建唯一索引。错误码:作品不存在 → **404**;未公开 / 已软删除 / 没有当前公开版本 → **409**(失败关闭,文案 `作品状态不允许收藏(未公开或已软删除)`,不含「不存在」/「已被删除」,不用错误码泄露未公开作品的存在性)。响应 `{ collected: true, replayed: boolean }` |
|
||||
| `DELETE /games/{gameId}/collection` | 取消收藏。**不要求 `Idempotency-Key`**:按确定性主键删除,重复调用结果完全相同(不存在也算成功),没有「重放 vs 新意图」需要区分。**也不要求作品仍公开 / 未被删除**:下架后拒绝取消只会给用户留下清理不掉的脏行。响应 `{ collected: false }`(**不带** `replayed`) |
|
||||
| `GET /my-collections` | 「我的收藏(收录)」列表:逐条用公开目录同一份 `public_game_payload` 投影,形状与公开目录一致 `{ games, nextCursor }`(`nextCursor` 恒为 `null`)。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 |
|
||||
|
||||
#### 后台(admin)
|
||||
|
||||
| 方法 / 路径 | 说明 |
|
||||
@@ -577,3 +589,4 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
|
||||
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` 小节。仍**不在**范围内的是「收藏数公开计数」「收藏动态流」「关注 / 粉丝」——它们各自需要独立的口径与表。
|
||||
|
||||
Reference in New Issue
Block a user