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
产品口径:「子代所有的都算父代的」,并要能溯源「多少是子代给的、多少是自己的」。本期**只做计算**,
不含资金/分成结算。
- 递归定义:`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,工作区保留未提交。
1106 lines
49 KiB
Rust
1106 lines
49 KiB
Rust
//! 游戏分发 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);
|
||
}
|
||
}
|