use super::*; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PuzzleImageModel { GptImage2, Gemini31FlashPreview, } impl PuzzleImageModel { pub(crate) fn provider_name(self) -> &'static str { VECTOR_ENGINE_PROVIDER } pub(crate) fn request_model_name(self) -> &'static str { VECTOR_ENGINE_GPT_IMAGE_2_MODEL } pub(crate) fn candidate_source_type(self) -> &'static str { match self { Self::GptImage2 => "generated:gpt-image-2", Self::Gemini31FlashPreview => "generated:nanobanana2", } } } pub(crate) struct PuzzleVectorEngineSettings { pub(crate) base_url: String, pub(crate) api_key: String, } pub(crate) struct PuzzleGeneratedImages { pub(crate) task_id: String, pub(crate) images: Vec, } pub(crate) struct PuzzleResolvedReferenceImage { pub(crate) mime_type: String, pub(crate) bytes_len: usize, pub(crate) bytes: Vec, } pub(crate) struct GeneratedPuzzleImageCandidate { pub(crate) record: PuzzleGeneratedImageCandidateRecord, pub(crate) downloaded_image: PuzzleDownloadedImage, } impl GeneratedPuzzleImageCandidate { pub(crate) fn into_record(self) -> PuzzleGeneratedImageCandidateRecord { self.record } } pub(crate) trait GeneratedPuzzleImageCandidatesExt { fn into_records(self) -> Vec; } impl GeneratedPuzzleImageCandidatesExt for Vec { fn into_records(self) -> Vec { self.into_iter() .map(GeneratedPuzzleImageCandidate::into_record) .collect() } } #[derive(Clone)] pub(crate) struct PuzzleDownloadedImage { pub(crate) extension: String, pub(crate) mime_type: String, pub(crate) bytes: Vec, } impl PuzzleDownloadedImage { pub(crate) fn from_resolved_reference_image(image: PuzzleResolvedReferenceImage) -> Self { Self { extension: puzzle_mime_to_extension(image.mime_type.as_str()).to_string(), mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()), bytes: image.bytes, } } } pub(crate) struct ParsedPuzzleImageDataUrl { pub(crate) mime_type: String, pub(crate) bytes: Vec, } pub(crate) struct GeneratedPuzzleAssetResponse { pub(crate) image_src: String, pub(crate) asset_id: String, } pub(crate) struct GeneratedPuzzleUiBackgroundResponse { pub(crate) image_src: String, pub(crate) object_key: String, } pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { match value.map(str::trim).filter(|value| !value.is_empty()) { Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => { tracing::warn!( requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL, "拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all" ); PuzzleImageModel::Gemini31FlashPreview } _ => PuzzleImageModel::GptImage2, } } pub(crate) fn require_puzzle_vector_engine_settings( state: &AppState, ) -> Result { let base_url = state .config .vector_engine_base_url .trim() .trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "VectorEngine 图片生成地址未配置", "reason": "VECTOR_ENGINE_BASE_URL 未配置", })), ); } let api_key = state .config .vector_engine_api_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "VectorEngine 图片生成密钥未配置", "reason": "VECTOR_ENGINE_API_KEY 未配置", })) })?; Ok(PuzzleVectorEngineSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), }) } pub(crate) fn build_puzzle_image_http_client( state: &AppState, image_model: PuzzleImageModel, ) -> Result { let provider = image_model.provider_name(); let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms; reqwest::Client::builder() .timeout(Duration::from_millis(request_timeout_ms.max(1))) // 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。 .http1_only() .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": provider, "message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"), })) }) } pub(crate) fn to_puzzle_generated_image_candidate( candidate: &PuzzleGeneratedImageCandidateRecord, ) -> PuzzleGeneratedImageCandidate { // SpacetimeDB 模块反序列化的是 module-puzzle 的持久化结构,必须保留 snake_case 字段名;HTTP 响应层再单独映射为 camelCase。 PuzzleGeneratedImageCandidate { candidate_id: candidate.candidate_id.clone(), image_src: candidate.image_src.clone(), asset_id: candidate.asset_id.clone(), prompt: candidate.prompt.clone(), actual_prompt: candidate.actual_prompt.clone(), source_type: candidate.source_type.clone(), selected: candidate.selected, } } pub(crate) async fn create_puzzle_vector_engine_image_generation( http_client: &reqwest::Client, settings: &PuzzleVectorEngineSettings, image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, reference_image: Option<&PuzzleResolvedReferenceImage>, ) -> Result { let request_body = build_puzzle_vector_engine_image_request_body( image_model, prompt, negative_prompt, size, candidate_count, reference_image, ); let request_url = puzzle_vector_engine_images_generation_url(settings); let request_started_at = Instant::now(); let response = http_client .post(request_url.as_str()) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::CONTENT_TYPE, "application/json") .json(&request_body) .send() .await .map_err(|error| { map_puzzle_vector_engine_request_error(format!( "创建拼图 VectorEngine 图片生成任务失败:{error}" )) })?; let status = response.status(); let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64; tracing::info!( provider = VECTOR_ENGINE_PROVIDER, image_model = image_model.request_model_name(), endpoint = %request_url, status = status.as_u16(), prompt_chars = prompt.chars().count(), size, has_reference_image = reference_image.is_some(), elapsed_ms = upstream_elapsed_ms, "拼图 VectorEngine 图片生成 HTTP 返回" ); let response_text = response.text().await.map_err(|error| { map_puzzle_vector_engine_request_error(format!( "读取拼图 VectorEngine 图片生成响应失败:{error}" )) })?; if !status.is_success() { return Err(map_puzzle_vector_engine_upstream_error( status, response_text.as_str(), "创建拼图 VectorEngine 图片生成任务失败", )); } let payload = parse_puzzle_json_payload( response_text.as_str(), "解析拼图 VectorEngine 图片生成响应失败", )?; let image_urls = extract_puzzle_image_urls(&payload); if !image_urls.is_empty() { let download_started_at = Instant::now(); let images = download_puzzle_images_from_urls( http_client, format!("vector-engine-{}", current_utc_micros()), image_urls, candidate_count, ) .await?; tracing::info!( provider = VECTOR_ENGINE_PROVIDER, image_model = image_model.request_model_name(), image_count = images.images.len(), elapsed_ms = download_started_at.elapsed().as_millis() as u64, "拼图 VectorEngine 图片下载完成" ); return Ok(images); } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "拼图 VectorEngine 图片生成未返回图片地址", })), ) } pub(crate) async fn create_puzzle_vector_engine_image_edit( http_client: &reqwest::Client, settings: &PuzzleVectorEngineSettings, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, reference_image: &PuzzleResolvedReferenceImage, ) -> Result { let request_url = puzzle_vector_engine_images_edit_url(settings); let task_id = format!("vector-engine-edit-{}", current_utc_micros()); let file_name = format!( "puzzle-reference.{}", puzzle_mime_to_extension(reference_image.mime_type.as_str()) ); let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) .file_name(file_name) .mime_str(reference_image.mime_type.as_str()) .map_err(|error| { map_puzzle_vector_engine_request_error(format!( "构造拼图 VectorEngine 图片编辑参考图失败:{error}" )) })?; let form = reqwest::multipart::Form::new() .part("image", image_part) .text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string()) .text( "prompt", build_puzzle_vector_engine_prompt(prompt, negative_prompt), ) .text("n", candidate_count.clamp(1, 1).to_string()) .text("size", size.to_string()); let request_started_at = Instant::now(); let response = http_client .post(request_url.as_str()) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(reqwest::header::ACCEPT, "application/json") .multipart(form) .send() .await .map_err(|error| { map_puzzle_vector_engine_reqwest_error( "创建拼图 VectorEngine 图片编辑任务失败", &request_url, error, ) })?; let status = response.status(); tracing::info!( provider = VECTOR_ENGINE_PROVIDER, image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL, endpoint = %request_url, status = status.as_u16(), prompt_chars = prompt.chars().count(), size, reference_mime = %reference_image.mime_type, reference_bytes = reference_image.bytes_len, elapsed_ms = request_started_at.elapsed().as_millis() as u64, "拼图 VectorEngine 图片编辑 HTTP 返回" ); let response_text = response.text().await.map_err(|error| { map_puzzle_vector_engine_request_error(format!( "读取拼图 VectorEngine 图片编辑响应失败:{error}" )) })?; if !status.is_success() { return Err(map_puzzle_vector_engine_upstream_error( status, response_text.as_str(), "创建拼图 VectorEngine 图片编辑任务失败", )); } let payload = parse_puzzle_json_payload( response_text.as_str(), "解析拼图 VectorEngine 图片编辑响应失败", )?; let image_urls = extract_puzzle_image_urls(&payload); if !image_urls.is_empty() { return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count) .await; } let b64_images = extract_puzzle_b64_images(&payload); if !b64_images.is_empty() { return Ok(puzzle_images_from_base64( task_id, b64_images, candidate_count, )); } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "拼图 VectorEngine 图片编辑未返回图片", })), ) } pub(crate) fn build_puzzle_vector_engine_image_request_body( image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, reference_image: Option<&PuzzleResolvedReferenceImage>, ) -> Value { let mut body = Map::from_iter([ ( "model".to_string(), Value::String(image_model.request_model_name().to_string()), ), ( "prompt".to_string(), Value::String(build_puzzle_vector_engine_prompt(prompt, negative_prompt)), ), ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), ]); if let Some(reference_image) = reference_image && let Some(reference_data_url) = build_puzzle_generation_reference_image_data_url(reference_image) { body.insert("image".to_string(), json!([reference_data_url])); } Value::Object(body) } pub(crate) fn build_puzzle_vector_engine_generation_prompt( prompt: &str, has_reference_image: bool, ) -> String { let prompt = prompt.trim(); if !has_reference_image { return prompt.to_string(); } format!( concat!( "请以随请求提供的参考图作为第一优先级生成依据,严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围;", "允许按下面文字要求做风格化和细节增强,但不要改成与参考图无关的新画面。\n", "{prompt}" ), prompt = prompt, ) } pub(crate) fn build_puzzle_generation_reference_image_data_url( image: &PuzzleResolvedReferenceImage, ) -> Option { let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice()) .unwrap_or_else(|| image.bytes.clone()); let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { "image/png" } else { image.mime_type.as_str() }; Some(format!( "data:{};base64,{}", normalize_puzzle_downloaded_image_mime_type(mime_type), BASE64_STANDARD.encode(bytes) )) } pub(crate) fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option> { let image = image::load_from_memory(bytes).ok()?; let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle); let mut cursor = std::io::Cursor::new(Vec::new()); resized.write_to(&mut cursor, ImageFormat::Png).ok()?; Some(cursor.into_inner()) } pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool { reference_image_src .map(str::trim) .map(|value| !value.is_empty()) .unwrap_or(false) } pub(crate) fn collect_puzzle_reference_image_sources( legacy_reference_image_src: Option<&str>, reference_image_srcs: &[String], ) -> Vec { let mut sources = Vec::new(); for source in legacy_reference_image_src .into_iter() .chain(reference_image_srcs.iter().map(String::as_str)) { let normalized = source.trim(); if normalized.is_empty() { continue; } if !sources .iter() .any(|existing: &String| existing == normalized) { sources.push(normalized.to_string()); } if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT { break; } } sources } pub(crate) fn has_puzzle_reference_images( legacy_reference_image_src: Option<&str>, reference_image_srcs: &[String], ) -> bool { !collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs) .is_empty() } pub(crate) fn should_use_puzzle_reference_image_edit( reference_image_src: Option<&str>, use_reference_image_edit: bool, ) -> bool { use_reference_image_edit && has_puzzle_reference_image(reference_image_src) } pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String { let prompt = prompt.trim(); let negative_prompt = negative_prompt.trim(); if negative_prompt.is_empty() { return prompt.to_string(); } format!("{prompt}\n避免:{negative_prompt}") } pub(crate) fn puzzle_vector_engine_images_generation_url( settings: &PuzzleVectorEngineSettings, ) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/generations", settings.base_url) } else { format!("{}/v1/images/generations", settings.base_url) } } pub(crate) fn puzzle_vector_engine_images_edit_url( settings: &PuzzleVectorEngineSettings, ) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/edits", settings.base_url) } else { format!("{}/v1/images/edits", settings.base_url) } } pub(crate) async fn download_puzzle_images_from_urls( http_client: &reqwest::Client, task_id: String, image_urls: Vec, candidate_count: u32, ) -> Result { let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); for image_url in image_urls .into_iter() .take(candidate_count.clamp(1, 1) as usize) { images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); } Ok(PuzzleGeneratedImages { task_id, images }) } pub(crate) async fn resolve_puzzle_reference_image_as_data_url( state: &AppState, http_client: &reqwest::Client, source: &str, ) -> Result { let trimmed = source.trim(); if trimmed.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", "message": "参考图不能为空。", })), ); } if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { let bytes_len = parsed.bytes.len(); if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", "message": "参考图过大,请压缩后重试。", "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, "actualBytes": bytes_len, })), ); } return Ok(PuzzleResolvedReferenceImage { mime_type: parsed.mime_type, bytes_len, bytes: parsed.bytes, }); } if !trimmed.starts_with('/') { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", })), ); } let object_key = trimmed.trim_start_matches('/'); if LegacyAssetPrefix::from_object_key(object_key).is_none() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", "message": "参考图当前只支持 /generated-* 旧路径。", })), ); } let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: object_key.to_string(), expire_seconds: Some(60), }) .map_err(map_puzzle_asset_oss_error)?; let response = http_client .get(signed.signed_url) .send() .await .map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?; let status = response.status(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("image/png") .to_string(); let body = response.bytes().await.map_err(|error| { map_puzzle_image_request_error(format!("读取拼图参考图内容失败:{error}")) })?; if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取参考图失败,状态码:{status}"), "objectKey": object_key, })), ); } if body.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": "读取参考图失败:对象内容为空", "objectKey": object_key, })), ); } let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); let bytes_len = body.len(); Ok(PuzzleResolvedReferenceImage { mime_type, bytes_len, bytes: body.to_vec(), }) } pub(crate) async fn download_puzzle_remote_image( http_client: &reqwest::Client, image_url: &str, ) -> Result { let response = http_client.get(image_url).send().await.map_err(|error| { map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}")) })?; let status = response.status(); let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("image/jpeg") .to_string(); let bytes = response.bytes().await.map_err(|error| { map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}")) })?; if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "puzzle-image", "message": "下载拼图正式图片失败", "status": status.as_u16(), })), ); } let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); Ok(PuzzleDownloadedImage { extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), mime_type, bytes: bytes.to_vec(), }) } pub(crate) async fn persist_puzzle_generated_asset( state: &AppState, owner_user_id: &str, session_id: &str, level_name: &str, candidate_id: &str, task_id: &str, image: PuzzleDownloadedImage, generated_at_micros: i64, ) -> 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 asset_id = format!("asset-{generated_at_micros}"); let put_result = oss_client .put_object( &http_client, OssPutObjectRequest { prefix: LegacyAssetPrefix::PuzzleAssets, path_segments: vec![ sanitize_path_segment(session_id, "session"), sanitize_path_segment(level_name, "puzzle"), sanitize_path_segment(candidate_id, "candidate"), asset_id.clone(), ], file_name: format!("image.{}", image.extension), content_type: Some(image.mime_type.clone()), access: OssObjectAccess::Private, metadata: build_puzzle_asset_metadata(owner_user_id, session_id, candidate_id), body: image.bytes, }, ) .await .map_err(map_puzzle_asset_oss_error)?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await .map_err(map_puzzle_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, AssetObjectAccessPolicy::Private, head.content_type.or(Some(image.mime_type)), head.content_length, head.etag, "puzzle_cover_image".to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), None, Some(session_id.to_string()), generated_at_micros, ) .map_err(map_puzzle_asset_field_error)?, ) .await; match asset_object { Ok(asset_object) => { 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, PUZZLE_ENTITY_KIND.to_string(), session_id.to_string(), candidate_id.to_string(), "puzzle_cover_image".to_string(), Some(owner_user_id.to_string()), None, generated_at_micros, ) .map_err(map_puzzle_asset_field_error)?, ) .await { handle_puzzle_asset_spacetime_index_error( error, owner_user_id, session_id, candidate_id, "绑定拼图资产对象到实体", )?; } } Err(error) => handle_puzzle_asset_spacetime_index_error( error, owner_user_id, session_id, candidate_id, "确认拼图资产对象", )?, } Ok(GeneratedPuzzleAssetResponse { image_src: put_result.legacy_public_path, asset_id, }) } pub(crate) async fn persist_puzzle_ui_background_image( state: &AppState, owner_user_id: &str, session_id: &str, level_name: &str, task_id: &str, image: DownloadedOpenAiImage, ) -> 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 put_result = oss_client .put_object( &http_client, OssPutObjectRequest { prefix: LegacyAssetPrefix::PuzzleAssets, path_segments: vec![ sanitize_path_segment(session_id, "session"), sanitize_path_segment(level_name, "puzzle"), "ui-background".to_string(), sanitize_path_segment(task_id, "task"), ], file_name: format!("background.{}", image.extension), content_type: Some(image.mime_type.clone()), access: OssObjectAccess::Private, metadata: build_puzzle_ui_background_asset_metadata(owner_user_id, session_id), body: image.bytes, }, ) .await .map_err(map_puzzle_asset_oss_error)?; Ok(GeneratedPuzzleUiBackgroundResponse { image_src: put_result.legacy_public_path, object_key: put_result.object_key, }) } pub(crate) fn handle_puzzle_asset_spacetime_index_error( error: SpacetimeClientError, owner_user_id: &str, session_id: &str, candidate_id: &str, stage: &str, ) -> Result<(), AppError> { if should_skip_asset_operation_billing_for_connectivity(&error) { // 中文注释:OSS 已经持有真实图片,资产索引的 SpacetimeDB 短暂失败只影响历史检索,不应阻断本次生图展示。 tracing::warn!( provider = "spacetimedb", owner_user_id, session_id, candidate_id, stage, error = %error, "拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过" ); return Ok(()); } Err(map_puzzle_asset_spacetime_error(error)) } pub(crate) fn build_puzzle_asset_metadata( owner_user_id: &str, session_id: &str, candidate_id: &str, ) -> BTreeMap { BTreeMap::from([ ("asset_kind".to_string(), "puzzle_cover_image".to_string()), ("owner_user_id".to_string(), owner_user_id.to_string()), ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), ("entity_id".to_string(), session_id.to_string()), ("slot".to_string(), candidate_id.to_string()), ]) } pub(crate) fn build_puzzle_ui_background_asset_metadata( owner_user_id: &str, session_id: &str, ) -> BTreeMap { BTreeMap::from([ ( "asset_kind".to_string(), "puzzle_ui_background_image".to_string(), ), ("owner_user_id".to_string(), owner_user_id.to_string()), ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), ("entity_id".to_string(), session_id.to_string()), ("slot".to_string(), "ui_background".to_string()), ]) } pub(crate) fn parse_puzzle_json_payload( raw_text: &str, fallback_message: &str, ) -> Result { serde_json::from_str::(raw_text).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{fallback_message}:{error}"), })) }) } pub(crate) fn parse_puzzle_image_data_url(value: &str) -> Option { let body = value.strip_prefix("data:")?; let (mime_type, data) = body.split_once(";base64,")?; if !mime_type.starts_with("image/") { return None; } let bytes = decode_puzzle_base64(data)?; Some(ParsedPuzzleImageDataUrl { mime_type: mime_type.to_string(), bytes, }) } pub(crate) fn decode_puzzle_base64(value: &str) -> Option> { let cleaned = value.trim().replace(char::is_whitespace, ""); let mut output = Vec::with_capacity(cleaned.len() * 3 / 4); let mut buffer = 0u32; let mut bits = 0u8; for byte in cleaned.bytes() { let value = match byte { b'A'..=b'Z' => byte - b'A', b'a'..=b'z' => byte - b'a' + 26, b'0'..=b'9' => byte - b'0' + 52, b'+' => 62, b'/' => 63, b'=' => break, _ => return None, } as u32; buffer = (buffer << 6) | value; bits += 6; while bits >= 8 { bits -= 8; output.push(((buffer >> bits) & 0xFF) as u8); } } Some(output) } pub(crate) fn extract_puzzle_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_puzzle_strings_by_key(payload, "image", &mut urls); collect_puzzle_strings_by_key(payload, "url", &mut urls); let mut deduped = Vec::new(); for url in urls { if !deduped.contains(&url) { deduped.push(url); } } deduped } pub(crate) fn extract_puzzle_b64_images(payload: &Value) -> Vec { let mut values = Vec::new(); collect_puzzle_strings_by_key(payload, "b64_json", &mut values); values } pub(crate) fn puzzle_images_from_base64( task_id: String, b64_images: Vec, candidate_count: u32, ) -> PuzzleGeneratedImages { let images = b64_images .into_iter() .take(candidate_count.clamp(1, 1) as usize) .filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str())) .collect(); PuzzleGeneratedImages { task_id, images } } pub(crate) fn decode_puzzle_generated_image_base64(raw: &str) -> Option { let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; let mime_type = infer_puzzle_image_mime_type(bytes.as_slice()); Some(PuzzleDownloadedImage { extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), mime_type, bytes, }) } pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option { let mut results = Vec::new(); collect_puzzle_strings_by_key(payload, target_key, &mut results); results.into_iter().next() } pub(crate) fn collect_puzzle_strings_by_key( payload: &Value, target_key: &str, results: &mut Vec, ) { match payload { Value::Array(entries) => { for entry in entries { collect_puzzle_strings_by_key(entry, target_key, results); } } Value::Object(object) => { for (key, value) in object { if key == target_key { collect_puzzle_string_values(value, results); } collect_puzzle_strings_by_key(value, target_key, results); } } _ => {} } } pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec) { match payload { Value::String(text) => results.push(text.to_string()), Value::Array(items) => { for item in items { collect_puzzle_string_values(item, results); } } _ => {} } } pub(crate) fn infer_puzzle_image_mime_type(bytes: &[u8]) -> String { if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { return "image/png".to_string(); } if bytes.starts_with(b"\xFF\xD8\xFF") { return "image/jpeg".to_string(); } if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { return "image/webp".to_string(); } if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { return "image/gif".to_string(); } "image/png".to_string() } pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') .next() .map(str::trim) .unwrap_or("image/jpeg"); match mime_type { "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { mime_type.to_string() } _ => "image/jpeg".to_string(), } } pub(crate) fn puzzle_mime_to_extension(mime_type: &str) -> &str { match mime_type { "image/png" => "png", "image/webp" => "webp", "image/gif" => "gif", _ => "jpg", } } pub(crate) fn map_puzzle_image_request_error(message: String) -> AppError { let is_timeout = is_puzzle_request_timeout_message(message.as_str()); let status = if is_timeout { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; AppError::from_status(status).with_details(json!({ "provider": "puzzle-image", "message": message, "timeout": is_timeout, })) } pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppError { let is_timeout = is_puzzle_request_timeout_message(message.as_str()); let status = if is_timeout { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; AppError::from_status(status).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": message, "timeout": is_timeout, })) } pub(crate) fn map_puzzle_vector_engine_reqwest_error( context: &str, request_url: &str, error: reqwest::Error, ) -> AppError { let message = format!( "{context}:{}", normalize_puzzle_reqwest_error_message(&error) ); let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str()); let is_connect = error.is_connect(); let status = if is_timeout { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; let source = error.source().map(ToString::to_string).unwrap_or_default(); tracing::warn!( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, timeout = is_timeout, connect = is_connect, request = error.is_request(), body = error.is_body(), source = %source, message = %message, "拼图 VectorEngine 请求发送失败" ); AppError::from_status(status).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": message, "reason": resolve_puzzle_vector_engine_request_failure_reason(&error), "endpoint": request_url, "timeout": is_timeout, "connect": is_connect, "request": error.is_request(), "body": error.is_body(), "source": source, })) } pub(crate) fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String { error .to_string() .split_whitespace() .collect::>() .join(" ") } pub(crate) fn resolve_puzzle_vector_engine_request_failure_reason( error: &reqwest::Error, ) -> &'static str { if error.is_timeout() { return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"; } if error.is_connect() { return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置"; } if error.is_body() { return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小"; } "VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误" } pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("timed out") || lower.contains("timeout") || lower.contains("operation timed out") || lower.contains("deadline has elapsed") } pub(crate) fn map_puzzle_vector_engine_upstream_error( upstream_status: reqwest::StatusCode, raw_text: &str, fallback_message: &str, ) -> AppError { let message = parse_puzzle_api_error_message(raw_text, fallback_message); let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); let is_timeout = is_puzzle_request_timeout_message(message.as_str()) || is_puzzle_request_timeout_message(raw_excerpt.as_str()); let status = if is_timeout { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; tracing::warn!( provider = VECTOR_ENGINE_PROVIDER, upstream_status = upstream_status.as_u16(), timeout = is_timeout, message = %message, raw_excerpt = %raw_excerpt, "拼图 VectorEngine 上游请求失败" ); AppError::from_status(status).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "upstreamStatus": upstream_status.as_u16(), "message": message, "rawExcerpt": raw_excerpt, "timeout": is_timeout, })) } pub(crate) fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String { let trimmed = raw_text.trim(); if trimmed.is_empty() { return fallback_message.to_string(); } if let Ok(payload) = serde_json::from_str::(trimmed) && let Some(message) = find_first_puzzle_string_by_key(&payload, "message") { return message; } fallback_message.to_string() } pub(crate) fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { let normalized = raw_text.split_whitespace().collect::>().join(" "); if normalized.chars().count() <= max_chars { return normalized; } let keep_chars = max_chars.saturating_sub(3); format!( "{}...", normalized.chars().take(keep_chars).collect::() ) } pub(crate) fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } pub(crate) fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } pub(crate) fn map_puzzle_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "asset-object", "message": error.to_string(), })) } pub(crate) fn sanitize_path_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 } } pub(crate) fn current_utc_micros() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) }