use super::*; use crate::openai_image_generation::{ OpenAiReferenceImage, create_openai_image_edit_with_references, }; #[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 { 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) request_timeout_ms: u64, pub(crate) external_api_audit_state: Option, pub(crate) external_api_audit_user_id: Option, pub(crate) external_api_audit_profile_id: Option, pub(crate) external_api_audit_request_id: Option, } 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) fn from_openai_image(image: DownloadedOpenAiImage) -> Self { Self { extension: image.extension, mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()), bytes: image.bytes, } } } impl PuzzleVectorEngineSettings { fn to_openai_settings(&self) -> crate::openai_image_generation::OpenAiImageSettings { crate::openai_image_generation::OpenAiImageSettings { base_url: self.base_url.clone(), api_key: self.api_key.clone(), request_timeout_ms: self.request_timeout_ms, external_api_audit_state: self.external_api_audit_state.clone(), external_api_audit_user_id: self.external_api_audit_user_id.clone(), external_api_audit_profile_id: self.external_api_audit_profile_id.clone(), external_api_audit_request_id: self.external_api_audit_request_id.clone(), } } pub(crate) fn with_external_api_audit_context( mut self, request_context: &RequestContext, user_id: Option, profile_id: Option, ) -> Self { self.external_api_audit_user_id = user_id; self.external_api_audit_profile_id = profile_id; self.external_api_audit_request_id = Some(request_context.request_id().to_string()); self } } 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, } #[derive(Clone, Debug)] pub(crate) struct GeneratedPuzzleLevelAssetResponse { pub(crate) image_src: String, pub(crate) object_key: String, } #[derive(Clone, Debug)] pub(crate) struct GeneratedPuzzleLevelAssetBundle { pub(crate) level_scene: GeneratedPuzzleLevelAssetResponse, pub(crate) ui_spritesheet: GeneratedPuzzleLevelAssetResponse, pub(crate) level_background: GeneratedPuzzleLevelAssetResponse, } 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 = GPT_IMAGE_2_MODEL, "拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2" ); PuzzleImageModel::Gemini31FlashPreview } _ => PuzzleImageModel::GptImage2, } } pub(crate) fn require_puzzle_vector_engine_settings( state: &PuzzleApiState, ) -> Result { let base_url = state.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 .vector_engine_api_key() .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(), request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1), external_api_audit_state: Some(state.root_state().clone()), external_api_audit_user_id: None, external_api_audit_profile_id: None, external_api_audit_request_id: None, }) } pub(crate) fn build_puzzle_image_http_client( state: &PuzzleApiState, _image_model: PuzzleImageModel, ) -> Result { let settings = require_puzzle_vector_engine_settings(state)?; build_openai_image_http_client(&settings.to_openai_settings()) } 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 { if let Some(reference_image) = reference_image { return create_puzzle_vector_engine_image_edit( http_client, settings, image_model, prompt, negative_prompt, size, candidate_count, reference_image, ) .await; } let generated = create_openai_image_generation( http_client, &settings.to_openai_settings(), prompt, Some(negative_prompt), size, candidate_count, &[], "拼图 VectorEngine 图片生成失败", ) .await?; Ok(PuzzleGeneratedImages { task_id: generated.task_id, images: generated .images .into_iter() .map(PuzzleDownloadedImage::from_openai_image) .collect(), }) } pub(crate) async fn create_puzzle_vector_engine_image_edit( http_client: &reqwest::Client, settings: &PuzzleVectorEngineSettings, _image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, reference_image: &PuzzleResolvedReferenceImage, ) -> Result { let file_name = format!( "puzzle-reference.{}", puzzle_mime_to_extension(reference_image.mime_type.as_str()) ); let generated = create_openai_image_edit_with_references( http_client, &settings.to_openai_settings(), prompt, Some(negative_prompt), size, candidate_count, &[OpenAiReferenceImage { bytes: reference_image.bytes.clone(), mime_type: reference_image.mime_type.clone(), file_name, }], "拼图 VectorEngine 图片编辑失败", ) .await?; Ok(PuzzleGeneratedImages { task_id: generated.task_id, images: generated .images .into_iter() .map(PuzzleDownloadedImage::from_openai_image) .collect(), }) } pub(crate) fn build_puzzle_downloaded_image_reference( image: &PuzzleDownloadedImage, ) -> PuzzleResolvedReferenceImage { PuzzleResolvedReferenceImage { mime_type: image.mime_type.clone(), bytes_len: image.bytes.len(), bytes: image.bytes.clone(), } } #[cfg(test)] 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 body = serde_json::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())), ]); let _ = reference_image; 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 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], reference_image_asset_object_id: Option<&str>, reference_image_asset_object_ids: &[String], ) -> Vec { let mut sources = Vec::new(); for source in reference_image_asset_object_id .into_iter() .chain(reference_image_asset_object_ids.iter().map(String::as_str)) .map(|asset_object_id| { asset_object_id .trim() .strip_prefix("asset-object:") .unwrap_or_else(|| asset_object_id.trim()) }) .filter(|asset_object_id| !asset_object_id.is_empty()) .map(|asset_object_id| format!("asset-object:{asset_object_id}")) .chain( legacy_reference_image_src .into_iter() .chain(reference_image_srcs.iter().map(String::as_str)) .map(str::to_string), ) { 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], reference_image_asset_object_id: Option<&str>, reference_image_asset_object_ids: &[String], ) -> bool { !collect_puzzle_reference_image_sources( legacy_reference_image_src, reference_image_srcs, reference_image_asset_object_id, reference_image_asset_object_ids, ) .is_empty() } pub(crate) fn should_use_puzzle_reference_image_generation( reference_image_src: Option<&str>, use_reference_image_generation: bool, ) -> bool { use_reference_image_generation && has_puzzle_reference_image(reference_image_src) } #[cfg(test)] 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 parse_puzzle_asset_object_reference(source: &str) -> Option<&str> { source .trim() .strip_prefix("asset-object:") .map(str::trim) .filter(|value| !value.is_empty()) } pub(crate) async fn resolve_puzzle_reference_image( state: &PuzzleApiState, http_client: &reqwest::Client, source: &str, owner_user_id: Option<&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(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) { return resolve_puzzle_reference_asset_object( state, http_client, asset_object_id, owner_user_id, ) .await; } 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": build_puzzle_reference_image_too_large_message(bytes_len), "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": "参考图必须是 assetObjectId、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": "参考图当前只支持 assetObjectId 或 /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 signed_read_url = signed.signed_url; download_signed_puzzle_reference_image( http_client, signed_read_url, object_key, None, "referenceImageSrc", ) .await } pub(crate) async fn resolve_puzzle_reference_image_as_data_url( state: &PuzzleApiState, http_client: &reqwest::Client, source: &str, ) -> Result { resolve_puzzle_reference_image(state, http_client, source, None).await } async fn resolve_puzzle_reference_asset_object( state: &PuzzleApiState, http_client: &reqwest::Client, asset_object_id: &str, owner_user_id: Option<&str>, ) -> Result { let asset_object = state .spacetime_client() .get_asset_object(asset_object_id.to_string()) .await .map_err(map_puzzle_client_error)? .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "field": "referenceImageAssetObjectId", "assetObjectId": asset_object_id, "message": "参考图资产不存在或当前账号不可见。", })) })?; let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; validate_puzzle_reference_asset_object( &asset_object, owner_user_id, oss_client.config_bucket(), )?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: asset_object.object_key.clone(), expire_seconds: Some(60), }) .map_err(map_puzzle_asset_oss_error)?; let content_type = asset_object.content_type.clone(); download_signed_puzzle_reference_image( http_client, signed.signed_url, asset_object.object_key.as_str(), content_type.as_deref(), "referenceImageAssetObjectId", ) .await } pub(crate) fn validate_puzzle_reference_asset_object( asset_object: &module_assets::AssetObjectRecord, owner_user_id: Option<&str>, oss_bucket: &str, ) -> Result<(), AppError> { if asset_object.bucket.trim() != oss_bucket.trim() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "field": "referenceImageAssetObjectId", "assetObjectId": asset_object.asset_object_id, "message": "参考图资产 bucket 与当前服务 OSS 配置不一致。", })), ); } if asset_object.asset_kind.trim() != "puzzle_cover_image" { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "field": "referenceImageAssetObjectId", "assetObjectId": asset_object.asset_object_id, "message": "参考图资产类型不属于拼图图片。", })), ); } let content_type = asset_object .content_type .as_deref() .map(str::trim) .unwrap_or_default(); if !content_type.starts_with("image/") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "field": "referenceImageAssetObjectId", "assetObjectId": asset_object.asset_object_id, "message": "参考图资产不是图片类型。", })), ); } if asset_object.content_length == 0 || asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64 { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "field": "referenceImageAssetObjectId", "assetObjectId": asset_object.asset_object_id, "message": build_puzzle_reference_image_too_large_message( asset_object.content_length as usize, ), "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, "actualBytes": asset_object.content_length, })), ); } if let Some(expected_owner_user_id) = owner_user_id .map(str::trim) .filter(|value| !value.is_empty()) { let actual_owner_user_id = asset_object .owner_user_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); if actual_owner_user_id != Some(expected_owner_user_id) { return Err( AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "asset-object", "field": "referenceImageAssetObjectId", "assetObjectId": asset_object.asset_object_id, "message": "参考图资产不属于当前账号。", })), ); } } Ok(()) } async fn download_signed_puzzle_reference_image( http_client: &reqwest::Client, signed_read_url: String, object_key: &str, fallback_content_type: Option<&str>, field: &str, ) -> Result { let response = http_client .get(signed_read_url.as_str()) .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()) .or(fallback_content_type) .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, "field": field, })), ); } if body.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": "读取参考图失败:对象内容为空", "objectKey": object_key, "field": field, })), ); } 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 persist_puzzle_generated_asset( state: &PuzzleApiState, 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: &PuzzleApiState, 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) async fn persist_puzzle_level_asset_image( state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, task_id: &str, path_segment: &str, asset_kind: &str, slot: &str, file_stem: &str, image: PuzzleDownloadedImage, ) -> 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"), sanitize_path_segment(path_segment, "level-asset"), sanitize_path_segment(task_id, "task"), ], file_name: format!("{file_stem}.{}", image.extension), content_type: Some(image.mime_type.clone()), access: OssObjectAccess::Private, metadata: build_puzzle_level_asset_metadata( owner_user_id, session_id, asset_kind, slot, ), body: image.bytes, }, ) .await .map_err(map_puzzle_asset_oss_error)?; Ok(GeneratedPuzzleLevelAssetResponse { 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 build_puzzle_level_asset_metadata( owner_user_id: &str, session_id: &str, asset_kind: &str, slot: &str, ) -> BTreeMap { BTreeMap::from([ ("asset_kind".to_string(), asset_kind.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(), slot.to_string()), ]) } 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 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 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_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()) }