feat(游戏共创): 衍生作品发布必填「本次核心改动说明」(版本级存储 + 可达错误码)
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

按需求文档 revision 1554「发布设置页 → 二创信息区(必填项)」:衍生作品(有血缘行)发布新版本时**必须**
提供「本次核心改动说明」;母版(0 代)不需要——提供了也忽略、不校验、不落库。

- **存储**:`game_distribution_version` **表尾追加** `change_summary: Option<String>`(`#[default(None)]`);
  列序属 wire format,`game_distribution_version_type` / `_version_snapshot_type` / `_create_version_input_type`
  三个绑定同步重生成(仓外临时 out-dir,只回写受影响文件)。
- **请求**:`GameDistributionCreateVersionRequest.change_summary`(`#[serde(default, skip_serializing_if)]`:
  缺省不序列化 `null`,保住旧客户端的幂等摘要)。
- **校验**:纯函数 `game_distribution_change_summary_violation(value, is_derivative)`,trim 后按**字符**计
  **20..=500** —— 20 是「能写清改了什么」的下限,500 约 1 KiB 上限,兼顾详情页/族谱展示与存储;
  超限或缺失**失败关闭**,错误码 `FORK_CHANGE_SUMMARY_REQUIRED` / `FORK_CHANGE_SUMMARY_INVALID`(均 **400**),
  已登记进 api-server 的 `FORK_` 映射表并纳入「FORK 码由模块真实文案可达」的枚举测试(防上次那种不可达码)。
- **公开投影**:`currentVersion.changeSummary`(衍生为字符串、母版 `null`,发键不发值),版本负载键集合
  逐字钉死为 id/version/entryUrl/sha256/publishedAt/controls/changeSummary,不带对象键/素材 id/审核字段。
- **契约与登记**:Rust DTO + TS 镜像 + parity(`version_summary_payload` 加 `mustEmit: ['changeSummary']`)。
- **文档**:技术方案 `§3.4`(请求增量 + 长度口径与理由 + 两个错误码 + 幂等摘要口径 + 公开投影)、数据契约
  文档的版本表小节(表尾追加列)。
- **测试**:纯函数 5 条(衍生缺失/超短(ASCII 与汉字)/超长(ASCII 与汉字)/区间内 trim 边界/母版忽略)+
  事务 4 条 + 接口 3 条(含公开负载键集合与母版 null)。

