use std::collections::BTreeMap; use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::StatusCode, response::Response, }; use module_ai::{ AiResultReferenceKind, AiStageCompletionInput, AiTaskCreateInput, AiTaskKind, AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id, }; 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_llm::{LlmMessage, LlmTextRequest}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, }; use serde_json::{Value, json}; use shared_contracts::assets::{ CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload, CharacterVisualGenerateRequest, CharacterVisualGenerateResponse, CharacterVisualPublishRequest, CharacterVisualPublishResponse, }; use spacetime_client::SpacetimeClientError; use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, }; const CHARACTER_VISUAL_MODEL: &str = "rust-svg-character-visual"; const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual"; const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; pub async fn generate_character_visual( State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { character_visual_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "character-visual", "message": error.body_text(), })), ) })?; // 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。 let owner_user_id = "asset-tool".to_string(); let task_id = generate_ai_task_id(current_utc_micros()); let prompt = build_character_visual_prompt( payload.prompt_text.as_str(), payload.character_brief_text.as_deref(), ); let character_id = normalize_required_text(payload.character_id.as_str(), "character"); let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL); let size = normalize_required_text(payload.size.as_str(), "1024*1024"); let candidate_count = payload.candidate_count.clamp(1, 4); let created = create_visual_task( &state, &task_id, &owner_user_id, &character_id, &model, &prompt, ) .map_err(|error| character_visual_error_response(&request_context, error))?; let result = async { state .ai_task_service() .start_task(task_id.as_str(), current_utc_micros()) .map_err(map_ai_task_error)?; state .ai_task_service() .start_stage( task_id.as_str(), AiTaskStageKind::PreparePrompt, current_utc_micros(), ) .map_err(map_ai_task_error)?; state .ai_task_service() .complete_stage(AiStageCompletionInput { task_id: task_id.clone(), stage_kind: AiTaskStageKind::PreparePrompt, text_output: Some(prompt.clone()), structured_payload_json: Some( json!({ "characterId": character_id, "sourceMode": payload.source_mode, "size": size, "referenceImageCount": payload.reference_image_data_urls.len(), }) .to_string(), ), warning_messages: Vec::new(), completed_at_micros: current_utc_micros(), }) .map_err(map_ai_task_error)?; let visual_seed = generate_visual_seed_with_llm(&state, &prompt, &character_id).await; state .ai_task_service() .start_stage( task_id.as_str(), AiTaskStageKind::RequestModel, current_utc_micros(), ) .map_err(map_ai_task_error)?; state .ai_task_service() .complete_stage(AiStageCompletionInput { task_id: task_id.clone(), stage_kind: AiTaskStageKind::RequestModel, text_output: Some(visual_seed.clone()), structured_payload_json: None, warning_messages: Vec::new(), completed_at_micros: current_utc_micros(), }) .map_err(map_ai_task_error)?; let drafts = persist_visual_drafts( &state, &owner_user_id, &character_id, &task_id, &visual_seed, &size, candidate_count, ) .await?; let result_payload = json!({ "drafts": drafts, "draftRelativeDir": format!( "generated-character-drafts/{}/visual/{}", sanitize_storage_segment(character_id.as_str(), "character"), task_id ), }); state .ai_task_service() .start_stage( task_id.as_str(), AiTaskStageKind::NormalizeResult, current_utc_micros(), ) .map_err(map_ai_task_error)?; state .ai_task_service() .complete_stage(AiStageCompletionInput { task_id: task_id.clone(), stage_kind: AiTaskStageKind::NormalizeResult, text_output: None, structured_payload_json: Some(result_payload.to_string()), warning_messages: Vec::new(), completed_at_micros: current_utc_micros(), }) .map_err(map_ai_task_error)?; state .ai_task_service() .complete_stage(AiStageCompletionInput { task_id: task_id.clone(), stage_kind: AiTaskStageKind::PersistResult, text_output: Some("角色主形象候选草稿已写入 OSS。".to_string()), structured_payload_json: Some(result_payload.to_string()), warning_messages: Vec::new(), completed_at_micros: current_utc_micros(), }) .map_err(map_ai_task_error)?; state .ai_task_service() .complete_task(task_id.as_str(), current_utc_micros()) .map_err(map_ai_task_error)?; Ok::<_, AppError>(drafts) } .await; let drafts = match result { Ok(drafts) => drafts, Err(error) => { let _ = state.ai_task_service().fail_task( created.task_id.as_str(), error.message().to_string(), current_utc_micros(), ); return Err(character_visual_error_response(&request_context, error)); } }; Ok(json_success_body( Some(&request_context), CharacterVisualGenerateResponse { ok: true, task_id, model, prompt, drafts, }, )) } pub async fn get_character_visual_job( State(state): State, Extension(request_context): Extension, Path(task_id): Path, ) -> Result, Response> { let task = state .ai_task_service() .get_task(task_id.as_str()) .map_err(map_ai_task_error) .map_err(|error| character_visual_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), build_character_visual_job_payload(task), )) } pub async fn publish_character_visual( State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { character_visual_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "character-visual", "message": error.body_text(), })), ) })?; // 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。 let owner_user_id = "asset-tool".to_string(); let character_id = normalize_required_text(payload.character_id.as_str(), "character"); if payload.selected_preview_source.trim().is_empty() { return Err(character_visual_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "character-visual", "message": "selectedPreviewSource is required.", })), )); } let asset_id = format!("visual-{}", current_utc_millis()); let published = persist_published_visual( &state, &owner_user_id, &character_id, asset_id.as_str(), payload.selected_preview_source.as_str(), payload.prompt_text.as_deref(), ) .await .map_err(|error| character_visual_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), CharacterVisualPublishResponse { ok: true, asset_id, portrait_path: published, override_map: json!({}), save_message: if payload.update_character_override == Some(false) { "主形象已写入 OSS 并绑定当前角色,可直接写回当前自定义世界角色。".to_string() } else { "主形象已写入 OSS 并绑定当前角色;Rust 后端不再写本地角色覆盖文件。".to_string() }, }, )) } fn create_visual_task( state: &AppState, task_id: &str, owner_user_id: &str, character_id: &str, model: &str, prompt: &str, ) -> Result { state .ai_task_service() .create_task(AiTaskCreateInput { task_id: task_id.to_string(), task_kind: AiTaskKind::CustomWorldGeneration, owner_user_id: owner_user_id.to_string(), request_label: "生成角色主形象".to_string(), source_module: "assets.character_visual".to_string(), source_entity_id: Some(character_id.to_string()), request_payload_json: Some( json!({ "characterId": character_id, "model": model, "prompt": prompt, }) .to_string(), ), stages: AiTaskKind::CustomWorldGeneration.default_stage_blueprints(), created_at_micros: current_utc_micros(), }) .map_err(map_ai_task_error) } async fn generate_visual_seed_with_llm( state: &AppState, prompt: &str, character_id: &str, ) -> String { let fallback = format!("{character_id}:{prompt}"); let Some(llm_client) = state.llm_client() else { return fallback; }; let request = LlmTextRequest::new(vec![ LlmMessage::system( "你是游戏角色主形象草稿描述器。只输出一句中文视觉摘要,不要输出 Markdown。", ), LlmMessage::user( json!({ "task": "summarize_character_visual_seed", "characterId": character_id, "prompt": prompt, }) .to_string(), ), ]) .with_max_tokens(96); llm_client .request_text(request) .await .ok() .map(|response| response.content.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or(fallback) } async fn persist_visual_drafts( state: &AppState, owner_user_id: &str, character_id: &str, task_id: &str, visual_seed: &str, size: &str, candidate_count: u32, ) -> Result, AppError> { let mut drafts = Vec::with_capacity(candidate_count as usize); for index in 0..candidate_count { let file_name = format!("candidate-{:02}.svg", index + 1); let body = build_character_visual_svg(size, visual_seed, format!("候选 {}", index + 1).as_str()) .into_bytes(); let put_result = put_character_visual_object( state, LegacyAssetPrefix::CharacterDrafts, vec![ sanitize_storage_segment(character_id, "character"), "visual".to_string(), task_id.to_string(), ], file_name, "image/svg+xml".to_string(), body, build_asset_metadata( CHARACTER_VISUAL_ASSET_KIND, owner_user_id, CHARACTER_VISUAL_ENTITY_KIND, character_id, "draft", ), ) .await?; drafts.push(CharacterVisualDraftPayload { id: format!("candidate-{}", index + 1), label: format!("候选 {}", index + 1), image_src: put_result.legacy_public_path, width: parse_size(size).0, height: parse_size(size).1, }); } Ok(drafts) } async fn persist_published_visual( state: &AppState, owner_user_id: &str, character_id: &str, asset_id: &str, selected_preview_source: &str, prompt_text: Option<&str>, ) -> Result { let oss_client = require_oss_client(state)?; let http_client = reqwest::Client::new(); let source_object_key = resolve_object_key_from_legacy_path(selected_preview_source)?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: source_object_key.clone(), }, ) .await .map_err(map_character_visual_oss_error)?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: source_object_key, expire_seconds: Some(60), }) .map_err(map_character_visual_oss_error)?; let source_body = http_client .get(signed.signed_url) .send() .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取候选主形象失败:{error}"), })) })? .error_for_status() .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取候选主形象失败:{error}"), })) })? .bytes() .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取候选主形象内容失败:{error}"), })) })? .to_vec(); let content_type = head .content_type .clone() .unwrap_or_else(|| "image/svg+xml".to_string()); let file_name = match content_type.as_str() { "image/png" => "master.png", "image/jpeg" => "master.jpg", "image/webp" => "master.webp", _ => "master.svg", } .to_string(); let put_result = put_character_visual_object( state, LegacyAssetPrefix::Characters, vec![ sanitize_storage_segment(character_id, "character"), "visual".to_string(), asset_id.to_string(), ], file_name, content_type.clone(), source_body, build_asset_metadata( CHARACTER_VISUAL_ASSET_KIND, owner_user_id, CHARACTER_VISUAL_ENTITY_KIND, character_id, CHARACTER_VISUAL_SLOT, ), ) .await?; let confirmed = confirm_character_visual_asset_object( state, owner_user_id, character_id, asset_id, put_result.object_key.clone(), content_type, prompt_text.map(str::to_string), ) .await?; bind_character_visual_asset( state, owner_user_id, character_id, confirmed.record.asset_object_id, ) .await?; Ok(put_result.legacy_public_path) } async fn put_character_visual_object( state: &AppState, prefix: LegacyAssetPrefix, path_segments: Vec, file_name: String, content_type: String, body: Vec, metadata: BTreeMap, ) -> Result { let oss_client = require_oss_client(state)?; oss_client .put_object( &reqwest::Client::new(), OssPutObjectRequest { prefix, path_segments, file_name, content_type: Some(content_type), access: OssObjectAccess::Private, metadata, body, }, ) .await .map_err(map_character_visual_oss_error) } async fn confirm_character_visual_asset_object( state: &AppState, owner_user_id: &str, character_id: &str, source_job_id: &str, object_key: String, content_type: String, prompt_text: Option, ) -> Result { let oss_client = require_oss_client(state)?; let head = oss_client .head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key }) .await .map_err(map_character_visual_oss_error)?; let now_micros = current_utc_micros(); let record = state .spacetime_client() .confirm_asset_object( build_asset_object_upsert_input( generate_asset_object_id(now_micros), head.bucket, head.object_key, AssetObjectAccessPolicy::Private, head.content_type.or(Some(content_type)), head.content_length, prompt_text.or(head.etag), CHARACTER_VISUAL_ASSET_KIND.to_string(), Some(source_job_id.to_string()), Some(owner_user_id.to_string()), None, Some(character_id.to_string()), now_micros, ) .map_err(map_asset_object_prepare_error)?, ) .await .map_err(map_character_visual_spacetime_error)?; let _ = state.ai_task_service().attach_result_reference( source_job_id, AiResultReferenceKind::AssetObject, record.asset_object_id.clone(), Some("角色主形象正式对象".to_string()), now_micros, ); Ok(module_assets::ConfirmAssetObjectResult { record }) } async fn bind_character_visual_asset( state: &AppState, owner_user_id: &str, character_id: &str, asset_object_id: String, ) -> Result<(), AppError> { let now_micros = current_utc_micros(); state .spacetime_client() .bind_asset_object_to_entity( build_asset_entity_binding_input( generate_asset_binding_id(now_micros), asset_object_id, CHARACTER_VISUAL_ENTITY_KIND.to_string(), character_id.to_string(), CHARACTER_VISUAL_SLOT.to_string(), CHARACTER_VISUAL_ASSET_KIND.to_string(), Some(owner_user_id.to_string()), None, now_micros, ) .map_err(map_asset_binding_prepare_error)?, ) .await .map_err(map_character_visual_spacetime_error)?; Ok(()) } fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload { let request_payload = task .request_payload_json .as_deref() .and_then(|value| serde_json::from_str::(value).ok()) .unwrap_or_else(|| json!({})); let result = task .latest_structured_payload_json .as_deref() .and_then(|value| serde_json::from_str::(value).ok()); CharacterAssetJobStatusPayload { task_id: task.task_id, kind: "visual".to_string(), status: match task.status { AiTaskStatus::Pending => CharacterAssetJobStatusText::Queued, AiTaskStatus::Running => CharacterAssetJobStatusText::Running, AiTaskStatus::Completed => CharacterAssetJobStatusText::Completed, AiTaskStatus::Failed | AiTaskStatus::Cancelled => CharacterAssetJobStatusText::Failed, }, character_id: request_payload .get("characterId") .and_then(Value::as_str) .unwrap_or_default() .to_string(), animation: None, strategy: None, model: request_payload .get("model") .and_then(Value::as_str) .unwrap_or(CHARACTER_VISUAL_MODEL) .to_string(), prompt: request_payload .get("prompt") .and_then(Value::as_str) .unwrap_or_default() .to_string(), created_at: format_utc_micros(task.created_at_micros), updated_at: format_utc_micros(task.updated_at_micros), result, error_message: task.failure_message, } } fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String { let merged = [character_brief_text.unwrap_or_default(), prompt_text] .into_iter() .map(str::trim) .filter(|value| !value.is_empty()) .collect::>() .join("\n"); format!( "{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。", if merged.is_empty() { "自定义世界角色,服装完整,姿态自然。" } else { merged.as_str() } ) } fn build_character_visual_svg(size: &str, label: &str, candidate_label: &str) -> String { let (width, height) = parse_size(size); format!( r##" {title} {candidate} "##, width = width, height = height, shadow_x = width / 2, shadow_y = height * 5 / 6, shadow_rx = width / 5, shadow_ry = height / 28, body_x = width * 45 / 100, body_y = height * 34 / 100, body_c1x = width * 34 / 100, body_c1y = height * 50 / 100, body_c2x = width * 43 / 100, body_c2y = height * 72 / 100, body_x2 = width * 56 / 100, body_y2 = height * 72 / 100, leg_x = width * 48 / 100, leg_y = height * 84 / 100, leg2_x = width * 62 / 100, head_x = width * 53 / 100, head_y = height * 25 / 100, head_r = (width.min(height) / 12).max(18), weapon_x = width * 57 / 100, weapon_y = height * 42 / 100, weapon_x2 = width * 76 / 100, weapon_y2 = height * 34 / 100, weapon_w = (width.min(height) / 90).max(4), text_y = height * 91 / 100, sub_y = height * 96 / 100, font_main = (width.min(height) / 28).max(14), font_sub = (width.min(height) / 36).max(11), title = escape_svg_text(label), candidate = escape_svg_text(candidate_label), ) } fn resolve_object_key_from_legacy_path(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "character-visual", "message": "selectedPreviewSource is required.", })), ); } if trimmed.starts_with("data:") { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "character-visual", "message": "Rust 版 publish 当前要求 selectedPreviewSource 为已写入 OSS 的 /generated-* 路径。", }))); } Ok(trimmed.trim_start_matches('/').to_string()) } fn build_asset_metadata( asset_kind: &str, owner_user_id: &str, entity_kind: &str, entity_id: &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(), entity_kind.to_string()), ("entity_id".to_string(), entity_id.to_string()), ("slot".to_string(), slot.to_string()), ]) } fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) }) } fn normalize_required_text(value: &str, fallback: &str) -> String { value .trim() .split_whitespace() .collect::>() .join(" ") .chars() .take(180) .collect::() .trim() .to_string() .if_empty_then(fallback) } fn sanitize_storage_segment(value: &str, fallback: &str) -> String { let normalized = value .trim() .chars() .map(|character| match character { 'a'..='z' | '0'..='9' | '-' | '_' => character, 'A'..='Z' => character.to_ascii_lowercase(), _ => '-', }) .collect::(); let normalized = collapse_dashes(&normalized); if normalized.is_empty() { fallback.to_string() } else { normalized } } fn collapse_dashes(value: &str) -> String { value .chars() .fold( (String::new(), false), |(mut output, last_is_dash), character| { let is_dash = character == '-'; if is_dash && last_is_dash { return (output, true); } output.push(character); (output, is_dash) }, ) .0 .trim_matches('-') .to_string() } fn parse_size(size: &str) -> (u32, u32) { let mut parts = size.split('*'); let width = parts .next() .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0) .unwrap_or(1024); let height = parts .next() .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0) .unwrap_or(1024); (width, height) } fn escape_svg_text(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") } fn format_utc_micros(micros: i64) -> String { module_runtime::format_utc_micros(micros) } fn current_utc_millis() -> i64 { current_utc_micros() / 1_000 } fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } fn map_ai_task_error(error: AiTaskServiceError) -> AppError { let status = match error { AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND, AiTaskServiceError::TaskAlreadyExists => StatusCode::CONFLICT, AiTaskServiceError::Field(_) | AiTaskServiceError::StageNotFound => StatusCode::BAD_REQUEST, AiTaskServiceError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR, }; AppError::from_status(status).with_details(json!({ "provider": "ai-task", "message": error.to_string(), })) } fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "message": error.to_string(), })) } fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-entity-binding", "message": error.to_string(), })) } fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError { let status = match error { platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => { StatusCode::BAD_REQUEST } platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND, platform_oss::OssError::Request(_) | platform_oss::OssError::SerializePolicy(_) | platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "aliyun-oss", "message": error.to_string(), })) } fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } trait EmptyFallback { fn if_empty_then(self, fallback: &str) -> String; } impl EmptyFallback for String { fn if_empty_then(self, fallback: &str) -> String { if self.is_empty() { fallback.to_string() } else { self } } } #[cfg(test)] mod tests { use super::*; #[test] fn build_character_visual_prompt_keeps_generation_constraints() { let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者")); assert!(prompt.contains("潮雾港向导")); assert!(prompt.contains("右向斜侧身")); assert!(prompt.contains("纯绿色背景")); } #[test] fn sanitize_storage_segment_keeps_legacy_safe_shape() { assert_eq!( sanitize_storage_segment("Harbor Guide/潮雾", "character"), "harbor-guide" ); } }