diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 6fa1b786a..5a7e62262 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -64,7 +64,7 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [ key: 'game-distribution:publish', label: '游戏发布', description: - '游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中', + '未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行', }, ]; 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 dc0417d65..da397f1fa 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 @@ -12,6 +12,8 @@ use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{ current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot, }; +use base64::Engine; + use reqwest::header::{HeaderName, HeaderValue, CONTENT_TYPE}; use reqwest::{Method, StatusCode}; use serde::{Deserialize, Serialize}; @@ -33,6 +35,8 @@ const HTTP_TIMEOUT: Duration = Duration::from_secs(60); const COVER_QUEUE_POLL_INTERVAL: Duration = Duration::from_millis(1_600); const COVER_QUEUE_TIMEOUT: Duration = Duration::from_secs(20 * 60); const COVER_QUEUE_MAX_POLLS: usize = 800; +const COVER_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024; + const PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -62,6 +66,8 @@ struct CoverGenerationPayload { #[serde(default)] image_src: Option, #[serde(default)] + object_key: Option, + #[serde(default)] asset_object_id: Option, #[serde(default)] asset: Option, @@ -287,6 +293,48 @@ fn read_cover_asset_id(payload: &CoverGenerationPayload) -> Option { .filter(|value| !value.is_empty()) } +fn cover_preview_data_url(media_type: &str, bytes: &[u8]) -> Result { + let media_type = media_type + .split(';') + .next() + .unwrap_or(media_type) + .trim() + .to_ascii_lowercase(); + if !media_type.starts_with("image/") { + return Err("游戏封面预览不是受支持的图片类型".to_string()); + } + if bytes.is_empty() || bytes.len() > COVER_PREVIEW_MAX_BYTES { + return Err("游戏封面预览超过安全大小限制".to_string()); + } + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + Ok(format!("data:{media_type};base64,{encoded}")) +} + +async fn resolve_cover_preview_data_url( + snapshot: &PlatformSessionSnapshot, + payload: &CoverGenerationPayload, +) -> Result { + current_scoped_session(snapshot)?; + let client = build_client()?; + let source = json!({ + "objectKey": payload.object_key.as_deref(), + "imageSrc": payload.image_src.as_deref(), + }); + let download = crate::assets::resolve_canvas_resource_download_with_limit_route_and_fence( + &client, + snapshot.api_base_url.as_str(), + snapshot.access_token.as_str(), + &source, + COVER_PREVIEW_MAX_BYTES, + "/api/assets/read-url", + || Ok(()), + ) + .await? + .ok_or_else(|| "游戏封面预览内容为空".to_string())?; + current_scoped_session(snapshot)?; + cover_preview_data_url(&download.media_type, &download.bytes) +} + async fn resolve_cover_queue( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, @@ -349,6 +397,7 @@ async fn resolve_cover_queue( .map_err(|error| format!("封面生成结果格式无效:{error}"))?; return Ok(CoverGenerationPayload { image_src: completed.image_src.or(initial.image_src), + object_key: completed.object_key.or(initial.object_key), asset_object_id: completed.asset_object_id.or(initial.asset_object_id), asset: completed.asset.or(initial.asset), task_id: completed.task_id.or(initial.task_id), @@ -462,9 +511,16 @@ pub(crate) async fn generate_game_distribution_cover( let payload = resolve_cover_queue(&client, &snapshot, initial).await?; let asset_object_id = read_cover_asset_id(&payload).ok_or_else(|| "生成游戏封面未返回平台素材 ID".to_string())?; + let preview_url = match resolve_cover_preview_data_url(&snapshot, &payload).await { + Ok(value) => value, + Err(error) => { + app_log!("游戏封面生成成功但预览下载失败:{error}"); + String::new() + } + }; Ok(GameDistributionCoverGenerationResult { asset_object_id, - preview_url: payload.image_src.unwrap_or_default().trim().to_string(), + preview_url, task_id: payload.task_id.unwrap_or_default().trim().to_string(), model: payload .model @@ -811,4 +867,14 @@ mod tests { Some("asset-from-top-level") ); } + #[test] + fn cover_preview_data_url_accepts_images_and_rejects_invalid_payloads() { + assert_eq!( + cover_preview_data_url("image/png; charset=binary", &[0, 1, 2]) + .expect("image data URL"), + "data:image/png;base64,AAEC" + ); + assert!(cover_preview_data_url("text/plain", &[0, 1, 2]).is_err()); + assert!(cover_preview_data_url("image/png", &[]).is_err()); + } } diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md b/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md index 646340772..f012f3802 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md @@ -49,6 +49,8 @@ AGC 发布面板完成三项收敛: ## 6. 本次缺陷修复记录 - 2026-09-23:AGC 生成游戏封面请求补充 `generationInputs.source = "ai-game-creator-client"`。队列 worker 依据该来源选择 `GameCreatorResourceEditor` 结果契约;未标记来源时会按 `Standard` 紧凑化并省略 `result`,导致生成完成后无法回传 `assetObjectId`。对应前端定向测试已锁定请求字段与平台素材 ID 回填链路。 +- 2026-10-02:生成结果中的 `imageSrc/objectKey` 是私有素材引用,不再直接交给 WebView ``;AGC Rust 发布 facade 经鉴权 `read-url` 有界下载为 `data:` 图片预览,预览失败不阻断已登记素材发布。 + ## 7. 本轮核对(2026-09-28) 六项交付结果都在合并后的工作树里,并有本轮实测: diff --git a/server-rs/crates/api-server/src/frontend_runtime_config.rs b/server-rs/crates/api-server/src/frontend_runtime_config.rs index dea103eca..51dd3388b 100644 --- a/server-rs/crates/api-server/src/frontend_runtime_config.rs +++ b/server-rs/crates/api-server/src/frontend_runtime_config.rs @@ -16,8 +16,8 @@ use crate::{ pub struct FrontendRuntimeConfigResponse { pub image_editor_agent_sidebar_enabled: bool, pub agc_template_library_enabled: bool, - /// 游戏发布灰度:运营未配置 `game-distribution:publish` 时对已登录作者默认开放, - /// 显式收紧后只有白名单/灰度命中的作者拿到 `true`;匿名一律 `false`(发布必须先登录)。 + /// 游戏发布灰度:未配置 `game-distribution:publish` 或 `enabled=false` 时不开放; + /// 显式配置后按 enabled、白名单、标签和灰度比例判定,匿名一律 `false`(发布必须先登录)。 pub game_distribution_publish_enabled: bool, } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 8e7bd07c8..a644d5166 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -84,7 +84,7 @@ pub fn creation_entry_feature_gate_key(creation_type_id: &str) -> String { pub const IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY: &str = "image-editor:agent-sidebar"; pub const AGC_TEMPLATE_LIBRARY_GATE_KEY: &str = "agc:template-library"; -/// 游戏分发发布开关:`gate 行缺失`或 `enabled=false` 都表示**未开放**(灰度默认关闭); +/// 游戏分发发布开关:gate 行缺失或 `enabled=false` 都表示**未开放**; /// 只有 `enabled=true` 且白名单 / 灰度比例 / 用户标签命中时才允许发布。 pub const GAME_DISTRIBUTION_PUBLISH_GATE_KEY: &str = "game-distribution:publish"; diff --git a/src/services/frontendRuntimeConfigService.ts b/src/services/frontendRuntimeConfigService.ts index c047cad3a..27d7ffa36 100644 --- a/src/services/frontendRuntimeConfigService.ts +++ b/src/services/frontendRuntimeConfigService.ts @@ -6,8 +6,8 @@ export type FrontendRuntimeConfig = { imageEditorAgentSidebarEnabled: boolean; agcTemplateLibraryEnabled: boolean; /** - * 游戏发布灰度开关:后端未配置 `game-distribution:publish` 时对已登录作者默认开放, - * 显式收紧后只有白名单/灰度命中的作者为 `true`,匿名恒为 `false`。 + * 游戏发布灰度:未配置 `game-distribution:publish` 或 `enabled=false` 时不开放; + * 显式配置后只有白名单/灰度命中的作者为 `true`,匿名恒为 `false`。 */ gameDistributionPublishEnabled: boolean; };