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 b5a3dc902..31e379efb 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -273,6 +273,27 @@ struct GameDistributionMediaReadQuery { expire_seconds: Option, } +/// 一条游戏行当前媒体(封面 + 截图)的 objectKey 集合;空串一律忽略。 +fn game_media_object_keys(game: &GameDistributionGameRecord) -> Vec { + let mut keys = Vec::new(); + if let Some(cover) = game.cover_object_key.as_deref() { + let cover = cover.trim(); + if !cover.is_empty() { + keys.push(cover.to_string()); + } + } + keys.extend( + game.screenshots_json + .as_deref() + .and_then(|json| serde_json::from_str::>(json).ok()) + .unwrap_or_default() + .into_iter() + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()), + ); + keys +} + /// 按 objectKey 判定该对象是否是某已发布作品的当前公开媒体;非公开一律 404。 async fn ensure_public_media_object_key( state: &AppState, @@ -293,24 +314,62 @@ async fn ensure_public_media_object_key( Ok(object_key.to_string()) } -/// 公开读取游戏发布媒体(封面/截图)的签名读地址;与素材库 ACL 无关。 -async fn read_game_distribution_media_url( - State(state): State, - Extension(ctx): Extension, - Query(query): Query, +/// 按 objectKey 判定该对象是否是调用者自己作品当前行的媒体;未发布 / 待审 / 被驳回都放行。 +/// +/// 与公开读共用同一把签名器,区别只在授权来源:公开读要求「已发布 + 命中当前公开媒体」, +/// 这里要求「命中调用者自己作品当前行的 `cover_object_key` / `screenshots_json`」。因此作者 +/// 不必先把作品发出去才能看到自己刚传的封面/截图,也无需给素材库 ACL 开特例。 +async fn ensure_owner_media_object_key( + state: &AppState, + game_id: &str, + owner_user_id: &str, + query: &GameDistributionMediaReadQuery, +) -> Result { + let object_key = query.object_key.trim(); + if object_key.is_empty() { + return Err(bad_request("缺少 objectKey")); + } + let game = state + .spacetime_client() + .get_game_distribution_game(GameDistributionGetGameRecordInput { + game_id: game_id.to_string(), + owner_user_id: Some(owner_user_id.to_string()), + }) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + if !game_media_object_keys(&game) + .iter() + .any(|key| key == object_key) + { + return Err(AppError::from_status(StatusCode::NOT_FOUND)); + } + Ok(object_key.to_string()) +} + +/// 媒体读地址的换签口径:公开读与 owner 读共用同一档有效期。 +fn clamp_game_distribution_media_expire_seconds(expire_seconds: Option) -> Option { + object_read::clamp_public_read_expire_seconds( + expire_seconds, + object_read::ObjectReadScope::Public, + ) +} + +/// 换签游戏发布媒体(封面/截图)签名读地址;公开读与 owner 读共用,与素材库 ACL 无关。 +async fn sign_game_distribution_media_read_url( + state: &AppState, + ctx: &RequestContext, + object_key: &str, + expire_seconds: Option, ) -> 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, - ), + object_key, + clamp_game_distribution_media_expire_seconds(expire_seconds), ) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -318,7 +377,36 @@ async fn read_game_distribution_media_url( "message": error.to_string(), })) })?; - Ok(json_success_body(Some(&ctx), json!({ "read": payload }))) + Ok(json_success_body(Some(ctx), json!({ "read": payload }))) +} + +/// 同源中转游戏发布媒体字节;公开读与 owner 读共用。 +async fn proxy_game_distribution_media_read_bytes( + state: &AppState, + object_key: &str, + expire_seconds: Option, +) -> Result { + 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, + clamp_game_distribution_media_expire_seconds(expire_seconds), + "game-distribution-media-read-bytes", + ) + .await +} + +/// 公开读取游戏发布媒体(封面/截图)的签名读地址;与素材库 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?; + sign_game_distribution_media_read_url(&state, &ctx, &object_key, query.expire_seconds).await } /// 公开同源读取游戏发布媒体字节;授权判定与 `read-url` 同口径。 @@ -327,20 +415,34 @@ async fn read_game_distribution_media_bytes( 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 + proxy_game_distribution_media_read_bytes(&state, &object_key, query.expire_seconds).await +} + +/// owner 读取自己作品媒体(封面/截图)的签名读地址:未发布 / 待审 / 被驳回都可见。 +async fn read_owner_game_distribution_media_url( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(game_id): Path, + Query(query): Query, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + let object_key = + ensure_owner_media_object_key(&state, &game_id, &owner_user_id, &query).await?; + sign_game_distribution_media_read_url(&state, &ctx, &object_key, query.expire_seconds).await +} + +/// owner 同源读取自己作品媒体字节;授权判定与 owner `read-url` 同口径。 +async fn read_owner_game_distribution_media_bytes( + State(state): State, + Extension(auth): Extension, + Path(game_id): Path, + Query(query): Query, +) -> Result { + let owner_user_id = auth.claims().user_id().to_string(); + let object_key = + ensure_owner_media_object_key(&state, &game_id, &owner_user_id, &query).await?; + proxy_game_distribution_media_read_bytes(&state, &object_key, query.expire_seconds).await } pub fn router(state: AppState) -> Router { @@ -508,6 +610,24 @@ pub fn router(state: AppState) -> Router { ) .route_layer(middleware::from_fn(add_no_store_response_headers)); + // owner 作用域媒体读:作者预览自己作品(未发布 / 待审 / 被驳回)的封面与截图。 + // 与公开读共用签名器,但要求 bearer,且 objectKey 必须命中自己作品当前行的媒体。 + // 软删作品在 `get_game_distribution_game` 已返回 None(404),这里不为其开路。 + let owner_media = Router::new() + .route( + "/api/game-distribution/my-games/{game_id}/media/read-url", + get(read_owner_game_distribution_media_url), + ) + .route( + "/api/game-distribution/my-games/{game_id}/media/read-bytes", + get(read_owner_game_distribution_media_bytes), + ) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .route_layer(middleware::from_fn(add_no_store_response_headers)); + Router::new() .route( "/api/game-distribution/releases/{game_id}/{*asset_path}", @@ -536,6 +656,7 @@ pub fn router(state: AppState) -> Router { get(serve_admin_version_preview_entry), ) .merge(public_games) + .merge(owner_media) .merge(protected) .merge(play_sessions) .merge(user_reviews) @@ -5134,6 +5255,25 @@ mod tests { .expect("路由响应"); assert_eq!(media_read_with_key.status(), StatusCode::BAD_GATEWAY); + // owner 作用域媒体读已挂载且要求登录态:作者预览未发布/被驳回作品的封面与截图走这里, + // 不再依赖公开读,也不给素材库 ACL 开特例。 + for uri in [ + "/api/game-distribution/my-games/game_1/media/read-url?objectKey=generated%2Fcover.png", + "/api/game-distribution/my-games/game_1/media/read-bytes?objectKey=generated%2Fcover.png", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "uri={uri}"); + } + // 作者作品详情是 owner 作用域路由,未带 Bearer 时同样在进入业务前被拒绝。 let unauthenticated_owner_game = app .clone() @@ -6141,6 +6281,25 @@ mod tests { assert_eq!(user_agent_tag(&headers), "test-agent"); } + #[test] + fn game_media_object_keys_reads_cover_and_screenshots_and_skips_blanks() { + let mut game = paid_game_record("user_author", 0); + game.cover_object_key = Some(" cover.png ".to_string()); + game.screenshots_json = Some(r#"["shot-1.png"," ","shot-2.png"]"#.to_string()); + assert_eq!( + game_media_object_keys(&game), + vec![ + "cover.png".to_string(), + "shot-1.png".to_string(), + "shot-2.png".to_string(), + ] + ); + + game.cover_object_key = Some(" ".to_string()); + game.screenshots_json = Some("not-json".to_string()); + assert!(game_media_object_keys(&game).is_empty()); + } + fn paid_game_record(owner_user_id: &str, price_mud_points: u64) -> GameDistributionGameRecord { GameDistributionGameRecord { game_id: "game_1".to_string(),