门禁:`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema` 0(98 表);`cargo check --all-targets` 0;
`cargo test -p module-game-distribution` **119 passed**;`cargo test -p api-server game_distribution` **104 passed**;
DTO parity 0(62 组 / 17 构建器 / 15 手拼类型);`check:encoding` 0(5535 files);
`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。

注:本块**未提交** `scripts/check-game-distribution-lineage-e2e.mjs` / `check-game-distribution-theme-e2e.mjs` /
`capture-game-lineage-visual.mjs` 的配套改动(属别的 session 的文件,工作区里保留未提交,需其 owner 决定:
衍生作品发布若不传 `changeSummary` 现在会 400,那三个脚本需要同步补字段)。
This commit is contained in:
2026-10-06 17:24:42 +08:00
parent 8ff01d65dd
commit d0176f48b7
16 changed files with 841 additions and 10 deletions
@@ -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<String>`、`project_bundle_bytes: u64`(`0` = 未上传)、`project_bundle_sha256: Option<String>`,保存作者**可选**上传的工程源码包(与发行包并列的第二份私有资产)。三列由 `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<String>`(`#[default(None::<String>)]`),保存**衍生作品**发布新版本时必填的「本次核心改动说明」(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)
@@ -308,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 里加该字段。**另追加 `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<String>,
| `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,**不叠加**发布灰度)
@@ -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;
};
/**
@@ -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`,不是省略)。
@@ -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::<Vec<_>>();
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]
@@ -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<str>) -> Option<String> {
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:?}"
);
}
}
}
@@ -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);
}
}
@@ -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,
@@ -193,6 +193,12 @@ pub struct GameDistributionVersionSummary {
pub sha256: String,
pub published_at: String,
pub controls: Vec<String>,
/// 本版相对改编来源作品的核心改动说明(衍生作品必有;母版为 `None`)。
///
/// 版本级字段:同一作品的不同代版本各有各的说明,详情页与族谱溯源据此展示「每一代的差异」。
/// 公开投影与作者侧读到的是同一列,它不含任何私有字段(不含对象键 / 素材 id)。
#[serde(default)]
pub change_summary: Option<String>,
}
#[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<String>,
}
/// 作者编辑游戏级展示资料的请求体。
@@ -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 =
@@ -222,6 +222,10 @@ pub struct GameDistributionVersionRecord {
pub project_bundle_bytes: u64,
/// 工程源包整包 SHA-256;未上传为 `None`。
pub project_bundle_sha256: Option<String>,
/// 本版的核心改动说明(衍生作品必有,母版为 `None`)。
///
/// 不含私有字段,可直接进入公开投影(`changeSummary`);展示层按它渲染「每一代的差异」。
pub change_summary: Option<String>,
}
#[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,
}
}
@@ -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<String>,
}
#[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",
@@ -20,6 +20,7 @@ pub struct GameDistributionCreateVersionInput {
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
pub change_summary: Option<String>,
}
impl __sdk::InModule for GameDistributionCreateVersionInput {
@@ -25,6 +25,7 @@ pub struct GameDistributionVersionSnapshot {
pub project_bundle_object_key: Option<String>,
pub project_bundle_bytes: u64,
pub project_bundle_sha_256: Option<String>,
pub change_summary: Option<String>,
}
impl __sdk::InModule for GameDistributionVersionSnapshot {
@@ -35,6 +35,7 @@ pub struct GameDistributionVersion {
pub project_bundle_object_key: Option<String>,
pub project_bundle_bytes: u64,
pub project_bundle_sha_256: Option<String>,
pub change_summary: Option<String>,
}
impl __sdk::InModule for GameDistributionVersion {
@@ -77,6 +78,7 @@ pub struct GameDistributionVersionCols {
pub project_bundle_bytes: __sdk::__query_builder::Col<GameDistributionVersion, u64>,
pub project_bundle_sha_256:
__sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
pub change_summary: __sdk::__query_builder::Col<GameDistributionVersion, Option<String>>,
}
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"),
}
}
}
@@ -984,6 +984,15 @@ pub struct GameDistributionVersion {
/// 工程源包整包 SHA-256;未上传为 `None`。与字节数成对写入,缺一即视为不可用。
#[default(None::<String>)]
pub(crate) project_bundle_sha256: Option<String>,
/// 本版相对**改编来源**作品的核心改动说明(衍生作品必填,母版恒为 `NULL`)。
///
/// 挂在版本上而不是作品上:它回答的是「**这一版**改了什么」,同一作品的不同代版本各有各的
/// 说明;详情页与族谱溯源逐版展示它,因此必须随版本冻结、不可在之后被覆盖。
/// 写入前由事务 trim(首尾空白不占额度);母版即使传了也不校验、不落库(写侧写 `NULL`),
/// 避免一个与血缘无关的字段在母版上分叉出第二套语义。校验规则见
/// `module_game_distribution::game_distribution_change_summary_violation`。
#[default(None::<String>)]
pub(crate) change_summary: Option<String>,
}
/// 买断制购买记录。每账号每游戏最多一条,`(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<String>,
}
#[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<String>,
/// 本版的核心改动说明(衍生作品必有;母版恒为 `None`)。
///
/// api-server 的公开版本负载把它投影成 `changeSummary`(详情页 / 族谱溯源展示「每一代的差异」);
/// 它不含任何私有字段,因此可以直接进入公开投影。
pub change_summary: Option<String>,
}
/// 后台游戏管理页的版本历史条目:补上审核与公开时间,供运营判断下架与恢复影响。
@@ -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<Option<String>, 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::<Vec<_>>();
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<String>,",
"字段形状必须与设计一致"
);
assert_eq!(
lines[field_at - 1],
"#[default(None::<String>)]",
"表尾追加的列必须带默认值,旧行才有可填的值"
);
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<String>,"),
"版本快照必须带上 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]
@@ -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,