From 4b1f24a8fc71336eb08a1db4fae01660cb721868 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 01:14:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=94=B6=E5=BD=95=EF=BC=88=E6=94=B6=E8=97=8F=EF=BC=89=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E4=B8=8E=E6=96=87=E6=A1=A3=E5=90=8C=E6=AD=A5=EF=BC=88?= =?UTF-8?q?=E5=9D=97=203/3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `shared-contracts`:新增 `GameDistributionCollectionState { collected, replayed: Option }` 承载 PUT/DELETE 共用形状(PUT 才发 `replayed`);既有 `GameDistributionGameSummary` 增 `collected: Option`(公开详情登录时才带)——该类型只被 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 --- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 10 ++++++ ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 21 +++++++++--- .../shared/src/contracts/gameDistribution.ts | 21 ++++++++++++ .../check-game-distribution-dto-parity.mjs | 16 +++++++++ .../shared-contracts/src/game_distribution.rs | 34 +++++++++++++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index cf234e5bf..59579b3ed 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 提供评价投影。 diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 4ab310ab5..e4b1f173a 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -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=` 的 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, | 方法 / 路径 | 说明 | | --- | --- | -| `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, #### 登录用户(Bearer,**不叠加**发布灰度) -灰度只针对「发布」,改编不应当被发布开关挡住:任何已登录用户都能改编已授权公开的作品。三个接口共用同一条校验(同一个函数,规则不分叉),错误码沿用 `FORK_*`: +灰度只针对「发布」,改编不应当被发布开关挡住:任何已登录用户都能改编已授权公开的作品。改编的三个取件接口共用同一条校验(同一个函数,规则不分叉),错误码沿用 `FORK_*`。本章末尾的收藏(收录)三条路由同样只要求 Bearer,但它们是**用户态**接口,与改编取件无关,不走 `FORK_*` 错误码: | 方法 / 路径 | 说明 | | --- | --- | @@ -328,6 +330,16 @@ pub(crate) project_bundle_sha256: Option, | `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` 小节。仍**不在**范围内的是「收藏数公开计数」「收藏动态流」「关注 / 粉丝」——它们各自需要独立的口径与表。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 3aa521519..a6e4452f7 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -288,6 +288,14 @@ export type GameDistributionGame = { forkCount?: number; /** 血缘摘要;根作品或旧数据为 null/缺省。 */ lineage?: GameDistributionLineage | null; + /** + * 当前登录用户是否已收藏(收录)该作品。 + * + * **只有公开详情在登录时返回**:匿名请求(含携带无效 token 按匿名处理)不带这个键, + * 也不发 `false`——`false` 会把「未登录」说成「没收藏」,客户端没法区分,会渲染出错误的 + * 收藏按钮态。公开目录不返回。值来自服务端真实投影,不是前端本地状态。 + */ + collected?: boolean; playCount: number; createdAt: string; }; @@ -448,3 +456,16 @@ export type GameDistributionForkDeclaration = { parentGameId: string; parentVersionId: string; }; + +/** + * 收藏(收录)写入 / 取消后的权威投影值。 + * + * `collected` 是**调用后**的收藏事实(服务端真实投影,不是前端本地状态);PUT 与 DELETE + * 共用一个形状,客户端不按「动词」推断结果。`replayed` 只在带幂等键的 PUT 上有意义: + * 命中同键收据时为 `true`(本次没产生新事实);DELETE 按确定性主键删除、天然幂等, + * 因此不返回该键。 + */ +export type GameDistributionCollectionState = { + collected: boolean; + replayed?: boolean; +}; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 5f64f76f3..28a0194b4 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -80,6 +80,8 @@ const PAIRS = [ 'GameDistributionSetForkAuthorizationRequest', 'GameDistributionSetForkAuthorizationRequest', ], + // 收藏(收录):PUT / DELETE 共用同一个权威投影值形状(`replayed` 只有 PUT 会发)。 + ['GameDistributionCollectionState', 'GameDistributionCollectionState'], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], @@ -129,8 +131,22 @@ const RESPONSE_BUILDERS = [ base: 'game_payload', mustEmit: ['currentVersion', 'ratingSummary', 'forkCount', 'lineage'], }, + { + // 公开详情在 `public_game_payload` 之上按**可选登录态**追加 `collected`;匿名请求不加键, + // 因此它不能进 `mustEmit`(那条路径本来就不发),但必须是一个真实构建器: + // 这层证据说明「登录时确实会发出 `collected`」,而不是只写在 DTO 注释里。 + fn: 'public_game_detail_payload', + ts: 'GameDistributionGame', + base: 'public_game_payload', + }, { fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' }, { fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' }, + { + // 「我的收藏」复用公开目录的分页字段与条目形状;游标恒为 null(收藏量受用户自身规模约束)。 + fn: 'my_collections_payload', + ts: 'GameDistributionListResponse', + mustEmit: ['games', 'nextCursor'], + }, ]; function camelCase(value) { diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 4885c2b06..2beb4cc6e 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -22,6 +22,17 @@ pub const GAME_DISTRIBUTION_MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; /// api-server 据此映射成 HTTP 409。 pub const GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT: &str = "VERSION_NUMBER_CONFLICT"; +/// 收藏(收录)被拒的统一文案:作品存在,但当前**不可收藏**(未公开 / 已软删除 / 没有当前公开版本)。 +/// +/// 两处共用同一个常量,避免只改一边就让 HTTP 语义漂移: +/// - `spacetime-module` 的 `ensure_game_distribution_collectable` 返回它; +/// - `api-server` 的 `map_spacetime_error` 靠其中的 `状态` 子串命中 **409** 分支。 +/// +/// 文案刻意**不含**「不存在」与「已被删除」:那两个子串会先命中 404 分支,等于用错误码对外 +/// 确认未公开作品的存在性(与软删除作品按「不存在」处理同一口径)。 +pub const GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT: &str = + "作品状态不允许收藏(未公开或已软删除)"; + /// 发布页免费生成简介与分类的输入。 /// /// 只传经过裁剪的项目摘要,不传本地绝对路径、聊天记录、凭据或完整 manifest。 @@ -204,6 +215,14 @@ pub struct GameDistributionGameSummary { /// 该作品的改编来源;母版为空。 #[serde(default, skip_serializing_if = "Option::is_none")] pub lineage: Option, + /// 当前登录用户是否已收藏(收录)该作品。 + /// + /// **只有公开详情在登录时下发**:匿名请求(含携带无效 token 按匿名处理)不发这个键, + /// 也不发 `false`——`false` 会把「未登录」说成「没收藏」,客户端无法区分,就会渲染出 + /// 错误的收藏按钮态。公开目录不填。值来自 `is_game_distribution_collected_and_return` + /// 的真实投影,不是客户端本地状态。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collected: Option, } /// 作品级共创(Fork)授权档位:只允许单向提升开放度。 @@ -337,6 +356,21 @@ pub struct GameDistributionForkSourceResponse { pub fork_source: GameDistributionForkSource, } +/// 收藏(收录)写入 / 取消后的权威投影值。 +/// +/// `collected` 是**调用后**的收藏事实(服务端真实投影,不是前端本地状态),PUT 与 DELETE +/// 共用一个形状,客户端不需要按「动词」推断结果。 +/// +/// `replayed` 只在带幂等键的「收藏」上有意义:`PUT` 命中同键收据时回 `true`(本次没有产生 +/// 新事实)。`DELETE` 按确定性主键删除、天然幂等,因此**不下发**该键(`None`)。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionCollectionState { + pub collected: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replayed: Option, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionListResponse {