修复游戏封面预览并统一发布灰度说明
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m53s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m41s
Project CI / Frontend tests (pull_request) Failing after 1m5s
Project CI / Repository checks (pull_request) Failing after 23s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m48s
Project CI / Native shell tests (pull_request) Successful in 5m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m9s

封面生成结果经鉴权 read-url 转为安全 data URL 预览,失败时不阻断已登记素材发布。

统一后台、前端与后端对 game-distribution:publish 未配置和关闭状态的说明。
This commit is contained in:
2026-10-02 16:03:28 +08:00
parent 72aff6a6e6
commit 772e9f0d42
6 changed files with 75 additions and 7 deletions
@@ -64,7 +64,7 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
key: 'game-distribution:publish',
label: '游戏发布',
description:
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
'未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行',
},
];
@@ -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<String>,
#[serde(default)]
object_key: Option<String>,
#[serde(default)]
asset_object_id: Option<String>,
#[serde(default)]
asset: Option<CoverAssetPayload>,
@@ -287,6 +293,48 @@ fn read_cover_asset_id(payload: &CoverGenerationPayload) -> Option<String> {
.filter(|value| !value.is_empty())
}
fn cover_preview_data_url(media_type: &str, bytes: &[u8]) -> Result<String, String> {
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<String, String> {
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());
}
}
@@ -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 `<img>`;AGC Rust 发布 facade 经鉴权 `read-url` 有界下载为 `data:` 图片预览,预览失败不阻断已登记素材发布。
## 7. 本轮核对(2026-09-28)
六项交付结果都在合并后的工作树里,并有本轮实测:
@@ -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,
}
@@ -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";
+2 -2
View File
@@ -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;
};