diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b42d671b8..c41fa0f49 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -585,6 +585,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。 - 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction` 由 `api-server` 按 `status` 派生,不落表。 - 工程源包(M2b,2026-10-05):版本行末尾追加三个可空/零默认列 `project_bundle_object_key: Option`、`project_bundle_bytes: u64`(`0` = 未上传)、`project_bundle_sha256: Option`,保存作者**可选**上传的工程源码包(与发行包并列的第二份私有资产)。三列由 `confirm_game_distribution_project_bundle_and_return` 一次性写入(`upload_project_bundle` 幂等动作),只在版本尚未公开(`awaiting_upload` / `upload_failed`)时接受;同一版本已确认工程包后换内容按冲突拒绝,同内容按幂等重放。作品授权非 `forbidden` 时,工程包与发行包走同一道取件鉴权与校验,**平台不设独立的「源码可见性」开关**——作者不传即退化产物级改编。对象键只在服务端使用(投影里只回 `projectBundleBytes` / `projectBundleSha256`),且键名与发行包不同(`.project.zip`),避免同 (作品, 版本) 的两份资产互相覆盖或串缓存。 +- 核心改动说明(2026-10-06):版本行末尾再追加可空列 `change_summary: Option`(`#[default(None::)]`),保存**衍生作品**发布新版本时必填的「本次核心改动说明」(0 代母版恒为 `NULL`——传了也不校验、不落库)。它是**版本级**事实:同一作品的不同代版本各有各的说明,随版本冻结不可改。由 `create_game_distribution_version_and_return` 在**写库前**校验(失败关闭,不在库里留下没有说明的衍生版本),判据是**血缘行**(不接受客户端自称派生);长度按 trim 后的**字符**数计、区间 `20–500`(常量 `GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS` / `_MAX_CHARS`),违规走 `FORK_CHANGE_SUMMARY_REQUIRED` / `FORK_CHANGE_SUMMARY_INVALID`(均 400)。公开投影只多下发 `changeSummary`(衍生必有、母版 `null`),不含任何私有字段。 ### 后台游戏管理读模型与恢复动作(2026-09-23) diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index dd76ff3b3..def270d8a 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -308,7 +308,7 @@ pub(crate) project_bundle_sha256: Option, | 方法 / 路径 | 说明 | | --- | --- | -| `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`、不得有任何共享缓存。**再追加 `themes: [{ themeId, name, badge }]`(2026-10-06 增量,共创主题)**:该作品所属的**公开**主题(只含 `status == published`);**匿名与登录都发、无主题时恒发空数组**(与「仅登录才发」的 `collected` 不同——没有主题与没登录因此不会被混成同一种缺键)。口径是「先取该作品的**根**、再按根反查公开主题」,所以**第 N 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取) | +| `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`、不得有任何共享缓存。**再追加 `themes: [{ themeId, name, badge }]`(2026-10-06 增量,共创主题)**:该作品所属的**公开**主题(只含 `status == published`);**匿名与登录都发、无主题时恒发空数组**(与「仅登录才发」的 `collected` 不同——没有主题与没登录因此不会被混成同一种缺键)。口径是「先取该作品的**根**、再按根反查公开主题」,所以**第 N 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取)**。**版本摘要 `currentVersion` 再追加 `changeSummary`(2026-10-06,衍生作品发布必填项)**:该版本相对**改编来源**作品的核心改动说明——衍生作品必有(`20–500` 个字符,详见下方作者路由的 `POST /versions` 行),0 代母版为 **`null`**(发键不发值:客户端不必靠缺键猜),因此详情页与族谱溯源能逐代展示「这一版改了什么」。它不含任何私有字段(没有对象键 / 素材 id),匿名可读,也不需要按查看者变化 | | `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status, coverObjectKey }`(`coverObjectKey` 为该节点作品**当前生效的封面对象键**,与公开目录 / 详情**同源同口径**、都来自游戏行 `cover_object_key`,**无封面时为 `null`**;只发对象键,不带 `coverAssetId` 等私有 id),按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点 | | `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 | | `GET /api/game-distribution/themes?limit=&cursor=`(新,2026-10-06) | 公开共创主题列表。**匿名可读 + `no-store`**,只含 `status == published` 的主题。响应 `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`;`memberCount` = 该主题**真实可见成员数**(不截断、不套响应体积上限,也不是成员行总数)。它与详情 `roots` 的长度在可见成员超过 50 时**故意不相等**——「展示上限」不该污染「这个主题有多少棵树」,判定口径仍是同一份。分页沿用 `/my-collections` 那套游标惯例:`limit` 缺省 **20**、上限 **50**、超界**截断**(客户端拿到一个完整页,而不是需要重试的错误);`cursor` 形如 `"{createdAtMicros}:{themeId}"`(解析只切第一个冒号);**非法游标 → 400 `THEME_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页);`nextCursor` 为真实值,**末页为 `null`**。排序 `created_at` 倒序 + `themeId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」 | @@ -321,6 +321,7 @@ pub(crate) project_bundle_sha256: Option, | `PUT /games/{gameId}/fork-authorization`(新) | body `{ expectedForkAuthorization, forkAuthorization }` + `Idempotency-Key`;只允许提升;返回最新 `forkAuthorization` 与 `replayed` | | `PUT /versions/{versionId}/project-bundle`(**M2b 已实现**) | `application/octet-stream` 整包一次上传(≤ 200 MiB);另有分片族 `GET …/project-bundle/upload-state`、`PUT …/project-bundle/chunk`(偏移头 `x-genarrative-upload-offset`)、`POST …/project-bundle/complete`(服务端独立跑工程包 zip 门禁 + 算摘要 + 确认)、`POST …/project-bundle/reset`(丢弃未确认的暂存对象)。前置:调用者是该版本作者;版本处于 `awaiting_upload` / `upload_failed`;该版本**尚无**已确认的工程包(换内容 → 409)。对象键由服务端派生(`…/{version_id}.project.zip`),不接受客户端指定。**两个按实现为准的细节(端到端实测,A5/A5b/A6)**:① **非作者上传返回 `404` 而不是 `403`**——api-server 用 `load_owner_version_or_404` 把 owner 不匹配按「版本不存在」处理,与发行包上行族同口径,既不会泄露「这个版本存在但不属于你」,响应里也不含对象键;② **阶段门先判「已存在」再判版本档位**——`ensure_project_bundle_uploadable` 先看 `project_bundle_bytes > 0`(→ 409 `PROJECT_BUNDLE_ALREADY_EXISTS`,因为确认工程包不驱动版本状态机,已确认的版本可能仍停在 `awaiting_upload`),再看 `status`(→ 409 `PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED`);因此「已公开**且已有**工程包」返回的是 `ALREADY_EXISTS`,只有「已公开**但还没有**工程包」才落到 `UPLOAD_NOT_ALLOWED` | | `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId`;再追加可选 `forkAuthorization`(`forbidden` / `nonCommercial` / `full`,**缺省禁止共创**,非法取值整请求 400 + 平台信封),使作者**上架时**即可选择授权档位,不必事后提升 | +| `POST /games/{gameId}/versions`(**既有,请求增量**,2026-10-06「衍生作品发布必填核心改动说明」) | 追加可选 `changeSummary`:「本次核心改动说明」,发布设置页的「二创信息区」随包提交。**谁必填**:**衍生作品**(该 `gameId` **有血缘行**,即带过 `fork` 声明、代际 ≥ 1)**必须**提供;**0 代母版忽略该字段**——不校验、不落库(写 `NULL`)而不是「可选」,否则一个与血缘无关的字段会在母版上分叉出第二套语义。判定取**血缘行**本身、不接受客户端自称派生(`fork` 声明只在创建作品时生效一次),后续每一版都按血缘行判。**长度口径**:先 `trim` 再按**字符**数计(`chars().count()`,不是字节数——中文一字 3 字节,按字节算会让中文作者只能写 1/3 的内容),区间 **20–500**(常量 `GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS` / `_MAX_CHARS` 单点持有)。下限 20 的理由:这一项要回答「这一版相对来源作品改了什么」,比作品标题(40)短、比主题角标(16)长,是一句话能说清的最小规模;只写「改了」这类敷衍串等于没有说明,而展示层要把它当「这一代的差异」读。上限 500 的理由:它是发布流程里的一个文本框、不是正文(作品详细介绍 2000 才是正文),500 个字符足够写清「加了什么玩法、换了什么美术、修了什么」,同时保证详情页 / 族谱里相邻代际不会被挤出首屏。**失败关闭**且两条失败各有稳定码(都 400,客户端可只补说明或改短重试,不必按 409 去刷新后重放):缺失 / trim 后为空 → **`FORK_CHANGE_SUMMARY_REQUIRED`**;越界 → **`FORK_CHANGE_SUMMARY_INVALID`**。**存储**:`game_distribution_version` 表尾追加可空列 `change_summary`——存**版本级**(同一作品的不同代各说各的改动),随版本冻结不可改。**投影**:公开版本摘要发 `changeSummary`(衍生必有、母版 `null`),见上方公开详情行。**幂等**:该字段属于请求体,因此参与请求摘要——同 `Idempotency-Key` 换说明会被按「同键不同请求」拒绝(409);`serde` 上缺省**不序列化 `null`**,省略该键的旧客户端请求与升级前逐字节一致,同键重放不会被误判成新请求 | #### 登录用户(Bearer,**不叠加**发布灰度) diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 31479a2c6..1f1e31ab5 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -111,6 +111,13 @@ export type GameDistributionVersionSummary = { sha256: string; publishedAt: string; controls: string[]; + /** + * 本版相对改编来源作品的核心改动说明(衍生作品必有;母版为 `null`)。 + * + * 版本级字段:详情页与族谱溯源逐版渲染「每一代的差异」。公开投影里它不含任何私有字段 + * (没有对象键 / 素材 id),因此匿名可读;母版发 `null` 而不是省略该键。 + */ + changeSummary?: string | null; }; /** @@ -614,6 +621,15 @@ export type GameDistributionCreateVersionRequest = { packageFileCount: number; packageEntryPath: 'index.html'; gameMetadata: GameDistributionCreateGameRequest; + /** + * 「本次核心改动说明」:**衍生作品**(该作品有改编来源)发布新版本时必填, + * trim 后按**字符**计 20–500 个字符;0 代母版忽略该字段(不校验、不落库)。 + * + * 缺失 → 400 `FORK_CHANGE_SUMMARY_REQUIRED`;长度越界 → 400 `FORK_CHANGE_SUMMARY_INVALID`。 + * 它参与请求摘要:同一个 `Idempotency-Key` 换了说明会被按「同键不同请求」拒绝(409), + * 省略该键的旧客户端请求与升级前逐字节一致(缺省不序列化 `null`)。 + */ + changeSummary?: string | null; }; /** diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 5d0a76f81..a8e8a57a1 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -181,7 +181,14 @@ const RESPONSE_BUILDERS = [ mustEmit: ['themes'], }, { fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' }, - { fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' }, + { + // 公开版本摘要:`changeSummary` 在 TS 里可省略(旧响应没有这个键),但这条路径**必须发出** + // ——衍生作品上它是详情页 / 族谱溯源展示「每一代差异」的唯一来源,缺了会静默退化成「无差异」; + // 母版发 `null`(不是省略),「没有来源作品」与「服务端忘了发」必须可分辨。 + fn: 'version_summary_payload', + ts: 'GameDistributionVersionSummary', + mustEmit: ['changeSummary'], + }, { // 「我的收藏」复用公开目录的条目形状,但有自己的分页契约(默认 20 / 上限 50 / 真实游标): // `nextCursor` 是这条路径**必须**发出的键(最后一页发 `null`,不是省略)。 diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 0b3ec305c..bb901bf99 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -2923,6 +2923,8 @@ async fn create_version( idempotency_key, request_digest, now_micros: now, + // 原样透传(未 trim、未校验):规则只在写入事务里判定一次,HTTP 层不做第二套判据。 + change_summary: payload.change_summary.clone(), }, ) .await @@ -5577,6 +5579,13 @@ fn lineage_payload(lineage: &spacetime_client::GameDistributionLineageRecord) -> }) } +/// 公开版本摘要。 +/// +/// `changeSummary` 是本版相对来源作品的核心改动说明:衍生作品必有、母版为 `null`。它是公开投影的 +/// 一部分(详情页 / 族谱溯源展示「每一代的差异」),不含任何私有字段,因此不随查看者变化。 +/// +/// 注意:`scripts/check-game-distribution-dto-parity.mjs` 按「键前面必须是 `{` 或 `,`」抓顶层键, +/// 且不剥注释,因此 `json!` 字面量里不要插注释行——否则该键会被判成缺失。 fn version_summary_payload(version: &GameDistributionVersionRecord) -> Value { json!({ "id": version.version_id, @@ -5585,6 +5594,7 @@ fn version_summary_payload(version: &GameDistributionVersionRecord) -> Value { "sha256": version.package_sha256, "publishedAt": version.updated_at, "controls": [], + "changeSummary": version.change_summary, }) } @@ -5964,6 +5974,14 @@ fn map_spacetime_error(error: SpacetimeClientError) -> AppError { "FORK_AUTHORIZATION_UNKNOWN" => { (StatusCode::BAD_REQUEST, "FORK_AUTHORIZATION_UNKNOWN") } + // 共创发布(衍生作品必填的核心改动说明):两类失败都是**请求非法**(400)—— + // 客户端补一句说明或改短重试即可,不该按 409 去刷新状态后重放。 + "FORK_CHANGE_SUMMARY_REQUIRED" => { + (StatusCode::BAD_REQUEST, "FORK_CHANGE_SUMMARY_REQUIRED") + } + "FORK_CHANGE_SUMMARY_INVALID" => { + (StatusCode::BAD_REQUEST, "FORK_CHANGE_SUMMARY_INVALID") + } _ => (StatusCode::CONFLICT, "FORK_ERROR"), }; AppError::from_status(status) @@ -7022,6 +7040,8 @@ mod tests { project_bundle_object_key: None, project_bundle_bytes: 0, project_bundle_sha256: None, + // 这份夹具是母版版本:核心改动说明恒为 `None`。 + change_summary: None, }; let payload = version_detail_payload(&version, &game); @@ -7113,6 +7133,7 @@ mod tests { project_bundle_object_key: None, project_bundle_bytes: 0, project_bundle_sha256: None, + change_summary: None, }; let public = game_payload(&game); @@ -7885,6 +7906,7 @@ mod tests { project_bundle_object_key: None, project_bundle_bytes, project_bundle_sha256: None, + change_summary: None, } } @@ -9538,6 +9560,15 @@ mod tests { project_bundle_object_key: None, project_bundle_bytes: 0, project_bundle_sha256: None, + change_summary: None, + } + } + + /// 衍生作品的公开版本夹具:只比上者多一条核心改动说明(母版恒为 `None`)。 + fn public_derivative_version_record(change_summary: &str) -> GameDistributionVersionRecord { + GameDistributionVersionRecord { + change_summary: Some(change_summary.to_string()), + ..public_version_record(None, None) } } @@ -9769,6 +9800,106 @@ mod tests { } } + /// **可达性(Fork 一族)**:模块产出的每一条 `FORK_*` 文案都必须映射出契约里的码与状态码。 + /// + /// 这一条是 2026-10-06「衍生作品发布必填核心改动说明」补的:新增的 + /// `FORK_CHANGE_SUMMARY_REQUIRED` / `_INVALID` 若只写进映射表而没写进模块真正产出的文案 + /// (或反过来),都会在这里红。用的是**模块真实产出**的 `Display` 文案,而不是 + /// `format!("{code}: 具体原因")` 这类合成串——合成串只能证明「映射表里有这一行」,证明不了 + /// 「模块真的会产出这个前缀」,上一轮 `THEME_INVALID_CURSOR` 就是这么变成死代码的。 + /// + /// 断言 `error.code()` 与期望码相等同时钉住了「登记进映射表」:未登记的码会落到 + /// `FORK_ERROR` 兜底分支(409),与期望的 400/403/404 不等。 + #[test] + fn fork_error_codes_are_reachable_from_module_messages() { + use module_game_distribution::GameDistributionError as DomainError; + + let cases = [ + ( + DomainError::ForkAuthorizationUnknown { + value: "x".to_string(), + }, + StatusCode::BAD_REQUEST, + "FORK_AUTHORIZATION_UNKNOWN", + ), + ( + DomainError::ForkAuthorizationDowngradeNotAllowed { + current: "full".to_string(), + requested: "forbidden".to_string(), + }, + StatusCode::CONFLICT, + "FORK_AUTHORIZATION_DOWNGRADE_NOT_ALLOWED", + ), + ( + DomainError::ForkNotAuthorized, + StatusCode::FORBIDDEN, + "FORK_NOT_AUTHORIZED", + ), + ( + DomainError::ForkSourceNotFound, + StatusCode::NOT_FOUND, + "FORK_SOURCE_NOT_FOUND", + ), + ( + DomainError::ForkSourceNotAvailable, + StatusCode::CONFLICT, + "FORK_SOURCE_NOT_AVAILABLE", + ), + ( + DomainError::ForkSourceVersionMismatch, + StatusCode::CONFLICT, + "FORK_SOURCE_VERSION_MISMATCH", + ), + ( + DomainError::ForkDeclarationOnExistingGame, + StatusCode::CONFLICT, + "FORK_DECLARATION_ON_EXISTING_GAME", + ), + ( + DomainError::ForkChangeSummaryRequired, + StatusCode::BAD_REQUEST, + "FORK_CHANGE_SUMMARY_REQUIRED", + ), + ( + DomainError::ForkChangeSummaryInvalid, + StatusCode::BAD_REQUEST, + "FORK_CHANGE_SUMMARY_INVALID", + ), + ]; + for (error, expected_status, expected_code) in cases { + let message = error.to_string(); + assert!( + message.starts_with(&format!("{expected_code}:")), + "模块产出的文案必须以契约码 + 冒号开头(否则 HTTP 面不可达):{message}" + ); + let mapped = map_spacetime_error(SpacetimeClientError::Procedure(message.clone())); + assert_eq!(mapped.status_code(), expected_status, "{message}"); + assert_eq!( + mapped.code(), + expected_code, + "稳定码必须原样透传到 HTTP 面(未登记的码会退化成 FORK_ERROR/409):{message}" + ); + } + + // 新增的两个码必须是**模块导出的常量**本身,而不是在本文件里再抄一遍字面量: + // 常量改名或改字时,这条断言与上面的枚举一起把两侧同时钉住。 + assert_eq!( + module_game_distribution::GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE, + "FORK_CHANGE_SUMMARY_REQUIRED" + ); + assert_eq!( + module_game_distribution::GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE, + "FORK_CHANGE_SUMMARY_INVALID" + ); + + // 未登记的 Fork 码会落到兜底分支(409 `FORK_ERROR`):这正是上面那条断言有意义的原因。 + let unknown = map_spacetime_error(SpacetimeClientError::Procedure( + "FORK_BRAND_NEW: 未登记".to_string(), + )); + assert_eq!(unknown.status_code(), StatusCode::CONFLICT); + assert_eq!(unknown.code(), "FORK_ERROR"); + } + /// 主题列表响应形状:`themes` / `nextCursor` 一定发出;游标是真实值,最后一页为 `null`; /// 单条只有契约里那五个键(`memberCount` 是可见成员数,不是成员行总数)。 #[test] @@ -10535,6 +10666,8 @@ mod tests { fork_authorization: GameDistributionForkAuthorization::Forbidden, fork: None, }, + // 这份夹具不关心核心改动说明(派生判定与它无关的用例都留空)。 + change_summary: None, } } @@ -10631,6 +10764,98 @@ mod tests { assert_eq!(free["currentVersion"]["entryUrl"], json!("/games/game_1/")); } + /// 公开版本负载必须带出「本次核心改动说明」:详情页与族谱溯源据此展示每一代的差异。 + /// + /// 两条路径都要过:`version_summary_payload`(列表/详情的版本摘要本体)与 + /// `public_game_payload` 里那份 `currentVersion`(详情页用的就是它)。母版发 `null` 而不是 + /// 缺键——「这一代没有来源作品」与「服务端忘了发这个字段」是两回事,客户端不该靠缺键猜。 + #[test] + fn public_version_payload_carries_change_summary_for_derivatives_and_null_for_masters() { + let summary_text = "把跳台改成三段,并重画全部背景与角色立绘"; + + let master_summary = + version_summary_payload(&public_version_record(Some("/games/game_1/"), None)); + assert_eq!(master_summary["changeSummary"], Value::Null); + assert_eq!(master_summary["id"], json!("version_1")); + + let derivative_summary = + version_summary_payload(&public_derivative_version_record(summary_text)); + assert_eq!(derivative_summary["changeSummary"], json!(summary_text)); + + // 详情页路径:`currentVersion` 用的是同一个构建器,因此母版与衍生都按同一口径发出。 + let detail = public_game_payload( + public_game_record( + paid_game_record("user_author", 0), + public_derivative_version_record(summary_text), + ), + &GameViewerContext::default(), + ); + assert_eq!( + detail["currentVersion"]["changeSummary"], + json!(summary_text) + ); + let master_detail = public_game_payload( + public_game_record( + paid_game_record("user_author", 0), + public_version_record(Some("/games/game_1/"), None), + ), + &GameViewerContext::default(), + ); + assert_eq!( + master_detail["currentVersion"]["changeSummary"], + Value::Null + ); + + // 契约层也要能读:公开负载反序列化回 shared-contracts 的版本摘要不留残差。 + let parsed: shared_contracts::game_distribution::GameDistributionVersionSummary = + serde_json::from_value(detail["currentVersion"].clone()) + .expect("公开版本摘要应能按契约解析"); + assert_eq!(parsed.change_summary.as_deref(), Some(summary_text)); + } + + /// 核心改动说明是**版本级**字段:同一衍生作品的不同版本各带各的说明;公开投影只多出这一个 + /// 键,不夹带对象键 / 素材 id 之类的私有字段。 + #[test] + fn change_summary_is_version_scoped_and_never_leaks_private_keys() { + let first = public_derivative_version_record("第一版:加了二段跳"); + let second = GameDistributionVersionRecord { + version_id: "version_2".to_string(), + version_number: 2, + change_summary: Some("第二版:修掉了落地判定".to_string()), + ..public_derivative_version_record("第一版:加了二段跳") + }; + let first_payload = version_summary_payload(&first); + let second_payload = version_summary_payload(&second); + assert_eq!(first_payload["changeSummary"], json!("第一版:加了二段跳")); + assert_eq!( + second_payload["changeSummary"], + json!("第二版:修掉了落地判定") + ); + assert_eq!(first_payload["version"], json!("1")); + assert_eq!(second_payload["version"], json!("2")); + + // 键集合逐字钉住:多出任何一个私有字段(对象键 / 素材 id / 审核理由)都会被这条抓住。 + let mut keys = first_payload + .as_object() + .expect("公开版本摘要必须是对象") + .keys() + .cloned() + .collect::>(); + keys.sort(); + assert_eq!( + keys, + vec![ + "changeSummary".to_string(), + "controls".to_string(), + "entryUrl".to_string(), + "id".to_string(), + "publishedAt".to_string(), + "sha256".to_string(), + "version".to_string(), + ] + ); + } + /// 审核列表与版本详情都读版本冻结价;历史版本缺 `priceMudPoints` 一律按免费(0), /// 不回退游戏行价,与审核通过后实际生效的价格口径一致。 #[test] diff --git a/server-rs/crates/module-game-distribution/src/domain.rs b/server-rs/crates/module-game-distribution/src/domain.rs index 34787115e..64cbecc09 100644 --- a/server-rs/crates/module-game-distribution/src/domain.rs +++ b/server-rs/crates/module-game-distribution/src/domain.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::errors::{GameDistributionError, GameDistributionFieldError}; +use crate::errors::{ + GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE, + GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE, GameDistributionError, + GameDistributionFieldError, +}; use shared_kernel::{build_prefixed_seed_id, normalize_required_string}; pub const GAME_ID_PREFIX: &str = "game_"; @@ -315,6 +319,56 @@ pub fn fork_lineage_visible_identity( ) } +/// 「本次核心改动说明」的最小长度(**字符数**,不是字节数)。 +/// +/// 取 20 的理由:这一项要回答「这一版相对来源作品改了什么」,比作品标题(40)短、比角标(16)长—— +/// 一句话能说清的最小规模。下限不是防呆摆设:只写「改了」这类 2–3 个字的敷衍串等于没有说明, +/// 而展示层(详情页 / 族谱溯源)要把它当作「每一代的差异」读,写不足就是缺失。 +pub const GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS: usize = 20; + +/// 「本次核心改动说明」的长度上限(**字符数**,不是字节数)。 +/// +/// 取 500 的理由:它是发布流程里的一个文本框,不是正文——作品详细介绍(2000)才是正文。 +/// 500 个字符足够写清「加了什么玩法、换了什么美术、修了什么」,同时保证详情页上一代的差异 +/// 不会把族谱节点挤出首屏。计数按 `chars()` 而不是字节:中文一个字 3 字节,按字节算会让中文 +/// 作者只能写三分之一的内容。 +pub const GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS: usize = 500; + +/// 衍生作品发布新版本时必填的「本次核心改动说明」校验。 +/// +/// 返回**违规对应的错误码**(`None` = 合法)。返回码而不是布尔,是为了让「必填」与「超限」这两条 +/// 语义不同的失败在 HTTP 面各有稳定码(`FORK_CHANGE_SUMMARY_REQUIRED` / `_INVALID`),而不是被 +/// 压成同一句文案让客户端只能猜。 +/// +/// 规则本身只写在这一处(唯一判据来源是写入事务 `create_game_distribution_version_tx`;api-server +/// 不做 HTTP 层预校验),因此「HTTP 挡住的」与「落库挡住的」永远是同一套判据: +/// - **母版(`is_derivative == false`)恒为 `None`**:0 代作品的「基于哪个作品创作」是空的, +/// 「本次改动」这一项对它没有意义。这是**忽略**而不是**可选**——母版即使传了说明也 +/// 不校验、不落库(写侧把它写成 `NULL`),否则一个和血缘无关的字段会在母版上分叉出第二套语义。 +/// - 衍生作品缺失(`None` 或 trim 后为空)→ 必填码;trim 后字符数不在 +/// `MIN..=MAX` → 非法码。 +/// +/// 判定前统一 `trim`:一串空白与缺失同罪(调用方忘掉 trim 也不会把空白说明写进库), +/// 计数也按 trim 后的字符数算,首尾空白不占额度。 +pub fn game_distribution_change_summary_violation( + value: Option<&str>, + is_derivative: bool, +) -> Option<&'static str> { + if !is_derivative { + return None; + } + let trimmed = value.unwrap_or_default().trim(); + if trimmed.is_empty() { + return Some(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE); + } + if !(GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS..=GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS) + .contains(&trimmed.chars().count()) + { + return Some(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE); + } + None +} + pub(crate) fn normalize_id(value: impl AsRef) -> Option { normalize_required_string(value) } @@ -507,6 +561,14 @@ mod fork_authorization_tests { GameDistributionError::ForkDeclarationOnExistingGame, "FORK_DECLARATION_ON_EXISTING_GAME", ), + ( + GameDistributionError::ForkChangeSummaryRequired, + crate::GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE, + ), + ( + GameDistributionError::ForkChangeSummaryInvalid, + crate::GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE, + ), ]; for (error, code) in cases { assert!( @@ -514,6 +576,12 @@ mod fork_authorization_tests { "expected {code} prefix, got {}", error ); + // 前缀后面必须真的跟冒号:api-server 用 `split(':').next()` 取码,缺了冒号就会把整句 + // 文案当成码,落到未登记分支变成 `FORK_ERROR`(且状态码退化成通用 409)。 + assert!( + error.to_string().starts_with(&format!("{code}:")), + "expected {code} to be followed by ':', got {error}" + ); } } @@ -599,3 +667,105 @@ mod fork_authorization_tests { assert!(empty_name.to_string().contains("主题名不能为空")); } } + +/// 「本次核心改动说明」的规则:范围两端 + trim 口径 + 母版忽略,逐条钉死。 +/// +/// 这些用例只碰纯函数,不需要 `ReducerContext`——规则本身在事务外可验证,事务那边只留一条 +/// 「是否真的调用它、且只对衍生作品调用」的结构断言。 +#[cfg(test)] +mod change_summary_tests { + use super::*; + + fn violation(value: Option<&str>, is_derivative: bool) -> Option<&'static str> { + game_distribution_change_summary_violation(value, is_derivative) + } + + /// 衍生作品:`None`、空串与「只有空白」都是「没写」,回必填码。 + #[test] + fn derivative_without_a_summary_is_required() { + for value in [None, Some(""), Some(" "), Some("\n\t \n")] { + assert_eq!( + violation(value, true), + Some(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE), + "value={value:?}" + ); + } + } + + /// 衍生作品:不足下限(19 个字符)回非法码——按**字符**计数,19 个汉字同样是 19。 + #[test] + fn derivative_below_the_floor_is_invalid() { + let short_ascii = "a".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS - 1); + let short_cjk = "改".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS - 1); + let borderline = "改".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS); + assert_eq!( + violation(Some(&short_ascii), true), + Some(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE) + ); + assert_eq!( + violation(Some(&short_cjk), true), + Some(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE), + "必须按字符计数:19 个汉字是 19 不是 57" + ); + assert_eq!(violation(Some(&borderline), true), None, "下限本身合法"); + } + + /// 衍生作品:超过上限(501 个字符)回非法码;上限本身合法(含中文按字符算的对照)。 + #[test] + fn derivative_above_the_ceiling_is_invalid() { + let over_ascii = "a".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS + 1); + let over_cjk = "改".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS + 1); + let at_ceiling = "改".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS); + assert_eq!( + violation(Some(&over_ascii), true), + Some(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE) + ); + assert_eq!( + violation(Some(&over_cjk), true), + Some(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE), + "必须按字符计数:501 个汉字是 501 不是 1503(按字节算会误判成超限 3 倍)" + ); + assert_eq!(violation(Some(&at_ceiling), true), None, "上限本身合法"); + } + + /// 区间内合法;首尾空白不占额度(trim 后计数),因此「刚好 20 个字 + 一堆空格」仍然合法。 + #[test] + fn derivative_inside_the_range_passes_after_trimming() { + let text = "把跳台改成三段,并重画全部背景与角色立绘"; + assert_eq!( + text.chars().count(), + GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS + ); + assert_eq!(violation(Some(text), true), None); + let padded = format!( + "\n {} \t", + "改".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS) + ); + assert_eq!( + violation(Some(&padded), true), + None, + "trim 后正好 500 个字符必须合法" + ); + } + + /// 母版(0 代):无论传什么(缺失 / 超短 / 超长 / 正常)都恒为 `None`—— + /// 「基于哪个作品创作」对母版不存在,说明这一项对它没有意义,因此是**忽略**而不是**可选**。 + #[test] + fn master_ignores_the_summary_entirely() { + let overlong = "a".repeat(GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS + 1); + let cases = [ + None, + Some(""), + Some("改了"), + Some(overlong.as_str()), + Some("把跳台改成三段,并重画全部背景"), + ]; + for value in cases { + assert_eq!( + violation(value, false), + None, + "母版必须忽略该字段:value={value:?}" + ); + } + } +} diff --git a/server-rs/crates/module-game-distribution/src/errors.rs b/server-rs/crates/module-game-distribution/src/errors.rs index faca85fdf..c643e9ba7 100644 --- a/server-rs/crates/module-game-distribution/src/errors.rs +++ b/server-rs/crates/module-game-distribution/src/errors.rs @@ -1,6 +1,12 @@ use std::{error::Error, fmt}; -use crate::{GameVersionStatus, GameVisibility, domain::MAX_GAME_PRICE_MUD_POINTS}; +use crate::{ + GameVersionStatus, GameVisibility, + domain::{ + GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS, GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS, + MAX_GAME_PRICE_MUD_POINTS, + }, +}; /// 共创主题错误码的字面量。 /// @@ -23,6 +29,15 @@ pub const GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE: &str = "THEME_MEMBER_NOT /// 作为成员的目标作品不存在。 pub const GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE: &str = "THEME_MEMBER_GAME_NOT_FOUND"; +/// 共创(Fork)发布:衍生作品的版本缺少「本次核心改动说明」。 +/// +/// 与 `FORK_*` 同族,因此 api-server 的 `FORK_` 映射分支**先于**「不存在 / 状态 / 不匹配」这些 +/// 子串分支命中它;两个码都要逐条登记进映射表,否则会退化成默认的 `FORK_ERROR`(409)。 +pub const GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE: &str = + "FORK_CHANGE_SUMMARY_REQUIRED"; +/// 共创(Fork)发布:「本次核心改动说明」长度不在允许区间内。 +pub const GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE: &str = "FORK_CHANGE_SUMMARY_INVALID"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GameDistributionFieldError { MissingGameId, @@ -135,6 +150,15 @@ pub enum GameDistributionError { ThemeMemberGameNotFound { game_id: String, }, + /// 共创(Fork)发布:衍生作品的新版本必须提供「本次核心改动说明」。 + /// + /// 只在**衍生**作品上可达(0 代母版忽略该字段,见 + /// `game_distribution_change_summary_violation`);缺失与「trim 后为空」在这条码上合一。 + ForkChangeSummaryRequired, + /// 共创(Fork)发布:「本次核心改动说明」长度不在允许区间内。 + /// + /// 上限与下限都只从 `domain` 的常量取(文案插值它们),改区间不会漏改文案。 + ForkChangeSummaryInvalid, /// 免费游戏不需要购买;客户端应直接进入游玩。 FreeGameNotPurchasable, /// 作者本人无需购买:合同约定作者免购买即可游玩自己的付费作品,禁止产生扣费。 @@ -223,6 +247,22 @@ impl fmt::Display for GameDistributionError { GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE ) } + // 共创(Fork)发布:两类失败各有稳定码(必填 / 超限),不计入同一条码——客户端可以 + // 只补一句说明重试,而不是把整份发布资料重填。码串取上面的常量,避免「常量改了、 + // Display 没改」这种只有真栈才会发现的漂移。长度文案插值 `domain` 的上下限常量, + // 改区间时前后端口径不会分叉。 + Self::ForkChangeSummaryRequired => write!( + formatter, + "{}: 衍生作品发布新版本必须填写本次核心改动说明", + GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE + ), + Self::ForkChangeSummaryInvalid => write!( + formatter, + "{}: 本次核心改动说明需为 {} 到 {} 个字符(按字符计,忽略首尾空白)", + GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE, + GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS, + GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS + ), Self::FreeGameNotPurchasable => formatter.write_str("免费游戏不需要购买"), Self::OwnerCannotPurchase => formatter.write_str("作者本人无需购买"), Self::PriceChanged => formatter.write_str("价格已变化,请刷新后重试"), @@ -261,4 +301,23 @@ mod tests { format!("priceMudPoints 必须是 0 到 {MAX_GAME_PRICE_MUD_POINTS} 之间的整数") ); } + + /// 核心改动说明的两个码同样以常量前缀开头(api-server 按 `"{CODE}: "` 前缀映射 400), + /// 且超限文案插值 `domain` 的上下限常量——改区间不会漏改文案。 + #[test] + fn change_summary_messages_start_with_their_codes_and_interpolate_the_range() { + let required = GameDistributionError::ForkChangeSummaryRequired.to_string(); + assert!(required.starts_with(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE)); + assert!(required.contains("核心改动说明")); + + let invalid = GameDistributionError::ForkChangeSummaryInvalid.to_string(); + assert!(invalid.starts_with(GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE)); + assert!(invalid.contains(&GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS.to_string())); + assert!(invalid.contains(&GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS.to_string())); + assert_eq!( + GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS, 20, + "下限是契约的一部分(技术方案 §3.4),改它必须同步文档与前端文案" + ); + assert_eq!(GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS, 500); + } } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 261abba3e..f23ea0a31 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -34,16 +34,19 @@ pub use commands::{ }; pub use domain::{ FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL, - ForkAuthorization, GAME_PURCHASE_ID_PREFIX, GameDistributionAction, GamePurchaseDecision, - GamePurchaseSnapshot, GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility, - MAX_GAME_PRICE_MUD_POINTS, can_serve_as_fork_source, compute_request_digest, - counts_as_public_derivative, fork_lineage_visible_identity, game_purchase_id, generate_game_id, + ForkAuthorization, GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS, + GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS, GAME_PURCHASE_ID_PREFIX, GameDistributionAction, + GamePurchaseDecision, GamePurchaseSnapshot, GameSnapshot, GameVersionSnapshot, + GameVersionStatus, GameVisibility, MAX_GAME_PRICE_MUD_POINTS, can_serve_as_fork_source, + compute_request_digest, counts_as_public_derivative, fork_lineage_visible_identity, + game_distribution_change_summary_violation, game_purchase_id, generate_game_id, generate_game_version_id, next_generation, normalize_game_price_mud_points, resolve_game_purchase_decision, resolve_version_number, }; pub use errors::{ - GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE, GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE, - GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE, + GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_INVALID_CODE, + GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE, GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE, + GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE, GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE, GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE, GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE, GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE, GameDistributionError, GameDistributionFieldError, diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index f699bf766..919c40eac 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -193,6 +193,12 @@ pub struct GameDistributionVersionSummary { pub sha256: String, pub published_at: String, pub controls: Vec, + /// 本版相对改编来源作品的核心改动说明(衍生作品必有;母版为 `None`)。 + /// + /// 版本级字段:同一作品的不同代版本各有各的说明,详情页与族谱溯源据此展示「每一代的差异」。 + /// 公开投影与作者侧读到的是同一列,它不含任何私有字段(不含对象键 / 素材 id)。 + #[serde(default)] + pub change_summary: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -612,6 +618,18 @@ pub struct GameDistributionCreateVersionRequest { pub package_file_count: u32, pub package_entry_path: String, pub game_metadata: GameDistributionCreateGameRequest, + /// 「本次核心改动说明」:衍生作品发布新版本时**必填**(按字符计、trim 后 20–500 个字符), + /// 0 代母版**忽略**该字段(不校验、不落库)。 + /// + /// 派生作品判定取**血缘行**(该作品有父即衍生),不接受客户端自称;缺失 / 超长一律失败关闭, + /// 错误码 `FORK_CHANGE_SUMMARY_REQUIRED` / `FORK_CHANGE_SUMMARY_INVALID`(均 400)。 + /// + /// 写成 `#[serde(default, skip_serializing_if = "Option::is_none")]`:与 `version_number` 同一 + /// 纪律——api-server 的幂等摘要对整个请求体取摘要,**未携带该字段的旧客户端请求必须逐字节 + /// 与升级前一致**,否则升级后同 `Idempotency-Key` 的重放会被误判成「同键不同请求」而 409。 + /// 反过来,真的换了说明又复用同一个键,就该按冲突拒绝(说明是这次请求的一部分)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change_summary: Option, } /// 作者编辑游戏级展示资料的请求体。 @@ -832,6 +850,94 @@ mod tests { assert_eq!(serialized["versionNumber"], 5); } + #[test] + fn create_version_request_change_summary_is_optional_and_never_serialized_as_null() { + let request = |extra: serde_json::Value| { + let mut payload = serde_json::json!({ + "packageSha256": "a".repeat(64), + "packageBytes": 10, + "packageFileCount": 1, + "packageEntryPath": "index.html", + "gameMetadata": { + "title": "游戏", + "summary": "简介", + "category": "益智", + "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, + "inputModes": ["keyboard"], + "orientation": "responsive", + }, + }); + if let serde_json::Value::Object(fields) = &mut payload { + for (key, value) in extra.as_object().expect("extra 必须是对象") { + fields.insert(key.clone(), value.clone()); + } + } + payload + }; + + // 旧客户端不带该字段:解析成 `None`,且序列化时**省略该键**——api-server 对请求体取幂等 + // 摘要,多出一个 `"changeSummary":null` 会让升级后的重放被误判成不同请求(409)。 + let legacy: GameDistributionCreateVersionRequest = + serde_json::from_value(request(serde_json::json!({}))).expect("旧请求应可解析"); + assert_eq!(legacy.change_summary, None); + let serialized = serde_json::to_value(&legacy).expect("应可序列化"); + assert!( + serialized.get("changeSummary").is_none(), + "缺省字段必须整个省略而不是发 null:{serialized}" + ); + + let with_summary: GameDistributionCreateVersionRequest = serde_json::from_value(request( + serde_json::json!({ "changeSummary": "把跳台改成三段,并重画全部背景" }), + )) + .expect("带说明的请求应可解析"); + assert_eq!( + with_summary.change_summary.as_deref(), + Some("把跳台改成三段,并重画全部背景") + ); + let serialized = serde_json::to_value(&with_summary).expect("应可序列化"); + assert_eq!( + serialized["changeSummary"], + serde_json::json!("把跳台改成三段,并重画全部背景") + ); + } + + #[test] + fn version_summary_change_summary_tolerates_missing_and_null() { + // 公开版本负载在母版上发 `null`;旧响应则可能整个缺键——两种都必须能解析。 + for payload in [ + serde_json::json!({ + "id": "version_1", + "version": "1", + "entryUrl": null, + "sha256": "c".repeat(64), + "publishedAt": "2026-10-06T00:00:00Z", + "controls": [], + }), + serde_json::json!({ + "id": "version_1", + "version": "1", + "entryUrl": null, + "sha256": "c".repeat(64), + "publishedAt": "2026-10-06T00:00:00Z", + "controls": [], + "changeSummary": null, + }), + serde_json::json!({ + "id": "version_1", + "version": "1", + "entryUrl": null, + "sha256": "c".repeat(64), + "publishedAt": "2026-10-06T00:00:00Z", + "controls": [], + "changeSummary": "把跳台改成三段,并重画全部背景", + }), + ] { + let summary: GameDistributionVersionSummary = + serde_json::from_value(payload).expect("版本摘要应可解析"); + assert_eq!(summary.id, "version_1"); + } + } + #[test] fn update_game_metadata_request_round_trips_publication_revision() { let payload: GameDistributionUpdateGameMetadataRequest = diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index b3bbce213..201437086 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -222,6 +222,10 @@ pub struct GameDistributionVersionRecord { pub project_bundle_bytes: u64, /// 工程源包整包 SHA-256;未上传为 `None`。 pub project_bundle_sha256: Option, + /// 本版的核心改动说明(衍生作品必有,母版为 `None`)。 + /// + /// 不含私有字段,可直接进入公开投影(`changeSummary`);展示层按它渲染「每一代的差异」。 + pub change_summary: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -518,6 +522,7 @@ fn map_version( project_bundle_bytes: value.project_bundle_bytes, // 生成绑定把 `project_bundle_sha256` 折成 `project_bundle_sha_256`(同 `package_sha_256`)。 project_bundle_sha256: value.project_bundle_sha_256, + change_summary: value.change_summary, } } diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index af29fe2b7..15fa89aad 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -127,6 +127,11 @@ pub struct GameDistributionCreateVersionRecordInput { pub idempotency_key: String, pub request_digest: String, pub now_micros: i64, + /// 本版相对改编来源的核心改动说明;衍生作品必填、母版忽略。 + /// + /// api-server **原样透传**(未 trim、未校验):规则只在事务里判定一次, + /// 避免 HTTP 层与落库层各写一套阈值。 + pub change_summary: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1338,6 +1343,7 @@ impl SpacetimeClient { idempotency_key: input.idempotency_key, request_digest: input.request_digest, now_micros: input.now_micros, + change_summary: input.change_summary, }; self.call_after_connect( "create_game_distribution_version", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs index e2529a295..651d2546d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_version_input_type.rs @@ -20,6 +20,7 @@ pub struct GameDistributionCreateVersionInput { pub idempotency_key: String, pub request_digest: String, pub now_micros: i64, + pub change_summary: Option, } impl __sdk::InModule for GameDistributionCreateVersionInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs index 222cc7cc7..3f6e84c22 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_snapshot_type.rs @@ -25,6 +25,7 @@ pub struct GameDistributionVersionSnapshot { pub project_bundle_object_key: Option, pub project_bundle_bytes: u64, pub project_bundle_sha_256: Option, + pub change_summary: Option, } impl __sdk::InModule for GameDistributionVersionSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs index 7fbe9a32d..fe4195db2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_version_type.rs @@ -35,6 +35,7 @@ pub struct GameDistributionVersion { pub project_bundle_object_key: Option, pub project_bundle_bytes: u64, pub project_bundle_sha_256: Option, + pub change_summary: Option, } impl __sdk::InModule for GameDistributionVersion { @@ -77,6 +78,7 @@ pub struct GameDistributionVersionCols { pub project_bundle_bytes: __sdk::__query_builder::Col, pub project_bundle_sha_256: __sdk::__query_builder::Col>, + pub change_summary: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for GameDistributionVersion { @@ -126,6 +128,7 @@ impl __sdk::__query_builder::HasCols for GameDistributionVersion { table_name, "project_bundle_sha_256", ), + change_summary: __sdk::__query_builder::Col::new(table_name, "change_summary"), } } } diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 47992f662..66725aca1 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -984,6 +984,15 @@ pub struct GameDistributionVersion { /// 工程源包整包 SHA-256;未上传为 `None`。与字节数成对写入,缺一即视为不可用。 #[default(None::)] pub(crate) project_bundle_sha256: Option, + /// 本版相对**改编来源**作品的核心改动说明(衍生作品必填,母版恒为 `NULL`)。 + /// + /// 挂在版本上而不是作品上:它回答的是「**这一版**改了什么」,同一作品的不同代版本各有各的 + /// 说明;详情页与族谱溯源逐版展示它,因此必须随版本冻结、不可在之后被覆盖。 + /// 写入前由事务 trim(首尾空白不占额度);母版即使传了也不校验、不落库(写侧写 `NULL`), + /// 避免一个与血缘无关的字段在母版上分叉出第二套语义。校验规则见 + /// `module_game_distribution::game_distribution_change_summary_violation`。 + #[default(None::)] + pub(crate) change_summary: Option, } /// 买断制购买记录。每账号每游戏最多一条,`(user_id, game_id)` 唯一性由购买事务强制 @@ -1183,6 +1192,11 @@ pub struct GameDistributionCreateVersionInput { pub idempotency_key: String, pub request_digest: String, pub now_micros: i64, + /// 本版相对改编来源的核心改动说明;由 api-server 原样透传,校验与 trim 全在事务里 + /// (`ensure_game_distribution_change_summary`)——HTTP 层不预校验,避免两套判据漂移。 + /// + /// 追加在末尾:本类型是 `SpacetimeType`,会被折进生成绑定的 procedure 入参。 + pub change_summary: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1612,6 +1626,11 @@ pub struct GameDistributionVersionSnapshot { pub project_bundle_bytes: u64, /// 工程源包整包 SHA-256;未上传为 `None`。 pub project_bundle_sha256: Option, + /// 本版的核心改动说明(衍生作品必有;母版恒为 `None`)。 + /// + /// api-server 的公开版本负载把它投影成 `changeSummary`(详情页 / 族谱溯源展示「每一代的差异」); + /// 它不含任何私有字段,因此可以直接进入公开投影。 + pub change_summary: Option, } /// 后台游戏管理页的版本历史条目:补上审核与公开时间,供运营判断下架与恢复影响。 @@ -3565,6 +3584,40 @@ fn resolve_game_distribution_version_number( } } +/// 规范化并校验「本次核心改动说明」;母版(`is_derivative == false`)恒回 `Ok(None)`。 +/// +/// 规则本身只有一份实现(`module_game_distribution::game_distribution_change_summary_violation`), +/// 这里只把「违规码」映射成领域错误类型的文案:HTTP 层按 `"{CODE}: "` 前缀映射状态码,因此码必须 +/// 原样透出,不能在这里自拼一句中文(那会退化成通用 `BAD_REQUEST`)。 +/// +/// 失败关闭:衍生作品缺说明或超限都直接 `Err`,不会被静默降级成「没有说明也照发」; +/// 合法值统一按 **trim 后**的形态落库,与计数口径(trim 后字符数)保持一致。 +fn ensure_game_distribution_change_summary( + change_summary: Option<&str>, + is_derivative: bool, +) -> Result, String> { + if !is_derivative { + // 母版:**忽略**而不是「可选」——传了也不校验、不落库(写 NULL)。否则一个与血缘无关的 + // 字段会在母版上分叉出第二套语义(母版有了说明,展示层就要判断该不该显示)。 + return Ok(None); + } + match module_game_distribution::game_distribution_change_summary_violation( + change_summary, + is_derivative, + ) { + Some(module_game_distribution::GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE) => Err( + module_game_distribution::GameDistributionError::ForkChangeSummaryRequired.to_string(), + ), + Some(_) => Err( + module_game_distribution::GameDistributionError::ForkChangeSummaryInvalid.to_string(), + ), + None => Ok(change_summary + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string)), + } +} + fn create_game_distribution_version_tx( ctx: &ReducerContext, input: GameDistributionCreateVersionInput, @@ -3628,6 +3681,20 @@ fn create_game_distribution_version_tx( return Err("版本 ID 已存在".to_string()); } + // 衍生作品(有血缘行)的新版本必须提供「本次核心改动说明」,母版忽略该字段。 + // + // 判据取**血缘行本身**,而不是请求里的改编声明:声明只在创建作品时生效一次,之后的每一版 + // 都按血缘行判定,两者不会出现「有血缘但按母版放过」的缺口。校验失败即整体失败关闭, + // 不会留下一个没有说明的衍生版本。 + let is_derivative = ctx + .db + .game_distribution_lineage() + .game_id() + .find(&game_id) + .is_some(); + let change_summary = + ensure_game_distribution_change_summary(input.change_summary.as_deref(), is_derivative)?; + let max_existing_version = ctx .db .game_distribution_version() @@ -3689,6 +3756,8 @@ fn create_game_distribution_version_tx( project_bundle_object_key: None, project_bundle_bytes: 0, project_bundle_sha256: None, + // 衍生作品必填、母版恒为 `None`(见上面的校验:母版传入也被忽略)。 + change_summary, }); insert_game_distribution_receipt( ctx, @@ -7104,6 +7173,7 @@ fn game_distribution_version_snapshot( project_bundle_object_key: version.project_bundle_object_key.clone(), project_bundle_bytes: version.project_bundle_bytes, project_bundle_sha256: version.project_bundle_sha256.clone(), + change_summary: version.change_summary.clone(), } } @@ -7362,6 +7432,161 @@ mod tests { panic!("{marker} 的函数体不闭合"); } + /// 版本表的 `change_summary` 必须留在**表尾**并带默认值。 + /// + /// schema guard 只允许「末尾追加 + 显式默认」:插在中间会让既有行的列序整体错位(不是迁移能补 + /// 的事),漏了默认值则旧行没有可填充的值。版本快照(procedure 出参)也必须带上它,否则 + /// 公开投影根本读不到这一列。 + #[test] + fn version_change_summary_stays_at_the_table_tail_with_a_default() { + let source = include_str!("game_distribution.rs"); + let table = function_body(source, "pub struct GameDistributionVersion {"); + let lines = table.lines().map(str::trim).collect::>(); + let field_at = lines + .iter() + .position(|line| line.starts_with("pub(crate) change_summary")) + .expect("版本表必须有 change_summary 字段"); + assert_eq!( + lines[field_at], "pub(crate) change_summary: Option,", + "字段形状必须与设计一致" + ); + assert_eq!( + lines[field_at - 1], + "#[default(None::)]", + "表尾追加的列必须带默认值,旧行才有可填的值" + ); + let last_field = lines + .iter() + .rev() + .find(|line| line.starts_with("pub(crate) ") && line.ends_with(',')) + .expect("版本表必须有字段"); + assert!( + last_field.starts_with("pub(crate) change_summary"), + "change_summary 必须是版本表的最后一个字段(只能末尾追加):{last_field}" + ); + + let snapshot = function_body(source, "pub struct GameDistributionVersionSnapshot {"); + assert!( + snapshot + .lines() + .any(|line| line.trim() == "pub change_summary: Option,"), + "版本快照必须带上 change_summary,否则公开投影读不到" + ); + } + + /// 创建版本事务:**是否衍生**取血缘行(不接受客户端自称),说明只对衍生作品校验。 + /// + /// 顺序同样是不变量:幂等重放先返回(重放不重复校验)、判定早于校验、校验早于写库。 + #[test] + fn create_version_tx_validates_change_summary_against_the_lineage_row() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn create_game_distribution_version_tx("); + for required in [ + "ensure_game_distribution_change_summary(input.change_summary.as_deref(), is_derivative)", + "game_distribution_lineage()", + "change_summary,", + ] { + assert!(body.contains(required), "创建版本事务缺少:{required}"); + } + let receipt_at = body + .find("find_game_distribution_receipt") + .expect("幂等收据必须存在"); + let lineage_at = body + .find("game_distribution_lineage()") + .expect("必须按血缘行判定是否衍生"); + let ensure_at = body + .find("ensure_game_distribution_change_summary(") + .expect("必须调用说明校验"); + let insert_at = body + .find(".insert(GameDistributionVersion {") + .expect("必须插入版本行"); + assert!( + receipt_at < lineage_at, + "幂等重放必须先返回:重放不该因为「这次没带说明」而被拒" + ); + assert!( + lineage_at < ensure_at, + "先按血缘行算出 is_derivative、再校验说明" + ); + assert!( + ensure_at < insert_at, + "校验必须在写库之前:失败关闭,不能留下没有说明的衍生版本" + ); + } + + /// 说明校验助手只做「违规码 → 领域错误文案」的映射,规则本身仍由模块纯函数单点持有: + /// 两个码分别映射到两个不同变体,HTTP 面才分得清「没填」与「超限」。 + #[test] + fn change_summary_helper_maps_each_code_to_its_own_error_and_trims() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn ensure_game_distribution_change_summary("); + for required in [ + "module_game_distribution::game_distribution_change_summary_violation", + "GAME_DISTRIBUTION_FORK_CHANGE_SUMMARY_REQUIRED_CODE", + "GameDistributionError::ForkChangeSummaryRequired", + "GameDistributionError::ForkChangeSummaryInvalid", + // 合法值按 trim 后的形态落库,与计数口径(trim 后字符数)一致。 + "str::trim", + ] { + assert!(body.contains(required), "说明校验助手缺少:{required}"); + } + assert!( + !body.contains("20") && !body.contains("500"), + "阈值不得在事务里再抄一遍:区间只由模块常量持有" + ); + } + + /// 说明校验助手的**行为**(不依赖 `ReducerContext`,因此可以直接跑): + /// 衍生作品缺说明 / 超短 / 超长都失败关闭并带上各自的稳定码;母版一律忽略; + /// 合法值按 trim 后的形态落库。 + #[test] + fn change_summary_helper_fails_closed_for_derivatives_and_ignores_masters() { + let valid = "把跳台改成三段,并重画全部背景与角色立绘"; + let too_short = "改了"; + let too_long = + "改".repeat(module_game_distribution::GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS + 1); + + // 合法:trim 后落库(首尾空白不进列,也不占额度)。 + assert_eq!( + ensure_game_distribution_change_summary(Some(&format!(" {valid}\n")), true), + Ok(Some(valid.to_string())) + ); + + // 缺失 / 空白 → 必填码;超短 / 超长 → 非法码。文案必须带 `"{CODE}: "` 前缀, + // 否则 api-server 的 FORK 分支抓不到码(会退化成 FORK_ERROR / 409)。 + let required = ensure_game_distribution_change_summary(None, true) + .expect_err("衍生作品缺说明必须失败关闭"); + assert!( + required.starts_with("FORK_CHANGE_SUMMARY_REQUIRED:"), + "必填失败必须带稳定码前缀:{required}" + ); + for value in [Some(" "), Some(too_short), Some(too_long.as_str())] { + let error = ensure_game_distribution_change_summary(value, true) + .expect_err("衍生作品缺说明或超限必须失败关闭"); + let expected = if value == Some(" ") { + "FORK_CHANGE_SUMMARY_REQUIRED:" + } else { + "FORK_CHANGE_SUMMARY_INVALID:" + }; + assert!(error.starts_with(expected), "value={value:?} got={error}"); + } + + // 母版:无论传什么(含超长)都回 `Ok(None)` —— 忽略而不是「可选」,不校验也不落库。 + for value in [ + None, + Some(""), + Some(too_short), + Some(too_long.as_str()), + Some(valid), + ] { + assert_eq!( + ensure_game_distribution_change_summary(value, false), + Ok(None), + "母版必须忽略该字段:value={value:?}" + ); + } + } + /// 收藏事务:收据命中先校验摘要(重放不写库)、写入前只判主键是否存在(**不是**去重依据, /// 去重由确定性主键承担),且同一次调用只可能插入一行。 #[test] diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4742ad35d..c8bb47a4a 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -215,6 +215,8 @@ macro_rules! migration_tables { // 定价:游戏表末尾追加 price_mud_points(默认 0),购买记录含成交价快照与钱包流水 ID。 // 资料随版本冻结:版本表末尾追加 metadata_json,游戏表末尾追加 // cover_object_key / screenshots_json,均为可空列,旧行按“未冻结资料”处理。 + // 核心改动说明(共创):版本表末尾再追加可空 change_summary(衍生作品必填、母版为 + // NULL),随版本导出/导入——它是「这一版改了什么」的版本级事实,不是可重算的投影。 // 软删除:游戏表末尾追加可空 deleted_at;非空表示作者已删除该作品, // 行本身仍随迁移导出,版本行与冻结资料保持不可变。 game_distribution_game,