Files
Genarrative/server-rs/crates/shared-contracts/src/game_distribution.rs
T
lhk229 b5069fd3e3
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m23s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m4s
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 11m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m22s
功能:游戏广场展示真实评分摘要
公开游戏列表与详情在同一读取快照中返回有效评价均分和人数
同步共享契约、公开投影映射、生成绑定与 no-store 响应
广场与详情复用评分文本组件,区分无人评分与摘要不可用
扩展必要展示和响应契约测试,保留原有目录交互
更新规范、实施计划与本地定向测试及联调验证证据
2026-10-01 08:40:58 +00:00

338 lines
11 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;
/// 发布页免费生成简介与分类的输入。
///
/// 只传经过裁剪的项目摘要,不传本地绝对路径、聊天记录、凭据或完整 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。
pub entry_url: String,
pub sha256: String,
pub published_at: String,
pub controls: Vec<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>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionListResponse {
pub games: Vec<GameDistributionGameSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[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,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionCreateVersionRequest {
#[serde(default)]
pub local_project_id: Option<String>,
pub package_sha256: String,
pub package_bytes: u64,
pub package_file_count: u32,
pub package_entry_path: String,
pub game_metadata: GameDistributionCreateGameRequest,
}
#[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 status: GameDistributionVersionStatus,
/// 这一版所属游戏的公开切换版本号(撤回与审核回传用)。
pub publication_revision: u64,
#[serde(default)]
pub review_reason: Option<String>,
pub created_at: String,
pub updated_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"));
}
}