This commit is contained in:
2026-05-14 14:21:17 +08:00
parent 7a75f5d612
commit d33c937ebc
191 changed files with 1916 additions and 1549 deletions

View File

@@ -74,8 +74,8 @@ use crate::{
config::AppConfig,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
DownloadedOpenAiImage, OpenAiGeneratedImages, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
@@ -1839,7 +1839,7 @@ async fn compile_match3d_draft_for_session(
.await
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 点。
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 点。
async fn execute_billable_match3d_draft_generation<T, Fut>(
state: &AppState,
request_context: &RequestContext,
@@ -1896,7 +1896,7 @@ async fn consume_match3d_draft_generation_points(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿点预扣因 SpacetimeDB 连接不可用而降级跳过"
"抓大鹅草稿点预扣因 SpacetimeDB 连接不可用而降级跳过"
);
Ok(false)
}
@@ -1931,7 +1931,7 @@ async fn refund_match3d_draft_generation_points(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿生成失败后的点退款失败"
"抓大鹅草稿生成失败后的点退款失败"
);
}
}
@@ -2530,6 +2530,26 @@ fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
}))
}
fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
"missingAssets": ["背景音乐"],
}))
}
fn require_match3d_background_music_title(
plan: &Match3DGeneratedBackgroundMusicPlan,
) -> Result<String, AppError> {
let title = normalize_match3d_audio_title(plan.title.as_str());
if title.is_empty() {
return Err(match3d_background_music_missing_error(
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
));
}
Ok(title)
}
fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
Match3DWorkProfileResponse {
summary: map_match3d_work_summary_response(item),
@@ -3164,7 +3184,7 @@ async fn generate_match3d_item_assets(
&& assets
.iter()
.take(target_item_count)
.any(|asset| asset.background_music.is_some())
.any(has_match3d_background_music_audio)
{
return Ok(assets.into_iter().take(target_item_count).collect());
}
@@ -3369,9 +3389,11 @@ async fn generate_match3d_item_image_assets_in_batches(
.iter()
.position(|seed| !seed.persist_asset)
.unwrap_or(chunk_seeds.len());
debug_assert!(chunk_seeds[persisted_seed_count..]
.iter()
.all(|seed| !seed.persist_asset));
debug_assert!(
chunk_seeds[persisted_seed_count..]
.iter()
.all(|seed| !seed.persist_asset)
);
let persisted_seeds = chunk_seeds
.into_iter()
.take(persisted_seed_count)
@@ -3585,13 +3607,16 @@ async fn ensure_match3d_background_music_asset(
.min_by_key(|(_, asset)| match3d_item_sort_index(asset.item_id.as_str()))
.map(|(index, _)| index)
else {
return Ok(assets);
return Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
match3d_background_music_missing_error("抓大鹅草稿缺少可写入背景音乐的物品素材"),
));
};
let title = normalize_match3d_audio_title(plan.title.as_str());
if title.is_empty() {
return Ok(assets);
}
let title = require_match3d_background_music_title(plan).map_err(|error| {
match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)
})?;
let style = normalize_match3d_audio_style(plan.style.as_str());
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
.await
@@ -3615,13 +3640,18 @@ async fn ensure_match3d_background_music_asset(
.await?;
}
Err(error) => {
tracing::warn!(
tracing::error!(
provider = MATCH3D_AGENT_PROVIDER,
session_id,
profile_id,
error = %error,
"抓大鹅草稿背景音乐生成失败,保留草稿并允许结果页重试"
"抓大鹅草稿背景音乐生成失败,终止本次草稿生成并等待重试"
);
return Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
error,
));
}
}
@@ -4382,6 +4412,14 @@ fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) ->
view_count >= MATCH3D_ITEM_VIEW_COUNT
}
fn has_match3d_background_music_audio(asset: &Match3DGeneratedItemAsset) -> bool {
asset
.background_music
.as_ref()
.map(|music| music.audio_src.trim())
.is_some_and(|value| !value.is_empty())
}
fn has_match3d_required_item_images(
assets: &[Match3DGeneratedItemAsset],
required_item_count: usize,
@@ -4984,7 +5022,11 @@ async fn wait_match3d_apimart_generated_images(
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(task_id.to_string(), b64_images, 1));
return Ok(match3d_images_from_base64(
task_id.to_string(),
b64_images,
1,
));
}
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
@@ -5026,7 +5068,8 @@ async fn download_match3d_images_from_urls(
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
images
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
}
Ok(OpenAiGeneratedImages {
task_id,
@@ -6459,6 +6502,79 @@ mod tests {
);
}
#[test]
fn match3d_background_music_ready_requires_audio_src() {
let mut asset = Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
image_src: None,
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: Some("果园轻舞".to_string()),
background_music_style: Some("轻快, 休闲".to_string()),
background_music_prompt: Some(String::new()),
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
};
assert!(
!has_match3d_background_music_audio(&asset),
"只有音乐元信息时不能把草稿音乐阶段视为完成"
);
asset.background_music = Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("match3d_background_music".to_string()),
audio_src: "/generated-match3d-assets/music.mp3".to_string(),
prompt: Some(String::new()),
title: Some("果园轻舞".to_string()),
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
});
assert!(has_match3d_background_music_audio(&asset));
}
#[test]
fn match3d_background_music_missing_error_lists_required_asset() {
let error = match3d_background_music_missing_error("抓大鹅草稿背景音乐名称为空");
let body = error.body_text();
assert!(body.contains("抓大鹅草稿背景音乐名称为空"));
assert!(body.contains("背景音乐"));
}
#[test]
fn match3d_background_music_title_is_required_for_auto_draft() {
let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
title: " ,。 ".to_string(),
style: "轻快, 休闲".to_string(),
prompt: String::new(),
})
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
assert!(missing.body_text().contains("背景音乐"));
let title = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
title: " 果园轻舞。 ".to_string(),
style: "轻快, 休闲".to_string(),
prompt: String::new(),
})
.expect("valid title should pass");
assert_eq!(title, "果园轻舞");
}
#[test]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
@@ -6618,10 +6734,14 @@ mod tests {
})
.collect::<Vec<_>>();
let plan = build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets);
let plan =
build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets);
assert_eq!(plan.requested_item_names, vec!["新物品"]);
assert_eq!(plan.padded_item_names.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE);
assert_eq!(
plan.padded_item_names.len(),
MATCH3D_MATERIAL_ITEM_BATCH_SIZE
);
assert_eq!(plan.padded_item_names[0], "新物品");
}
@@ -6660,6 +6780,29 @@ mod tests {
assert!(negative_prompt.contains("真实 3D 渲染"));
}
#[test]
fn match3d_material_sheet_request_uses_apimart_nanobanana_contract() {
let body = build_match3d_apimart_nanobanana_image_request_body(
"生成水果素材图",
"文字、水印",
MATCH3D_MATERIAL_APIMART_SIZE,
);
assert_eq!(body["model"], MATCH3D_MATERIAL_APIMART_MODEL);
assert_eq!(body["size"], MATCH3D_MATERIAL_APIMART_SIZE);
assert_eq!(body["resolution"], MATCH3D_MATERIAL_APIMART_RESOLUTION);
assert_eq!(body["n"], 1);
assert_eq!(body["official_fallback"], true);
assert!(body.get("image").is_none());
assert!(body.get("image_urls").is_none());
assert!(
body["prompt"]
.as_str()
.unwrap_or_default()
.contains("文字、水印")
);
}
#[test]
fn match3d_background_and_container_prompts_keep_ui_layers_split() {
let config = config("水果", 3, 3);