use std::time::Duration; use axum::{ Json, extract::{State, rejection::JsonRejection}, http::StatusCode, response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use reqwest::{header, multipart}; use serde_json::{Value, json}; use shared_contracts::hyper3d as contract; use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, }; const HYPER3D_PROVIDER: &str = "hyper3d-rodin"; const RODIN_GEN2_TIER: &str = "Gen-2"; const DEFAULT_GEOMETRY_FILE_FORMAT: &str = "glb"; const DEFAULT_MATERIAL: &str = "PBR"; const DEFAULT_QUALITY: &str = "medium"; const DEFAULT_MESH_MODE: &str = "Quad"; const DEFAULT_CONDITION_MODE: &str = "concat"; const MAX_PROMPT_CHARS: usize = 2_000; const MAX_NEGATIVE_PROMPT_CHARS: usize = 1_000; const MAX_IMAGE_COUNT: usize = 5; const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; #[derive(Clone, Debug)] struct Hyper3dSettings { base_url: String, api_key: String, request_timeout_ms: u64, } #[derive(Clone, Debug)] struct DecodedImageDataUrl { bytes: Vec, mime_type: String, file_name: String, } #[derive(Clone, Debug)] struct SubmitOptions { seed: Option, geometry_file_format: String, material: String, quality: String, mesh_mode: String, addons: Vec, bbox_condition: Option>, preview_render: bool, } pub async fn submit_hyper3d_text_to_model( State(state): State, axum::extract::Extension(request_context): axum::extract::Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; submit_text_to_model(&state, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) .map_err(|error| error.into_response_with_context(Some(&request_context))) } pub async fn submit_hyper3d_image_to_model( State(state): State, axum::extract::Extension(request_context): axum::extract::Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; submit_image_to_model(&state, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) .map_err(|error| error.into_response_with_context(Some(&request_context))) } pub async fn get_hyper3d_task_status( State(state): State, axum::extract::Extension(request_context): axum::extract::Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; query_task_status(&state, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) .map_err(|error| error.into_response_with_context(Some(&request_context))) } pub async fn get_hyper3d_downloads( State(state): State, axum::extract::Extension(request_context): axum::extract::Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; query_downloads(&state, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) .map_err(|error| error.into_response_with_context(Some(&request_context))) } async fn submit_text_to_model( state: &AppState, payload: contract::Hyper3dTextToModelRequest, ) -> Result { let settings = require_hyper3d_settings(state)?; let http_client = build_hyper3d_http_client(&settings)?; let prompt = normalize_required_text(&payload.prompt, "prompt", MAX_PROMPT_CHARS)?; let options = SubmitOptions::from_text_request(&payload)?; let mut form = multipart::Form::new() .text("tier", RODIN_GEN2_TIER.to_string()) .text("prompt", prompt); form = append_common_submit_fields(form, &options)?; if let Some(negative_prompt) = normalize_optional_limited_text( payload.negative_prompt.as_deref(), MAX_NEGATIVE_PROMPT_CHARS, )? { form = form.text("negative_prompt", negative_prompt); } let response = post_hyper3d_multipart( &http_client, &settings, "/rodin", form, "提交 Hyper3D 文生模型任务失败", ) .await?; Ok(build_submit_response( contract::Hyper3dGenerationMode::TextToModel, response, )?) } pub(crate) async fn submit_image_to_model( state: &AppState, payload: contract::Hyper3dImageToModelRequest, ) -> Result { let settings = require_hyper3d_settings(state)?; let http_client = build_hyper3d_http_client(&settings)?; let options = SubmitOptions::from_image_request(&payload)?; let mut form = multipart::Form::new().text("tier", RODIN_GEN2_TIER.to_string()); form = append_common_submit_fields(form, &options)?; let condition_mode = normalize_enum( payload.condition_mode.as_deref(), DEFAULT_CONDITION_MODE, &["concat", "fuse"], "conditionMode", )?; form = form.text("condition_mode", condition_mode); if let Some(prompt) = normalize_optional_limited_text(payload.prompt.as_deref(), MAX_PROMPT_CHARS)? { form = form.text("prompt", prompt); } for image_url in payload .image_urls .iter() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { form = form.text("image_urls", image_url.to_string()); } for image in decode_image_data_urls(&payload.image_data_urls)? { let part = multipart::Part::bytes(image.bytes) .file_name(image.file_name) .mime_str(&image.mime_type) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "message": format!("构造图生模型图片字段失败:{error}"), })) })?; form = form.part("images", part); } if payload.image_data_urls.is_empty() && payload.image_urls.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": "imageDataUrls", "message": "图生模型至少需要一张参考图", })), ); } if payload.image_data_urls.len() + payload.image_urls.len() > MAX_IMAGE_COUNT { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": "imageDataUrls", "message": format!("图生模型最多支持 {} 张参考图", MAX_IMAGE_COUNT), })), ); } let response = post_hyper3d_multipart( &http_client, &settings, "/rodin", form, "提交 Hyper3D 图生模型任务失败", ) .await?; Ok(build_submit_response( contract::Hyper3dGenerationMode::ImageToModel, response, )?) } pub(crate) async fn query_task_status( state: &AppState, payload: contract::Hyper3dTaskStatusRequest, ) -> Result { let settings = require_hyper3d_settings(state)?; let http_client = build_hyper3d_http_client(&settings)?; // 中文注释:Hyper3D 返回的 subscriptionKey 是上游 opaque token,只做非空校验,不做人为 256 字符截断。 let subscription_key = normalize_required_opaque_text(&payload.subscription_key, "subscriptionKey")?; let response = post_hyper3d_json( &http_client, &settings, "/status", json!({ "subscription_key": subscription_key }), "查询 Hyper3D 模型任务状态失败", ) .await?; let jobs = extract_job_statuses(&response); let status = normalize_task_status( find_first_string_by_key(&response, "status") .or_else(|| jobs.first().map(|job| job.status.clone())) .as_deref() .unwrap_or("unknown"), ); Ok(contract::Hyper3dTaskStatusResponse { ok: true, provider: HYPER3D_PROVIDER.to_string(), status, jobs, raw: response, }) } pub(crate) async fn query_downloads( state: &AppState, payload: contract::Hyper3dDownloadRequest, ) -> Result { let settings = require_hyper3d_settings(state)?; let http_client = build_hyper3d_http_client(&settings)?; let task_uuid = normalize_required_text(&payload.task_uuid, "taskUuid", 256)?; let response = post_hyper3d_json( &http_client, &settings, "/download", json!({ "task_uuid": task_uuid }), "获取 Hyper3D 模型下载列表失败", ) .await?; Ok(contract::Hyper3dDownloadResponse { ok: true, provider: HYPER3D_PROVIDER.to_string(), files: extract_download_files(&response), raw: response, }) } impl SubmitOptions { fn from_text_request(payload: &contract::Hyper3dTextToModelRequest) -> Result { Self::new( payload.seed, payload.geometry_file_format.as_deref(), payload.material.as_deref(), payload.quality.as_deref(), payload.mesh_mode.as_deref(), payload.addons.clone(), payload.bbox_condition.clone(), payload.preview_render, ) } fn from_image_request( payload: &contract::Hyper3dImageToModelRequest, ) -> Result { Self::new( payload.seed, payload.geometry_file_format.as_deref(), payload.material.as_deref(), payload.quality.as_deref(), payload.mesh_mode.as_deref(), payload.addons.clone(), payload.bbox_condition.clone(), payload.preview_render, ) } #[allow(clippy::too_many_arguments)] fn new( seed: Option, geometry_file_format: Option<&str>, material: Option<&str>, quality: Option<&str>, mesh_mode: Option<&str>, addons: Vec, bbox_condition: Option>, preview_render: Option, ) -> Result { Ok(Self { seed, geometry_file_format: normalize_enum( geometry_file_format, DEFAULT_GEOMETRY_FILE_FORMAT, &["glb", "usdz", "fbx", "obj", "stl"], "geometryFileFormat", )?, material: normalize_enum( material, DEFAULT_MATERIAL, &["PBR", "Shaded", "All"], "material", )?, quality: normalize_enum( quality, DEFAULT_QUALITY, &["high", "medium", "low", "extra-low"], "quality", )?, mesh_mode: normalize_enum(mesh_mode, DEFAULT_MESH_MODE, &["Quad", "Raw"], "meshMode")?, addons: normalize_addons(addons)?, bbox_condition: normalize_bbox_condition(bbox_condition)?, preview_render: preview_render.unwrap_or(true), }) } } fn append_common_submit_fields( mut form: multipart::Form, options: &SubmitOptions, ) -> Result { form = form .text( "geometry_file_format", options.geometry_file_format.to_string(), ) .text("material", options.material.to_string()) .text("quality", options.quality.to_string()) .text("mesh_mode", options.mesh_mode.to_string()) .text("preview_render", options.preview_render.to_string()); if let Some(seed) = options.seed { form = form.text("seed", seed.to_string()); } for addon in &options.addons { form = form.text("addons", addon.to_string()); } if let Some(bbox_condition) = &options.bbox_condition { form = form.text( "bbox_condition", serde_json::to_string(bbox_condition).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": "bboxCondition", "message": format!("bboxCondition 序列化失败:{error}"), })) })?, ); } Ok(form) } fn require_hyper3d_settings(state: &AppState) -> Result { let base_url = state.config.hyper3d_base_url.trim().trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": HYPER3D_PROVIDER, "reason": "HYPER3D_BASE_URL 未配置", "message": "Hyper3D Rodin 服务地址未配置,请设置 HYPER3D_BASE_URL 或 RODIN_BASE_URL 后重启 api-server。", })), ); } let api_key = state .config .hyper3d_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": HYPER3D_PROVIDER, "reason": "HYPER3D_API_KEY 未配置", "message": "Hyper3D Rodin API Key 未配置,请在本地私密环境设置 HYPER3D_API_KEY 或 RODIN_API_KEY 后重启 api-server。", })) })?; Ok(Hyper3dSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), request_timeout_ms: state.config.hyper3d_model_request_timeout_ms.max(1), }) } fn build_hyper3d_http_client(settings: &Hyper3dSettings) -> Result { reqwest::Client::builder() .timeout(Duration::from_millis(settings.request_timeout_ms)) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": HYPER3D_PROVIDER, "message": format!("构造 Hyper3D HTTP 客户端失败:{error}"), })) }) } async fn post_hyper3d_multipart( http_client: &reqwest::Client, settings: &Hyper3dSettings, path: &str, form: multipart::Form, failure_context: &str, ) -> Result { let response = http_client .post(format!("{}{}", settings.base_url, path)) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(header::ACCEPT, "application/json") .multipart(form) .send() .await .map_err(|error| hyper3d_bad_gateway(format!("{failure_context}:{error}")))?; parse_hyper3d_response(response, failure_context).await } async fn post_hyper3d_json( http_client: &reqwest::Client, settings: &Hyper3dSettings, path: &str, body: Value, failure_context: &str, ) -> Result { let response = http_client .post(format!("{}{}", settings.base_url, path)) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(header::ACCEPT, "application/json") .header(header::CONTENT_TYPE, "application/json") .json(&body) .send() .await .map_err(|error| hyper3d_bad_gateway(format!("{failure_context}:{error}")))?; parse_hyper3d_response(response, failure_context).await } async fn parse_hyper3d_response( response: reqwest::Response, failure_context: &str, ) -> Result { let status = response.status(); let raw_text = response.text().await.map_err(|error| { hyper3d_bad_gateway(format!("{failure_context}:读取上游响应失败:{error}")) })?; if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": HYPER3D_PROVIDER, "message": parse_api_error_message(&raw_text, failure_context), "status": status.as_u16(), "rawExcerpt": truncate_raw(&raw_text), })), ); } serde_json::from_str::(&raw_text).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": HYPER3D_PROVIDER, "message": format!("{failure_context}:解析上游 JSON 失败:{error}"), "rawExcerpt": truncate_raw(&raw_text), })) }) } fn build_submit_response( mode: contract::Hyper3dGenerationMode, response: Value, ) -> Result { let task_uuid = find_root_string_by_keys(&response, &["uuid", "task_uuid", "taskUuid"]) .or_else(|| find_first_string_by_keys(&response, &["task_uuid", "taskUuid"])) .ok_or_else(|| hyper3d_bad_gateway("Hyper3D 已响应,但未返回任务 uuid"))?; let subscription_key = find_root_string_by_keys(&response, &["subscription_key", "subscriptionKey"]) .or_else(|| { find_first_string_by_keys(&response, &["subscription_key", "subscriptionKey"]) }) .ok_or_else(|| hyper3d_bad_gateway("Hyper3D 已响应,但未返回 subscription_key"))?; let job_uuids = extract_job_uuids(&response); let message = find_first_string_by_keys(&response, &["message", "detail"]); Ok(contract::Hyper3dTaskSubmitResponse { ok: true, provider: HYPER3D_PROVIDER.to_string(), mode, task_uuid, subscription_key, job_uuids, message, tier: RODIN_GEN2_TIER.to_string(), }) } fn extract_job_statuses(payload: &Value) -> Vec { let Some(array) = find_first_array_by_keys(payload, &["jobs", "tasks"]) else { return Vec::new(); }; array .iter() .filter_map(|value| { let status = find_first_string_by_keys(value, &["status", "state"]) .map(|value| normalize_task_status(&value))?; Some(contract::Hyper3dJobStatusPayload { uuid: find_first_string_by_keys(value, &["uuid", "task_uuid", "taskUuid"]), progress: find_first_f64_by_keys(value, &["progress", "percentage"]) .map(|value| value as f32), message: find_first_string_by_keys(value, &["message", "detail", "error"]), status, }) }) .collect() } fn extract_job_uuids(payload: &Value) -> Vec { let mut job_uuids = Vec::new(); if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) { for job in jobs { if let Some(uuid) = find_first_string_by_keys(job, &["uuid", "task_uuid", "taskUuid"]) && !job_uuids.contains(&uuid) { job_uuids.push(uuid); } } } for uuid in collect_strings_by_keys(payload, &["job_uuids", "jobUuids", "uuids"]) { if !job_uuids.contains(&uuid) { job_uuids.push(uuid); } } job_uuids } fn extract_download_files(payload: &Value) -> Vec { let mut files = Vec::new(); collect_download_files(payload, &mut files); let mut deduped = Vec::new(); for file in files { if !deduped .iter() .any(|entry: &contract::Hyper3dDownloadFilePayload| entry.url == file.url) { deduped.push(file); } } deduped } fn collect_download_files(value: &Value, output: &mut Vec) { match value { Value::Object(object) => { let maybe_url = object .get("url") .or_else(|| object.get("download_url")) .or_else(|| object.get("downloadUrl")) .and_then(Value::as_str) .map(str::trim) .filter(|value| value.starts_with("http://") || value.starts_with("https://")); if let Some(url) = maybe_url { let name = object .get("name") .or_else(|| object.get("file_name")) .or_else(|| object.get("filename")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("model") .to_string(); output.push(contract::Hyper3dDownloadFilePayload { name, url: url.to_string(), }); } for nested in object.values() { collect_download_files(nested, output); } } Value::Array(items) => { for item in items { collect_download_files(item, output); } } _ => {} } } fn decode_image_data_urls(values: &[String]) -> Result, AppError> { values .iter() .enumerate() .map(|(index, value)| decode_image_data_url(value, index + 1)) .collect() } fn decode_image_data_url(value: &str, index: usize) -> Result { let value = value.trim(); let Some((metadata, encoded)) = value.split_once(',') else { return Err(invalid_image_data_url("参考图必须是 data URL")); }; if !metadata.starts_with("data:image/") || !metadata.ends_with(";base64") { return Err(invalid_image_data_url( "参考图只支持 image/png、image/jpeg 或 image/webp 的 base64 data URL", )); } let mime_type = metadata .trim_start_matches("data:") .trim_end_matches(";base64") .to_string(); let extension = match mime_type.as_str() { "image/png" => "png", "image/jpeg" | "image/jpg" => "jpg", "image/webp" => "webp", _ => { return Err(invalid_image_data_url( "参考图只支持 image/png、image/jpeg 或 image/webp", )); } }; let bytes = BASE64_STANDARD .decode(encoded) .map_err(|_| invalid_image_data_url("参考图 base64 解码失败"))?; if bytes.is_empty() || bytes.len() > MAX_IMAGE_BYTES { return Err(invalid_image_data_url("参考图为空或超过 10MB")); } Ok(DecodedImageDataUrl { bytes, mime_type, file_name: format!("reference-{index:02}.{extension}"), }) } fn invalid_image_data_url(message: &str) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": "imageDataUrls", "message": message, })) } fn normalize_required_text( value: &str, field: &'static str, max_chars: usize, ) -> Result { let normalized = value.trim().to_string(); if normalized.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": field, "message": format!("{field} 不能为空"), })), ); } if normalized.chars().count() > max_chars { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": field, "message": format!("{field} 超过 {} 字符", max_chars), })), ); } Ok(normalized) } fn normalize_required_opaque_text(value: &str, field: &'static str) -> Result { let normalized = value.trim().to_string(); if normalized.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": field, "message": format!("{field} 不能为空"), })), ); } Ok(normalized) } fn normalize_optional_limited_text( value: Option<&str>, max_chars: usize, ) -> Result, AppError> { let Some(normalized) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; if normalized.chars().count() > max_chars { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "message": format!("文本超过 {} 字符", max_chars), })), ); } Ok(Some(normalized.to_string())) } fn normalize_enum( value: Option<&str>, default_value: &str, allowed_values: &[&str], field: &'static str, ) -> Result { let value = value .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(default_value); if let Some(allowed) = allowed_values .iter() .find(|allowed| allowed.eq_ignore_ascii_case(value)) { return Ok((*allowed).to_string()); } Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": field, "message": format!("{} 取值非法", field), "allowed": allowed_values, })), ) } fn normalize_addons(values: Vec) -> Result, AppError> { let mut addons = Vec::new(); for value in values { let value = value.trim(); if value.is_empty() { continue; } if value != "HighPack" { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": "addons", "message": "addons 首版只支持 HighPack", })), ); } if !addons.iter().any(|addon| addon == value) { addons.push(value.to_string()); } } Ok(addons) } fn normalize_bbox_condition(value: Option>) -> Result>, AppError> { let Some(value) = value else { return Ok(None); }; if value.len() != 3 || value.iter().any(|item| !item.is_finite() || *item <= 0.0) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": HYPER3D_PROVIDER, "field": "bboxCondition", "message": "bboxCondition 必须包含 3 个正数", })), ); } Ok(Some(value)) } fn normalize_task_status(status: &str) -> String { match status.trim().to_ascii_lowercase().as_str() { "waiting" | "pending" | "queued" => "waiting".to_string(), "generating" | "running" | "processing" => "generating".to_string(), "done" | "finished" | "completed" | "success" | "succeeded" => "done".to_string(), "failed" | "error" | "canceled" | "cancelled" => "failed".to_string(), _ => "unknown".to_string(), } } fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { if let Ok(parsed) = serde_json::from_str::(raw_text) { for key in ["message", "detail", "error"] { if let Some(message) = find_first_string_by_key(&parsed, key) && !message.trim().is_empty() { return message; } } } raw_text .trim() .chars() .take(240) .collect::() .trim() .to_string() .chars() .next() .map(|_| raw_text.trim().chars().take(240).collect()) .unwrap_or_else(|| fallback_message.to_string()) } fn find_first_array_by_keys<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec> { match value { Value::Object(object) => { for (key, value) in object { if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) && let Some(array) = value.as_array() { return Some(array); } if let Some(found) = find_first_array_by_keys(value, keys) { return Some(found); } } None } Value::Array(items) => items .iter() .find_map(|item| find_first_array_by_keys(item, keys)), _ => None, } } fn find_first_string_by_keys(value: &Value, keys: &[&str]) -> Option { keys.iter() .find_map(|key| find_first_string_by_key(value, key)) } fn find_root_string_by_keys(value: &Value, keys: &[&str]) -> Option { let object = value.as_object()?; for key in keys { if let Some(text) = object .iter() .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key)) .and_then(|(_, value)| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) { return Some(text.to_string()); } } None } fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { match value { Value::Object(object) => { for (key, value) in object { if key.eq_ignore_ascii_case(target_key) && let Some(text) = value.as_str() { return Some(text.trim().to_string()); } if let Some(found) = find_first_string_by_key(value, target_key) { return Some(found); } } None } Value::Array(items) => items .iter() .find_map(|item| find_first_string_by_key(item, target_key)), _ => None, } } fn find_first_f64_by_keys(value: &Value, keys: &[&str]) -> Option { match value { Value::Object(object) => { for (key, value) in object { if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) && let Some(number) = value.as_f64() { return Some(number); } if let Some(found) = find_first_f64_by_keys(value, keys) { return Some(found); } } None } Value::Array(items) => items .iter() .find_map(|item| find_first_f64_by_keys(item, keys)), _ => None, } } fn collect_strings_by_keys(value: &Value, keys: &[&str]) -> Vec { let mut results = Vec::new(); collect_strings(value, keys, &mut results); let mut deduped = Vec::new(); for result in results { if !deduped.contains(&result) { deduped.push(result); } } deduped } fn collect_strings(value: &Value, keys: &[&str], output: &mut Vec) { match value { Value::Object(object) => { for (key, value) in object { if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) { match value { Value::String(text) if !text.trim().is_empty() => { output.push(text.trim().to_string()); } Value::Array(items) => { for item in items { if let Some(text) = item.as_str().map(str::trim) && !text.is_empty() { output.push(text.to_string()); } } } _ => {} } } collect_strings(value, keys, output); } } Value::Array(items) => { for item in items { collect_strings(item, keys, output); } } _ => {} } } fn truncate_raw(raw_text: &str) -> String { raw_text.chars().take(800).collect() } fn hyper3d_bad_gateway(message: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": HYPER3D_PROVIDER, "message": message.into(), })) } fn parse_json_payload( request_context: &RequestContext, payload: Result, JsonRejection>, ) -> Result, Response> { payload.map_err(|rejection| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message(format!("请求体 JSON 不合法:{rejection}")) .into_response_with_context(Some(request_context)) }) } #[cfg(test)] mod tests { use super::*; #[test] fn validates_and_defaults_submit_options() { let payload = contract::Hyper3dTextToModelRequest { prompt: "宝箱".to_string(), negative_prompt: None, seed: Some(7), geometry_file_format: None, material: None, quality: None, mesh_mode: None, addons: vec!["HighPack".to_string()], bbox_condition: Some(vec![1.0, 2.0, 3.0]), preview_render: None, }; let options = SubmitOptions::from_text_request(&payload).expect("options should build"); assert_eq!(options.geometry_file_format, "glb"); assert_eq!(options.material, "PBR"); assert_eq!(options.quality, "medium"); assert_eq!(options.mesh_mode, "Quad"); assert_eq!(options.addons, vec!["HighPack"]); assert!(options.preview_render); } #[test] fn rejects_invalid_bbox_condition() { let error = normalize_bbox_condition(Some(vec![1.0, 0.0, 3.0])) .expect_err("invalid bbox should fail"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); } #[test] fn accepts_opaque_subscription_key_without_length_cap() { let long_key = "a".repeat(300); let normalized = normalize_required_opaque_text(&format!(" {long_key} "), "subscriptionKey") .expect("subscription key should be accepted"); assert_eq!(normalized, long_key); } #[test] fn decodes_png_data_url() { let data_url = format!( "data:image/png;base64,{}", BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") ); let image = decode_image_data_url(&data_url, 1).expect("image should decode"); assert_eq!(image.mime_type, "image/png"); assert_eq!(image.file_name, "reference-01.png"); assert!(!image.bytes.is_empty()); } #[test] fn extracts_submit_response_from_nested_payload() { let response = build_submit_response( contract::Hyper3dGenerationMode::TextToModel, json!({ "uuid": "task-1", "subscription_key": "sub-1", "jobs": [{ "uuid": "job-1" }], "message": "submitted" }), ) .expect("submit response should build"); assert_eq!(response.task_uuid, "task-1"); assert_eq!(response.subscription_key, "sub-1"); assert_eq!(response.job_uuids, vec!["job-1"]); } #[test] fn extracts_download_files_from_list() { let files = extract_download_files(&json!({ "list": [ { "name": "model.glb", "url": "https://cdn.example/model.glb" }, { "name": "preview.png", "url": "https://cdn.example/preview.png" } ] })); assert_eq!(files.len(), 2); assert_eq!(files[0].name, "model.glb"); } #[test] fn normalizes_status_values() { assert_eq!(normalize_task_status("Waiting"), "waiting"); assert_eq!(normalize_task_status("Generating"), "generating"); assert_eq!(normalize_task_status("Done"), "done"); assert_eq!(normalize_task_status("Failed"), "failed"); } }