fix: stabilize puzzle vector engine asset generation

This commit is contained in:
2026-06-03 02:40:07 +08:00
parent 67ba40c678
commit 08577b66c5
6 changed files with 215 additions and 28 deletions

View File

@@ -341,6 +341,8 @@ fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {
prompt_chars = failure.prompt_chars,
reference_image_count = failure.reference_image_count,
image_model = failure.image_model,
request_id = %failure.request_id.as_deref().unwrap_or_default(),
error_source = %failure.error_source.as_deref().unwrap_or_default(),
error = %failure.error_message,
"外部 API 调用失败"
);
@@ -394,6 +396,10 @@ mod tests {
)
.with_status_code(Some(429))
.with_retryable(true)
.with_error_source(Some(
"client error (SendRequest) -> connection closed before message completed"
.to_string(),
))
.with_latency_ms(Some(1234))
.with_prompt_chars(Some(88))
.with_reference_image_count(Some(2))
@@ -414,6 +420,10 @@ mod tests {
assert_eq!(metadata["promptChars"], 88);
assert_eq!(metadata["referenceImageCount"], 2);
assert_eq!(metadata["imageModel"], "gpt-image-2-all");
assert_eq!(
metadata["errorSource"],
"client error (SendRequest) -> connection closed before message completed"
);
assert!(matches!(metadata["occurredAt"], Value::String(_)));
}

View File

@@ -424,6 +424,7 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
details["referenceImageCount"] = json!(audit.reference_image_count);
details["imageModel"] = json!(audit.image_model);
details["rawExcerpt"] = json!(audit.raw_excerpt);
details["errorSource"] = json!(audit.error_source);
}
AppError::from_status(status).with_details(details)

View File

@@ -317,7 +317,16 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
);
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
let scene_generated = create_puzzle_vector_engine_image_generation(
let bundle_started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
"拼图关卡资产包生成开始"
);
let scene_started_at = Instant::now();
let scene_generated = match create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
PuzzleImageModel::GptImage2,
@@ -328,7 +337,34 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
Some(&puzzle_reference),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
.map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图生成完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡场景图生成失败"
);
return Err(error);
}
};
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
@@ -336,7 +372,8 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
}))
})?;
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
let scene_persist_future = persist_puzzle_level_asset_image(
let scene_persist_started_at = Instant::now();
let level_scene = persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
@@ -347,8 +384,18 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"level_scene",
"scene",
scene_image,
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot = "level_scene",
elapsed_ms = scene_persist_started_at.elapsed().as_millis() as u64,
"拼图关卡场景图持久化完成"
);
let spritesheet_future = generate_and_persist_puzzle_level_asset(
let ui_spritesheet = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
@@ -362,8 +409,9 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"puzzle_ui_spritesheet_image",
"ui_spritesheet",
"spritesheet",
);
let background_future = generate_and_persist_puzzle_level_asset(
)
.await?;
let level_background = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
@@ -377,14 +425,21 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
"puzzle_level_background_image",
"level_background",
"background",
);
let (level_scene, ui_spritesheet, level_background) =
tokio::join!(scene_persist_future, spritesheet_future, background_future);
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
elapsed_ms = bundle_started_at.elapsed().as_millis() as u64,
"拼图关卡资产包生成完成"
);
Ok(GeneratedPuzzleLevelAssetBundle {
level_scene: level_scene?,
ui_spritesheet: ui_spritesheet?,
level_background: level_background?,
level_scene,
ui_spritesheet,
level_background,
})
}
@@ -403,7 +458,20 @@ async fn generate_and_persist_puzzle_level_asset(
slot: &str,
file_stem: &str,
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
let generated = create_puzzle_vector_engine_image_generation(
let started_at = Instant::now();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
size,
prompt_chars = prompt.chars().count(),
reference_image_bytes = reference_image.bytes_len,
"拼图关卡资产生成请求开始"
);
let generated = match create_puzzle_vector_engine_image_generation(
http_client,
settings,
PuzzleImageModel::GptImage2,
@@ -414,7 +482,36 @@ async fn generate_and_persist_puzzle_level_asset(
Some(reference_image),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
.map_err(map_puzzle_generation_endpoint_error)
{
Ok(generated) => {
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"拼图关卡资产生成请求完成"
);
generated
}
Err(error) => {
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = started_at.elapsed().as_millis() as u64,
error = %error,
"拼图关卡资产生成请求失败"
);
return Err(error);
}
};
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
@@ -427,7 +524,8 @@ async fn generate_and_persist_puzzle_level_asset(
image
};
persist_puzzle_level_asset_image(
let persist_started_at = Instant::now();
let persisted = persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
@@ -439,7 +537,19 @@ async fn generate_and_persist_puzzle_level_asset(
file_stem,
image,
)
.await
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PuzzleImageModel::GptImage2.request_model_name(),
session_id,
level_name,
slot,
asset_kind,
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
"拼图关卡资产持久化完成"
);
Ok(persisted)
}
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(

View File

@@ -11,6 +11,7 @@ pub fn build_vector_engine_image_http_client(
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
.http1_only()
.pool_max_idle_per_host(0)
.build()
.map_err(|error| PlatformImageError::InvalidConfig {
provider: VECTOR_ENGINE_PROVIDER,
@@ -29,7 +30,14 @@ pub(super) fn map_reqwest_error(
) -> PlatformImageError {
let is_timeout = error.is_timeout();
let is_connect = error.is_connect();
let source = error.source().map(ToString::to_string);
let source_chain_parts = collect_error_source_chain(&error);
let source = source_chain_parts.first().cloned();
let source_chain_depth = source_chain_parts.len();
let source_chain = if source_chain_parts.is_empty() {
None
} else {
Some(source_chain_parts.join(" -> "))
};
let message = format!("{context}{error}");
let audit = build_failure_audit(
request_url,
@@ -40,7 +48,7 @@ pub(super) fn map_reqwest_error(
is_timeout,
is_connect,
message.as_str(),
source.clone(),
source_chain.clone().or_else(|| source.clone()),
None,
Some(latency_ms),
prompt_chars,
@@ -56,6 +64,8 @@ pub(super) fn map_reqwest_error(
body = error.is_body(),
status = error.status().map(|status| status.as_u16()).unwrap_or_default(),
source = %source.clone().unwrap_or_default(),
source_chain = %source_chain.clone().unwrap_or_default(),
source_chain_depth,
message = %message,
elapsed_ms = latency_ms,
prompt_chars,
@@ -72,7 +82,62 @@ pub(super) fn map_reqwest_error(
request: error.is_request(),
body: error.is_body(),
status_code: error.status().map(|status| status.as_u16()),
source,
source: source_chain.or(source),
audit: Some(audit),
}
}
fn collect_error_source_chain(error: &(dyn Error + 'static)) -> Vec<String> {
let mut chain = Vec::new();
let mut next = error.source();
while let Some(source) = next {
chain.push(source.to_string());
next = source.source();
}
chain
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt;
#[derive(Debug)]
struct TestError {
message: &'static str,
source: Option<Box<TestError>>,
}
impl fmt::Display for TestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message)
}
}
impl Error for TestError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source
.as_deref()
.map(|source| source as &(dyn Error + 'static))
}
}
#[test]
fn collect_error_source_chain_keeps_nested_causes() {
let error = TestError {
message: "top",
source: Some(Box::new(TestError {
message: "middle",
source: Some(Box::new(TestError {
message: "bottom",
source: None,
})),
})),
};
assert_eq!(
collect_error_source_chain(&error),
vec!["middle".to_string(), "bottom".to_string()]
);
}
}