use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::Response, }; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde_json::{Value, json}; use shared_contracts::wooden_fish::{ WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use crate::generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client, create_openai_image_edit, create_openai_image_edit_with_references, require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation, }, }; const WOODEN_FISH_PROVIDER: &str = "wooden-fish"; const WOODEN_FISH_CREATION_PROVIDER: &str = "wooden-fish-creation"; const WOODEN_FISH_RUNTIME_PROVIDER: &str = "wooden-fish-runtime"; const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景"; const DEFAULT_HIT_SOUND_PROMPT: &str = "清脆短促的木鱼敲击声"; const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object"; const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png"; const WOODEN_FISH_ENTITY_KIND: &str = "wooden_fish_work"; const WOODEN_FISH_HIT_OBJECT_SLOT: &str = "hit_object"; const WOODEN_FISH_HIT_OBJECT_ASSET_KIND: &str = "wooden_fish_hit_object"; const WOODEN_FISH_BACKGROUND_SLOT: &str = "background"; const WOODEN_FISH_BACKGROUND_ASSET_KIND: &str = "wooden_fish_background"; const WOODEN_FISH_HIT_SOUND_SLOT: &str = "hit_sound"; const WOODEN_FISH_HIT_SOUND_ASSET_KIND: &str = "wooden_fish_hit_sound"; const WOODEN_FISH_HIT_SOUND_DURATION_SECONDS: u8 = 3; const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../public/wooden-fish/default-hit-object.png" )); pub async fn create_wooden_fish_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; validate_workspace_request(&request_context, &payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("wooden-fish-session-"); let now = current_utc_micros(); let draft = build_wooden_fish_draft(&payload); let session = WoodenFishSessionSnapshotResponse { session_id, owner_user_id, status: WoodenFishGenerationStatus::Draft, draft: Some(draft), created_at: format_timestamp_micros(now), updated_at: format_timestamp_micros(now), }; Ok(json_success_body( Some(&request_context), WoodenFishSessionResponse { session: state .spacetime_client() .create_wooden_fish_session(session) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?, }, )) } pub async fn get_wooden_fish_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .get_wooden_fish_session(session_id, owner_user_id) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishSessionResponse { session }, )) } pub async fn execute_wooden_fish_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let Json(mut payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); maybe_generate_hit_object_asset( &state, &request_context, &session_id, owner_user_id.as_str(), &mut payload, ) .await?; maybe_generate_hit_sound_asset( &state, &request_context, &session_id, owner_user_id.as_str(), &mut payload, ) .await?; let response = state .spacetime_client() .execute_wooden_fish_action(session_id, owner_user_id, payload) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), response)) } pub async fn publish_wooden_fish_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .publish_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishWorkMutationResponse { item: work }, )) } pub async fn get_wooden_fish_runtime_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .get_wooden_fish_runtime_work(profile_id) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishWorkDetailResponse { item: work }, )) } pub async fn start_wooden_fish_run( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; let run = state .spacetime_client() .start_wooden_fish_run(payload, authenticated.claims().user_id().to_string()) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishRunResponse { run }, )) } pub async fn checkpoint_wooden_fish_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .checkpoint_wooden_fish_run( run_id, authenticated.claims().user_id().to_string(), payload, ) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishRunResponse { run }, )) } pub async fn finish_wooden_fish_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .finish_wooden_fish_run( run_id, authenticated.claims().user_id().to_string(), payload, ) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishRunResponse { run }, )) } pub async fn list_wooden_fish_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let gallery = state .spacetime_client() .list_wooden_fish_gallery() .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), gallery)) } pub async fn get_wooden_fish_gallery_detail( State(state): State, Path(public_work_code): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?; let work = state .spacetime_client() .get_wooden_fish_gallery_detail(public_work_code) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishGalleryDetailResponse { item: work }, )) } fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> WoodenFishDraftResponse { WoodenFishDraftResponse { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: None, work_title: payload.work_title.trim().to_string(), work_description: payload.work_description.trim().to_string(), theme_tags: normalize_tags(payload.theme_tags.clone()), hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT), hit_object_reference_image_src: payload .hit_object_reference_image_src .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), hit_sound_prompt: payload .hit_sound_prompt .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .or_else(|| Some(DEFAULT_HIT_SOUND_PROMPT.to_string())), floating_words: normalize_floating_words(payload.floating_words.clone()), hit_object_asset: None, background_asset: None, hit_sound_asset: payload.hit_sound_asset.clone(), cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, } } fn validate_workspace_request( request_context: &RequestContext, payload: &WoodenFishWorkspaceCreateRequest, ) -> Result<(), Response> { ensure_non_empty(request_context, &payload.work_title, "workTitle")?; if payload.template_id.trim() != WOODEN_FISH_TEMPLATE_ID { return Err(wooden_fish_error_response( request_context, WOODEN_FISH_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_PROVIDER, "message": "templateId 必须为 wooden-fish", })), )); } Ok(()) } async fn maybe_generate_hit_object_asset( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &mut WoodenFishActionRequest, ) -> Result<(), Response> { if !matches!( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft | shared_contracts::wooden_fish::WoodenFishActionType::RegenerateHitObject ) { return Ok(()); } if payload.hit_object_asset.is_some() && payload.background_asset.is_some() { return Ok(()); } let profile_id = resolve_hit_object_profile_id(state, request_context, session_id, owner_user_id, payload) .await?; payload.profile_id = Some(profile_id.clone()); let prompt = payload .hit_object_prompt .as_deref() .map(|value| clean_string(value, DEFAULT_HIT_OBJECT_PROMPT)) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_HIT_OBJECT_PROMPT.to_string()); let generated = generate_wooden_fish_image_assets( state, owner_user_id, session_id, profile_id.as_str(), prompt.as_str(), payload.hit_object_reference_image_src.as_deref(), ) .await .map_err(|error| { wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error) })?; payload.hit_object_asset = Some(generated.hit_object_asset); payload.background_asset = Some(generated.background_asset); Ok(()) } fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset { WoodenFishImageAsset { asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(), image_src: DEFAULT_HIT_OBJECT_IMAGE_SRC.to_string(), image_object_key: "public/wooden-fish/default-hit-object.png".to_string(), asset_object_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(), generation_provider: "bundled-default".to_string(), prompt: DEFAULT_HIT_OBJECT_PROMPT.to_string(), width: 1024, height: 1024, } } fn is_default_hit_object_prompt(prompt: &str) -> bool { let normalized = normalize_hit_object_prompt_for_default_match(prompt); normalized.is_empty() || normalized == normalize_hit_object_prompt_for_default_match(DEFAULT_HIT_OBJECT_PROMPT) || normalized == normalize_hit_object_prompt_for_default_match("卡通木鱼,圆润可爱,透明背景") || normalized == normalize_hit_object_prompt_for_default_match("卡通木鱼,透明背景,居中,圆润可爱") || normalized == normalize_hit_object_prompt_for_default_match("卡通木鱼") } fn normalize_hit_object_prompt_for_default_match(prompt: &str) -> String { prompt .chars() .filter(|ch| !ch.is_whitespace() && !matches!(ch, ',' | ',' | '。' | '.')) .collect::() } async fn resolve_hit_object_profile_id( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &WoodenFishActionRequest, ) -> Result { if let Some(profile_id) = payload .profile_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { return Ok(profile_id.to_string()); } if matches!( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft ) { return Ok(build_prefixed_uuid_id("wooden-fish-profile-")); } let session = state .spacetime_client() .get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string()) .await .map_err(|error| { wooden_fish_error_response( request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; session .draft .and_then(|draft| draft.profile_id) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { wooden_fish_error_response( request_context, WOODEN_FISH_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_PROVIDER, "message": "wooden-fish action 需要先完成 compile-draft", })), ) }) } async fn maybe_generate_hit_sound_asset( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &mut WoodenFishActionRequest, ) -> Result<(), Response> { if !matches!( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft | shared_contracts::wooden_fish::WoodenFishActionType::GenerateHitSound ) { return Ok(()); } if matches!( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft ) && payload.hit_sound_asset.is_some() { return Ok(()); } let profile_id = resolve_hit_object_profile_id(state, request_context, session_id, owner_user_id, payload) .await?; payload.profile_id = Some(profile_id.clone()); let prompt = payload .hit_sound_prompt .as_deref() .map(|value| clean_string(value, DEFAULT_HIT_SOUND_PROMPT)) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_HIT_SOUND_PROMPT.to_string()); let asset = generate_wooden_fish_hit_sound_asset( state, owner_user_id, profile_id.as_str(), prompt.as_str(), ) .await .map_err(|error| { wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error) })?; payload.hit_sound_asset = Some(asset); Ok(()) } async fn generate_wooden_fish_hit_sound_asset( state: &AppState, owner_user_id: &str, profile_id: &str, prompt: &str, ) -> Result { let final_prompt = build_wooden_fish_hit_sound_prompt(prompt); let generated = generate_sound_effect_asset_for_creation( state, owner_user_id, final_prompt.clone(), Some(WOODEN_FISH_HIT_SOUND_DURATION_SECONDS), None, GeneratedCreationAudioTarget { entity_kind: WOODEN_FISH_ENTITY_KIND.to_string(), entity_id: profile_id.to_string(), slot: WOODEN_FISH_HIT_SOUND_SLOT.to_string(), asset_kind: WOODEN_FISH_HIT_SOUND_ASSET_KIND.to_string(), profile_id: Some(profile_id.to_string()), storage_prefix: LegacyAssetPrefix::WoodenFishAssets, }, ) .await?; map_generated_creation_audio_to_wooden_fish_asset( profile_id, final_prompt.as_str(), generated, WOODEN_FISH_HIT_SOUND_DURATION_SECONDS, ) } fn build_wooden_fish_hit_sound_prompt(prompt: &str) -> String { format!( "为敲木鱼玩法生成一次点击触发的短促敲击音效:{}。要求:干净、清脆、无旋律、无环境噪声、无语音、无文字提示音,适合高频点击时叠加播放。", clean_string(prompt, DEFAULT_HIT_SOUND_PROMPT) ) } fn map_generated_creation_audio_to_wooden_fish_asset( profile_id: &str, prompt: &str, asset: shared_contracts::creation_audio::CreationAudioAsset, duration_seconds: u8, ) -> Result { let asset_object_id = asset .asset_object_id .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "敲木鱼音效生成完成但缺少资产对象 ID", })) })?; let audio_object_key = asset.audio_src.trim().trim_start_matches('/').to_string(); if audio_object_key.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "敲木鱼音效生成完成但缺少音频地址", })), ); } Ok(WoodenFishAudioAsset { asset_id: format!("{profile_id}-hit-sound-{}", asset.task_id), audio_src: asset.audio_src, audio_object_key, asset_object_id, source: "generated".to_string(), prompt: asset.prompt.or_else(|| Some(prompt.to_string())), duration_ms: Some(u32::from(duration_seconds) * 1_000), }) } struct WoodenFishGeneratedImageAssets { hit_object_asset: WoodenFishImageAsset, background_asset: WoodenFishImageAsset, } async fn generate_wooden_fish_image_assets( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> Result { let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let clean_reference_image_src = hit_object_reference_image_src .map(str::trim) .filter(|value| !value.is_empty()); let theme = resolve_wooden_fish_generation_theme(prompt, clean_reference_image_src); let default_reference_image = default_wooden_fish_reference_image()?; let theme_reference_image = resolve_wooden_fish_theme_reference_image(clean_reference_image_src)?; let (hit_object_asset, background_reference_image) = if should_generate_wooden_fish_hit_object(prompt, clean_reference_image_src) { let hit_object_prompt = build_wooden_fish_hit_object_prompt(theme.as_str()); let mut reference_images = vec![default_reference_image.clone()]; if let Some(reference_image) = theme_reference_image { reference_images.push(reference_image); } let generated = create_openai_image_edit_with_references( &http_client, &settings, hit_object_prompt.as_str(), None, "1:1", reference_images.as_slice(), "生成敲木鱼敲击物图案失败", ) .await?; let task_id = generated.task_id.clone(); let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "生成敲木鱼敲击物图案失败:上游未返回图片", })) })?; let background_reference_image = downloaded_wooden_fish_reference_image(&image, "wooden-fish-generated-hit-object"); let hit_object_asset = persist_wooden_fish_image_asset( state, owner_user_id, session_id, profile_id, task_id.as_str(), hit_object_prompt.as_str(), image, current_utc_micros(), WoodenFishImageSlotPersistSpec { slot: WOODEN_FISH_HIT_OBJECT_SLOT, asset_kind: WOODEN_FISH_HIT_OBJECT_ASSET_KIND, asset_id_part: "hit-object", width: 1024, height: 1024, }, ) .await?; (hit_object_asset, background_reference_image) } else { ( default_wooden_fish_hit_object_asset(), default_reference_image, ) }; let background_prompt = build_wooden_fish_background_prompt(theme.as_str()); let background_generated = create_openai_image_edit( &http_client, &settings, background_prompt.as_str(), None, "9:16", &background_reference_image, "生成敲木鱼背景环境图失败", ) .await?; let background_task_id = background_generated.task_id.clone(); let background_image = background_generated .images .into_iter() .next() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "生成敲木鱼背景环境图失败:上游未返回图片", })) })?; let background_asset = persist_wooden_fish_image_asset( state, owner_user_id, session_id, profile_id, background_task_id.as_str(), background_prompt.as_str(), background_image, current_utc_micros(), WoodenFishImageSlotPersistSpec { slot: WOODEN_FISH_BACKGROUND_SLOT, asset_kind: WOODEN_FISH_BACKGROUND_ASSET_KIND, asset_id_part: "background", width: 1024, height: 1536, }, ) .await?; Ok(WoodenFishGeneratedImageAssets { hit_object_asset, background_asset, }) } fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String { format!( "生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } fn build_wooden_fish_background_prompt(prompt: &str) -> String { format!( "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\n主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } fn should_generate_wooden_fish_hit_object( prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> bool { hit_object_reference_image_src.is_some() || !is_default_hit_object_prompt(prompt) } fn resolve_wooden_fish_generation_theme( prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> String { let prompt = clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT); if !is_default_hit_object_prompt(prompt.as_str()) { return prompt; } if hit_object_reference_image_src.is_some() { return "用户提供参考图".to_string(); } prompt } fn default_wooden_fish_reference_image() -> Result { let bytes = DEFAULT_HIT_OBJECT_REFERENCE_BYTES.to_vec(); if bytes.is_empty() { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "message": "敲木鱼默认参考图为空", })), ); } Ok(OpenAiReferenceImage { bytes, mime_type: "image/png".to_string(), file_name: "wooden-fish-default-hit-object-reference.png".to_string(), }) } fn resolve_wooden_fish_theme_reference_image( source: Option<&str>, ) -> Result, AppError> { let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; if !source.to_ascii_lowercase().starts_with("data:image/") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "field": "hitObjectReferenceImageSrc", "message": "敲木鱼参考图必须是 base64 图片 Data URL。", })), ); } let decoded = decode_generated_image_asset_data_url(source).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "field": "hitObjectReferenceImageSrc", "message": "敲木鱼参考图必须是 base64 图片 Data URL。", })) })?; Ok(Some(OpenAiReferenceImage { file_name: format!("wooden-fish-theme-reference.{}", decoded.format.extension), mime_type: decoded.format.mime_type, bytes: decoded.bytes, })) } fn downloaded_wooden_fish_reference_image( image: &DownloadedOpenAiImage, file_name_stem: &str, ) -> OpenAiReferenceImage { OpenAiReferenceImage { bytes: image.bytes.clone(), mime_type: image.mime_type.clone(), file_name: format!("{file_name_stem}.{}", image.extension), } } struct WoodenFishImageSlotPersistSpec { slot: &'static str, asset_kind: &'static str, asset_id_part: &'static str, width: u32, height: u32, } async fn persist_wooden_fish_image_asset( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, task_id: &str, prompt: &str, image: DownloadedOpenAiImage, generated_at_micros: i64, spec: WoodenFishImageSlotPersistSpec, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let http_client = reqwest::Client::new(); let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { prefix: LegacyAssetPrefix::WoodenFishAssets, path_segments: vec![ sanitize_wooden_fish_asset_segment(session_id, "session"), sanitize_wooden_fish_asset_segment(profile_id, "profile"), spec.slot.to_string(), format!("asset-{generated_at_micros}"), ], file_stem: "image".to_string(), image: GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(image.mime_type.as_str()), bytes: image.bytes, }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(spec.asset_kind.to_string()), owner_user_id: Some(owner_user_id.to_string()), entity_kind: Some(WOODEN_FISH_ENTITY_KIND.to_string()), entity_id: Some(profile_id.to_string()), slot: Some(spec.slot.to_string()), provider: Some("image2".to_string()), task_id: Some(task_id.to_string()), }, extra_metadata: BTreeMap::from([ ("profile_id".to_string(), profile_id.to_string()), ("session_id".to_string(), session_id.to_string()), ]), }) .map_err(map_wooden_fish_generated_image_asset_error)?; let persisted_mime_type = prepared.format.mime_type.clone(); let put_result = oss_client .put_object(&http_client, prepared.request) .await .map_err(map_wooden_fish_asset_oss_error)?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await .map_err(map_wooden_fish_asset_oss_error)?; let asset_object = state .spacetime_client() .confirm_asset_object( build_asset_object_upsert_input( generate_asset_object_id(generated_at_micros), head.bucket, head.object_key.clone(), AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, head.etag, spec.asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), Some(profile_id.to_string()), Some(profile_id.to_string()), generated_at_micros, ) .map_err(map_wooden_fish_asset_field_error)?, ) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) })?; if let Err(error) = state .spacetime_client() .bind_asset_object_to_entity( build_asset_entity_binding_input( generate_asset_binding_id(generated_at_micros), asset_object.asset_object_id.clone(), WOODEN_FISH_ENTITY_KIND.to_string(), profile_id.to_string(), spec.slot.to_string(), spec.asset_kind.to_string(), Some(owner_user_id.to_string()), Some(profile_id.to_string()), generated_at_micros, ) .map_err(map_wooden_fish_asset_field_error)?, ) .await { tracing::warn!( provider = "spacetimedb", owner_user_id, session_id, profile_id, slot = spec.slot, error = %error, "敲木鱼图片资产绑定失败,历史素材索引可能缺少绑定记录" ); } Ok(WoodenFishImageAsset { asset_id: format!("{profile_id}-{}-{generated_at_micros}", spec.asset_id_part), image_src: put_result.legacy_public_path, image_object_key: head.object_key, asset_object_id: asset_object.asset_object_id, generation_provider: "image2".to_string(), prompt: prompt.to_string(), width: spec.width, height: spec.height, }) } fn map_wooden_fish_generated_image_asset_error( error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError, ) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "generated-image-assets", "message": format!("准备敲木鱼图片资产上传请求失败:{error:?}"), })) } fn map_wooden_fish_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } fn map_wooden_fish_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "wooden-fish-assets", "message": error.to_string(), })) } fn sanitize_wooden_fish_asset_segment(value: &str, fallback: &str) -> String { let sanitized = value .trim() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { ch } else { '-' } }) .collect::() .trim_matches('-') .to_string(); if sanitized.is_empty() { fallback.to_string() } else { sanitized } } fn ensure_non_empty( request_context: &RequestContext, value: &str, field: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(wooden_fish_error_response( request_context, WOODEN_FISH_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_PROVIDER, "field": field, "message": format!("{field} 不能为空"), })), )); } Ok(()) } fn clean_string(value: &str, fallback: &str) -> String { let value = value.trim(); if value.is_empty() { fallback.to_string() } else { value.to_string() } } fn normalize_tags(tags: Vec) -> Vec { let mut normalized = Vec::new(); for tag in tags { let tag = tag.trim(); if tag.is_empty() || normalized.iter().any(|item| item == tag) { continue; } normalized.push(tag.to_string()); if normalized.len() >= 6 { break; } } normalized } fn normalize_floating_words(words: Vec) -> Vec { let mut normalized = Vec::new(); for word in words { let word = normalize_floating_word(&word); if word.is_empty() || normalized.iter().any(|item| item == &word) { continue; } normalized.push(word); if normalized.len() >= 8 { break; } } if normalized.is_empty() { vec![ "幸运".to_string(), "健康".to_string(), "财富".to_string(), "姻缘".to_string(), "幸福".to_string(), "事业".to_string(), "成功".to_string(), "功德".to_string(), ] } else { normalized } } fn normalize_floating_word(word: &str) -> String { word.trim() .trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace()) .trim_end_matches(['+', '+']) .trim() .to_string() } fn wooden_fish_json( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, Response> { payload.map_err(|error| { wooden_fish_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": error.to_string(), })), ) }) } fn map_wooden_fish_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("发布需要") || message.contains("不能为空") || message.contains("必须") => { StatusCode::BAD_REQUEST } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn wooden_fish_error_response( request_context: &RequestContext, provider: &str, error: AppError, ) -> Response { let mut response = error.into_response_with_context(Some(request_context)); response.headers_mut().insert( HeaderName::from_static("x-genarrative-provider"), header::HeaderValue::from_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static("wooden-fish")), ); response } fn current_utc_micros() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; #[test] fn wooden_fish_hit_object_prompt_uses_hidden_image2_flow() { let prompt = build_wooden_fish_hit_object_prompt("赛博莲花木鱼"); assert_eq!( prompt, "生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:赛博莲花木鱼" ); } #[test] fn wooden_fish_background_prompt_uses_hidden_image2_flow() { let prompt = build_wooden_fish_background_prompt("赛博莲花木鱼"); assert_eq!( prompt, "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\n主题为:赛博莲花木鱼" ); } #[test] fn wooden_fish_theme_reference_image_decodes_data_url_for_image2() { let source = format!( "data:image/png;base64,{}", BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nreference") ); let image = resolve_wooden_fish_theme_reference_image(Some(source.as_str())) .expect("data url should parse") .expect("reference image should exist"); assert_eq!(image.mime_type, "image/png"); assert_eq!(image.file_name, "wooden-fish-theme-reference.png"); assert!(image.bytes.starts_with(b"\x89PNG\r\n\x1A\n")); } #[test] fn wooden_fish_theme_reference_image_rejects_non_data_url() { let error = resolve_wooden_fish_theme_reference_image(Some("/generated/example.png")) .expect_err("legacy path should not be accepted as direct image2 reference"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("Data URL")); } #[test] fn wooden_fish_default_hit_object_uses_bundled_asset() { let asset = default_wooden_fish_hit_object_asset(); assert_eq!(asset.asset_id, DEFAULT_HIT_OBJECT_ASSET_ID); assert_eq!(asset.image_src, DEFAULT_HIT_OBJECT_IMAGE_SRC); assert_eq!(asset.generation_provider, "bundled-default"); assert_eq!(asset.width, 1024); assert_eq!(asset.height, 1024); } #[test] fn wooden_fish_default_prompt_matches_legacy_defaults() { assert!(is_default_hit_object_prompt(DEFAULT_HIT_OBJECT_PROMPT)); assert!(is_default_hit_object_prompt("卡通木鱼,圆润可爱,透明背景")); assert!(is_default_hit_object_prompt( "卡通木鱼,透明背景,居中,圆润可爱" )); assert!(is_default_hit_object_prompt("卡通木鱼")); assert!(!is_default_hit_object_prompt("赛博莲花木鱼")); } #[test] fn wooden_fish_asset_segment_sanitizes_for_oss_object_key() { assert_eq!( sanitize_wooden_fish_asset_segment("wooden-fish/profile:1", "fallback"), "wooden-fish-profile-1" ); assert_eq!( sanitize_wooden_fish_asset_segment(" ", "fallback"), "fallback" ); } #[test] fn wooden_fish_audio_asset_maps_from_generated_sound_effect() { let asset = shared_contracts::creation_audio::CreationAudioAsset { task_id: "task-hit-sound-1".to_string(), provider: "vector-engine-vidu".to_string(), asset_object_id: Some("assetobj-hit-sound-1".to_string()), asset_kind: Some(WOODEN_FISH_HIT_SOUND_ASSET_KIND.to_string()), audio_src: "/generated-wooden-fish-assets/wooden-fish-profile-1/hit-sound.mp3" .to_string(), prompt: Some("清脆木鱼声".to_string()), title: None, updated_at: None, }; let mapped = map_generated_creation_audio_to_wooden_fish_asset( "wooden-fish-profile-1", "清脆木鱼声", asset, WOODEN_FISH_HIT_SOUND_DURATION_SECONDS, ) .expect("generated sound effect should map to wooden fish audio asset"); assert_eq!( mapped.asset_id, "wooden-fish-profile-1-hit-sound-task-hit-sound-1" ); assert_eq!( mapped.audio_object_key, "generated-wooden-fish-assets/wooden-fish-profile-1/hit-sound.mp3" ); assert_eq!(mapped.asset_object_id, "assetobj-hit-sound-1"); assert_eq!(mapped.source, "generated"); assert_eq!(mapped.duration_ms, Some(3_000)); } }