diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index cc4d8cff0..9039f401e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -1327,12 +1327,14 @@ pub(crate) async fn publish_local_project_game( .filter(|value| !value.is_empty()) .ok_or_else(|| "发布需要本地项目标识,请重新打开项目后再试".to_string())? .to_string(); + // 发布媒体合同已切到 objectKey 沿用 + multipart 直传(M1);AGC 面板与直传改造在 M3, + // 当前先用 objectKey 做存在性校验,避免带旧 assetId 发出必然被拒的请求。 if metadata - .cover_asset_id + .cover_object_key .as_deref() .map(str::trim) - .unwrap_or_default() - .is_empty() + .filter(|value| !value.is_empty()) + .is_none() { return Err("请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场".to_string()); } @@ -1704,7 +1706,7 @@ mod tests { description: None, category: "益智".to_string(), tags: vec![], - cover_asset_id: Some("asset-cover".to_string()), + cover_object_key: Some("generated/cover.png".to_string()), screenshots: vec![], device_support: shared_contracts::game_distribution::GameDistributionDeviceSupport { desktop: true, diff --git a/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md b/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md index d7a396474..e654bb348 100644 --- a/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md +++ b/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md @@ -77,7 +77,7 @@ ### 3.3 发布媒体直传 - 端点:`POST /api/game-distribution/games`、`POST /api/game-distribution/games/{game_id}/versions`、`PATCH /api/game-distribution/my-games/{game_id}` 改为 `multipart/form-data`。 -- 字段:元数据 JSON 文本 part;`cover` 二进制 part(可选);`coverObjectKey` 文本 part(沿用线上封面时);`screenshots` 文本 JSON 数组(沿用线上的 objectKey 列表);截图二进制 parts(新增/替换,≤6 张)。 +- 字段:`metadata` 文本 part(JSON,即原请求体的元数据部分,去掉媒体字段);`cover` 二进制 part(可选,出现时覆盖 `metadata.coverObjectKey`);`screenshot` 二进制 parts(可重复,按出现顺序消费)。`metadata.coverObjectKey` 为沿用线上封面时的 objectKey;`metadata.screenshots` 为 `(string|null)[]`,`string` 沿用该 objectKey、`null` 取下一个 `screenshot` part,数组顺序即最终截图顺序。 - 存储:新图直接落**项目快照桶** `agc/project-snapshots/v1/game-distribution/media//{cover|screenshot}-.`(与发行包同桶、与素材库解耦,不建 `asset_object`);沿用图校验 objectKey 必须属于该游戏当前媒体;构建冻结资料(只含 objectKey)。 - 读:新增 `GET /api/game-distribution/media/read-url`,按 objectKey 返回签名读地址;判定 = 已发布且 active 的 game 且 objectKey 命中 `cover_object_key` / `screenshots_json`,**不经素材库 ACL**;需要同源字节时再补 `.../media/read-bytes`。签名与字节中转先抽公共函数,供 `/api/assets/read-url` 与新路由共用。 - 整包:`PUT .../versions/{version_id}/package` 及分片端点保持现状(raw zip / octet-stream)。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 5c736a021..db197810b 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -60,7 +60,7 @@ | Date | 2026-10-06 | | 适用边界 | game-distribution 发布资料媒体(封面/截图)的上传与公开读取;发行包上传链路不变 | -- 写:`POST /api/game-distribution/games`、`POST /api/game-distribution/games/{gameId}/versions`、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:元数据 JSON 文本 part;`cover` 二进制 part(可选);`coverObjectKey` 文本 part(沿用线上封面);`screenshots` 文本 JSON 数组(沿用线上 objectKey);截图二进制 parts(≤6 张)。硬切,不再接受 `coverAssetId`。 +- 写:`POST /api/game-distribution/games`、`POST /api/game-distribution/games/{gameId}/versions`、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:`metadata` 文本 part(JSON,含 `coverObjectKey` 与 `screenshots: (string|null)[]`);`cover` 二进制 part(可选,出现时覆盖 `coverObjectKey`);`screenshot` 二进制 parts(≤6 张,按 `null` 槽位顺序消费)。`string` 槽位表示沿用线上 objectKey。硬切,不再接受 `coverAssetId`。 - 存储:新图由服务端写入项目快照桶 `agc/project-snapshots/v1/game-distribution/media//{cover|screenshot}-.`,**不建 `asset_object`**;冻结资料与游戏行只存 `coverObjectKey` / 截图 objectKey。 - 读:`GET /api/game-distribution/media/read-url`(匿名)按 objectKey 返回签名读地址;判定 = 已发布且 active 的 game 且 objectKey 命中其冻结媒体;需要同源字节时用 `.../media/read-bytes`。 - 幂等:`request_digest` 覆盖规范化元数据 + 新图字节 hash + 沿用 objectKey,保持重放语义。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 4f3d1ab0d..13d19f796 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -124,7 +124,7 @@ export type GameDistributionGame = { icon: string; /** * 公开封面的 OSS objectKey;未上传封面时为空,展示层需回退到 coverColor/icon 占位。 - * 通过 `/api/assets/read-url` 换签名 URL 读取,不接受直连外链。 + * 通过 `/api/game-distribution/media/read-url` 换签名 URL 读取,不接受直连外链。 */ coverObjectKey?: string | null; /** 公开截图的 OSS objectKey 列表,最多 6 张,随版本冻结。 */ @@ -169,9 +169,10 @@ export type GameDistributionCreateGameRequest = { description?: string; category: GameDistributionCategory; tags?: string[]; - coverAssetId?: string; - /** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */ - screenshots?: string[]; + /** 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 */ + coverObjectKey?: string | null; + /** 与最终截图顺序一一对应:`string` 沿用线上 objectKey,`null` 取按序的 `screenshot` 二进制 part。 */ + screenshots?: (string | null)[]; deviceSupport: GameDistributionDeviceSupport; inputModes: GameDistributionInputMode[]; orientation: GameDistributionOrientation; @@ -214,9 +215,10 @@ export type GameDistributionUpdateGameMetadataRequest = { description?: string | null; category: GameDistributionCategory; tags?: string[]; - coverAssetId?: string | null; - /** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */ - screenshots?: string[]; + /** 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 */ + coverObjectKey?: string | null; + /** 与最终截图顺序一一对应:`string` 沿用线上 objectKey,`null` 取按序的 `screenshot` 二进制 part。 */ + screenshots?: (string | null)[]; deviceSupport: GameDistributionDeviceSupport; inputModes: GameDistributionInputMode[]; orientation: GameDistributionOrientation; @@ -252,17 +254,10 @@ export type GameDistributionRecoveryAction = | 'fix_package' | 'fix_metadata'; -/** 冻结资料里的截图:同时保留素材 ID(作者续发可复用)与对象键(展示换签用)。 */ -export type GameDistributionFrozenScreenshot = { - assetId: string; - objectKey: string; -}; - /** * 随版本冻结的游戏资料快照。 * - * 只有作者本人(版本回读)与管理员(审核回读)会拿到素材 ID;公开投影只给对象键。 - * 历史版本可能没有快照,读取方必须按空值处理。 + * 发布媒体只保留对象键;历史版本可能没有快照,读取方必须按空值处理。 */ export type GameDistributionVersionFrozenMetadata = { title?: string; @@ -270,9 +265,8 @@ export type GameDistributionVersionFrozenMetadata = { description?: string; category?: GameDistributionCategory; tags?: string[]; - coverAssetId?: string | null; coverObjectKey?: string | null; - screenshots?: GameDistributionFrozenScreenshot[]; + screenshots?: string[]; deviceSupport?: GameDistributionDeviceSupport; inputModes?: GameDistributionInputMode[]; orientation?: GameDistributionOrientation; diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 4fe0fd5f0..012d9d9f9 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -8,6 +8,7 @@ pub mod external_api; pub mod external_generation; pub mod frontend_runtime_config; pub mod game_distribution; +pub mod game_distribution_media; pub mod health; pub mod internal; pub mod platform; diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 249c38ed3..ce89afb56 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -10,7 +10,7 @@ use axum::{ Json, Router, body::{Body, Bytes}, extract::{ - DefaultBodyLimit, Extension, Path, Query, Request, State, + DefaultBodyLimit, Extension, Multipart, Path, Query, Request, State, rejection::{JsonRejection, QueryRejection}, }, http::{HeaderMap, HeaderValue, StatusCode, header}, @@ -76,12 +76,17 @@ use crate::{ auth::{AuthenticatedAccessToken, optional_access_token_from_headers, require_bearer_auth}, game_play_counter::{GamePlayOutcome, GamePlayReport}, http_error::AppError, + object_read, platform_errors::{map_llm_error, map_oss_error}, request_context::{RequestContext, client_ip_from_headers}, state::AppState, tracking::{TrackingEventDraft, record_tracking_event_after_success}, }; +use super::game_distribution_media::{ + MediaKind, PublishMultipart, UploadedImage, parse_publish_multipart, upload_media_image, +}; + pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as usize + 1024; /// 分片续传的固定分片大小:200 MiB 上限下最多 25 片,单片远低于反代放行量。 /// 客户端只能使用服务端下发的值,不得自行改变分片边界,否则权威偏移会立刻对不上。 @@ -97,8 +102,6 @@ const MAX_ADMIN_GAME_LIST_LIMIT: u32 = 200; const ADMIN_GAME_LIST_STATUSES: [&str; 4] = ["published", "unpublished", "suspended", "deleted"]; const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128; const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024; -/// 首版截图上限,与主规范冻结口径一致。 -const MAX_GAME_SCREENSHOTS: usize = 6; const GAME_DISTRIBUTION_OBJECT_PREFIX: &str = "agc/project-snapshots/v1/game-distribution/"; const GAME_DISTRIBUTION_PUBLISHED_STATUS: &str = "published"; /// 发行包 PUT 的尝试次数与退避,口径与 `platform-oss` 的可重试分类一致。 @@ -264,6 +267,85 @@ struct AdminRestoreGameRequest { expected_publication_revision: u64, } +/// 公开媒体读地址的查询参数。 +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GameDistributionMediaReadQuery { + object_key: String, + #[serde(default)] + expire_seconds: Option, +} + +/// 按 objectKey 判定该对象是否是某已发布作品的当前公开媒体;非公开一律 404。 +async fn ensure_public_media_object_key( + state: &AppState, + query: &GameDistributionMediaReadQuery, +) -> Result { + let object_key = query.object_key.trim(); + if object_key.is_empty() { + return Err(bad_request("缺少 objectKey")); + } + let granted = state + .spacetime_client() + .get_game_distribution_media_read_access(object_key.to_string()) + .await + .map_err(map_spacetime_error)?; + if !granted { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + Ok(object_key.to_string()) +} + +/// 公开读取游戏发布媒体(封面/截图)的签名读地址;与素材库 ACL 无关。 +async fn read_game_distribution_media_url( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result, AppError> { + let object_key = ensure_public_media_object_key(&state, &query).await?; + let oss = state.project_snapshot_oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("游戏发布媒体 OSS 未配置") + })?; + let payload = object_read::sign_read_url( + oss, + object_key.as_str(), + object_read::clamp_public_read_expire_seconds( + query.expire_seconds, + object_read::ObjectReadScope::Public, + ), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })) + })?; + Ok(json_success_body(Some(&ctx), json!({ "read": payload }))) +} + +/// 公开同源读取游戏发布媒体字节;授权判定与 `read-url` 同口径。 +async fn read_game_distribution_media_bytes( + State(state): State, + Query(query): Query, +) -> Result { + let object_key = ensure_public_media_object_key(&state, &query).await?; + let oss = state.project_snapshot_oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("游戏发布媒体 OSS 未配置") + })?; + object_read::proxy_read_bytes( + oss, + object_key.as_str(), + object_read::clamp_public_read_expire_seconds( + query.expire_seconds, + object_read::ObjectReadScope::Public, + ), + "game-distribution-media-read-bytes", + ) + .await +} + pub fn router(state: AppState) -> Router { let admin_user_reviews = Router::new() .route( @@ -402,6 +484,14 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/games/{game_id}/plays", post(record_game_play), ) + .route( + "/api/game-distribution/media/read-url", + get(read_game_distribution_media_url), + ) + .route( + "/api/game-distribution/media/read-bytes", + get(read_game_distribution_media_bytes), + ) .route_layer(middleware::from_fn(add_no_store_response_headers)); // 付费作品的播放会话:签发接口按管理员 / 用户令牌分别判定,网关凭令牌读取包内资源。 @@ -1550,7 +1640,7 @@ fn game_metadata_update_as_create_request( description: payload.description.clone(), category: payload.category.clone(), tags: payload.tags.clone(), - cover_asset_id: payload.cover_asset_id.clone(), + cover_object_key: payload.cover_object_key.clone(), screenshots: payload.screenshots.clone(), device_support: payload.device_support.clone(), input_modes: payload.input_modes.clone(), @@ -1611,16 +1701,41 @@ async fn update_owner_game_metadata( Extension(auth): Extension, headers: HeaderMap, Path(game_id): Path, - Json(payload): Json, + multipart: Multipart, ) -> Result, AppError> { ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?; let idempotency_key = idempotency_key(&headers)?; let owner_user_id = auth.claims().user_id().to_string(); - // 资料校验与素材归属解析复用创建游戏同一套:编辑不能绕过"必须有封面/截图必须是本人图片"。 + let PublishMultipart { + metadata, + cover, + screenshots, + } = parse_publish_multipart(multipart).await?; + let payload: GameDistributionUpdateGameMetadataRequest = + serde_json::from_value(metadata).map_err(|_| bad_request("metadata 字段不合法"))?; + // 资料校验与媒体归属解析复用创建游戏同一套:编辑不能绕过"必须有封面/沿用图必须属于本作品"。 let create_metadata = game_metadata_update_as_create_request(&payload); validate_game_metadata(&create_metadata)?; - let (cover_asset_id, cover_object_key, screenshots) = - resolve_owned_game_media(&state, owner_user_id.as_str(), &create_metadata).await?; + let current = state + .spacetime_client() + .get_game_distribution_game(GameDistributionGetGameRecordInput { + game_id: game_id.clone(), + owner_user_id: Some(owner_user_id.clone()), + }) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let request_digest = publish_request_digest(&payload, cover.as_ref(), screenshots.as_slice())?; + let (cover_object_key, resolved_screenshots) = resolve_publish_media( + &state, + game_id.as_str(), + payload.cover_object_key.clone(), + &payload.screenshots, + cover, + screenshots, + Some(¤t), + ) + .await?; let audit = build_game_metadata_update_audit( owner_user_id.as_str(), game_id.as_str(), @@ -1628,10 +1743,6 @@ async fn update_owner_game_metadata( payload.category.as_str(), payload.expected_publication_revision, ); - let request_digest = compute_request_digest( - &serde_json::to_vec(&(game_id.as_str(), &payload)) - .map_err(|error| internal(error.to_string()))?, - ); let log_owner_user_id = owner_user_id.clone(); let log_title = payload.title.clone(); let game = state @@ -1646,10 +1757,10 @@ async fn update_owner_game_metadata( category: payload.category, tags_json: serde_json::to_string(&payload.tags) .map_err(|error| internal(error.to_string()))?, - cover_asset_id: Some(cover_asset_id), cover_object_key: Some(cover_object_key), screenshots_json: Some( - serde_json::to_string(&screenshots).map_err(|error| internal(error.to_string()))?, + serde_json::to_string(&resolved_screenshots) + .map_err(|error| internal(error.to_string()))?, ), device_support_desktop: payload.device_support.desktop, device_support_mobile: payload.device_support.mobile, @@ -1743,31 +1854,50 @@ async fn create_game( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Json(payload): Json, + multipart: Multipart, ) -> Result, AppError> { ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?; let idempotency_key = idempotency_key(&headers)?; + let PublishMultipart { + metadata, + cover, + screenshots, + } = parse_publish_multipart(multipart).await?; + let payload: GameDistributionCreateGameRequest = + serde_json::from_value(metadata).map_err(|_| bad_request("metadata 字段不合法"))?; validate_game_metadata(&payload)?; - // 创建游戏时就把封面/截图的归属与类型校验掉:否则游戏行会先落一个不属于当前作者 - // 或根本不存在的素材 ID,直到创建版本才失败,留下无法解释的半成品资料。 - resolve_owned_game_media(&state, auth.claims().user_id(), &payload).await?; let now = now_micros(); let game_id = format!("game_{}", Uuid::new_v4().simple()); - let request_digest = compute_request_digest( - &serde_json::to_vec(&payload).map_err(|error| internal(error.to_string()))?, - ); + let owner_user_id = auth.claims().user_id().to_string(); + // 幂等摘要只覆盖请求内容(元数据 + 图片字节),不含服务端生成的 objectKey, + // 这样重放同一个 Idempotency-Key 时摘要稳定、命中既有结果。 + let request_digest = publish_request_digest(&payload, cover.as_ref(), screenshots.as_slice())?; + let (cover_object_key, resolved_screenshots) = resolve_publish_media( + &state, + game_id.as_str(), + payload.cover_object_key.clone(), + &payload.screenshots, + cover, + screenshots, + None, + ) + .await?; let game = state .spacetime_client() .create_game_distribution_game(spacetime_client::GameDistributionCreateGameRecordInput { game_id, - owner_user_id: auth.claims().user_id().to_string(), + owner_user_id, title: payload.title, summary: payload.summary, description: payload.description, category: payload.category, tags_json: serde_json::to_string(&payload.tags) .map_err(|error| internal(error.to_string()))?, - cover_asset_id: payload.cover_asset_id, + cover_object_key: Some(cover_object_key), + screenshots_json: Some( + serde_json::to_string(&resolved_screenshots) + .map_err(|error| internal(error.to_string()))?, + ), author_name: None, author_avatar_url: None, device_support_desktop: payload.device_support.desktop, @@ -1792,30 +1922,59 @@ async fn create_version( Extension(auth): Extension, headers: HeaderMap, Path(game_id): Path, - Json(payload): Json, + multipart: Multipart, ) -> Result, AppError> { ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?; let idempotency_key = idempotency_key(&headers)?; + let owner_user_id = auth.claims().user_id().to_string(); + let PublishMultipart { + metadata, + cover, + screenshots, + } = parse_publish_multipart(multipart).await?; + let payload: GameDistributionCreateVersionRequest = + serde_json::from_value(metadata).map_err(|_| bad_request("metadata 字段不合法"))?; validate_version_declaration(&payload)?; + validate_game_metadata(&payload.game_metadata)?; + let current = state + .spacetime_client() + .get_game_distribution_game(GameDistributionGetGameRecordInput { + game_id: game_id.clone(), + owner_user_id: Some(owner_user_id.clone()), + }) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; let now = now_micros(); let version_id = format!("gamever_{}", Uuid::new_v4().simple()); - let metadata_json = resolve_version_metadata_json( + let request_digest = publish_request_digest( + &(game_id.as_str(), &payload), + cover.as_ref(), + screenshots.as_slice(), + )?; + let (cover_object_key, resolved_screenshots) = resolve_publish_media( &state, - auth.claims().user_id(), + game_id.as_str(), + payload.game_metadata.cover_object_key.clone(), + &payload.game_metadata.screenshots, + cover, + screenshots, + Some(¤t), + ) + .await?; + let metadata_json = resolve_version_metadata_json( &payload.game_metadata, + cover_object_key.as_str(), + resolved_screenshots.as_slice(), payload.price_mud_points, ) .await?; - let request_digest = compute_request_digest( - &serde_json::to_vec(&(game_id.as_str(), &payload, metadata_json.as_str())) - .map_err(|error| internal(error.to_string()))?, - ); let version = state .spacetime_client() .create_game_distribution_version( spacetime_client::GameDistributionCreateVersionRecordInput { game_id, - owner_user_id: auth.claims().user_id().to_string(), + owner_user_id, version_id, version_number: payload.version_number, metadata_json, @@ -3409,24 +3568,16 @@ fn validate_game_metadata(payload: &GameDistributionCreateGameRequest) -> Result if payload.device_support.mobile && !payload.device_support.touch { return Err(bad_request("声明支持移动端时必须支持触控")); } - if payload - .cover_asset_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_none() - { - return Err(bad_request("发布游戏必须提供封面")); - } - if payload.screenshots.len() > MAX_GAME_SCREENSHOTS { + if payload.screenshots.len() > super::game_distribution_media::MAX_GAME_SCREENSHOTS { return Err(bad_request("游戏截图最多 6 张")); } if payload .screenshots .iter() - .any(|screenshot| screenshot.trim().is_empty()) + .flatten() + .any(|object_key| object_key.trim().is_empty()) { - return Err(bad_request("游戏截图素材 ID 不能为空")); + return Err(bad_request("游戏截图沿用 objectKey 不能为空")); } Ok(()) } @@ -3602,11 +3753,8 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value { let screenshots = game .screenshots_json .as_deref() - .and_then(|json| serde_json::from_str::>(json).ok()) - .unwrap_or_default() - .into_iter() - .map(|screenshot| screenshot.object_key) - .collect::>(); + .and_then(|json| serde_json::from_str::>(json).ok()) + .unwrap_or_default(); let input_modes = serde_json::from_str::>(&game.input_modes_json) .unwrap_or_default(); @@ -3748,54 +3896,89 @@ async fn ensure_publish_enabled(state: &AppState, user_id: Option<&str>) -> Resu } } -/// 冻结资料快照里的截图素材。 -#[derive(Debug, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct FrozenGameScreenshot { - asset_id: String, - object_key: String, +/// 校验封面/截图归属并生成版本冻结资料 JSON。 +/// +/// 新图由服务端在发布写路径直接上传快照桶并派生 objectKey;沿用槽位必须命中该游戏当前媒体。 +async fn resolve_publish_media( + state: &AppState, + game_id: &str, + cover_object_key: Option, + screenshot_slots: &[Option], + cover: Option, + screenshots: Vec, + current: Option<&GameDistributionGameRecord>, +) -> Result<(String, Vec), AppError> { + let current_cover = current.and_then(|game| game.cover_object_key.clone()); + let current_screenshots = current + .and_then(|game| game.screenshots_json.as_deref()) + .and_then(|json| serde_json::from_str::>(json).ok()) + .unwrap_or_default(); + + let cover_object_key = match cover { + Some(image) => upload_media_image(state, game_id, MediaKind::Cover, &image).await?, + None => { + let requested = cover_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| bad_request("发布游戏必须提供封面"))?; + if current_cover.as_deref() != Some(requested) { + return Err(bad_request("沿用封面必须属于该作品当前媒体")); + } + requested.to_string() + } + }; + + let mut resolved = Vec::with_capacity(screenshot_slots.len()); + let mut new_images = screenshots.into_iter(); + for slot in screenshot_slots { + match slot { + Some(object_key) => { + let requested = object_key.trim(); + if requested.is_empty() { + return Err(bad_request("游戏截图沿用 objectKey 不能为空")); + } + if !current_screenshots.iter().any(|key| key == requested) { + return Err(bad_request("沿用截图必须属于该作品当前媒体")); + } + resolved.push(requested.to_string()); + } + None => { + let image = new_images + .next() + .ok_or_else(|| bad_request("截图二进制 part 数量与槽位不匹配"))?; + resolved + .push(upload_media_image(state, game_id, MediaKind::Screenshot, &image).await?); + } + } + } + if new_images.next().is_some() { + return Err(bad_request("截图二进制 part 数量与槽位不匹配")); + } + Ok((cover_object_key, resolved)) } -/// 校验封面/截图素材归属并生成版本冻结资料 JSON。 -/// -/// 对象键由服务端从素材记录派生,客户端只能提供素材 ID;素材必须属于当前作者且是图片。 -async fn resolve_owned_game_media( - state: &AppState, - owner_user_id: &str, - metadata: &GameDistributionCreateGameRequest, -) -> Result<(String, String, Vec), AppError> { - let cover_asset_id = metadata - .cover_asset_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| bad_request("发布游戏必须提供封面"))? - .to_string(); - let cover_object_key = - resolve_owned_image_object_key(state, owner_user_id, cover_asset_id.as_str()).await?; - let mut screenshots = Vec::with_capacity(metadata.screenshots.len()); - for asset_id in &metadata.screenshots { - let asset_id = asset_id.trim(); - if asset_id.is_empty() { - return Err(bad_request("游戏截图素材 ID 不能为空")); - } - let object_key = resolve_owned_image_object_key(state, owner_user_id, asset_id).await?; - screenshots.push(FrozenGameScreenshot { - asset_id: asset_id.to_string(), - object_key, - }); +/// 发布写请求的幂等摘要:元数据结构化序列化 + 各图片原始字节,覆盖三条写路径。 +fn publish_request_digest( + payload: &T, + cover: Option<&UploadedImage>, + screenshots: &[UploadedImage], +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(serde_json::to_vec(payload).map_err(|error| internal(error.to_string()))?); + hasher.update([u8::from(cover.is_some())]); + for image in cover.into_iter().chain(screenshots.iter()) { + hasher.update(Sha256::digest(image.bytes.as_slice())); } - Ok((cover_asset_id, cover_object_key, screenshots)) + Ok(format!("{:x}", hasher.finalize())) } async fn resolve_version_metadata_json( - state: &AppState, - owner_user_id: &str, metadata: &GameDistributionCreateGameRequest, + cover_object_key: &str, + screenshots: &[String], price_mud_points: u64, ) -> Result { - let (cover_asset_id, cover_object_key, screenshots) = - resolve_owned_game_media(state, owner_user_id, metadata).await?; let tags = metadata .tags .iter() @@ -3811,7 +3994,6 @@ async fn resolve_version_metadata_json( .unwrap_or_else(|| metadata.summary.trim().to_string()), "category": metadata.category, "tags": tags, - "coverAssetId": cover_asset_id, "coverObjectKey": cover_object_key, "screenshots": screenshots, "deviceSupport": { @@ -3827,28 +4009,6 @@ async fn resolve_version_metadata_json( serde_json::to_string(&snapshot).map_err(|error| internal(error.to_string())) } -async fn resolve_owned_image_object_key( - state: &AppState, - owner_user_id: &str, - asset_object_id: &str, -) -> Result { - let asset = state - .spacetime_client() - .get_asset_object(asset_object_id.to_string()) - .await - .map_err(map_spacetime_error)? - .ok_or_else(|| bad_request("封面或截图素材不存在"))?; - if asset.owner_user_id.as_deref() != Some(owner_user_id) { - return Err(AppError::from_status(StatusCode::FORBIDDEN) - .with_message("封面或截图素材不属于当前账号")); - } - let content_type = asset.content_type.as_deref().unwrap_or(""); - if !content_type.starts_with("image/") { - return Err(bad_request("封面和截图必须是图片素材")); - } - Ok(asset.object_key) -} - /// 读取当前主体名下的版本;未知版本和别人的版本都按不可见处理(404)。 async fn load_owner_version_or_404( state: &AppState, @@ -4708,7 +4868,7 @@ mod tests { description: Some("描述".to_string()), category: "益智".to_string(), tags: vec!["测试".to_string()], - cover_asset_id: None, + cover_object_key: None, device_support: GameDistributionDeviceSupport { desktop: true, mobile: false, @@ -4800,19 +4960,9 @@ mod tests { } #[test] - fn metadata_requires_cover_and_limits_screenshots() { + fn metadata_limits_screenshots() { let mut payload = metadata(); - payload.cover_asset_id = None; - assert_eq!( - validate_game_metadata(&payload) - .expect_err("缺少封面必须被拒") - .status_code(), - StatusCode::BAD_REQUEST - ); - - let mut payload = metadata(); - payload.cover_asset_id = Some("asset_cover".to_string()); - payload.screenshots = (0..7).map(|index| format!("asset_{index}")).collect(); + payload.screenshots = (0..7).map(|index| Some(format!("asset_{index}"))).collect(); assert_eq!( validate_game_metadata(&payload) .expect_err("超过 6 张截图必须被拒") @@ -4821,9 +4971,31 @@ mod tests { ); let mut payload = metadata(); - payload.cover_asset_id = Some("asset_cover".to_string()); - payload.screenshots = (0..6).map(|index| format!("asset_{index}")).collect(); - validate_game_metadata(&payload).expect("封面 + 6 张截图应通过校验"); + payload.screenshots = (0..6).map(|index| Some(format!("asset_{index}"))).collect(); + validate_game_metadata(&payload).expect("6 张截图应通过校验"); + } + + #[test] + fn publish_request_digest_covers_metadata_and_image_bytes() { + let payload = metadata(); + let image = |bytes: &[u8]| UploadedImage { + content_type: "image/png".to_string(), + bytes: bytes.to_vec(), + }; + let base = publish_request_digest(&payload, None, &[]).expect("摘要"); + assert_eq!( + base, + publish_request_digest(&payload, None, &[]).expect("摘要") + ); + let with_cover = + publish_request_digest(&payload, Some(&image(b"cover-a")), &[]).expect("摘要"); + assert_ne!(base, with_cover); + let with_other_cover = + publish_request_digest(&payload, Some(&image(b"cover-b")), &[]).expect("摘要"); + assert_ne!(with_cover, with_other_cover); + let with_screenshot = + publish_request_digest(&payload, None, &[image(b"cover-a")]).expect("摘要"); + assert_ne!(base, with_screenshot); } #[test] @@ -4836,7 +5008,6 @@ mod tests { description: "描述".to_string(), category: "益智".to_string(), tags_json: "[]".to_string(), - cover_asset_id: Some("asset_cover".to_string()), author_name: None, author_avatar_url: None, device_support_desktop: true, @@ -4852,9 +5023,7 @@ mod tests { updated_at: "2026-09-20T00:00:00Z".to_string(), local_project_id: None, cover_object_key: Some("generated/game-cover.png".to_string()), - screenshots_json: Some( - r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(), - ), + screenshots_json: Some(r#"["generated/shot-1.png"]"#.to_string()), price_mud_points: 0, }; let payload = game_payload(&game); @@ -4903,7 +5072,6 @@ mod tests { description: "描述".to_string(), category: "益智".to_string(), tags_json: "[]".to_string(), - cover_asset_id: Some("asset_cover".to_string()), author_name: None, author_avatar_url: None, device_support_desktop: true, @@ -4938,19 +5106,19 @@ mod tests { created_at: "2026-09-20T00:00:00Z".to_string(), updated_at: "2026-09-20T00:00:00Z".to_string(), metadata_json: Some( - r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/game-cover.png","screenshots":[{"assetId":"asset_shot","objectKey":"generated/shot.png"}]}"# + r#"{"coverObjectKey":"generated/game-cover.png","screenshots":["generated/shot.png"]}"# .to_string(), ), }; let payload = version_detail_payload(&version, &game); assert_eq!( - payload["version"]["frozenMetadata"]["coverAssetId"], - Value::String("asset_cover".to_string()) + payload["version"]["frozenMetadata"]["coverObjectKey"], + Value::String("generated/game-cover.png".to_string()) ); assert_eq!( - payload["version"]["frozenMetadata"]["screenshots"][0]["assetId"], - Value::String("asset_shot".to_string()) + payload["version"]["frozenMetadata"]["screenshots"][0], + Value::String("generated/shot.png".to_string()) ); // 历史版本没有冻结资料时按 null 返回,客户端必须按空值处理。 @@ -4965,7 +5133,7 @@ mod tests { let game = paid_game_record("user_author", 240); let version = public_version_record( Some("/games/game_1/"), - Some(r#"{"coverAssetId":"asset_cover","coverObjectKey":"generated/cover.png"}"#), + Some(r#"{"coverObjectKey":"generated/cover.png"}"#), ); let payload = public_game_payload( public_game_record(game, version), @@ -4993,7 +5161,6 @@ mod tests { description: "描述".to_string(), category: "益智".to_string(), tags_json: "[]".to_string(), - cover_asset_id: Some("asset_cover".to_string()), author_name: None, author_avatar_url: None, device_support_desktop: true, @@ -5190,6 +5357,31 @@ mod tests { .expect("路由响应"); assert_eq!(unauthenticated_create.status(), StatusCode::UNAUTHORIZED); + // 公开媒体读地址已挂载:缺 objectKey 由查询参数层拒绝,带 objectKey 走到 SpacetimeDB。 + let media_read_missing_key = app + .clone() + .oneshot( + Request::builder() + .uri("/api/game-distribution/media/read-url") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!(media_read_missing_key.status(), StatusCode::BAD_REQUEST); + + let media_read_with_key = app + .clone() + .oneshot( + Request::builder() + .uri("/api/game-distribution/media/read-url?objectKey=generated%2Fcover.png") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!(media_read_with_key.status(), StatusCode::BAD_GATEWAY); + // 作者作品详情是 owner 作用域路由,未带 Bearer 时同样在进入业务前被拒绝。 let unauthenticated_owner_game = app .clone() @@ -6124,8 +6316,8 @@ mod tests { description: Some("新描述".to_string()), category: "模拟".to_string(), tags: vec!["放置".to_string()], - cover_asset_id: Some("asset_cover".to_string()), - screenshots: vec!["asset_shot".to_string()], + cover_object_key: Some("generated/cover.png".to_string()), + screenshots: vec![Some("generated/shot.png".to_string())], device_support: GameDistributionDeviceSupport { desktop: true, mobile: true, @@ -6140,24 +6332,22 @@ mod tests { assert_eq!(converted.title, "新标题"); assert_eq!(converted.category, "模拟"); assert_eq!(converted.tags, vec!["放置".to_string()]); - assert_eq!(converted.cover_asset_id.as_deref(), Some("asset_cover")); - assert_eq!(converted.screenshots, vec!["asset_shot".to_string()]); + assert_eq!( + converted.cover_object_key.as_deref(), + Some("generated/cover.png") + ); + assert_eq!( + converted.screenshots, + vec![Some("generated/shot.png".to_string())] + ); assert!(converted.device_support.touch); assert_eq!( converted.input_modes, vec![GameDistributionInputMode::Touch] ); assert_eq!(converted.orientation, GameDistributionOrientation::Portrait); - // 同一套校验:合法资料通过,缺封面仍然被拒。 + // 同一套校验:合法资料通过。 validate_game_metadata(&converted).expect("合法资料应通过创建口径的校验"); - let mut without_cover = converted; - without_cover.cover_asset_id = None; - assert_eq!( - validate_game_metadata(&without_cover) - .expect_err("缺少封面必须被拒") - .status_code(), - StatusCode::BAD_REQUEST - ); } #[test] @@ -6278,7 +6468,6 @@ mod tests { description: "描述".to_string(), category: "益智".to_string(), tags_json: "[]".to_string(), - cover_asset_id: Some("asset_cover".to_string()), author_name: None, author_avatar_url: None, device_support_desktop: true, @@ -6352,7 +6541,7 @@ mod tests { description: None, category: "益智".to_string(), tags: vec![], - cover_asset_id: Some("asset_cover".to_string()), + cover_object_key: None, screenshots: vec![], device_support: GameDistributionDeviceSupport { desktop: true, @@ -6478,7 +6667,7 @@ mod tests { assert_eq!(detail["version"]["priceMudPoints"], json!(240)); // 历史版本缺 priceMudPoints:列表与详情都按免费(0),不回退游戏行当前价 999。 - version.metadata_json = Some(r#"{"coverAssetId":"asset_cover"}"#.to_string()); + version.metadata_json = Some(r#"{"coverObjectKey":"generated/cover.png"}"#.to_string()); assert_eq!( private_version_payload(&version)["priceMudPoints"], json!(0) diff --git a/server-rs/crates/api-server/src/modules/game_distribution_media.rs b/server-rs/crates/api-server/src/modules/game_distribution_media.rs new file mode 100644 index 000000000..75ee039ed --- /dev/null +++ b/server-rs/crates/api-server/src/modules/game_distribution_media.rs @@ -0,0 +1,184 @@ +//! 游戏分发发布媒体的 multipart 解析与对象写入。 +//! +//! 三条写路径(创建游戏、创建版本、编辑资料)统一收 `multipart/form-data`: +//! 一个 `metadata` 文本 part + 可选 `cover` 二进制 part + 按出现顺序的 `screenshot` 二进制 part。 +//! 新图由服务端写入项目快照桶并只回 objectKey,不建 `asset_object`,与素材库 ACL 解耦。 + +use std::collections::BTreeMap; + +use axum::{extract::Multipart, http::StatusCode}; +use platform_oss::{OssInternalPutObjectRequest, OssObjectAccess}; +use uuid::Uuid; + +use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState}; + +/// 发布媒体与发行包同桶,路径按 gameId 分组。 +pub(crate) const GAME_DISTRIBUTION_MEDIA_PREFIX: &str = + "agc/project-snapshots/v1/game-distribution/media/"; +pub(crate) const MAX_GAME_SCREENSHOTS: usize = 6; +const MAX_SINGLE_MEDIA_BYTES: usize = 8 * 1024 * 1024; + +/// 单个图片 part 的原始字节与声明类型。 +pub(crate) struct UploadedImage { + pub content_type: String, + pub bytes: Vec, +} + +/// 一次发布写请求里的图片素材(元数据 JSON 与文件 part 分离解析)。 +pub(crate) struct PublishMultipart { + pub metadata: serde_json::Value, + pub cover: Option, + pub screenshots: Vec, +} + +#[derive(Clone, Copy)] +pub(crate) enum MediaKind { + Cover, + Screenshot, +} + +impl MediaKind { + fn slug(self) -> &'static str { + match self { + MediaKind::Cover => "cover", + MediaKind::Screenshot => "screenshot", + } + } + + fn label(self) -> &'static str { + match self { + MediaKind::Cover => "封面", + MediaKind::Screenshot => "截图", + } + } +} + +/// 解析发布写请求;`metadata` 的 JSON 形状由各端点自行反序列化。 +pub(crate) async fn parse_publish_multipart( + mut multipart: Multipart, +) -> Result { + let mut metadata: Option = None; + let mut cover: Option = None; + let mut screenshots: Vec = Vec::new(); + + while let Some(field) = multipart.next_field().await.map_err(|error| { + tracing::warn!(error = %error, "游戏分发 multipart 字段解析失败"); + bad_request("multipart 请求无效") + })? { + let name = field.name().unwrap_or_default().to_string(); + match name.as_str() { + "metadata" => { + if metadata.is_some() { + return Err(bad_request("metadata 字段只能出现一次")); + } + let text = field + .text() + .await + .map_err(|_| bad_request("metadata 字段读取失败"))?; + metadata = Some( + serde_json::from_str(&text) + .map_err(|_| bad_request("metadata 字段不是合法 JSON"))?, + ); + } + "cover" => { + if cover.is_some() { + return Err(bad_request("cover 字段只能出现一次")); + } + cover = Some(read_image_field(field, MediaKind::Cover).await?); + } + "screenshot" => { + if screenshots.len() >= MAX_GAME_SCREENSHOTS { + return Err(bad_request(format!( + "游戏截图最多 {MAX_GAME_SCREENSHOTS} 张" + ))); + } + screenshots.push(read_image_field(field, MediaKind::Screenshot).await?); + } + other => return Err(bad_request(format!("不支持的 multipart 字段:{other}"))), + } + } + + let metadata = metadata.ok_or_else(|| bad_request("缺少 metadata 字段"))?; + Ok(PublishMultipart { + metadata, + cover, + screenshots, + }) +} + +/// 写入新图并返回 objectKey;失败按 OSS 错误口径上抛。 +pub(crate) async fn upload_media_image( + state: &AppState, + game_id: &str, + kind: MediaKind, + image: &UploadedImage, +) -> Result { + let oss = state.project_snapshot_oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("游戏发布媒体 OSS 未配置") + })?; + let object_key = format!( + "{GAME_DISTRIBUTION_MEDIA_PREFIX}{game_id}/{}-{}.{}", + kind.slug(), + Uuid::new_v4().simple(), + media_extension(image.content_type.as_str()), + ); + oss.put_internal_object( + state.editor_oss_http_client(), + OssInternalPutObjectRequest { + object_key: object_key.clone(), + content_type: Some(image.content_type.clone()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: image.bytes.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + Ok(object_key) +} + +async fn read_image_field( + field: axum::extract::multipart::Field<'_>, + kind: MediaKind, +) -> Result { + let label = kind.label(); + let content_type = field.content_type().unwrap_or_default().trim().to_string(); + if !content_type.starts_with("image/") { + return Err(bad_request(format!("{label}必须是图片"))); + } + let bytes = field + .bytes() + .await + .map_err(|_| bad_request(format!("{label}读取失败")))?; + if bytes.is_empty() { + return Err(bad_request(format!("{label}不能为空"))); + } + if bytes.len() > MAX_SINGLE_MEDIA_BYTES { + return Err( + AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_message(format!( + "{label}超过大小上限:{}MB", + MAX_SINGLE_MEDIA_BYTES / 1024 / 1024 + )), + ); + } + Ok(UploadedImage { + content_type, + bytes: bytes.to_vec(), + }) +} + +fn media_extension(content_type: &str) -> &'static str { + match content_type.trim().to_ascii_lowercase().as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + "image/gif" => "gif", + "image/avif" => "avif", + _ => "bin", + } +} + +fn bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) +} diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 93d753fe3..c9209a522 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -226,11 +226,12 @@ pub struct GameDistributionCreateGameRequest { pub category: String, #[serde(default)] pub tags: Vec, + /// 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 #[serde(default)] - pub cover_asset_id: Option, - /// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 + pub cover_object_key: Option, + /// 与最终截图顺序一一对应:`Some(objectKey)` 沿用线上,`None` 取按顺序出现的 `screenshot` 二进制 part。 #[serde(default)] - pub screenshots: Vec, + pub screenshots: Vec>, pub device_support: GameDistributionDeviceSupport, pub input_modes: Vec, pub orientation: GameDistributionOrientation, @@ -275,11 +276,12 @@ pub struct GameDistributionUpdateGameMetadataRequest { pub category: String, #[serde(default)] pub tags: Vec, + /// 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 #[serde(default)] - pub cover_asset_id: Option, - /// 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 + pub cover_object_key: Option, + /// 与最终截图顺序一一对应:`Some(objectKey)` 沿用线上,`None` 取按顺序出现的 `screenshot` 二进制 part。 #[serde(default)] - pub screenshots: Vec, + pub screenshots: Vec>, pub device_support: GameDistributionDeviceSupport, pub input_modes: Vec, pub orientation: GameDistributionOrientation, @@ -480,18 +482,27 @@ mod tests { "title": "游戏", "summary": "简介", "category": "益智", - "coverAssetId": "asset-cover", - "screenshots": ["asset-shot"], + "coverObjectKey": "generated/cover.png", + "screenshots": [null, "generated/shot.png"], "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")); + assert_eq!( + payload.cover_object_key.as_deref(), + Some("generated/cover.png") + ); + assert_eq!( + payload.screenshots, + vec![None, Some("generated/shot.png".to_string())] + ); let serialized = serde_json::to_value(&payload).expect("资料编辑请求应可序列化"); assert_eq!(serialized["expectedPublicationRevision"], 3); - assert_eq!(serialized["coverAssetId"], "asset-cover"); + assert_eq!(serialized["coverObjectKey"], "generated/cover.png"); + assert_eq!(serialized["screenshots"][0], serde_json::Value::Null); + assert_eq!(serialized["screenshots"][1], "generated/shot.png"); } #[test] diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index d84f9c04e..6280b952b 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -164,7 +164,6 @@ pub struct GameDistributionGameRecord { pub description: String, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub author_name: Option, pub author_avatar_url: Option, pub device_support_desktop: bool, @@ -274,7 +273,6 @@ fn map_game( description: value.description, category: value.category, tags_json: value.tags_json, - cover_asset_id: value.cover_asset_id, author_name: value.author_name, author_avatar_url: value.author_avatar_url, device_support_desktop: value.device_support_desktop, diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 23a09976c..8d3e8b512 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -76,7 +76,6 @@ pub struct GameDistributionCreateGameRecordInput { pub description: Option, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub author_name: Option, pub author_avatar_url: Option, pub device_support_desktop: bool, @@ -89,6 +88,10 @@ pub struct GameDistributionCreateGameRecordInput { pub now_micros: i64, /// 同一作者的本地项目标识;重复发布会复用既有 game_id。 pub local_project_id: Option, + /// 创建时即持久化的封面 OSS objectKey(发布媒体直传)。 + pub cover_object_key: Option, + /// 创建时即持久化的截图 OSS objectKey JSON 数组。 + pub screenshots_json: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -221,7 +224,6 @@ pub struct GameDistributionUpdateMetadataRecordInput { pub description: Option, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub cover_object_key: Option, pub screenshots_json: Option, pub device_support_desktop: bool, @@ -626,6 +628,38 @@ impl SpacetimeClient { .await } + /// 按 OSS objectKey 判定公开媒体是否可匿名读取;只回公开授权布尔。 + pub async fn get_game_distribution_media_read_access( + &self, + object_key: String, + ) -> Result { + self.call_after_connect( + "get_game_distribution_media_read_access_and_return", + move |connection, sender| { + connection + .procedures() + .get_game_distribution_media_read_access_and_return_then( + object_key, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(|result| { + if result.ok { + Ok(result.public_read_granted) + } else { + Err(SpacetimeClientError::procedure_failed( + result.error_message, + )) + } + }); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + /// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。 pub async fn increment_game_distribution_game_play_counts( &self, @@ -681,7 +715,6 @@ impl SpacetimeClient { description: input.description, category: input.category, tags_json: input.tags_json, - cover_asset_id: input.cover_asset_id, author_name: input.author_name, author_avatar_url: input.author_avatar_url, device_support_desktop: input.device_support_desktop, @@ -693,6 +726,8 @@ impl SpacetimeClient { request_digest: input.request_digest, now_micros: input.now_micros, local_project_id: input.local_project_id, + cover_object_key: input.cover_object_key, + screenshots_json: input.screenshots_json, }; self.call_after_connect( "create_game_distribution_game", @@ -1140,7 +1175,6 @@ impl SpacetimeClient { description: input.description, category: input.category, tags_json: input.tags_json, - cover_asset_id: input.cover_asset_id, cover_object_key: input.cover_object_key, screenshots_json: input.screenshots_json, device_support_desktop: input.device_support_desktop, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index fe30047c0..ff946828f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -444,6 +444,7 @@ pub mod game_distribution_game_type; pub mod game_distribution_get_game_input_type; pub mod game_distribution_idempotency_receipt_table; pub mod game_distribution_idempotency_receipt_type; +pub mod game_distribution_media_read_access_procedure_result_type; pub mod game_distribution_my_review_input_type; pub mod game_distribution_owner_game_list_input_type; pub mod game_distribution_owner_game_list_procedure_result_type; @@ -507,6 +508,7 @@ pub mod get_external_generation_job_summary_and_return_procedure; pub mod get_external_generation_queue_stats_and_return_procedure; pub mod get_feature_gate_config_procedure; pub mod get_game_distribution_game_and_return_procedure; +pub mod get_game_distribution_media_read_access_and_return_procedure; pub mod get_game_distribution_my_review_and_return_procedure; pub mod get_game_distribution_purchase_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; @@ -1359,6 +1361,7 @@ pub use game_distribution_game_type::GameDistributionGame; pub use game_distribution_get_game_input_type::GameDistributionGetGameInput; pub use game_distribution_idempotency_receipt_table::*; pub use game_distribution_idempotency_receipt_type::GameDistributionIdempotencyReceipt; +pub use game_distribution_media_read_access_procedure_result_type::GameDistributionMediaReadAccessProcedureResult; pub use game_distribution_my_review_input_type::GameDistributionMyReviewInput; pub use game_distribution_owner_game_list_input_type::GameDistributionOwnerGameListInput; pub use game_distribution_owner_game_list_procedure_result_type::GameDistributionOwnerGameListProcedureResult; @@ -1422,6 +1425,7 @@ pub use get_external_generation_job_summary_and_return_procedure::get_external_g pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return; pub use get_feature_gate_config_procedure::get_feature_gate_config; pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return; +pub use get_game_distribution_media_read_access_and_return_procedure::get_game_distribution_media_read_access_and_return; pub use get_game_distribution_my_review_and_return_procedure::get_game_distribution_my_review_and_return; pub use get_game_distribution_purchase_and_return_procedure::get_game_distribution_purchase_and_return; pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs index c9279cef3..1653049f4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs @@ -14,7 +14,6 @@ pub struct GameDistributionCreateGameInput { pub description: Option, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub author_name: Option, pub author_avatar_url: Option, pub device_support_desktop: bool, @@ -26,6 +25,8 @@ pub struct GameDistributionCreateGameInput { pub request_digest: String, pub now_micros: i64, pub local_project_id: Option, + pub cover_object_key: Option, + pub screenshots_json: Option, } impl __sdk::InModule for GameDistributionCreateGameInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs index 4a0feb68b..2d12c4065 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs @@ -14,7 +14,6 @@ pub struct GameDistributionGameSnapshot { pub description: String, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub author_name: Option, pub author_avatar_url: Option, pub device_support_desktop: bool, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs index 02da2a911..495167a9a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs @@ -14,7 +14,6 @@ pub struct GameDistributionGame { pub description: String, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub author_name: Option, pub author_avatar_url: Option, pub device_support_desktop: bool, @@ -50,7 +49,6 @@ pub struct GameDistributionGameCols { pub description: __sdk::__query_builder::Col, pub category: __sdk::__query_builder::Col, pub tags_json: __sdk::__query_builder::Col, - pub cover_asset_id: __sdk::__query_builder::Col>, pub author_name: __sdk::__query_builder::Col>, pub author_avatar_url: __sdk::__query_builder::Col>, pub device_support_desktop: __sdk::__query_builder::Col, @@ -82,7 +80,6 @@ impl __sdk::__query_builder::HasCols for GameDistributionGame { description: __sdk::__query_builder::Col::new(table_name, "description"), category: __sdk::__query_builder::Col::new(table_name, "category"), tags_json: __sdk::__query_builder::Col::new(table_name, "tags_json"), - cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), author_name: __sdk::__query_builder::Col::new(table_name, "author_name"), author_avatar_url: __sdk::__query_builder::Col::new(table_name, "author_avatar_url"), device_support_desktop: __sdk::__query_builder::Col::new( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_media_read_access_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_media_read_access_procedure_result_type.rs new file mode 100644 index 000000000..e7e55acf6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_media_read_access_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionMediaReadAccessProcedureResult { + pub ok: bool, + pub public_read_granted: bool, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionMediaReadAccessProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_metadata_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_metadata_input_type.rs index a99c2c59c..e9f644545 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_metadata_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_metadata_input_type.rs @@ -15,7 +15,6 @@ pub struct GameDistributionUpdateMetadataInput { pub description: Option, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub cover_object_key: Option, pub screenshots_json: Option, pub device_support_desktop: bool, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_media_read_access_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_media_read_access_and_return_procedure.rs new file mode 100644 index 000000000..e1df48423 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_media_read_access_and_return_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::game_distribution_media_read_access_procedure_result_type::GameDistributionMediaReadAccessProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetGameDistributionMediaReadAccessAndReturnArgs { + pub object_key: String, +} + +impl __sdk::InModule for GetGameDistributionMediaReadAccessAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_game_distribution_media_read_access_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_game_distribution_media_read_access_and_return { + fn get_game_distribution_media_read_access_and_return(&self, object_key: String) { + self.get_game_distribution_media_read_access_and_return_then(object_key, |_, _| {}); + } + + fn get_game_distribution_media_read_access_and_return_then( + &self, + object_key: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_game_distribution_media_read_access_and_return for super::RemoteProcedures { + fn get_game_distribution_media_read_access_and_return_then( + &self, + object_key: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionMediaReadAccessProcedureResult>( + "get_game_distribution_media_read_access_and_return", + GetGameDistributionMediaReadAccessAndReturnArgs { object_key }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 72710eb96..45b4baea4 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -6357,13 +6357,7 @@ pub(crate) fn asset_location_has_public_showcase_read_grant( asset_object: Option<&module_assets::AssetObjectUpsertSnapshot>, ) -> bool { let asset_object_granted = asset_object - .is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object)) - || asset_object.is_some_and(|asset_object| { - crate::game_distribution::game_distribution_asset_has_public_read_grant( - ctx, - asset_object.asset_object_id.as_str(), - ) - }); + .is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object)); let campaign_config = ctx .db .editor_showcase_campaign_config() diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 310afa888..c8b8b6f46 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -738,7 +738,6 @@ pub struct GameDistributionGame { pub(crate) description: String, pub(crate) category: String, pub(crate) tags_json: String, - pub(crate) cover_asset_id: Option, pub(crate) author_name: Option, pub(crate) author_avatar_url: Option, pub(crate) device_support_desktop: bool, @@ -758,7 +757,7 @@ pub struct GameDistributionGame { /// 审核通过后生效的封面对象键;客户端用它换平台签名读地址。 #[default(None::)] pub(crate) cover_object_key: Option, - /// 审核通过后生效的截图对象键 JSON 数组(与 `cover_asset_id` 同源,来自版本冻结资料)。 + /// 审核通过后生效的截图对象键 JSON 数组;每项是 OSS objectKey 字符串,来自版本冻结资料。 #[default(None::)] pub(crate) screenshots_json: Option, /// 作者软删除时间。非空表示该作品已从作者视图、公开目录/详情与发行网关下线; @@ -950,7 +949,6 @@ pub struct GameDistributionCreateGameInput { pub description: Option, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub author_name: Option, pub author_avatar_url: Option, pub device_support_desktop: bool, @@ -963,6 +961,10 @@ pub struct GameDistributionCreateGameInput { pub now_micros: i64, /// 同一作者的本地项目标识;重复发布会复用既有 game_id 而不是新建游戏。 pub local_project_id: Option, + /// 创建时即持久化的封面 OSS objectKey(发布媒体直传,不再依赖 assetId)。 + pub cover_object_key: Option, + /// 创建时即持久化的截图 OSS objectKey JSON 数组。 + pub screenshots_json: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1115,7 +1117,6 @@ pub struct GameDistributionUpdateMetadataInput { pub description: Option, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub cover_object_key: Option, pub screenshots_json: Option, pub device_support_desktop: bool, @@ -1191,7 +1192,6 @@ pub struct GameDistributionGameSnapshot { pub description: String, pub category: String, pub tags_json: String, - pub cover_asset_id: Option, pub author_name: Option, pub author_avatar_url: Option, pub device_support_desktop: bool, @@ -1340,6 +1340,14 @@ pub struct GameDistributionGetGameInput { pub owner_user_id: Option, } +/// 公开媒体读权限判定结果:只暴露 OSS objectKey 是否可被匿名读者访问。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionMediaReadAccessProcedureResult { + pub ok: bool, + pub public_read_granted: bool, + pub error_message: Option, +} + /// 买断制购买输入。`expected_price_mud_points` 与服务端当前价格做 CAS, /// 不一致说明作者已通过新版本调价,必须刷新后重试。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1845,6 +1853,39 @@ pub fn get_public_game_distribution_game_and_return( } } +/// 按 OSS objectKey 判定公开可读:只读判定,不接受 assetId。 +/// +/// 与资产读链路解耦:公开游戏的封面 objectKey 或截图 objectKey 列表命中即授权; +/// 未公开、已暂停、已删除、无有效版本的游戏不授权。 +#[spacetimedb::procedure] +pub fn get_game_distribution_media_read_access_and_return( + ctx: &mut ProcedureContext, + object_key: String, +) -> GameDistributionMediaReadAccessProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + let object_key = object_key.trim(); + if object_key.is_empty() { + return Err("object_key 不能为空".to_string()); + } + Ok(game_distribution_media_has_public_read_grant( + tx, object_key, + )) + }) { + Ok(public_read_granted) => GameDistributionMediaReadAccessProcedureResult { + ok: true, + public_read_granted, + error_message: None, + }, + Err(error) => GameDistributionMediaReadAccessProcedureResult { + ok: false, + public_read_granted: false, + error_message: Some(error), + }, + } +} + /// 管理员读取任意版本状态;admin 权限由 api-server 在调用前校验,procedure 只接受受信服务身份。 #[spacetimedb::procedure] pub fn get_game_distribution_version_and_return( @@ -2037,7 +2078,6 @@ fn create_game_distribution_game_tx( description: input.description.unwrap_or_default(), category, tags_json: input.tags_json, - cover_asset_id: input.cover_asset_id, author_name: input.author_name, author_avatar_url: input.author_avatar_url, device_support_desktop: input.device_support_desktop, @@ -2052,8 +2092,8 @@ fn create_game_distribution_game_tx( created_at: now, updated_at: now, local_project_id, - cover_object_key: None, - screenshots_json: None, + cover_object_key: input.cover_object_key, + screenshots_json: input.screenshots_json, deleted_at: None, // 新建游戏固定免费;付费价格只在审核通过时随版本冻结资料生效。 price_mud_points: 0, @@ -3161,7 +3201,6 @@ fn update_game_distribution_game_metadata_tx( game.description = input.description.unwrap_or_default(); game.category = required_game_distribution_text(input.category, "category")?; game.tags_json = input.tags_json; - game.cover_asset_id = input.cover_asset_id; game.cover_object_key = input.cover_object_key; game.screenshots_json = input.screenshots_json; game.device_support_desktop = input.device_support_desktop; @@ -3674,10 +3713,7 @@ fn get_public_game_distribution_game_tx( let Some(game) = ctx.db.game_distribution_game().game_id().find(&game_id) else { return Ok(None); }; - if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED - || game.active_version_id.is_none() - || game.deleted_at.is_some() - { + if !game_distribution_game_is_publicly_visible(&game) { return Ok(None); } Ok(public_game_distribution_snapshot(ctx, &game)) @@ -4084,7 +4120,6 @@ fn game_distribution_game_snapshot(game: &GameDistributionGame) -> GameDistribut description: game.description.clone(), category: game.category.clone(), tags_json: game.tags_json.clone(), - cover_asset_id: game.cover_asset_id.clone(), author_name: game.author_name.clone(), author_avatar_url: game.author_avatar_url.clone(), device_support_desktop: game.device_support_desktop, @@ -4116,10 +4151,9 @@ pub(crate) struct GameDistributionFrozenMetadata { pub category: String, #[serde(default)] pub tags: Vec, - pub cover_asset_id: String, pub cover_object_key: String, #[serde(default)] - pub screenshots: Vec, + pub screenshots: Vec, pub device_support: GameDistributionFrozenDeviceSupport, #[serde(default)] pub input_modes: Vec, @@ -4129,13 +4163,6 @@ pub(crate) struct GameDistributionFrozenMetadata { pub price_mud_points: u64, } -#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GameDistributionFrozenScreenshot { - pub asset_id: String, - pub object_key: String, -} - #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] pub(crate) struct GameDistributionFrozenDeviceSupport { pub desktop: bool, @@ -4162,7 +4189,6 @@ fn apply_game_distribution_frozen_metadata( game.description = frozen.description.clone().unwrap_or_default(); game.category = frozen.category.clone(); game.tags_json = serde_json::to_string(&frozen.tags).unwrap_or_else(|_| "[]".to_string()); - game.cover_asset_id = Some(frozen.cover_asset_id.clone()); game.cover_object_key = Some(frozen.cover_object_key.clone()); game.screenshots_json = serde_json::to_string(&frozen.screenshots).ok(); game.device_support_desktop = frozen.device_support.desktop; @@ -4175,31 +4201,29 @@ fn apply_game_distribution_frozen_metadata( game.price_mud_points = frozen.price_mud_points; } -/// 已公开游戏的封面/截图素材允许匿名读者换取签名读地址。 -pub(crate) fn game_distribution_asset_has_public_read_grant( +/// 公开可见判定与公开目录/公开详情一致:已发布、已有有效版本且未软删除。 +fn game_distribution_game_is_publicly_visible(game: &GameDistributionGame) -> bool { + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED + && game.active_version_id.is_some() + && game.deleted_at.is_none() +} + +/// 已公开游戏的封面/截图 OSS objectKey 允许匿名读者换取签名读地址。 +pub(crate) fn game_distribution_media_has_public_read_grant( ctx: &ReducerContext, - asset_object_id: &str, + object_key: &str, ) -> bool { ctx.db.game_distribution_game().iter().any(|game| { - if game.visibility != GAME_DISTRIBUTION_VISIBILITY_PUBLISHED - || game.active_version_id.is_none() - || game.deleted_at.is_some() - { + if !game_distribution_game_is_publicly_visible(&game) { return false; } - if game.cover_asset_id.as_deref() == Some(asset_object_id) { + if game.cover_object_key.as_deref() == Some(object_key) { return true; } game.screenshots_json .as_deref() - .and_then(|json| { - serde_json::from_str::>(json).ok() - }) - .is_some_and(|screenshots| { - screenshots - .iter() - .any(|screenshot| screenshot.asset_id == asset_object_id) - }) + .and_then(|json| serde_json::from_str::>(json).ok()) + .is_some_and(|screenshots| screenshots.iter().any(|key| key == object_key)) }) } @@ -4349,7 +4373,6 @@ mod tests { description: String::new(), category: "其他".to_string(), tags_json: "[]".to_string(), - cover_asset_id: None, author_name: None, author_avatar_url: None, device_support_desktop: true, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 206b50004..af2f3f0cc 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -215,6 +215,8 @@ macro_rules! migration_tables { // 定价:游戏表末尾追加 price_mud_points(默认 0),购买记录含成交价快照与钱包流水 ID。 // 资料随版本冻结:版本表末尾追加 metadata_json,游戏表末尾追加 // cover_object_key / screenshots_json,均为可空列,旧行按“未冻结资料”处理。 + // 媒体去 assetId 化:游戏表删除 cover_asset_id,封面/截图只保留 OSS objectKey + // (封面 cover_object_key、截图 screenshots_json 为 objectKey 字符串数组)。 // 软删除:游戏表末尾追加可空 deleted_at;非空表示作者已删除该作品, // 行本身仍随迁移导出,版本行与冻结资料保持不可变。 game_distribution_game, diff --git a/src/components/game-distribution/GameCover.tsx b/src/components/game-distribution/GameCover.tsx index ce21357de..78c542ec9 100644 --- a/src/components/game-distribution/GameCover.tsx +++ b/src/components/game-distribution/GameCover.tsx @@ -1,7 +1,7 @@ import './gameDistribution.css'; -import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import type { GameDistributionGame } from '../../services/gameDistributionClient'; +import { useGameDistributionMediaReadUrl } from './useGameDistributionMediaReadUrl'; /** * 游戏封面。 @@ -23,9 +23,7 @@ export function GameCover({ objectKey?: string | null; }) { const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim(); - const { resolvedUrl } = useResolvedAssetReadUrl(null, { - objectKey: targetKey, - }); + const { resolvedUrl } = useGameDistributionMediaReadUrl(targetKey); return (
({ }, })); +// 游戏封面/截图改走游戏分发媒体换签路由,用同一份换签结果桩替换,避免真实请求。 +vi.mock('./useGameDistributionMediaReadUrl', () => ({ + useGameDistributionMediaReadUrl: (objectKey: string | null | undefined) => { + const key = (objectKey ?? '').trim(); + return { + resolvedUrl: resolvedAssetUrlMock(key), + isResolving: false, + shouldResolve: Boolean(key), + }; + }, +})); + function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; diff --git a/src/components/game-distribution/GamePublishMediaFieldset.tsx b/src/components/game-distribution/GamePublishMediaFieldset.tsx index f929512fb..1539f6b8b 100644 --- a/src/components/game-distribution/GamePublishMediaFieldset.tsx +++ b/src/components/game-distribution/GamePublishMediaFieldset.tsx @@ -1,76 +1,60 @@ -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import { + createLocalGamePublishImage, GAME_PUBLISH_IMAGE_ACCEPT, - type GamePublishImageAsset, + type GamePublishImage, MAX_GAME_SCREENSHOTS, - uploadGamePublishImageAsset, + revokeGamePublishImagePreview, } from './gamePublishAssets'; type GamePublishMediaFieldsetProps = { - cover: GamePublishImageAsset | null; - screenshots: GamePublishImageAsset[]; - /** 提交流程进行中时禁止换图;上传中的禁用由组件自己叠加。 */ + cover: GamePublishImage | null; + screenshots: GamePublishImage[]; + /** 提交流程进行中时禁止换图。 */ disabled?: boolean; - /** - * 线上封面没有可复用素材 ID 时的提示。 - * - * 发布与资料编辑对“为什么必须重选”的说法不同,其余文案共用。 - */ - missingCoverHint: string; - onCoverChange: (asset: GamePublishImageAsset | null) => void; - onScreenshotsChange: (assets: GamePublishImageAsset[]) => void; + onCoverChange: (image: GamePublishImage | null) => void; + onScreenshotsChange: (images: GamePublishImage[]) => void; onError: (message: string) => void; - /** 上传进行中状态;调用方用它禁用提交。 */ - onUploadingChange: (uploading: boolean) => void; }; /** * 封面与截图选择区。 * - * 发布游戏与「作品资料编辑」共用同一份选图、上传与移除交互:两处都只需要素材 ID, - * 本地只负责把文件先传成平台素材再交给服务端冻结/保存。 + * 发布游戏与「作品资料编辑」共用同一份选图与移除交互:这里只做本地预检、生成 blob 预览并 + * 保留原始 `File`,不再提前上传成平台素材;文件会随保存/发布请求以 multipart 二进制 part 提交。 */ export function GamePublishMediaFieldset({ cover, screenshots, disabled = false, - missingCoverHint, onCoverChange, onScreenshotsChange, onError, - onUploadingChange, }: GamePublishMediaFieldsetProps) { const coverInputRef = useRef(null); const screenshotInputRef = useRef(null); - const [isUploading, setIsUploading] = useState(false); - const fieldDisabled = disabled || isUploading; - async function handleCoverSelected(file: File | null) { + function handleCoverSelected(file: File | null) { if (!file) return; onError(''); - setIsUploading(true); - onUploadingChange(true); try { - const uploaded = await uploadGamePublishImageAsset(file, { - kind: 'cover', - }); - onCoverChange(uploaded); - } catch (uploadError: unknown) { + const image = createLocalGamePublishImage(file, 'cover'); + revokeGamePublishImagePreview(cover); + onCoverChange(image); + } catch (selectError: unknown) { onError( - uploadError instanceof Error - ? uploadError.message - : '封面上传失败,请重试', + selectError instanceof Error + ? selectError.message + : '封面图片不可用,请重新选择', ); } finally { - setIsUploading(false); - onUploadingChange(false); // 清空 input 才能让作者重新选择同一张图。 if (coverInputRef.current) coverInputRef.current.value = ''; } } - async function handleScreenshotsSelected(files: File[]) { + function handleScreenshotsSelected(files: File[]) { if (files.length === 0) return; const remaining = MAX_GAME_SCREENSHOTS - screenshots.length; if (files.length > remaining) { @@ -83,38 +67,39 @@ export function GamePublishMediaFieldset({ return; } onError(''); - setIsUploading(true); - onUploadingChange(true); + let nextScreenshots = [...screenshots]; try { - // 逐张入库:中途失败时已经传好的截图仍然保留,作者不用重新选择。 - let nextScreenshots = [...screenshots]; + // 逐张收下:中途某张不合规时已选中的截图仍然保留,作者不用重新选择。 for (const file of files) { - const uploaded = await uploadGamePublishImageAsset(file, { - kind: 'screenshot', - }); - if (nextScreenshots.length >= MAX_GAME_SCREENSHOTS) continue; - nextScreenshots = [...nextScreenshots, uploaded]; + nextScreenshots = [ + ...nextScreenshots, + createLocalGamePublishImage(file, 'screenshot'), + ]; onScreenshotsChange(nextScreenshots); } - } catch (uploadError: unknown) { + } catch (selectError: unknown) { onError( - uploadError instanceof Error - ? uploadError.message - : '截图上传失败,请重试', + selectError instanceof Error + ? selectError.message + : '截图图片不可用,请重新选择', ); } finally { - setIsUploading(false); - onUploadingChange(false); if (screenshotInputRef.current) screenshotInputRef.current.value = ''; } } function removeScreenshot(index: number) { + revokeGamePublishImagePreview(screenshots[index]); onScreenshotsChange( screenshots.filter((_, itemIndex) => itemIndex !== index), ); } + function removeCover() { + revokeGamePublishImagePreview(cover); + onCoverChange(null); + } + return (
封面与截图 @@ -133,16 +118,16 @@ export function GamePublishMediaFieldset({ 游戏封面(必需) {/* 原生 file 控件在清空 value 后会显示“未选择任何文件”,与下方的“已选择”提示互相 - 矛盾;上传后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。 + 矛盾;选择后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。 */} { - void handleCoverSelected(event.target.files?.[0] ?? null); + handleCoverSelected(event.target.files?.[0] ?? null); }} /> @@ -150,10 +135,10 @@ export function GamePublishMediaFieldset({ - {cover?.assetObjectId + {cover?.file ? `已选择「${cover.name}」;重新选择即可替换当前封面。` : cover - ? missingCoverHint + ? `已沿用线上封面「${cover.name}」;重新选择即可替换。` : '封面会作为广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'} @@ -161,8 +146,8 @@ export function GamePublishMediaFieldset({ @@ -178,13 +163,9 @@ export function GamePublishMediaFieldset({ type="file" accept={GAME_PUBLISH_IMAGE_ACCEPT} multiple - disabled={ - fieldDisabled || screenshots.length >= MAX_GAME_SCREENSHOTS - } + disabled={disabled || screenshots.length >= MAX_GAME_SCREENSHOTS} onChange={(event) => { - void handleScreenshotsSelected( - Array.from(event.target.files ?? []), - ); + handleScreenshotsSelected(Array.from(event.target.files ?? [])); }} /> @@ -199,13 +180,13 @@ export function GamePublishMediaFieldset({ {screenshots.length > 0 ? (
    - {screenshots.map((asset, index) => ( + {screenshots.map((image, index) => (
  • - {asset.src ? ( - {`截图 + {image.src ? ( + {`截图 ) : ( {`截图 ${index + 1}`} @@ -214,7 +195,7 @@ export function GamePublishMediaFieldset({
) : null}
- {isUploading ? ( - 正在上传图片,请稍候… - ) : null} ); } diff --git a/src/components/game-distribution/GamePublishPage.test.tsx b/src/components/game-distribution/GamePublishPage.test.tsx index 6bfe3d94c..e104893ce 100644 --- a/src/components/game-distribution/GamePublishPage.test.tsx +++ b/src/components/game-distribution/GamePublishPage.test.tsx @@ -14,10 +14,7 @@ import { uploadGamePackage, } from '../../services/gameDistributionClient'; import { AuthUiContext } from '../auth/AuthUiContext'; -import { - resolveGamePublishImagePreview, - uploadGamePublishImageAsset, -} from './gamePublishAssets'; +import { resolveGamePublishImagePreview } from './gamePublishAssets'; import { resolvePublishMetadataError } from './gamePublishMetadata'; import { GamePublishPage } from './GamePublishPage'; @@ -29,12 +26,11 @@ vi.mock('../../services/frontendRuntimeConfigService', () => ({ loadFrontendRuntimeConfigMock(...args), })); -// 只替换真实上传与换签,保留常量与本地校验,避免测试真的打 OSS。 +// 只替换线上图换签,保留常量、本地校验与本地预览,避免测试真的打 OSS。 vi.mock('./gamePublishAssets', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - uploadGamePublishImageAsset: vi.fn(), resolveGamePublishImagePreview: vi.fn(), }; }); @@ -42,6 +38,7 @@ vi.mock('./gamePublishAssets', async (importOriginal) => { vi.mock('../../services/gameDistributionClient', () => ({ createGame: vi.fn(), createGameVersion: vi.fn(), + getGameDistributionMediaReadUrl: vi.fn().mockResolvedValue(''), getGameVersion: vi.fn(), listMyGames: vi.fn(), submitGameVersion: vi.fn(), @@ -118,6 +115,22 @@ function buildImageFile(name: string, type = 'image/png') { return new File(['image-bytes'], name, { type }); } +/** + * 独立内存 localStorage。 + * + * 新版 Node 的 jsdom localStorage 依赖运行时 flag,测试显式注入内存存储,草稿读写不再受 + * 运行环境影响(与 `gameDistributionClient.test.ts` 同一做法)。 + */ +function stubMemoryLocalStorage() { + const values = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }); +} + beforeEach(() => { loadFrontendRuntimeConfigMock.mockReset(); loadFrontendRuntimeConfigMock.mockResolvedValue({ @@ -125,23 +138,15 @@ beforeEach(() => { agcTemplateLibraryEnabled: false, gameDistributionPublishEnabled: true, }); - vi.mocked(uploadGamePublishImageAsset).mockReset(); vi.mocked(resolveGamePublishImagePreview).mockReset(); vi.mocked(resolveGamePublishImagePreview).mockResolvedValue(''); - vi.mocked(uploadGamePublishImageAsset).mockImplementation( - async (file: File) => ({ - assetObjectId: `asset-${file.name}`, - objectKey: `game-distribution/uploads/${file.name}`, - src: `https://cdn.example.com/${file.name}`, - name: file.name, - }), - ); vi.mocked(createGame).mockReset(); vi.mocked(createGameVersion).mockReset(); vi.mocked(getGameVersion).mockReset(); vi.mocked(listMyGames).mockReset(); vi.mocked(uploadGamePackage).mockReset(); vi.mocked(submitGameVersion).mockReset(); + stubMemoryLocalStorage(); window.localStorage.clear(); }); @@ -156,8 +161,8 @@ async function selectScreenshots(names: string[]) { fireEvent.change(screen.getByLabelText(/游戏截图/u), { target: { files: names.map((name) => buildImageFile(name)) }, }); - for (const name of names) { - await screen.findByText(new RegExp(`截图 ${names.indexOf(name) + 1}`, 'u')); + for (let index = 0; index < names.length; index += 1) { + await screen.findByRole('button', { name: `移除截图 ${index + 1}` }); } } @@ -182,7 +187,7 @@ test('元数据校验与服务端口径一致', () => { title: ' ', summary: '简介', deviceSupport: desktop, - coverAssetId: 'asset-cover', + hasCover: true, }), ).toBe('游戏标题必须为 1 到 40 个字符'); expect( @@ -190,7 +195,7 @@ test('元数据校验与服务端口径一致', () => { title: '游戏', summary: '简介', deviceSupport: { desktop: false, mobile: true, touch: false }, - coverAssetId: 'asset-cover', + hasCover: true, }), ).toBe('选择移动端时必须支持触控'); expect( @@ -198,7 +203,7 @@ test('元数据校验与服务端口径一致', () => { title: '游戏', summary: '简介', deviceSupport: { desktop: false, mobile: false, touch: false }, - coverAssetId: 'asset-cover', + hasCover: true, }), ).toBe('至少选择桌面端或移动端'); // 服务端要求发布必须带封面,且截图上限 6 张。 @@ -214,7 +219,7 @@ test('元数据校验与服务端口径一致', () => { title: '游戏', summary: '简介', deviceSupport: desktop, - coverAssetId: 'asset-cover', + hasCover: true, screenshotCount: 7, }), ).toBe('游戏截图最多 6 张'); @@ -223,7 +228,7 @@ test('元数据校验与服务端口径一致', () => { title: '游戏', summary: '简介', deviceSupport: desktop, - coverAssetId: 'asset-cover', + hasCover: true, screenshotCount: 6, }), ).toBe(''); @@ -247,7 +252,7 @@ test('草稿恢复按版本冻结价预填并锁住定价,提示写明将沿 status: 'uploaded', recoveryAction: 'upload', priceMudPoints: 240, - frozenMetadata: { coverAssetId: 'asset-cover-old' }, + frozenMetadata: { coverObjectKey: 'game-distribution/cover/old.png' }, }, } as never); @@ -274,7 +279,7 @@ test('越界价格在网页元数据兜底里被拦下', () => { title: '游戏', summary: '简介', deviceSupport: desktop, - coverAssetId: 'asset-cover', + hasCover: true, priceMudPoints: 1000001, }), ).toBe('买断价必须是 0 到 1000000 之间的整数泥点'); @@ -283,7 +288,7 @@ test('越界价格在网页元数据兜底里被拦下', () => { title: '游戏', summary: '简介', deviceSupport: desktop, - coverAssetId: 'asset-cover', + hasCover: true, priceMudPoints: 0, }), ).toBe(''); @@ -432,13 +437,13 @@ test('缺少封面时本地先拦截且不创建游戏', async () => { expect(uploadGamePackage).not.toHaveBeenCalled(); }); -test('封面上传失败时给出原因且不提交', async () => { - vi.mocked(uploadGamePublishImageAsset).mockRejectedValue( - new Error('游戏封面过大,请压缩后再上传(当前 9.0MB,最多 6MB)。'), - ); +test('封面本地预检失败时给出原因且不提交', async () => { await renderPage(); + const huge = buildImageFile('huge.png'); + // jsdom 里构造 9MB 字符串代价高,直接声明文件体积触发本地体积校验。 + Object.defineProperty(huge, 'size', { value: 9 * 1024 * 1024 }); fireEvent.change(screen.getByLabelText(/游戏封面/u), { - target: { files: [buildImageFile('huge.png')] }, + target: { files: [huge] }, }); expect( @@ -459,7 +464,7 @@ test('截图超过 6 张时本地拦截', async () => { }); expect(await screen.findByText(/游戏截图最多 6 张/u)).toBeTruthy(); - expect(uploadGamePublishImageAsset).not.toHaveBeenCalled(); + expect(screen.queryByText(/移除截图 1/u)).toBeNull(); expect(createGame).not.toHaveBeenCalled(); }); @@ -504,15 +509,28 @@ test('按创建游戏、创建版本、上传、送审顺序提交并展示审 summary: '测试简介', category: '休闲', inputModes: ['keyboard', 'mouse', 'touch'], - coverAssetId: 'asset-cover.png', - screenshots: ['asset-shot-1.png', 'asset-shot-2.png'], + // 本地新图在资料槽位里写 null,由同一次 multipart 的二进制 part 补位。 + coverObjectKey: null, + screenshots: [null, null], }), ); + const createMedia = vi.mocked(createGame).mock.calls[0]?.[2]; + expect(createMedia?.cover?.name).toBe('cover.png'); + expect(createMedia?.screenshots?.map((file) => file?.name)).toEqual([ + 'shot-1.png', + 'shot-2.png', + ]); expect(vi.mocked(createGameVersion).mock.calls[0]?.[0]).toBe('game-1'); const versionPayload = vi.mocked(createGameVersion).mock.calls[0]?.[1]; expect(versionPayload?.packageEntryPath).toBe('index.html'); expect(versionPayload?.packageFileCount).toBe(2); expect(versionPayload?.packageSha256).toMatch(/^[0-9a-f]{64}$/u); + const versionMedia = vi.mocked(createGameVersion).mock.calls[0]?.[3]; + expect(versionMedia?.cover?.name).toBe('cover.png'); + expect(versionMedia?.screenshots?.map((file) => file?.name)).toEqual([ + 'shot-1.png', + 'shot-2.png', + ]); expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[0]).toBe('gamever-1'); expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(0); @@ -649,14 +667,8 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号', recoveryAction: 'none', priceMudPoints: 240, frozenMetadata: { - coverAssetId: 'asset-cover-old', coverObjectKey: 'game-distribution/cover/old.png', - screenshots: [ - { - assetId: 'asset-shot-old', - objectKey: 'game-distribution/screenshot/old.png', - }, - ], + screenshots: ['game-distribution/screenshot/old.png'], }, }, } as never); @@ -683,13 +695,13 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号', expect(screen.getByLabelText('游戏名称')).toHaveProperty('value', '星轨防线'); expect(screen.getByLabelText('分类')).toHaveProperty('value', '动作'); - // 冻结资料直接回填封面与截图,作者不需要为了沿用封面重新上传一次。 + // 冻结资料直接回填封面与截图,作者不需要为了沿用封面重新选图。 await waitFor(() => expect(resolveGamePublishImagePreview).toHaveBeenCalledWith( 'game-distribution/cover/old.png', ), ); - expect(uploadGamePublishImageAsset).not.toHaveBeenCalled(); + expect(screen.queryByText(/本次发布需要重新选择一次封面图片/u)).toBeNull(); const file = await buildZipFile(); fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), { @@ -710,11 +722,15 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号', title: '星轨防线', category: '动作', tags: ['塔防'], - coverAssetId: 'asset-cover-old', - screenshots: ['asset-shot-old'], + // 未换图时沿用版本冻结资料里的 objectKey,二进制 part 不带封面。 + coverObjectKey: 'game-distribution/cover/old.png', + screenshots: ['game-distribution/screenshot/old.png'], }), }), ); + const updateMedia = vi.mocked(createGameVersion).mock.calls[0]?.[3]; + expect(updateMedia?.cover).toBeNull(); + expect(updateMedia?.screenshots).toEqual([null]); expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('gamever-3'); expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(4); expect( @@ -722,7 +738,7 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号', ).toBeTruthy(); }); -test('线上封面没有冻结素材时要求重新选择封面', async () => { +test('线上封面没有冻结快照时沿用游戏行封面对象键', async () => { vi.mocked(listMyGames).mockResolvedValue([ { id: 'game-1', @@ -747,7 +763,7 @@ test('线上封面没有冻结素材时要求重新选择封面', async () => { versions: [], } as never, ]); - // 历史版本没有冻结快照:只能看到线上封面,但没有可复用的素材 ID。 + // 历史版本没有冻结快照:回退到游戏行封面 objectKey,可以继续沿用。 vi.mocked(getGameVersion).mockResolvedValue({ game: { id: 'game-1', @@ -771,22 +787,26 @@ test('线上封面没有冻结素材时要求重新选择封面', async () => { renderPublishPage(createAuthValue(), 'game-1'); - expect( - await screen.findByText(/本次发布需要重新选择一次封面图片/u), - ).toBeTruthy(); await screen.findByLabelText('游戏名称'); + await waitFor(() => + expect(resolveGamePublishImagePreview).toHaveBeenCalledWith( + 'game-distribution/cover/legacy.png', + ), + ); const file = await buildZipFile(); fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), { target: { files: [file] }, }); fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' })); - expect( - await screen.findByText( - '本次发布需要重新选择一次封面图片(当前展示的是线上封面)', - ), - ).toBeTruthy(); - expect(createGameVersion).not.toHaveBeenCalled(); + await waitFor(() => expect(createGameVersion).toHaveBeenCalledTimes(1)); + expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + gameMetadata: expect.objectContaining({ + coverObjectKey: 'game-distribution/cover/legacy.png', + }), + }), + ); }); test('更新模式读不到归属游戏时失败关闭且不提交', async () => { diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index 9dc2cc97d..e355a8557 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -28,7 +28,7 @@ import { parseGameTagsInput, resolveGameInputModes, } from './gameMetadataOptions'; -import { type GamePublishImageAsset } from './gamePublishAssets'; +import { type GamePublishImage } from './gamePublishAssets'; import { GamePublishClientGuide } from './GamePublishClientGuide'; import { clearPublishDraft, @@ -94,14 +94,11 @@ export function GamePublishPage({ const [priceMode, setPriceMode] = useState('free'); const [priceInput, setPriceInput] = useState(''); const [packageFile, setPackageFile] = useState(null); - // 封面与截图都先上传成平台素材,再把素材 ID 交给服务端冻结;页面只保存素材标识与预览地址。 - const [coverAsset, setCoverAsset] = useState( - null, + // 封面与截图只保留本地文件 / 线上对象键与预览地址,发布时随 multipart 请求一起提交。 + const [coverImage, setCoverImage] = useState(null); + const [screenshotImages, setScreenshotImages] = useState( + [], ); - const [screenshotAssets, setScreenshotAssets] = useState< - GamePublishImageAsset[] - >([]); - const [isUploadingAsset, setIsUploadingAsset] = useState(false); // 发布灰度:`checking` 期间不渲染表单,避免白名单外的作者先上传再被后端拒绝。 const [publishGate, setPublishGate] = useState< 'checking' | 'allowed' | 'blocked' @@ -180,8 +177,8 @@ export function GamePublishPage({ title: detail.game.title, }); void applyVersionMediaDefaults(detail, { - setCover: setCoverAsset, - setScreenshots: setScreenshotAssets, + setCover: setCoverImage, + setScreenshots: setScreenshotImages, isCancelled: () => cancelled, }); }) @@ -262,8 +259,8 @@ export function GamePublishPage({ setPriceInput(''); } void applyVersionMediaDefaults(detail, { - setCover: setCoverAsset, - setScreenshots: setScreenshotAssets, + setCover: setCoverImage, + setScreenshots: setScreenshotImages, isCancelled: () => cancelled, }); }) @@ -287,12 +284,39 @@ export function GamePublishPage({ 0) : 0; - /** 展示中的线上封面没有素材 ID 时无法冻结进新版本,必须先本地拦一次并说明原因。 */ - function resolveMediaSubmitError() { - if (coverAsset && !coverAsset.assetObjectId.trim()) { - return '本次发布需要重新选择一次封面图片(当前展示的是线上封面)'; - } - return ''; + /** + * 组装提交给服务端的资料 DTO。 + * + * 媒体槽位只描述来源:本地新图写 `null`(由对应的二进制 part 补位),沿用线上图写 objectKey; + * 原始字节另由 `buildPublishMediaParts` 作为 multipart part 提交。 + */ + function buildPublishGameMetadata() { + return { + title: title.trim(), + summary: summary.trim(), + description: description.trim() || summary.trim(), + category, + tags: parseGameTagsInput(tagsInput), + coverObjectKey: coverImage?.file ? null : (coverImage?.objectKey ?? null), + screenshots: screenshotImages.map((image) => + image.file ? null : (image.objectKey ?? null), + ), + deviceSupport, + inputModes: resolveGameInputModes(deviceSupport), + orientation, + }; + } + + function buildPublishMediaParts() { + return { + cover: coverImage?.file ?? null, + screenshots: screenshotImages.map((image) => image.file ?? null), + }; + } + + /** 是否已有封面:本地新图或沿用线上图都算。 */ + function hasPublishCover() { + return Boolean(coverImage?.file || coverImage?.objectKey); } function dismissDraft() { @@ -357,11 +381,6 @@ export function GamePublishPage({ setError(updateContextError || '待更新游戏尚未就绪,请稍后重试'); return; } - const mediaError = resolveMediaSubmitError(); - if (mediaError) { - setError(mediaError); - return; - } const price = resolveGamePublishPriceMudPoints(priceMode, priceInput); if (price.error) { setError(price.error); @@ -371,8 +390,8 @@ export function GamePublishPage({ title, summary, deviceSupport, - coverAssetId: coverAsset?.assetObjectId, - screenshotCount: screenshotAssets.length, + hasCover: hasPublishCover(), + screenshotCount: screenshotImages.length, priceMudPoints: price.priceMudPoints, }); if (metadataError) { @@ -390,20 +409,8 @@ export function GamePublishPage({ try { const prepared = await prepareGamePackage(packageFile); const updateKey = resolvePublishKey(); - const inputModes = resolveGameInputModes(deviceSupport); - const tags = parseGameTagsInput(tagsInput); - const gameMetadata = { - title: title.trim(), - summary: summary.trim(), - description: description.trim() || summary.trim(), - category, - tags, - coverAssetId: coverAsset?.assetObjectId ?? '', - screenshots: screenshotAssets.map((asset) => asset.assetObjectId), - deviceSupport, - inputModes, - orientation, - }; + const gameMetadata = buildPublishGameMetadata(); + const media = buildPublishMediaParts(); const version = await createGameVersion( updateContext.gameId, { @@ -415,6 +422,7 @@ export function GamePublishPage({ gameMetadata, }, `${updateKey}:version`, + media, ); await uploadGamePackage( version.versionId, @@ -454,11 +462,6 @@ export function GamePublishPage({ await handleResumeSubmit(); return; } - const mediaError = resolveMediaSubmitError(); - if (mediaError) { - setError(mediaError); - return; - } const price = resolveGamePublishPriceMudPoints(priceMode, priceInput); if (price.error) { setError(price.error); @@ -468,8 +471,8 @@ export function GamePublishPage({ title, summary, deviceSupport, - coverAssetId: coverAsset?.assetObjectId, - screenshotCount: screenshotAssets.length, + hasCover: hasPublishCover(), + screenshotCount: screenshotImages.length, priceMudPoints: price.priceMudPoints, }); if (metadataError) { @@ -486,22 +489,10 @@ export function GamePublishPage({ setError(''); try { const prepared = await prepareGamePackage(packageFile); - const inputModes = resolveGameInputModes(deviceSupport); - const tags = parseGameTagsInput(tagsInput); - const gameMetadata = { - title: title.trim(), - summary: summary.trim(), - description: description.trim() || summary.trim(), - category, - tags, - coverAssetId: coverAsset?.assetObjectId ?? '', - screenshots: screenshotAssets.map((asset) => asset.assetObjectId), - deviceSupport, - inputModes, - orientation, - }; + const gameMetadata = buildPublishGameMetadata(); + const media = buildPublishMediaParts(); const publishKey = resolvePublishKey(); - const game = await createGame(gameMetadata, `${publishKey}:game`); + const game = await createGame(gameMetadata, `${publishKey}:game`, media); const version = await createGameVersion( game.id, { @@ -513,6 +504,7 @@ export function GamePublishPage({ gameMetadata, }, `${publishKey}:version`, + media, ); writePublishDraft({ ownerUserId: currentUserId, @@ -832,14 +824,12 @@ export function GamePublishPage({ onPriceInputChange={setPriceInput} />