daae35a1a1
- shared contracts:resolveGameCreationAppProjectVersion 改为按 manifest.versions 条数派生,删除 isGameCreationAppProjectVersion,projectVersion 注释降级为遗留兼容位 - Rust shared-contracts:删除 resolve_game_creation_app_project_version / is_game_creation_app_project_version / GAME_CREATION_APP_FIRST_PROJECT_VERSION,project_version 注释改遗留位并重写往返测试 - AGC 发布面板:项目版本改为 useMemo 派生只读输入框,删除本地 state、失焦落盘与 onManifestUpdated 回调 - AGC 发布服务:删除 saveGameDistributionProjectVersion,提交版本号缺省改为按 versions 派生 - AGC 原生:发布成功回写与绑定回填不再写 project_version(置空),删除 update_game_distribution_project_version 命令 - AGC 命令注册与白名单:desktop.rs 注销命令、check-config.mjs 移除白名单条目 - AGC 前端装配:App.tsx 移除两处 onManifestUpdated 传递 - AGC 测试:publish/panel 两条测试改为覆盖派生口径,删除四条写回用例 - 注释对齐:game_publish_attempt.rs / project/manifest.rs / styles.css / shared-contracts 的 game_distribution.rs 把版本标签口径改为「由 versions[] 派生」,清掉 projectVersion 遗留位表述 - 文档:decision-log 追加本决策条目,两份 2026-10-02 计划加取代标注,新增 2026-10-03 里程碑与实施计划
446 lines
16 KiB
Rust
446 lines
16 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";
|
|
|
|
/// 发布页免费生成简介与分类的输入。
|
|
///
|
|
/// 只传经过裁剪的项目摘要,不传本地绝对路径、聊天记录、凭据或完整 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>,
|
|
/// 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
|
|
///
|
|
/// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的
|
|
/// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端
|
|
/// 兼容行为:服务端按该游戏已有最大版本号 +1(此时溢出才会返回 409)。
|
|
/// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub version_number: Option<u64>,
|
|
pub package_sha256: String,
|
|
pub package_bytes: u64,
|
|
pub package_file_count: u32,
|
|
pub package_entry_path: String,
|
|
pub game_metadata: GameDistributionCreateGameRequest,
|
|
}
|
|
|
|
/// 作者编辑游戏级展示资料的请求体。
|
|
///
|
|
/// 只覆盖游戏行上的展示字段,随版本冻结的包摘要与资料快照不受影响;
|
|
/// `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,
|
|
#[serde(default)]
|
|
pub review_reason: Option<String>,
|
|
#[serde(default)]
|
|
pub entry_url: 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"));
|
|
}
|
|
|
|
#[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 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");
|
|
}
|
|
}
|