Files
Genarrative/server-rs/crates/shared-contracts/src/game_distribution.rs
T
suzmii 6aa2f97410
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (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 / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
feat(游戏共创): 贡献归集与归因(接口 + 契约 + 文档;反洗稿改判搁置)
产品口径:「子代所有的都算父代的」,并要能溯源「多少是子代给的、多少是自己的」。本期**只做计算**,
不含资金/分成结算。

- 递归定义:`inherited(W) = Σ_{c∈直接子代} total(c)`,`total(W) = own(W) + inherited(W)`;每个后代在它的每个
  祖先里只计一次。
- 接口:`GET /api/game-distribution/games/{game_id}/contribution`(Bearer + `no-store`)——**仅该作品作者**:
  未登录/失效 401(中间件)、非作者 403(复用既有 owner-mismatch → FORBIDDEN,不新增第二套鉴权)、
  作品不存在或根自身已软删 404。响应:`{ gameId, own, inherited, total, byGeneration[], directChildren[],
  nodeCount, truncated, truncatedReason }`;指标结构 `ContributionTotals{playCount}` 可扩展(将来加点赞/收藏/收入)。
- 不变量(测试钉住):`inherited == Σ byGeneration.total == Σ directChildren.total`、`total == own + inherited`、
  `nodeCount == Σ byGeneration.gameCount`;`byGeneration` 只含后代(绝对代际,升序)。
- 上限与截断:节点 500 / 深度 32;超限如实标 `truncated: true` + `truncatedReason ∈ {node_limit, depth_limit}`,
  已计入部分仍自洽(与族谱 `truncated` 同约定,不静默给半个数);遍历只用血缘表既有索引,**不新增表/索引**。
- 契约:shared-contracts 4 个 DTO + TS 镜像 + parity(+4 组 → 66 组);7 个生成绑定(含 procedure)。
- 文档:技术方案新增 `§3.11 贡献归集与权益归因`(定义/归因分解/接口/上限与截断/鉴权口径与理由)+ §3.4 路由行 +
  §5.4 实施证据;**任务 B**:反洗稿(相似度校验)从「待拍板」改为「**产品已决定搁置(2026-10-06)**」并从待拍板
  清单移除,结算口径统一为「本期只做贡献归集与归因计算」。
- 测试:module-game-distribution **131 passed**(contribution 12 条,含 A→B→C 三层链路不变量);spacetime-module
  **304 passed / 1 ignored**(+3 结构断言);spacetime-client 43 passed;api-server game_distribution **107 passed**(+3)。

门禁:wasm build 0;`cargo check --all-targets` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`
0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:project-bundle-policy-parity` OK;
`check:encoding` 0(5543 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。

注:本块未提交 `scripts/*.mjs`(`check-game-distribution-lineage-e2e.mjs` / `-theme-e2e.mjs` /
`capture-game-lineage-visual.mjs`)——属其它 owner,工作区保留未提交。
2026-10-06 18:05:49 +08:00

1106 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 游戏分发 API 的跨端 DTO。
//!
//! 领域状态机位于 `module-game-distribution`;本文件只描述 HTTP/客户端可见的
//! 数据形状,不能承载 owner、审核或公开切换规则。
use serde::{Deserialize, Serialize};
pub const GAME_DISTRIBUTION_CATEGORIES: [&str; 7] =
["休闲", "益智", "动作", "冒险", "模拟", "策略", "其他"];
/// 发行包体积上限(字节)。
///
/// 服务端校验与客户端「发布前检查」共用这一份:两边各写一个数字迟早会漂移,
/// 客户端要么放行服务端必拒的包(白等一次上传),要么拦掉服务端能收的包。
pub const GAME_DISTRIBUTION_MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024;
/// 版本号无法分配时的统一错误前缀(只在自动递增溢出时使用)。
///
/// 旧的「必须严格大于当前最大版本号」限制已经取消:同一 gameId 的同一个版本号可以反复提交,
/// 每次生成新的 versionId,也允许回退到更小的标签。显式版本号只要求 `>= 1`,`0` 这类非法值
/// 走普通 400;只有旧客户端缺省时按最大版本号自动递增、撞上 `u64::MAX` 才会带上此前缀,
/// api-server 据此映射成 HTTP 409。
pub const GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT: &str = "VERSION_NUMBER_CONFLICT";
/// 收藏(收录)被拒的统一文案:作品存在,但当前**不可收藏**(未公开 / 已软删除 / 没有当前公开版本)。
///
/// 两处共用同一个常量,避免只改一边就让 HTTP 语义漂移:
/// - `spacetime-module` 的 `ensure_game_distribution_collectable` 返回它;
/// - `api-server` 的 `map_spacetime_error` 靠其中的 `状态` 子串命中 **409** 分支。
///
/// 文案刻意**不含**「不存在」与「已被删除」:那两个子串会先命中 404 分支,等于用错误码对外
/// 确认未公开作品的存在性(与软删除作品按「不存在」处理同一口径)。
pub const GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT: &str =
"作品状态不允许收藏(未公开或已软删除)";
/// 共创主题相关错误的稳定前缀码(`模块前缀_原因`,与 `FORK_*` 同族写法)。
///
/// 领域侧(`spacetime-module`)与 api-server 的集中映射**共用这些常量**:新增一种主题错误只需
/// 在这里加一个码并在映射表里加一行,不会出现「事务里写了一个没人认识的字符串」这种静默漂移。
/// 公开侧刻意**不带**这些码:主题不存在 / 未发布一律只回「主题不存在」文案(不确认未发布主题的
/// 存在性),因此公开的 404 与这里的 `THEME_NOT_FOUND` 是两条不同的路径。
pub const GAME_DISTRIBUTION_THEME_NOT_FOUND: &str = "THEME_NOT_FOUND";
/// 后台请求参数非法:空 `name` / 非法 `status`(含列表过滤值)。
pub const GAME_DISTRIBUTION_THEME_BAD_REQUEST: &str = "THEME_BAD_REQUEST";
/// 主题列表游标格式非法。
pub const GAME_DISTRIBUTION_THEME_INVALID_CURSOR: &str = "THEME_INVALID_CURSOR";
/// 同 `Idempotency-Key` 不同请求摘要。
pub const GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT: &str = "THEME_IDEMPOTENCY_CONFLICT";
/// 目标作品有血缘行(非根),不能作为主题成员。
pub const GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT: &str = "THEME_MEMBER_NOT_ROOT";
/// 目标作品不存在。
pub const GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND: &str = "THEME_MEMBER_GAME_NOT_FOUND";
/// 发布页免费生成简介与分类的输入。
///
/// 只传经过裁剪的项目摘要,不传本地绝对路径、聊天记录、凭据或完整 manifest。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPublishMetadataSuggestionRequest {
pub name: String,
#[serde(default)]
pub goal: Option<String>,
#[serde(default)]
pub context: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPublishMetadataSuggestion {
pub summary: String,
pub category: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GameDistributionVersionStatus {
AwaitingUpload,
Uploaded,
Validating,
PendingReview,
Published,
UploadFailed,
ValidationFailed,
Rejected,
Cancelled,
Revoked,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GameDistributionVisibility {
Unpublished,
Published,
Suspended,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GameDistributionInputMode {
Keyboard,
Mouse,
Touch,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GameDistributionOrientation {
Landscape,
Portrait,
Responsive,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionDeviceSupport {
pub desktop: bool,
pub mobile: bool,
pub touch: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionAuthor {
pub id: String,
pub name: String,
#[serde(default)]
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionReview {
pub id: String,
pub game_id: String,
pub author: GameDistributionAuthor,
pub score: i64,
pub comment: String,
#[serde(default)]
pub is_hidden: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionRatingSummary {
pub average_score: Option<f64>,
pub rating_count: u64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionReviewsResponse {
pub reviews: Vec<GameDistributionReview>,
pub page: u32,
pub page_size: u32,
pub total: u64,
pub total_pages: u64,
pub rating_summary: GameDistributionRatingSummary,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionMyReviewResponse {
pub review: Option<GameDistributionReview>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionSaveReviewRequest {
pub score: i64,
#[serde(default)]
pub comment: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionSaveReviewResponse {
pub review: GameDistributionReview,
pub rating_summary: GameDistributionRatingSummary,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionVersionSummary {
pub id: String,
pub version: String,
/// 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
///
/// 买断制作品对未购买且非作者 / 非管理员的查看者不下发入口(`null`),游玩入口改由播放会话接口签发。
#[serde(default)]
pub entry_url: Option<String>,
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)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionGameSummary {
pub id: String,
pub title: String,
pub summary: String,
pub description: String,
pub category: String,
pub tags: Vec<String>,
pub cover_color: String,
pub icon: String,
/// 公开封面的 OSS objectKey;未上传封面时为空。
#[serde(default)]
pub cover_object_key: Option<String>,
/// 随版本冻结的公开截图 objectKey 列表。
#[serde(default)]
pub screenshots: Vec<String>,
pub author: GameDistributionAuthor,
pub device_support: GameDistributionDeviceSupport,
#[serde(default)]
pub input_modes: Vec<GameDistributionInputMode>,
#[serde(default)]
pub orientation: Option<GameDistributionOrientation>,
pub status: GameDistributionVisibility,
/// 公开切换 CAS 版本号:作者下架与管理员审核都必须回传当前值。
pub publication_revision: u64,
pub current_version: Option<GameDistributionVersionSummary>,
pub play_count: u64,
pub created_at: String,
/// 公开列表/详情保证返回;作者侧及旧响应可省略。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating_summary: Option<GameDistributionRatingSummary>,
/// 作品级共创授权档位;缺省按「禁止共创」解释。
#[serde(default)]
pub fork_authorization: GameDistributionForkAuthorization,
/// 直接改编该作品的已公开作品数。
#[serde(default)]
pub fork_count: u64,
/// 该作品的改编来源;母版为空。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lineage: Option<GameDistributionLineage>,
/// 当前登录用户是否已收藏(收录)该作品。
///
/// **只有公开详情在登录时下发**:匿名请求(含携带无效 token 按匿名处理)不发这个键,
/// 也不发 `false`——`false` 会把「未登录」说成「没收藏」,客户端无法区分,就会渲染出
/// 错误的收藏按钮态。公开目录不填。值来自 `is_game_distribution_collected_and_return`
/// 的真实投影,不是客户端本地状态。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collected: Option<bool>,
/// 该作品所属的**公开**共创主题:先取作品的根、再按根反查成员行(跨主题多归属,因此是数组)。
///
/// **只有公开详情下发**(匿名与登录都发;没有所属主题时发空数组,展示层不渲染空容器);
/// 公开列表与作者侧不发该键。第 N 代作品同样能看到主题——反查用的是**根**,不是作品自身。
/// 排序与公开主题列表共用同一份比较器(创建时间倒序 + `theme_id` 升序兜底)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub themes: Option<Vec<GameDistributionThemeReference>>,
/// 买断制价格(泥点);`0` 表示免费。旧响应可省略,按免费处理。
#[serde(default)]
pub price_mud_points: u64,
/// 当前查看者是否已拥有;匿名与未登录恒为 `false`。
#[serde(default)]
pub purchased: bool,
}
/// 作品级共创(Fork)授权档位:只允许单向提升开放度。
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GameDistributionForkAuthorization {
/// 仅可游玩,不可复刻改编。
#[default]
Forbidden,
/// 可二次开发,禁止盈利。
NonCommercial,
/// 可改编、可商用、可发布新版本。
Full,
}
/// 公开的改编血缘摘要:只含作品级信息,不含对象键与素材键。
///
/// `root_title` / `parent_title` 为 `None` 表示对应作品已被软删除,标题不再对外输出;
/// `parent_author_name` 同理(父作品被删时不回传作者名)。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionLineage {
/// 代际;直接改编母版为 1。
pub generation: u32,
pub root_game_id: String,
pub root_title: Option<String>,
pub parent_game_id: String,
pub parent_title: Option<String>,
#[serde(default)]
pub parent_author_name: Option<String>,
}
/// 创建游戏时可选携带的改编声明;只在全新作品上生效。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionForkDeclaration {
pub parent_game_id: String,
pub parent_version_id: String,
}
/// 作者提升作品共创授权:只升不降,`expected` 用于并发校验。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionSetForkAuthorizationRequest {
pub expected_fork_authorization: GameDistributionForkAuthorization,
pub fork_authorization: GameDistributionForkAuthorization,
}
/// 族谱树上对外可见的单个作品节点。
///
/// 只带作品级公开信息:不含**素材键 / 内部 id**(例如 `coverAssetId`);封面对象键
/// `cover_object_key` 是公开投影的一部分,与公开目录 / 详情同源同口径(都来自游戏行的
/// `cover_object_key`),无封面时为空。
/// 只有公开且未软删除的作品会成为节点(锚点可读性由接口在读出前判定,不可读即 404),
/// 因此标题必然存在;`author_name` 为 `None` 表示作者信息不可得。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionLineageNode {
pub game_id: String,
pub title: String,
#[serde(default)]
pub author_name: Option<String>,
/// 代际;母版为 0。
pub generation: u32,
/// 父作品 ID;母版为空。父作品不可用时仍回传 ID,展示层据此降级为「原作品不可用」。
#[serde(default)]
pub parent_game_id: Option<String>,
pub play_count: u64,
/// 可见性。当前**只有已公开作品会成为节点**(未公开节点被过滤、未公开锚点直接 404),
/// 所以线上取值实际恒为 `published`;保留该字段是为了将来允许非公开节点进入树时
/// 不必再改契约,届时展示层按此渲染状态。
pub status: GameDistributionVisibility,
/// 该节点作品当前生效的封面对象键(与公开目录 / 详情同源,都来自游戏行
/// `cover_object_key`);无封面时为 `null`。只发对象键,**不带** `coverAssetId` 等私有 id。
#[serde(default)]
pub cover_object_key: Option<String>,
}
/// 族谱读接口的响应:以根作品为顶的整棵树。
///
/// `nodes` 按代际升序、同代按创建时间升序稳定排序,含根(若根可见);超过节点上限时
/// 截断尾部并置 `truncated`,不静默丢弃。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionLineageResponse {
/// 请求作品所属的根作品 ID;根已删/未公开时仍回传,展示层据此标注树顶不可用。
pub root_game_id: String,
/// 根节点在可见集合中的投影;根已软删除或未公开时为空。
#[serde(default)]
pub root: Option<GameDistributionLineageNode>,
pub nodes: Vec<GameDistributionLineageNode>,
pub truncated: bool,
}
/// 直接衍生作品(「被改编」)列表的响应;只含对外可见的直接子代。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionDerivedResponse {
/// 被改编的父作品 ID。
pub game_id: String,
pub nodes: Vec<GameDistributionLineageNode>,
pub truncated: bool,
}
/// 归集的指标集合(当前只有游玩数)。
///
/// 单独成结构体而不是把字段摊进响应里:将来加点赞 / 收藏 / 收入只需加字段,
/// 递归定义(`inherited(W) = Σ 直接子代 total`、`total = own + inherited`)与投影形状都不动。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionTotals {
pub play_count: u64,
}
/// 按代际分解的一条:**只含后代**(不含根自身),`generation` 为绝对代际(与族谱一致)。
///
/// 语义:这一条是祖先 `inherited` 里**由这一代自己产生**的那一份,`total` 即该代贡献;
/// `inherited` 恒为 `0`——该代从更深代际继承到的量已计入更深代际那一条,重复计入会让
/// 「各代之和」大于祖先的 `inherited`(同一个后代会在多层分解里被重复计数)。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionGeneration {
pub generation: u32,
/// 这一代计入分解的后代节点数。
pub game_count: u64,
pub own: GameDistributionContributionTotals,
pub inherited: GameDistributionContributionTotals,
pub total: GameDistributionContributionTotals,
}
/// 直接子代明细的一条:`total` 是该直接子代**含自己整棵子树**的值。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionChild {
pub game_id: String,
pub generation: u32,
pub own: GameDistributionContributionTotals,
pub inherited: GameDistributionContributionTotals,
pub total: GameDistributionContributionTotals,
}
/// 作者视角的贡献归集与归因响应(`GET /api/game-distribution/games/{gameId}/contribution`)。
///
/// 不变量(在**截断后的子树**上依然成立):
/// - `inherited == Σ byGeneration.total == Σ directChildren.total`;
/// - `total == own + inherited`;
/// - `nodeCount == Σ byGeneration.gameCount`(= 参与计算的**后代**节点数,不含根)。
///
/// `truncated` 为真时 `truncatedReason` 取 `node_limit` / `depth_limit`,未截断为 `null`
/// (与族谱接口 `truncated` 同约定:超限如实标注,不静默给半个数)。
/// **本期只做计算**:响应里没有资金 / 分成 / 结算字段。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionContributionResponse {
pub game_id: String,
pub own: GameDistributionContributionTotals,
pub inherited: GameDistributionContributionTotals,
pub total: GameDistributionContributionTotals,
pub by_generation: Vec<GameDistributionContributionGeneration>,
pub direct_children: Vec<GameDistributionContributionChild>,
/// 参与计算的后代节点数(不含根)。
pub node_count: u64,
pub truncated: bool,
#[serde(default)]
pub truncated_reason: Option<String>,
}
/// Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在
/// 两者时优先 `project`。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GameDistributionForkSourceKind {
/// 已构建的发行成品包(静态可玩产物)。
Package,
/// 可编辑的工程源包(M2b 引入)。
Project,
}
/// Fork 取件元数据:客户端据此校验取到的内容并决定建项形态。
///
/// 只含版本身份与摘要,**不含 OSS 对象键**——下载走同源受鉴权路径 `download_path`,
/// 对象键只留在服务端。`sha256` / `bytes` 取自版本行已存的发行包摘要,不重新计算。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionForkSource {
pub game_id: String,
pub version_id: String,
pub source: GameDistributionForkSourceKind,
pub sha256: String,
pub bytes: u64,
/// 同源相对路径(如 `/api/game-distribution/games/game_1/fork-source/package`);
/// 客户端按当前 origin 解析后带 Bearer 请求,不接受绝对 URL。
pub download_path: String,
}
/// Fork 取件元数据接口(`GET /api/game-distribution/games/{gameId}/fork-source`)的响应体。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionForkSourceResponse {
pub fork_source: GameDistributionForkSource,
}
/// 收藏(收录)写入 / 取消后的权威投影值。
///
/// `collected` 是**调用后**的收藏事实(服务端真实投影,不是前端本地状态),PUT 与 DELETE
/// 共用一个形状,客户端不需要按「动词」推断结果。
///
/// `replayed` 只在带幂等键的「收藏」上有意义:`PUT` 命中同键收据时回 `true`(本次没有产生
/// 新事实)。`DELETE` 按确定性主键删除、天然幂等,因此**不下发**该键(`None`)。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionCollectionState {
pub collected: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replayed: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionListResponse {
pub games: Vec<GameDistributionGameSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
/// 「我的收藏(收录)」列表接口(`GET /api/game-distribution/my-collections`)的响应体。
///
/// 形状与公开目录 `GameDistributionListResponse` 一致(同一份公开投影),但**分页语义不同**:
/// 默认一页 20 条、上限 50 条(公开目录另有自己的口径),排序按「收藏时间倒序 + collection_id
/// 升序兜底」。`next_cursor` 是真实游标(`"{created_at_micros}:{collection_id}"`),
/// 最后一页为 `null`;游标格式非法时服务端返回 400。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionMyCollectionsResponse {
pub games: Vec<GameDistributionGameSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
/// 共创主题的三态处置:`draft`(未发布)/ `published`(公开可见)/ `archived`(归档下架)。
///
/// 后台写接口的入参用这个枚举而不是裸字符串:非法取值在反序列化阶段即被拒绝(api-server 映射
/// 400),不会带着一个「未定义状态」进入事务。模块侧的数据列仍是字符串,配套白名单是
/// `module_game_distribution::GAME_DISTRIBUTION_THEME_STATUSES`。
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GameDistributionThemeStatus {
/// 已创建但未对外发布:公开侧任何投影都看不到。
#[default]
Draft,
/// 已发布:公开列表、公开详情与作品详情的 `themes` 都会出现。
Published,
/// 已归档下架:不再公开,但主题行与成员行都保留。
Archived,
}
/// 作品详情「所属主题」增量的单个条目:只够渲染一个跳转入口。
///
/// 不含简介与成员数——详情页只需要「这条作品属于哪些主题」和一个能点进去的名字与角标,
/// 完整信息在主题页(`GameDistributionThemeDetail`)取。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionThemeReference {
pub theme_id: String,
pub name: String,
pub badge: String,
}
/// 公开主题列表的单条:主题名、简介、角标与**当前公开可见成员数**。
///
/// `member_count` 是**真实可见成员数**(同一份成员可见性判定,不含草稿 / 已下架成员),
/// 不是成员行总数:若回报「全部成员行数」,运营就能通过计数变化探测存在草稿 / 被下架成员。
/// 也正因为它是真实数,它与详情 `roots` 的长度**在可见成员超过响应体积上限时不再相等**——
/// 「下面这份 roots 被上限截断」是详情响应上独立的 `roots_truncated` 信号,不要用这个字段反推。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionThemeSummary {
pub theme_id: String,
pub name: String,
pub summary: String,
pub badge: String,
pub member_count: u64,
}
/// 公开主题列表接口(`GET /api/game-distribution/themes`)的响应体。
///
/// 只含 `status == published` 的主题。分页沿用既有游标惯例:默认 20 条、上限 50 条、超界截断;
/// `next_cursor` 是真实游标(`"{created_at_micros}:{theme_id}"`),最后一页为 `null`;游标格式
/// 非法时服务端返回 400。排序为创建时间倒序 + `theme_id` 升序兜底(全序,翻页不重不漏)。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionThemeListResponse {
pub themes: Vec<GameDistributionThemeSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
/// 公开主题详情接口(`GET /api/game-distribution/themes/{theme_id}`)的响应体。
///
/// 顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。`roots` 逐条是**公开目录同一份**
/// 作品投影(`GameDistributionGameSummary`),排序为 `sort_order` 升序 + `member_id` 升序兜底。
/// 主题不存在或未发布时接口返回 404(不返回空壳);已发布但可见成员为空时返回 200 + 空 `roots`。
///
/// 两个成员数相关的字段口径**刻意不同,不要互相推导**:
/// - `member_count` 是**真实可见成员数**(不截断),回答「这个主题有多少棵作品树」;
/// - `roots_truncated` 才是「**下面这份 `roots` 被响应体积上限截断**」的信号,回答「你看到的是
/// 不是全部」。它与族谱响应上的 `truncated` 是同一个约定(列表被上限截断时置 `true`),
/// 客户端按同一种方式分支;`roots.len()` 与 `member_count` 不等时就必须看这个字段。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionThemeDetail {
pub theme_id: String,
pub name: String,
pub summary: String,
pub badge: String,
pub member_count: u64,
pub roots: Vec<GameDistributionGameSummary>,
pub roots_truncated: bool,
}
/// 后台创建主题(`POST /admin/api/game-distribution/themes`)的请求体。
///
/// `name` 必填且非空(空名由 api-server 映射 400);其余字段省略时按「空文本 / 排序 0 /
/// `draft`」落库——与表列默认一致,因此「省略」与「显式传默认」是同一个请求,幂等摘要也相同。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionCreateThemeRequest {
pub name: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub badge: String,
#[serde(default)]
pub sort_order: i64,
#[serde(default)]
pub status: GameDistributionThemeStatus,
}
/// 后台更新主题(`PUT /admin/api/game-distribution/themes/{theme_id}`)的请求体。
///
/// **整体覆盖**这几个字段(不是部分更新):字段都必填,避免「省略 = 保留旧值」与「省略 = 清空」
/// 两种解释在客户端之间漂移。`updated_at` 每次生效即刷新。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionUpdateThemeRequest {
pub name: String,
pub summary: String,
pub badge: String,
pub sort_order: i64,
pub status: GameDistributionThemeStatus,
}
/// 后台增 / 改成员(`PUT /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}`)的请求体。
///
/// 成员身份完全由路径给出(`theme_id` + `root_game_id`),body 只承载运营权重;重复调用由确定性
/// 主键保证幂等(同主题同根只有一行,`created_at` 首次写入后不再变)。
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionUpsertThemeMemberRequest {
#[serde(default)]
pub sort_order: i64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionCreateGameRequest {
#[serde(default)]
pub local_project_id: Option<String>,
pub title: String,
pub summary: String,
#[serde(default)]
pub description: Option<String>,
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub cover_asset_id: Option<String>,
/// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。
#[serde(default)]
pub screenshots: Vec<String>,
pub device_support: GameDistributionDeviceSupport,
pub input_modes: Vec<GameDistributionInputMode>,
pub orientation: GameDistributionOrientation,
/// 上架时选择的共创授权档位;缺省按「禁止共创」解释(与表列默认一致,旧客户端不受影响)。
///
/// **只对母版(0 代作品)生效**:带 `fork` 声明时,新作品的档位在创建时**继承父作品当时的
/// 档位**,本字段一律被忽略且不报错(旧客户端会惯常带默认值),服务端也不接受「收窄」。
/// 字段形状刻意保持不变(非 `Option` + `#[serde(default)]`):改了会让幂等摘要漂移。
///
/// 写成非 `Option` + `#[serde(default)]` 而不是 `Option<..>`:省略与显式传 `forbidden`
/// 得到同一个值,因此 `create_game` 的幂等摘要(对整个请求体取摘要)也相同——省略与显式
/// 传默认不会变成两个不同的请求;未知取值由反序列化直接拒绝(api-server 映射 400),
/// 不会静默落成 `forbidden`。
#[serde(default)]
pub fork_authorization: GameDistributionForkAuthorization,
/// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。
#[serde(default)]
pub fork: Option<GameDistributionForkDeclaration>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionCreateVersionRequest {
#[serde(default)]
pub local_project_id: Option<String>,
/// 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
///
/// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的
/// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端
/// 兼容行为:服务端按该游戏已有最大版本号 +1(此时溢出才会返回 409)。
/// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_number: Option<u64>,
/// 作者提交的买断制价格(泥点);`0` 表示免费,缺省按免费处理。
/// 价格随版本冻结,审核通过时与资料一起生效到游戏行。
#[serde(default)]
pub price_mud_points: u64,
pub package_sha256: String,
pub package_bytes: u64,
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>,
}
/// 作者编辑游戏级展示资料的请求体。
///
/// 只覆盖游戏行上的展示字段,随版本冻结的包摘要与资料快照不受影响;
/// `expected_publication_revision` 是公开切换 CAS,并发变化返回 409。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionUpdateGameMetadataRequest {
pub expected_publication_revision: u64,
pub title: String,
pub summary: String,
#[serde(default)]
pub description: Option<String>,
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub cover_asset_id: Option<String>,
/// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。
#[serde(default)]
pub screenshots: Vec<String>,
pub device_support: GameDistributionDeviceSupport,
pub input_modes: Vec<GameDistributionInputMode>,
pub orientation: GameDistributionOrientation,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPrivateVersion {
pub version_id: String,
pub game_id: String,
pub version_number: u64,
pub package_sha256: String,
pub package_bytes: u64,
pub package_file_count: u32,
pub status: GameDistributionVersionStatus,
/// 这一版所属游戏的公开切换版本号(撤回与审核回传用)。
pub publication_revision: u64,
/// 该版本冻结的买断制价格(泥点);历史无价格版本按 `0` 展示为免费。
#[serde(default)]
pub price_mud_points: u64,
#[serde(default)]
pub review_reason: Option<String>,
#[serde(default)]
pub entry_url: Option<String>,
/// 工程源包字节数;`0` 表示该版本未上传工程源包(作者上架时选择不给源码)。
#[serde(default)]
pub project_bundle_bytes: u64,
/// 工程源包整包 SHA-256;未上传为 `None`。**对象键不下发**,只在服务端使用。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_bundle_sha256: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 买断制购买请求。`expectedPriceMudPoints` 与服务端当前价格做 CAS:
/// 不一致说明作者已通过新版本调价,客户端必须刷新详情后重试。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPurchaseRequest {
pub expected_price_mud_points: u64,
}
/// 购买记录快照;成交价随购买冻结,之后调价不影响既有所有权。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPurchase {
pub purchase_id: String,
pub game_id: String,
pub price_mud_points: u64,
pub created_at: String,
}
/// 购买响应:购买快照 + 支付后钱包余额 + 是否幂等重放。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPurchaseResponse {
pub purchase: GameDistributionPurchase,
pub wallet_balance: u64,
pub replayed: bool,
}
/// 付费游戏播放会话:`playUrl` 已绑定 gameId、用户与短时效,过期或下架即失效。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionPlaySessionResponse {
pub play_url: String,
pub expires_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_review_request_requires_integer_score_and_defaults_comment() {
let request: GameDistributionSaveReviewRequest =
serde_json::from_value(serde_json::json!({"score": 10})).unwrap();
assert_eq!(request.comment, "");
for payload in [
serde_json::json!({}),
serde_json::json!({"score": 8.5}),
serde_json::json!({"score": "8"}),
serde_json::json!({"score": 8, "comment": null}),
serde_json::json!({"score": 8, "comment": 123}),
] {
assert!(serde_json::from_value::<GameDistributionSaveReviewRequest>(payload).is_err());
}
let response =
serde_json::to_value(GameDistributionMyReviewResponse { review: None }).unwrap();
assert_eq!(response, serde_json::json!({"review": null}));
let summary = serde_json::to_value(GameDistributionRatingSummary {
average_score: None,
rating_count: 0,
})
.unwrap();
assert_eq!(
summary,
serde_json::json!({"averageScore": null, "ratingCount": 0})
);
}
#[test]
fn create_game_request_round_trips_local_project_id() {
let payload: GameDistributionCreateGameRequest =
serde_json::from_value(serde_json::json!({
"localProjectId": "proj-1",
"title": "游戏",
"summary": "简介",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
}))
.expect("请求应可解析");
assert_eq!(payload.local_project_id.as_deref(), Some("proj-1"));
let serialized = serde_json::to_value(&payload).expect("请求应可序列化");
assert_eq!(serialized["localProjectId"], "proj-1");
}
#[test]
fn legacy_create_game_request_without_local_project_id_stays_compatible() {
let payload: GameDistributionCreateGameRequest =
serde_json::from_value(serde_json::json!({
"title": "游戏",
"summary": "简介",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
}))
.expect("旧请求应继续可解析");
assert_eq!(payload.local_project_id, None);
}
#[test]
fn create_version_request_accepts_local_project_id() {
let payload: GameDistributionCreateVersionRequest =
serde_json::from_value(serde_json::json!({
"localProjectId": "proj-1",
"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",
},
}))
.expect("版本请求应可解析");
assert_eq!(payload.local_project_id.as_deref(), Some("proj-1"));
}
#[test]
fn create_version_request_version_number_is_optional() {
let legacy: GameDistributionCreateVersionRequest =
serde_json::from_value(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",
},
}))
.expect("旧客户端不传版本号应继续可解析");
assert_eq!(legacy.version_number, None);
let explicit: GameDistributionCreateVersionRequest =
serde_json::from_value(serde_json::json!({
"versionNumber": 5,
"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",
},
}))
.expect("显式版本号应可解析");
assert_eq!(explicit.version_number, Some(5));
let serialized = serde_json::to_value(&explicit).expect("显式版本号应可序列化");
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 =
serde_json::from_value(serde_json::json!({
"expectedPublicationRevision": 3,
"title": "游戏",
"summary": "简介",
"category": "益智",
"coverAssetId": "asset-cover",
"screenshots": ["asset-shot"],
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
}))
.expect("资料编辑请求应可解析");
assert_eq!(payload.expected_publication_revision, 3);
assert_eq!(payload.cover_asset_id.as_deref(), Some("asset-cover"));
let serialized = serde_json::to_value(&payload).expect("资料编辑请求应可序列化");
assert_eq!(serialized["expectedPublicationRevision"], 3);
assert_eq!(serialized["coverAssetId"], "asset-cover");
}
#[test]
fn purchase_and_play_session_dtos_round_trip() {
let request: GameDistributionPurchaseRequest =
serde_json::from_value(serde_json::json!({ "expectedPriceMudPoints": 120 }))
.expect("购买请求应可解析");
assert_eq!(request.expected_price_mud_points, 120);
let response = serde_json::to_value(GameDistributionPurchaseResponse {
purchase: GameDistributionPurchase {
purchase_id: "gdpurchase_1".to_string(),
game_id: "game-1".to_string(),
price_mud_points: 120,
created_at: "2026-10-05T00:00:00Z".to_string(),
},
wallet_balance: 880,
replayed: false,
})
.expect("购买响应应可序列化");
assert_eq!(response["purchase"]["purchaseId"], "gdpurchase_1");
assert_eq!(response["purchase"]["priceMudPoints"], 120);
assert_eq!(response["walletBalance"], 880);
assert_eq!(response["replayed"], false);
let session = serde_json::to_value(GameDistributionPlaySessionResponse {
play_url: "/api/game-distribution/play-sessions/abc/".to_string(),
expires_at: "2026-10-05T00:05:00Z".to_string(),
})
.expect("播放会话应可序列化");
assert_eq!(
session["playUrl"],
"/api/game-distribution/play-sessions/abc/"
);
assert_eq!(session["expiresAt"], "2026-10-05T00:05:00Z");
}
#[test]
fn legacy_game_summary_defaults_to_free_and_not_purchased() {
let payload: GameDistributionGameSummary = serde_json::from_value(serde_json::json!({
"id": "game-1",
"title": "游戏",
"summary": "简介",
"description": "",
"category": "益智",
"tags": [],
"coverColor": "#ffffff",
"icon": "game",
"author": { "id": "user-1", "name": "作者" },
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": [],
"orientation": "responsive",
"status": "published",
"publicationRevision": 1,
"currentVersion": null,
"playCount": 0,
"createdAt": "2026-10-05T00:00:00Z",
}))
.expect("旧公开响应应继续可解析");
assert_eq!(payload.price_mud_points, 0);
assert!(!payload.purchased);
let version: GameDistributionPrivateVersion = serde_json::from_value(serde_json::json!({
"versionId": "gamever_1",
"gameId": "game-1",
"versionNumber": 1,
"packageSha256": "a".repeat(64),
"packageBytes": 10,
"packageFileCount": 1,
"status": "pending_review",
"publicationRevision": 1,
"createdAt": "2026-10-05T00:00:00Z",
"updatedAt": "2026-10-05T00:00:00Z",
}))
.expect("旧版本 payload 应继续可解析");
assert_eq!(version.price_mud_points, 0);
}
}