use std::{ collections::BTreeMap, error::Error as StdError, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ Json, extract::{Extension, Path as AxumPath, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use image::ImageFormat; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, }; use serde_json::{Map, Value, json}; use shared_contracts::{ creation_audio::CreationAudioAsset, puzzle_agent::{ CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest, PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse, PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse, PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse, PuzzleCreatorIntentResponse, PuzzleDraftLevelResponse, PuzzleFormDraftResponse, PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse, PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest, }, puzzle_gallery::PuzzleGalleryDetailResponse, puzzle_runtime::{ AdvancePuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleOnboardingGenerateRequest, PuzzleOnboardingGenerateResponse, PuzzleOnboardingSaveRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse, }, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, asset_billing::{ execute_billable_asset_operation, execute_billable_asset_operation_with_cost, should_skip_asset_operation_billing_for_connectivity, }, auth::AuthenticatedAccessToken, http_error::AppError, llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL}, openai_image_generation::{ DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, platform_errors::map_oss_error, prompt::puzzle::{ draft::{ PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt, resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt, }, image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, level_name::{ PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt, build_puzzle_first_level_name_vision_user_text, }, tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt}, }, puzzle_agent_turn::{ PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_puzzle_agent_turn, }, puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ GeneratedCreationAudioTarget, generate_background_music_asset_for_creation, }, work_author::resolve_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works"; const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview"; const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music"; const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music"; #[cfg(test)] const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024"; const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768; const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512; const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024; const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2"; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素"; pub async fn create_puzzle_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; let seed_text = build_puzzle_form_seed_text(&payload); let session = state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id("puzzle-session-"), owner_user_id: authenticated.claims().user_id().to_string(), seed_text: seed_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&seed_text), created_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn generate_puzzle_onboarding_work( State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; let prompt_text = payload.prompt_text.trim().to_string(); ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &prompt_text, "promptText", )?; let now = current_utc_micros(); let session_id = build_prefixed_uuid_id("puzzle-onboarding-"); let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; let tags = generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; let candidates = generate_puzzle_image_candidates( &state, "onboarding-guest", session_id.as_str(), naming.level_name.as_str(), prompt_text.as_str(), None, false, Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2), 1, 0, ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_generation_endpoint_error(error), ) })? .into_records(); let selected = candidates.first().cloned().ok_or_else(|| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "新手引导拼图图片生成结果为空", })), ) })?; let level = PuzzleDraftLevelRecord { level_id: "onboarding-level-1".to_string(), level_name: naming.level_name.clone(), picture_description: prompt_text.clone(), picture_reference: None, ui_background_prompt: naming.ui_background_prompt.clone(), ui_background_image_src: None, ui_background_image_object_key: None, background_music: None, candidates, selected_candidate_id: Some(selected.candidate_id.clone()), cover_image_src: Some(selected.image_src.clone()), cover_asset_id: Some(selected.asset_id.clone()), generation_status: "ready".to_string(), }; let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack( naming.level_name.as_str(), level.picture_description.as_str(), )); let item = PuzzleWorkProfileRecord { work_id: format!("onboarding-work-{now}"), profile_id: format!("onboarding-profile-{now}"), owner_user_id: "onboarding-guest".to_string(), source_session_id: None, author_display_name: "陶泥儿主".to_string(), work_title: naming.level_name.clone(), work_description: prompt_text.clone(), level_name: naming.level_name, summary: prompt_text, theme_tags: tags, cover_image_src: level.cover_image_src.clone(), cover_asset_id: level.cover_asset_id.clone(), publication_status: "draft".to_string(), updated_at: format_timestamp_micros(now), published_at: None, play_count: 0, remix_count: 0, like_count: 0, recent_play_count_7d: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, anchor_pack, publish_ready: true, levels: vec![level.clone()], }; Ok(json_success_body( Some(&request_context), PuzzleOnboardingGenerateResponse { item: map_puzzle_work_profile_response(&state, item.clone()).summary, level: map_puzzle_draft_level_response(level), }, )) } pub async fn save_puzzle_onboarding_work( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": error.body_text(), })), ) })?; let prompt_text = payload.prompt_text.trim().to_string(); ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &prompt_text, "promptText", )?; let first_level = payload.item.levels.first().cloned().ok_or_else(|| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": "新手引导拼图缺少可保存关卡", })), ) })?; let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?; let work_title = payload.item.work_title.trim(); let work_title = if work_title.is_empty() { first_level.level_name.clone() } else { work_title.to_string() }; let work_description = payload.item.work_description.trim(); let work_description = if work_description.is_empty() { prompt_text.clone() } else { work_description.to_string() }; let summary = payload.item.summary.trim(); let summary = if summary.is_empty() { first_level.picture_description.clone() } else { summary.to_string() }; let now = current_utc_micros(); let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("puzzle-session-"); state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), seed_text: prompt_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&prompt_text), created_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str()); let item = state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id, work_title, work_description, level_name: first_level.level_name, summary, theme_tags: payload.item.theme_tags, cover_image_src: first_level.cover_image_src, cover_asset_id: first_level.cover_asset_id, levels_json: Some(levels_json), updated_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn get_puzzle_agent_session( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn submit_puzzle_agent_message( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let client_message_id = payload.client_message_id.trim().to_string(); let message_text = payload.text.trim().to_string(); if client_message_id.is_empty() || message_text.is_empty() { return Err(puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "clientMessageId and text are required", )); } let owner_user_id = authenticated.claims().user_id().to_string(); let submitted_session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), user_message_id: client_message_id, user_message_text: message_text, submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; let turn_result = run_puzzle_agent_turn( PuzzleAgentTurnRequest { llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, |_| {}, ) .await; let finalize_input = match turn_result { Ok(turn_result) => build_finalize_record_input( session_id.clone(), owner_user_id.clone(), format!("assistant-{session_id}-{}", current_utc_micros()), turn_result, current_utc_micros(), ), Err(error) => build_failed_finalize_record_input( session_id.clone(), owner_user_id.clone(), &submitted_session, error.to_string(), current_utc_micros(), ), }; let session = state .spacetime_client() .finalize_puzzle_agent_message(finalize_input) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn stream_puzzle_agent_message( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false); let session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), user_message_id: payload.client_message_id.trim().to_string(), user_message_text: payload.text.trim().to_string(), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; let state = state.clone(); let session_id_for_stream = session_id.clone(); let owner_user_id_for_stream = owner_user_id.clone(); let stream = async_stream::stream! { let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( "puzzle", owner_user_id_for_stream.as_str(), session_id_for_stream.as_str(), payload.client_message_id.as_str(), "拼图模板生成草稿", )); if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { tracing::warn!(error = %error, "拼图模板生成草稿任务启动失败,主生成流程继续执行"); } let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::(); let turn_result = { let run_turn = run_puzzle_agent_turn( PuzzleAgentTurnRequest { llm_client: state.llm_client(), session: &session, quick_fill_requested, enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { let _ = reply_tx.send(text.to_string()); }, ); tokio::pin!(run_turn); loop { tokio::select! { result = &mut run_turn => break result, maybe_text = reply_rx.recv() => { if let Some(text) = maybe_text { draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; yield Ok::(puzzle_sse_json_event_or_error( "reply_delta", json!({ "text": text }), )); } } } } }; while let Some(text) = reply_rx.recv().await { draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; yield Ok::(puzzle_sse_json_event_or_error( "reply_delta", json!({ "text": text }), )); } let finalize_input = match turn_result { Ok(turn_result) => build_finalize_record_input( session_id_for_stream.clone(), owner_user_id_for_stream.clone(), format!("assistant-{session_id_for_stream}-{}", current_utc_micros()), turn_result, current_utc_micros(), ), Err(error) => build_failed_finalize_record_input( session_id_for_stream.clone(), owner_user_id_for_stream.clone(), &session, error.to_string(), current_utc_micros(), ), }; let finalize_result = state .spacetime_client() .finalize_puzzle_agent_message(finalize_input) .await; let _final_session = match finalize_result { Ok(session) => session, Err(error) => { yield Ok::(puzzle_sse_json_event_or_error( "error", json!({ "message": error.to_string() }), )); return; } }; let final_session = match state .spacetime_client() .get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream) .await { Ok(session) => session, Err(error) => { yield Ok::(puzzle_sse_json_event_or_error( "error", json!({ "message": error.to_string() }), )); return; } }; let session_response = map_puzzle_agent_session_response(final_session); yield Ok::(puzzle_sse_json_event_or_error( "session", json!({ "session": session_response }), )); yield Ok::(puzzle_sse_json_event_or_error( "done", json!({ "ok": true }), )); }; Ok(Sse::new(stream).into_response()) } pub async fn execute_puzzle_agent_action( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); let action = payload.action.trim().to_string(); let billing_asset_id = format!("{session_id}:{now}"); tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, action = %action, image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), prompt_chars = payload .prompt_text .as_deref() .map(|value| value.chars().count()) .unwrap_or(0), has_reference_image = has_puzzle_reference_images( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), ), "拼图 Agent action 开始执行" ); let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let ai_redraw = payload.ai_redraw.unwrap_or(true); let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), ); let primary_reference_image_src = reference_image_sources.first().map(String::as_str); let prompt_text = payload .picture_description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| payload.prompt_text.as_deref()); let compile_session_id = match save_puzzle_form_payload_before_compile( &state, &request_context, &session_id, &owner_user_id, &payload, now, ) .await { Ok(next_session_id) => next_session_id, Err(response) => return Err(response), }; let session = if ai_redraw { execute_billable_asset_operation_with_cost( &state, &owner_user_id, "puzzle_initial_image", &billing_asset_id, PUZZLE_IMAGE_GENERATION_POINTS_COST, async { compile_puzzle_draft_with_initial_cover( &state, compile_session_id.clone(), owner_user_id.clone(), prompt_text, primary_reference_image_src, payload.image_model.as_deref(), now, ) .await }, ) .await } else { compile_puzzle_draft_with_uploaded_cover( &state, compile_session_id.clone(), owner_user_id.clone(), prompt_text, payload.reference_image_src.as_deref(), now, ) .await } .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "compile_puzzle_draft", "首关拼图草稿", if ai_redraw { "已编译首关草稿、生成首关画面并写入正式草稿。" } else { "已编译首关草稿,并直接应用上传图片为第一关图片。" }, session, ) } "save_puzzle_form_draft" => { let seed_text = build_puzzle_form_seed_text_from_parts( None, None, payload .picture_description .as_deref() .or(payload.prompt_text.as_deref()), ); let save_result = state .spacetime_client() .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), seed_text, saved_at_micros: now, }) .await; let session = match save_result { Ok(session) => Ok(session), Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { // 中文注释:旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, error = %error, "拼图表单自动保存 procedure 缺失,降级返回当前会话" ); state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|fallback_error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(fallback_error), ) }) } Err(error) => Err(puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), )), }; ( "save_puzzle_form_draft", "表单草稿保存", "拼图表单草稿已保存。", session, ) } "generate_puzzle_images" => { let target_level_id = payload.level_id.clone(); let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })) }); let session = execute_billable_asset_operation_with_cost( &state, &owner_user_id, "puzzle_generated_image", &billing_asset_id, PUZZLE_IMAGE_GENERATION_POINTS_COST, async { let levels_json = levels_json?; let session = get_puzzle_session_for_image_generation( &state, session_id.clone(), owner_user_id.clone(), &payload, levels_json.as_deref(), now, ) .await?; let mut draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; if let Some(levels_json) = levels_json.as_ref() { draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; } let mut target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; let fallback_level_name = target_level.level_name.clone(); let prompt = resolve_puzzle_level_image_prompt( payload.prompt_text.as_deref(), &target_level.picture_description, ); let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), ); let primary_reference_image_src = reference_image_sources.first().map(String::as_str); // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 let candidate_count = 1; let candidate_start_index = target_level.candidates.len(); let candidates = generate_puzzle_image_candidates( &state, owner_user_id.as_str(), &session.session_id, &target_level.level_name, &prompt, primary_reference_image_src, payload.ai_redraw.unwrap_or(true), payload.image_model.as_deref(), candidate_count, candidate_start_index, ) .await .map_err(map_puzzle_generation_endpoint_error)?; if candidates.is_empty() { return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图候选图生成结果为空", }), )); } if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( &state, target_level.picture_description.as_str(), &candidates[0].downloaded_image, ) .await { target_level.level_name = refined_naming.level_name; if refined_naming.ui_background_prompt.is_some() { target_level.ui_background_prompt = refined_naming.ui_background_prompt; } } let generated_level_name = target_level.level_name.clone(); let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module( &build_puzzle_levels_with_primary_update( &draft, &target_level, primary_reference_image_src, ), )?); let candidates_json = serde_json::to_string( &candidates .iter() .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) .collect::>(), ) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图候选图序列化失败:{error}"), })) })?; let save_result = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json: levels_json_with_generated_name, candidates_json, saved_at_micros: now, }) .await; match save_result { Ok(session) => Ok(session), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { // 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session.session_id, owner_user_id = %owner_user_id, error = %error, "拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" ); let fallback_session = replace_puzzle_session_draft_snapshot(session, draft, now); Ok(apply_generated_puzzle_candidates_to_session_snapshot( apply_generated_puzzle_first_level_name_to_session_snapshot( fallback_session, target_level.level_id.as_str(), generated_level_name.as_str(), fallback_level_name.as_str(), now, ), target_level.level_id.as_str(), candidates.into_records(), primary_reference_image_src, now, )) } Err(error) => Err(map_puzzle_client_error(error)), } }, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "generate_puzzle_images", "拼图图片生成", "已生成并替换当前拼图图片。", session, ) } "generate_puzzle_ui_background" => { let target_level_id = payload.level_id.clone(); let raw_prompt = payload .prompt_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or_default() .to_string(); let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })) }); let session = execute_billable_asset_operation_with_cost( &state, &owner_user_id, "puzzle_ui_background_image", &billing_asset_id, PUZZLE_IMAGE_GENERATION_POINTS_COST, async { let levels_json = levels_json?; let session = get_puzzle_session_for_image_generation( &state, session_id.clone(), owner_user_id.clone(), &payload, levels_json.as_deref(), now, ) .await?; let mut draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; if let Some(levels_json) = levels_json.as_ref() { draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; } let target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; let resolved_prompt = normalize_puzzle_ui_background_prompt( raw_prompt.as_str(), &draft, &target_level, ); let generated = generate_puzzle_ui_background_image( &state, owner_user_id.as_str(), &session.session_id, &target_level.level_name, resolved_prompt.as_str(), ) .await .map_err(map_puzzle_generation_endpoint_error)?; let save_result = state .spacetime_client() .save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput { session_id: session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json, prompt: resolved_prompt.clone(), image_src: generated.image_src.clone(), image_object_key: Some(generated.object_key.clone()), saved_at_micros: now, }) .await; match save_result { Ok(session) => Ok(session), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session.session_id, owner_user_id = %owner_user_id, error = %error, "拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" ); let fallback_session = replace_puzzle_session_draft_snapshot(session, draft, now); Ok(apply_generated_puzzle_ui_background_to_session_snapshot( fallback_session, target_level.level_id.as_str(), resolved_prompt, generated.image_src, Some(generated.object_key), now, )) } Err(error) => Err(map_puzzle_client_error(error)), } }, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "generate_puzzle_ui_background", "UI 背景图生成", "已生成拼图 UI 背景图。", session, ) } "generate_puzzle_tags" => { let work_title = payload .work_title .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "作品名称不能为空", ) })?; let work_description = payload .work_description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "作品描述不能为空", ) })?; let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })), ) })?; let generated_tags = generate_puzzle_work_tags(&state, work_title, work_description).await; let session = save_generated_puzzle_tags_to_session( &state, &session_id, &owner_user_id, &payload, generated_tags, levels_json, now, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "generate_puzzle_tags", "作品标签生成", "已生成 6 个作品标签。", session, ) } "select_puzzle_image" => { let candidate_id = payload .candidate_id .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "candidateId is required", ) })?; let session = state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: payload.level_id.clone(), candidate_id, selected_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) }); ( "select_puzzle_image", "正式图确认", "已应用正式拼图图片。", session, ) } "publish_puzzle_work" => { let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error, })), ) })?; let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let author_display_name = resolve_author_display_name(&state, &authenticated); let profile = execute_billable_asset_operation( &state, &owner_user_id, "puzzle_publish_work", &work_id, async { state .spacetime_client() .publish_puzzle_work(PuzzlePublishRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 work_id: work_id.clone(), profile_id, author_display_name, work_title: payload.work_title.clone(), work_description: payload.work_description.clone(), level_name: payload.level_name.clone(), summary: payload.summary.clone(), theme_tags: payload.theme_tags.clone(), levels_json, published_at_micros: now, }) .await .map_err(map_puzzle_client_error) }, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) })?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: profile.profile_id.clone(), operation_type: "publish_puzzle_work".to_string(), status: "completed".to_string(), phase_label: "作品发布".to_string(), phase_detail: "拼图作品已发布到广场。".to_string(), progress: 100, error: None, }, session: map_puzzle_agent_session_response(session), }, )); } other => { return Err(puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, format!("action `{other}` is not supported").as_str(), )); } }; let session = session?; Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: session.session_id.clone(), operation_type: operation_type.to_string(), status: "completed".to_string(), phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), progress: 100, error: None, }, session: map_puzzle_agent_session_response(session), }, )) } pub async fn get_puzzle_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_puzzle_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorksResponse { items: items .into_iter() .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) } pub async fn get_puzzle_work_detail( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(_authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_puzzle_work_detail(profile_id) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn put_puzzle_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), work_title: payload.work_title, work_description: payload.work_description, level_name: payload.level_name, summary: payload.summary, theme_tags: payload.theme_tags, cover_image_src: payload.cover_image_src, cover_asset_id: payload.cover_asset_id, levels_json: Some(serialize_puzzle_levels_response( &request_context, &payload.levels, )?), updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn delete_puzzle_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let items = state .spacetime_client() .delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorksResponse { items: items .into_iter() .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) } pub async fn claim_puzzle_work_point_incentive( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), claimed_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn list_puzzle_gallery( State(state): State, Extension(request_context): Extension, ) -> Result { if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { crate::telemetry::record_puzzle_gallery_cache_hit(); return Ok(puzzle_gallery_cached_json(&request_context, response)); } crate::telemetry::record_puzzle_gallery_cache_miss(); let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await; if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { crate::telemetry::record_puzzle_gallery_cache_hit(); return Ok(puzzle_gallery_cached_json(&request_context, response)); } let rebuild_started_at = std::time::Instant::now(); let items = state .spacetime_client() .list_puzzle_gallery() .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; let response = build_puzzle_gallery_window_response( items .into_iter() .map(|item| map_puzzle_gallery_card_response(&state, item)) .collect(), ); let cached_response = state .puzzle_gallery_cache() .store_response(response) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_GALLERY_PROVIDER, "message": format!("拼图广场缓存序列化失败:{error}"), })), ) })?; crate::telemetry::record_puzzle_gallery_cache_rebuild( rebuild_started_at.elapsed(), cached_response.data_json_len(), ); Ok(puzzle_gallery_cached_json(&request_context, cached_response)) } pub async fn get_puzzle_gallery_detail( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_puzzle_gallery_detail(profile_id) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn record_puzzle_gallery_like( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { profile_id, user_id: authenticated.claims().user_id().to_string(), liked_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn remix_puzzle_gallery_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .remix_puzzle_work(PuzzleWorkRemixRecordInput { source_profile_id: profile_id, target_owner_user_id: owner_user_id, target_session_id: build_prefixed_uuid_id("puzzle-session-"), target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), target_work_id: build_prefixed_uuid_id("puzzle-work-"), author_display_name: resolve_author_display_name(&state, &authenticated), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), remixed_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn start_puzzle_run( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.profile_id, "profileId", )?; let run = state .spacetime_client() .start_puzzle_run(PuzzleRunStartRecordInput { run_id: build_prefixed_uuid_id("puzzle-run-"), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: payload.profile_id.clone(), level_id: payload.level_id.clone(), started_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; record_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( "puzzle", payload.profile_id.clone(), &authenticated, "/api/runtime/puzzle/...", ) .profile_id(payload.profile_id.clone()) .extra(json!({ "levelId": payload.level_id, "runId": run.run_id, })), ) .await; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn get_puzzle_run( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .get_puzzle_run(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn swap_puzzle_pieces( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.first_piece_id, "firstPieceId", )?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.second_piece_id, "secondPieceId", )?; let run = state .spacetime_client() .swap_puzzle_pieces(PuzzleRunSwapRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), first_piece_id: payload.first_piece_id, second_piece_id: payload.second_piece_id, swapped_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn drag_puzzle_piece_or_group( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.piece_id, "pieceId", )?; let run = state .spacetime_client() .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), piece_id: payload.piece_id, target_row: payload.target_row, target_col: payload.target_col, dragged_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn advance_puzzle_next_level( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let payload = match payload { Ok(Json(payload)) => payload, Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => { AdvancePuzzleNextLevelRequest { target_profile_id: None, } } Err(error) => { return Err(puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), )); } }; let run = state .spacetime_client() .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), target_profile_id: payload.target_profile_id, advanced_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn update_puzzle_run_pause( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .update_puzzle_run_pause(PuzzleRunPauseRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), paused: payload.paused, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn use_puzzle_runtime_prop( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.prop_kind, "propKind", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let prop_kind = payload.prop_kind.trim().to_string(); let billing_asset_kind = match prop_kind.as_str() { "hint" => "puzzle_prop_hint", "reference" => "puzzle_prop_preview", "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", "extendTime" | "extend_time" => "puzzle_prop_extend_time", _ => { return Err(puzzle_bad_request( &request_context, PUZZLE_RUNTIME_PROVIDER, "unknown puzzle prop kind", )); } }; let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); let reducer_owner_user_id = owner_user_id.clone(); let reducer_run_id = run_id.clone(); let fallback_run_id = run_id.clone(); let fallback_owner_user_id = owner_user_id.clone(); let run_result = execute_billable_asset_operation( &state, &owner_user_id, billing_asset_kind, billing_asset_id.as_str(), async { state .spacetime_client() .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { run_id: reducer_run_id, owner_user_id: reducer_owner_user_id, prop_kind, used_at_micros: current_utc_micros(), spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST, }) .await .map_err(map_puzzle_client_error) }, ) .await; let run = match run_result { Ok(run) => run, Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => { // 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。 // 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。 state .spacetime_client() .get_puzzle_run(fallback_run_id, fallback_owner_user_id) .await .map_err(map_puzzle_client_error) .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error) })? } Err(error) => { return Err(puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, error, )); } }; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn submit_puzzle_leaderboard( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), profile_id: payload.profile_id, grid_size: payload.grid_size, elapsed_ms: payload.elapsed_ms.max(1_000), nickname: payload.nickname.trim().to_string(), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } mod mappers; use mappers::*; fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { title: None, work_description: None, picture_description: payload .picture_description .as_deref() .or(payload.seed_text.as_deref()), }) } fn build_puzzle_form_seed_text_from_parts( title: Option<&str>, work_description: Option<&str>, picture_description: Option<&str>, ) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { title, work_description, picture_description, }) } async fn save_puzzle_form_payload_before_compile( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &ExecutePuzzleAgentActionRequest, now: i64, ) -> Result { let seed_text = build_puzzle_form_seed_text_from_parts( None, None, payload .picture_description .as_deref() .or(payload.prompt_text.as_deref()), ); if seed_text.trim().is_empty() { return Ok(session_id.to_string()); } let save_result = state .spacetime_client() .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { session_id: session_id.to_string(), owner_user_id: owner_user_id.to_string(), seed_text: seed_text.clone(), saved_at_micros: now, }) .await .map(|_| ()); match save_result { Ok(()) => Ok(session_id.to_string()), Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { create_seeded_puzzle_session_when_form_save_missing( state, request_context, session_id, owner_user_id, seed_text, now, &error, ) .await } Err(error) => Err(puzzle_error_response( request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), )), } } async fn create_seeded_puzzle_session_when_form_save_missing( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, seed_text: String, now: i64, original_error: &SpacetimeClientError, ) -> Result { let current_session = state .spacetime_client() .get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string()) .await .map_err(|error| { puzzle_error_response( request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; if !current_session.seed_text.trim().is_empty() { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id, owner_user_id, error = %original_error, "拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译" ); return Ok(session_id.to_string()); } // 中文注释:旧 wasm 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 let replacement_session_id = build_prefixed_uuid_id("puzzle-session-"); let replacement = state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: replacement_session_id.clone(), owner_user_id: owner_user_id.to_string(), seed_text: seed_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&seed_text), created_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, old_session_id = %session_id, new_session_id = %replacement.session_id, owner_user_id, error = %original_error, "拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session" ); Ok(replacement.session_id) } fn select_puzzle_level_for_api( draft: &PuzzleResultDraftRecord, level_id: Option<&str>, ) -> Result { let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty()); if let Some(target_id) = normalized_level_id { return draft .levels .iter() .find(|level| level.level_id == target_id) .cloned() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图关卡不存在:{target_id}"), })) }); } let level = draft.levels.first().cloned(); level.ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿缺少可编辑关卡", })) }) } fn parse_puzzle_level_records_from_module_json( value: &str, ) -> Result, AppError> { let levels: Vec = serde_json::from_str(value).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图关卡列表 JSON 非法:{error}"), })) })?; Ok(levels .into_iter() .map(|level| PuzzleDraftLevelRecord { level_id: level.level_id, level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, ui_background_prompt: level.ui_background_prompt, ui_background_image_src: level.ui_background_image_src, ui_background_image_object_key: level.ui_background_image_object_key, background_music: level .background_music .map(map_puzzle_audio_asset_domain_record), candidates: level .candidates .into_iter() .map(|candidate| PuzzleGeneratedImageCandidateRecord { candidate_id: candidate.candidate_id, image_src: candidate.image_src, asset_id: candidate.asset_id, prompt: candidate.prompt, actual_prompt: candidate.actual_prompt, source_type: candidate.source_type, selected: candidate.selected, }) .collect(), selected_candidate_id: level.selected_candidate_id, cover_image_src: level.cover_image_src, cover_asset_id: level.cover_asset_id, generation_status: level.generation_status, }) .collect()) } async fn get_puzzle_session_for_image_generation( state: &AppState, session_id: String, owner_user_id: String, payload: &ExecutePuzzleAgentActionRequest, normalized_levels_json: Option<&str>, now: i64, ) -> Result { match state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await { Ok(session) => Ok(session), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { // 中文注释:结果页已经带有当前草稿快照;Maincloud 读取 session 短暂 503 时不应阻断外部生图。 let fallback_session = build_puzzle_session_snapshot_from_action_payload( session_id.as_str(), payload, normalized_levels_json, now, )?; tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, error = %error, "拼图图片生成读取 session 因 SpacetimeDB 连接不可用而降级使用前端草稿快照" ); Ok(fallback_session) } Err(error) => Err(map_puzzle_client_error(error)), } } fn build_puzzle_session_snapshot_from_action_payload( session_id: &str, payload: &ExecutePuzzleAgentActionRequest, normalized_levels_json: Option<&str>, now: i64, ) -> Result { let levels_json = normalized_levels_json.ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "spacetimedb", "message": "SpacetimeDB 暂不可用,且请求缺少拼图关卡快照,无法继续生成图片", })) })?; let levels = parse_puzzle_level_records_from_module_json(levels_json)?; let first_level = levels.first().cloned().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿缺少可编辑关卡", })) })?; let work_title = payload .work_title .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(first_level.level_name.as_str()) .to_string(); let work_description = payload .work_description .as_deref() .map(str::trim) .unwrap_or_default() .to_string(); let summary = payload .summary .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(first_level.picture_description.as_str()) .to_string(); let theme_tags = payload.theme_tags.clone().unwrap_or_default(); let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::empty_anchor_pack()); let draft = PuzzleResultDraftRecord { work_title, work_description, level_name: first_level.level_name.clone(), summary, theme_tags, forbidden_directives: Vec::new(), creator_intent: None, anchor_pack: anchor_pack.clone(), candidates: first_level.candidates.clone(), selected_candidate_id: first_level.selected_candidate_id.clone(), cover_image_src: first_level.cover_image_src.clone(), cover_asset_id: first_level.cover_asset_id.clone(), generation_status: first_level.generation_status.clone(), levels, form_draft: None, }; Ok(PuzzleAgentSessionRecord { session_id: session_id.to_string(), seed_text: String::new(), current_turn: 0, progress_percent: 94, stage: "ready_to_publish".to_string(), anchor_pack, draft: Some(draft), messages: Vec::new(), last_assistant_reply: None, published_profile_id: None, suggested_actions: Vec::new(), result_preview: None, updated_at: format_timestamp_micros(now), }) } fn map_puzzle_domain_anchor_pack( anchor_pack: module_puzzle::PuzzleAnchorPack, ) -> PuzzleAnchorPackRecord { PuzzleAnchorPackRecord { theme_promise: map_puzzle_domain_anchor_item(anchor_pack.theme_promise), visual_subject: map_puzzle_domain_anchor_item(anchor_pack.visual_subject), visual_mood: map_puzzle_domain_anchor_item(anchor_pack.visual_mood), composition_hooks: map_puzzle_domain_anchor_item(anchor_pack.composition_hooks), tags_and_forbidden: map_puzzle_domain_anchor_item(anchor_pack.tags_and_forbidden), } } fn map_puzzle_domain_anchor_item( anchor: module_puzzle::PuzzleAnchorItem, ) -> PuzzleAnchorItemRecord { PuzzleAnchorItemRecord { key: anchor.key, label: anchor.label, value: anchor.value, status: anchor.status.as_str().to_string(), } } fn serialize_puzzle_levels_response( request_context: &RequestContext, levels: &[PuzzleDraftLevelResponse], ) -> Result { let payload = levels .iter() .map(|level| { json!({ "level_id": level.level_id, "level_name": level.level_name, "picture_description": level.picture_description, "picture_reference": level.picture_reference, "ui_background_prompt": level.ui_background_prompt, "ui_background_image_src": level.ui_background_image_src, "ui_background_image_object_key": level.ui_background_image_object_key, "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates .iter() .map(|candidate| { json!({ "candidate_id": candidate.candidate_id, "image_src": candidate.image_src, "asset_id": candidate.asset_id, "prompt": candidate.prompt, "actual_prompt": candidate.actual_prompt, "source_type": candidate.source_type, "selected": candidate.selected, }) }) .collect::>(), "selected_candidate_id": level.selected_candidate_id, "cover_image_src": level.cover_image_src, "cover_asset_id": level.cover_asset_id, "generation_status": level.generation_status, }) }) .collect::>(); serde_json::to_string(&payload).map_err(|error| { puzzle_error_response( request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": format!("拼图关卡列表序列化失败:{error}"), })), ) }) } fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result, String> { let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else { return Ok(None); }; let levels: Vec = serde_json::from_str(raw).map_err(|error| format!("拼图关卡列表 JSON 非法:{error}"))?; let payload = levels .iter() .map(|level| { json!({ "level_id": level.level_id, "level_name": level.level_name, "picture_description": level.picture_description, "picture_reference": level.picture_reference, "ui_background_prompt": level.ui_background_prompt, "ui_background_image_src": level.ui_background_image_src, "ui_background_image_object_key": level.ui_background_image_object_key, "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates .iter() .map(|candidate| { json!({ "candidate_id": candidate.candidate_id, "image_src": candidate.image_src, "asset_id": candidate.asset_id, "prompt": candidate.prompt, "actual_prompt": candidate.actual_prompt, "source_type": candidate.source_type, "selected": candidate.selected, }) }) .collect::>(), "selected_candidate_id": level.selected_candidate_id, "cover_image_src": level.cover_image_src, "cover_asset_id": level.cover_asset_id, "generation_status": level.generation_status, }) }) .collect::>(); serde_json::to_string(&payload) .map(Some) .map_err(|error| format!("拼图关卡列表序列化失败:{error}")) } fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { let stable_suffix = session_id .strip_prefix("puzzle-session-") .unwrap_or(session_id); ( format!("puzzle-work-{stable_suffix}"), format!("puzzle-profile-{stable_suffix}"), ) } #[derive(Clone, Debug, Eq, PartialEq)] struct PuzzleLevelNaming { level_name: String, work_description: Option, work_tags: Vec, ui_background_prompt: Option, } impl PuzzleLevelNaming { fn fallback(picture_description: &str) -> Self { Self { level_name: build_fallback_puzzle_first_level_name(picture_description), work_description: None, work_tags: Vec::new(), ui_background_prompt: None, } } } async fn generate_puzzle_first_level_name( state: &AppState, picture_description: &str, ) -> PuzzleLevelNaming { if let Some(llm_client) = state.llm_client() { let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description); let response = llm_client .request_text( LlmTextRequest::new(vec![ LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) .with_responses_api(), ) .await; match response { Ok(response) => { if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str()) { return naming; } tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, picture_chars = picture_description.chars().count(), "拼图首关名模型返回非法,降级使用关键词名" ); } Err(error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, picture_chars = picture_description.chars().count(), error = %error, "拼图首关名生成失败,降级使用关键词名" ); } } } PuzzleLevelNaming::fallback(picture_description) } async fn generate_puzzle_first_level_name_from_image( state: &AppState, picture_description: &str, image: &PuzzleDownloadedImage, ) -> Option { let Some(llm_client) = state.creative_agent_gpt5_client() else { return None; }; let Some(image_data_url) = build_puzzle_level_name_image_data_url(image) else { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, picture_chars = picture_description.chars().count(), "拼图首关名图片输入压缩失败,保留文本关卡名" ); return None; }; let user_text = build_puzzle_first_level_name_vision_user_text(picture_description); let response = llm_client .request_text( LlmTextRequest::new(vec![ LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), LlmMessage::user_multimodal(vec![ LlmMessageContentPart::InputText { text: user_text }, LlmMessageContentPart::InputImage { image_url: image_data_url, }, ]), ]) .with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL) .with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS), ) .await; match response { Ok(response) => { parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, picture_chars = picture_description.chars().count(), "拼图首关名视觉模型返回非法,保留文本关卡名" ); None }) } Err(error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, picture_chars = picture_description.chars().count(), error = %error, "拼图首关名视觉生成失败,保留文本关卡名" ); None } } } fn build_puzzle_level_name_image_data_url(image: &PuzzleDownloadedImage) -> Option { let bytes = resize_puzzle_level_name_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) )) } fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option> { let image = image::load_from_memory(bytes).ok()?; let resized = image.resize( PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, 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()) } fn parse_puzzle_level_naming_from_text(text: &str) -> Option { let trimmed = text.trim(); let json_text = if let Some(start) = trimmed.find('{') && let Some(end) = trimmed.rfind('}') && end > start { &trimmed[start..=end] } else { trimmed }; let parsed = serde_json::from_str::(json_text).ok(); if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) { return None; } let raw_name = parsed .as_ref() .and_then(|value| value.get("levelName").and_then(Value::as_str)) .or_else(|| { parsed .as_ref() .and_then(|value| value.get("level_name").and_then(Value::as_str)) }) .unwrap_or(trimmed); let level_name = normalize_puzzle_first_level_name(raw_name)?; let work_description = parsed .as_ref() .and_then(parse_puzzle_generated_work_description_field); let work_tags = parsed .as_ref() .and_then(parse_puzzle_generated_work_tags_field) .unwrap_or_default(); let ui_background_prompt = parsed .as_ref() .and_then(parse_puzzle_ui_background_prompt_field); Some(PuzzleLevelNaming { level_name, work_description, work_tags, ui_background_prompt, }) } #[cfg(test)] fn parse_puzzle_first_level_name_from_text(text: &str) -> Option { parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name) } fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option { value .get("uiBackgroundPrompt") .and_then(Value::as_str) .or_else(|| value.get("ui_background_prompt").and_then(Value::as_str)) .and_then(normalize_puzzle_generated_ui_background_prompt) } fn parse_puzzle_generated_work_description_field(value: &Value) -> Option { value .get("workDescription") .and_then(Value::as_str) .or_else(|| value.get("work_description").and_then(Value::as_str)) .and_then(normalize_puzzle_generated_work_description) } fn normalize_puzzle_generated_work_description(value: &str) -> Option { let normalized = value .trim() .trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .split_whitespace() .collect::>() .join(""); let description = normalized.chars().take(80).collect::(); (description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description)) .then_some(description) } fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option> { let tags_value = value .get("workTags") .or_else(|| value.get("work_tags")) .or_else(|| value.get("themeTags")) .or_else(|| value.get("theme_tags")) .or_else(|| value.get("tags"))?; let raw_tags = match tags_value { Value::Array(items) => items .iter() .filter_map(Value::as_str) .map(ToString::to_string) .collect::>(), Value::String(text) => text .split([',', ',', '、', '\n', '|', '/']) .map(ToString::to_string) .collect::>(), _ => Vec::new(), }; let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags); (tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags) } fn normalize_puzzle_generated_work_tag_candidates( candidates: impl IntoIterator, ) -> Vec where S: AsRef, { let mut tags = Vec::new(); for candidate in candidates { let normalized = normalize_puzzle_tag(candidate.as_ref()); if normalized.is_empty() || looks_like_puzzle_json_field_name(&normalized) || tags.iter().any(|tag| tag == &normalized) { continue; } tags.push(normalized); if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT { break; } } tags } fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option { let normalized = value .trim() .trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .split_whitespace() .collect::>() .join(""); let filtered = normalized .replace("拼图槽", "") .replace("棋盘", "") .replace("HUD", "") .replace("按钮", "") .replace("文字", "") .replace("水印", "") .replace("数字", "") .replace("拼图碎片", "") .replace("完整拼图图像", "") .replace("教程浮层", ""); let prompt = filtered .chars() .take(160) .collect::() .trim() .trim_matches(|ch: char| matches!(ch, ',' | '。' | '、' | ';' | ':')) .to_string(); if prompt.chars().count() >= 12 { Some(prompt) } else { None } } fn normalize_puzzle_first_level_name(value: &str) -> Option { let normalized = value .trim() .trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')')) .chars() .filter(|ch| { !matches!( ch, '#' | '"' | '\'' | '`' | ' ' | '\t' | '\r' | '\n' | ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .take(12) .collect::(); let normalized = strip_puzzle_level_name_generic_words(normalized); if normalized.chars().count() >= 2 && !matches!( normalized.as_str(), "第一关" | "画面" | "拼图" | "作品" | "关卡" ) && !looks_like_puzzle_json_field_name(&normalized) { Some(normalized) } else { None } } fn looks_like_puzzle_json_field_name(value: &str) -> bool { let normalized = value.trim().trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }); let compact = normalized.to_ascii_lowercase().replace('_', ""); matches!(compact.as_str(), "levelnam" | "levelname") || [ "levelname", "workdescription", "worktags", "themetags", "uibackgroundprompt", ] .iter() .any(|field| { compact == *field || (compact.len() >= 6 && field.starts_with(compact.as_str())) || compact.starts_with(field) }) } fn looks_like_puzzle_json_fragment(value: &str) -> bool { let trimmed = value.trim(); if trimmed.starts_with('{') || trimmed.starts_with('[') { return true; } let lower = trimmed.to_ascii_lowercase(); [ "\"levelnam", "\"levelname\"", "\"level_name\"", "\"workdescription\"", "\"work_description\"", "\"worktags\"", "\"work_tags\"", "\"uibackgroundprompt\"", "\"ui_background_prompt\"", ] .iter() .any(|field| lower.contains(field)) } fn strip_puzzle_level_name_generic_words(mut value: String) -> String { for prefix in ["第一关", "关卡名", "关卡"] { value = value.trim_start_matches(prefix).to_string(); } for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] { value = value.trim_end_matches(suffix).to_string(); } value.chars().take(8).collect() } fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String { let source = picture_description.trim(); if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) { return "雨夜猫街".to_string(); } if source.contains("猫") && source.contains('灯') { return "暖灯猫街".to_string(); } for (keyword, level_name) in [ ("雨夜", "雨夜灯街"), ("猫", "暖灯猫街"), ("狗", "花园小狗"), ("神庙", "神庙遗光"), ("遗迹", "遗迹谜光"), ("森林", "森林秘境"), ("城市", "霓虹城市"), ("机械", "机械迷城"), ("蒸汽", "蒸汽街区"), ("海", "海岸微光"), ("花", "花园晨光"), ("雪", "雪境小径"), ("龙", "龙影高塔"), ("灯", "暖灯街角"), ("塔", "塔顶星光"), ] { if source.contains(keyword) { return level_name.to_string(); } } "奇境初见".to_string() } fn build_puzzle_levels_with_primary_update( draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, picture_reference: Option<&str>, ) -> Vec { let mut levels = draft.levels.clone(); if let Some(index) = levels .iter() .position(|level| level.level_id == target_level.level_id) .or_else(|| (!levels.is_empty()).then_some(0)) { levels[index].level_name = target_level.level_name.clone(); levels[index].ui_background_prompt = target_level.ui_background_prompt.clone(); levels[index].ui_background_image_src = target_level.ui_background_image_src.clone(); levels[index].ui_background_image_object_key = target_level.ui_background_image_object_key.clone(); if let Some(picture_reference) = picture_reference .map(str::trim) .filter(|value| !value.is_empty()) { levels[index].picture_reference = Some(picture_reference.to_string()); } } levels } fn attach_selected_puzzle_candidate_to_levels( levels: &mut [PuzzleDraftLevelRecord], target_level_id: &str, candidate: &PuzzleGeneratedImageCandidateRecord, ) { if let Some(index) = levels .iter() .position(|level| level.level_id == target_level_id) .or_else(|| (!levels.is_empty()).then_some(0)) { let level = &mut levels[index]; level.candidates.clear(); let mut candidate = candidate.clone(); candidate.selected = true; level.selected_candidate_id = Some(candidate.candidate_id.clone()); level.cover_image_src = Some(candidate.image_src.clone()); level.cover_asset_id = Some(candidate.asset_id.clone()); level.candidates.push(candidate); level.generation_status = "ready".to_string(); } } fn resolve_puzzle_initial_ui_background_prompt( draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, ) -> String { target_level .ui_background_prompt .as_deref() .and_then(normalize_puzzle_generated_ui_background_prompt) .unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level)) } fn normalize_puzzle_ui_background_prompt( raw_prompt: &str, draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, ) -> String { let prompt = raw_prompt.trim(); if !prompt.is_empty() { return prompt.chars().take(420).collect(); } let title = draft.work_title.trim(); let title = if title.is_empty() { target_level.level_name.trim() } else { title }; let tags = draft .theme_tags .iter() .map(|tag| tag.trim()) .filter(|tag| !tag.is_empty()) .collect::>() .join(","); [ title, draft.work_description.trim(), target_level.picture_description.trim(), tags.as_str(), PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER, ] .into_iter() .filter(|value| !value.is_empty()) .collect::>() .join("。") .chars() .take(420) .collect() } fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str) -> String { let level_name = level_name.trim(); let title_clause = if level_name.is_empty() { String::new() } else { format!("当前拼图关卡名称:{level_name}。") }; format!( "{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图,只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。" ) } fn attach_puzzle_level_ui_background( levels: &mut [PuzzleDraftLevelRecord], level_id: &str, prompt: String, generated: GeneratedPuzzleUiBackgroundResponse, ) { let Some(index) = levels .iter() .position(|level| level.level_id == level_id) .or_else(|| (!levels.is_empty()).then_some(0)) else { return; }; levels[index].ui_background_prompt = Some(prompt); levels[index].ui_background_image_src = Some(generated.image_src); levels[index].ui_background_image_object_key = Some(generated.object_key); } async fn generate_puzzle_background_music_required( state: &AppState, owner_user_id: &str, profile_id: &str, title: &str, ) -> Result { let normalized_title = title.trim(); if normalized_title.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", })), ); } generate_background_music_asset_for_creation( state, owner_user_id, String::new(), normalized_title.to_string(), Some("轻快, 拼图, 循环, instrumental".to_string()), None, GeneratedCreationAudioTarget { entity_kind: PUZZLE_ENTITY_KIND.to_string(), entity_id: profile_id.to_string(), slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(), asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(), profile_id: Some(profile_id.to_string()), storage_prefix: LegacyAssetPrefix::PuzzleAssets, }, ) .await } async fn generate_puzzle_initial_ui_background_required( state: &AppState, owner_user_id: &str, session_id: &str, draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, ) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> { let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level); let generated = generate_puzzle_ui_background_image( state, owner_user_id, session_id, target_level.level_name.as_str(), prompt.as_str(), ) .await?; Ok((prompt, generated)) } fn ensure_puzzle_initial_level_assets_ready( level: &PuzzleDraftLevelRecord, ) -> Result<(), AppError> { let has_ui_background = level .ui_background_image_src .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) || level .ui_background_image_object_key .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()); if has_ui_background { return Ok(()); } let mut missing = Vec::new(); if !has_ui_background { missing.push("UI背景图"); } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")), "missingAssets": missing, })), ) } fn find_puzzle_level_for_initial_asset_check<'a>( levels: &'a [PuzzleDraftLevelRecord], level_id: &str, ) -> Option<&'a PuzzleDraftLevelRecord> { levels .iter() .find(|level| level.level_id == level_id) .or_else(|| levels.first()) } async fn compile_puzzle_draft_with_initial_cover( state: &AppState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, image_model: Option<&str>, now: i64, ) -> Result { let compiled_session = state .spacetime_client() .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) .await .map_err(map_puzzle_compile_error)?; let draft = compiled_session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; let mut target_level = select_puzzle_level_for_api(&draft, None)?; let fallback_level_name = target_level.level_name.clone(); let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, &draft.summary, ); let image_level_name = if target_level.level_name.trim().is_empty() { build_fallback_puzzle_first_level_name(&target_level.picture_description) } else { target_level.level_name.clone() }; // 中文注释:首图 prompt 只依赖画面描述,关卡名分支可以和生图分支并行;OSS 临时路径使用已有名或确定性兜底名。 let level_name_future = generate_puzzle_first_level_name(state, &target_level.picture_description); // 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。 let candidates_future = generate_puzzle_image_candidates( state, owner_user_id.as_str(), &compiled_session.session_id, &image_level_name, &image_prompt, reference_image_src, true, image_model, 1, target_level.candidates.len(), ); let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future); target_level.level_name = generated_naming.level_name.clone(); target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); let mut generated_metadata = generated_naming; let candidates = candidates_result?; let selected_candidate_id = candidates .iter() .find(|candidate| candidate.record.selected) .or_else(|| candidates.first()) .map(|candidate| candidate.record.candidate_id.clone()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图候选图生成结果为空", })) })?; if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( state, target_level.picture_description.as_str(), &candidates[0].downloaded_image, ) .await { target_level.level_name = refined_naming.level_name; if refined_naming.ui_background_prompt.is_some() { target_level.ui_background_prompt = refined_naming.ui_background_prompt; } if refined_naming.work_description.is_some() { generated_metadata.work_description = refined_naming.work_description; } if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_metadata.work_tags = refined_naming.work_tags; } generated_metadata.level_name = target_level.level_name.clone(); generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); } let generated_level_name = target_level.level_name.clone(); let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); // 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。 let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required( state, owner_user_id.as_str(), compiled_session.session_id.as_str(), &draft, &target_level, ) .await?; attach_puzzle_level_ui_background( &mut updated_levels, target_level.level_id.as_str(), ui_prompt, ui_background, ); if let Some(selected_candidate) = candidates .iter() .find(|candidate| candidate.record.selected) .or_else(|| candidates.first()) { attach_selected_puzzle_candidate_to_levels( &mut updated_levels, target_level.level_id.as_str(), &selected_candidate.record, ); } let ready_level = find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿资源生成完成后未找到目标关卡", })) })?; ensure_puzzle_initial_level_assets_ready(ready_level)?; let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let work_title = if draft.work_title.trim().is_empty() || draft.work_title.trim() == fallback_level_name.trim() { generated_level_name.clone() } else { draft.work_title.clone() }; let work_description = if draft.work_description.trim().is_empty() { generated_metadata .work_description .clone() .unwrap_or_else(|| draft.work_description.clone()) } else { draft.work_description.clone() }; let theme_tags = if draft.theme_tags.is_empty() && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_metadata.work_tags.clone() } else { draft.theme_tags.clone() }; let candidates_json = serde_json::to_string( &candidates .iter() .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) .collect::>(), ) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图候选图序列化失败:{error}"), })) })?; let (saved_session, save_used_fallback) = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: compiled_session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json: levels_json_with_generated_name.clone(), candidates_json, saved_at_micros: current_utc_micros(), }) .await .map_err(map_puzzle_client_error) .map(|session| (session, false)) .or_else(|error| { if is_spacetimedb_connectivity_app_error(&error) { // 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" ); let session = apply_generated_puzzle_candidates_to_session_snapshot( apply_generated_puzzle_levels_to_session_snapshot( apply_generated_puzzle_first_level_name_to_session_snapshot( compiled_session.clone(), target_level.level_id.as_str(), generated_level_name.as_str(), fallback_level_name.as_str(), now, ), updated_levels.clone(), now, ), target_level.level_id.as_str(), candidates.into_records(), reference_image_src, now, ); Ok((session, true)) } else { Err(error) } })?; let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); match state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: owner_user_id.clone(), work_title, work_description: work_description.clone(), level_name: generated_level_name.clone(), summary: work_description, theme_tags, cover_image_src: ready_level.cover_image_src.clone(), cover_asset_id: ready_level.cover_asset_id.clone(), levels_json: levels_json_with_generated_name.clone(), updated_at_micros: now, }) .await .map_err(map_puzzle_client_error) { Ok(_) => {} Err(error) if is_spacetimedb_connectivity_app_error(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照" ); } Err(error) => return Err(error), } let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( saved_session, &generated_metadata, fallback_level_name.as_str(), now, ); if save_used_fallback { return Ok(saved_session); } match state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id, owner_user_id, level_id: Some(target_level.level_id), candidate_id: selected_candidate_id, selected_at_micros: current_utc_micros(), }) .await { Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( session, &generated_metadata, fallback_level_name.as_str(), now, )), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %saved_session.session_id, error = %error, "拼图首图选定回写因 SpacetimeDB 连接不可用而降级使用已生成快照" ); Ok(saved_session) } Err(error) => Err(map_puzzle_client_error(error)), } } async fn compile_puzzle_draft_with_uploaded_cover( state: &AppState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, now: i64, ) -> Result { let uploaded_image_src = reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "field": "referenceImageSrc", "message": "关闭 AI 重绘时必须上传拼图图片。", })) })?; let uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "field": "referenceImageSrc", "message": "关闭 AI 重绘时上传图必须是图片 Data URL。", })) })?; let compiled_session = state .spacetime_client() .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) .await .map_err(map_puzzle_compile_error)?; let draft = compiled_session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; let mut target_level = select_puzzle_level_for_api(&draft, None)?; let fallback_level_name = target_level.level_name.clone(); let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, &draft.summary, ); let image_level_name = if target_level.level_name.trim().is_empty() { build_fallback_puzzle_first_level_name(&target_level.picture_description) } else { target_level.level_name.clone() }; // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 let candidate_id = format!( "{}-candidate-{}", compiled_session.session_id, target_level.candidates.len() + 1 ); let uploaded_downloaded_image = PuzzleDownloadedImage { extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(), mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()), bytes: uploaded_image.bytes, }; let level_name_future = generate_puzzle_first_level_name(state, &target_level.picture_description); let image_level_name_future = generate_puzzle_first_level_name_from_image( state, target_level.picture_description.as_str(), &uploaded_downloaded_image, ); let persist_upload_future = persist_puzzle_generated_asset( state, owner_user_id.as_str(), &compiled_session.session_id, image_level_name.as_str(), candidate_id.as_str(), "uploaded-direct", uploaded_downloaded_image.clone(), current_utc_micros(), ); let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!( level_name_future, image_level_name_future, persist_upload_future ); if let Some(refined_naming) = refined_naming { generated_naming.level_name = refined_naming.level_name; if refined_naming.ui_background_prompt.is_some() { generated_naming.ui_background_prompt = refined_naming.ui_background_prompt; } if refined_naming.work_description.is_some() { generated_naming.work_description = refined_naming.work_description; } if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_naming.work_tags = refined_naming.work_tags; } } target_level.level_name = generated_naming.level_name.clone(); target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); let mut generated_metadata = generated_naming; generated_metadata.level_name = target_level.level_name.clone(); generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); let generated_level_name = target_level.level_name.clone(); let persisted_upload = persisted_upload_result?; let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); // 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。 let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required( state, owner_user_id.as_str(), compiled_session.session_id.as_str(), &draft, &target_level, ) .await?; attach_puzzle_level_ui_background( &mut updated_levels, target_level.level_id.as_str(), ui_prompt, ui_background, ); attach_selected_puzzle_candidate_to_levels( &mut updated_levels, target_level.level_id.as_str(), &PuzzleGeneratedImageCandidateRecord { candidate_id: candidate_id.clone(), image_src: persisted_upload.image_src.clone(), asset_id: persisted_upload.asset_id.clone(), prompt: image_prompt.clone(), actual_prompt: None, source_type: "uploaded".to_string(), selected: true, }, ); let ready_level = find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿资源生成完成后未找到目标关卡", })) })?; ensure_puzzle_initial_level_assets_ready(ready_level)?; let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let work_title = if draft.work_title.trim().is_empty() || draft.work_title.trim() == fallback_level_name.trim() { generated_level_name.clone() } else { draft.work_title.clone() }; let work_description = if draft.work_description.trim().is_empty() { generated_metadata .work_description .clone() .unwrap_or_else(|| draft.work_description.clone()) } else { draft.work_description.clone() }; let theme_tags = if draft.theme_tags.is_empty() && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_metadata.work_tags.clone() } else { draft.theme_tags.clone() }; let candidate = PuzzleGeneratedImageCandidateRecord { candidate_id: candidate_id.clone(), image_src: persisted_upload.image_src, asset_id: persisted_upload.asset_id, prompt: image_prompt, actual_prompt: None, source_type: "uploaded".to_string(), selected: true, }; let candidates_json = serde_json::to_string(&vec![to_puzzle_generated_image_candidate( &candidate, )]) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图上传图候选序列化失败:{error}"), })) })?; let (saved_session, save_used_fallback) = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: compiled_session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json: levels_json_with_generated_name.clone(), candidates_json, saved_at_micros: current_utc_micros(), }) .await .map_err(map_puzzle_client_error) .map(|session| (session, false)) .or_else(|error| { if is_spacetimedb_connectivity_app_error(&error) { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图上传图草稿回写不可用,降级返回本地快照" ); let session = apply_generated_puzzle_candidates_to_session_snapshot( apply_generated_puzzle_levels_to_session_snapshot( apply_generated_puzzle_first_level_name_to_session_snapshot( compiled_session.clone(), target_level.level_id.as_str(), generated_level_name.as_str(), fallback_level_name.as_str(), now, ), updated_levels.clone(), now, ), target_level.level_id.as_str(), vec![candidate.clone()], reference_image_src, now, ); Ok((session, true)) } else { Err(error) } })?; let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); match state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: owner_user_id.clone(), work_title, work_description: work_description.clone(), level_name: generated_level_name.clone(), summary: work_description, theme_tags, cover_image_src: ready_level.cover_image_src.clone(), cover_asset_id: ready_level.cover_asset_id.clone(), levels_json: levels_json_with_generated_name.clone(), updated_at_micros: now, }) .await .map_err(map_puzzle_client_error) { Ok(_) => {} Err(error) if is_spacetimedb_connectivity_app_error(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照" ); } Err(error) => return Err(error), } let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( saved_session, &generated_metadata, fallback_level_name.as_str(), now, ); if save_used_fallback { return Ok(saved_session); } match state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id, owner_user_id, level_id: Some(target_level.level_id), candidate_id, selected_at_micros: current_utc_micros(), }) .await { Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( session, &generated_metadata, fallback_level_name.as_str(), now, )), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %saved_session.session_id, error = %error, "拼图上传图选定回写因 SpacetimeDB 连接不可用而降级使用已保存快照" ); Ok(saved_session) } Err(error) => Err(map_puzzle_client_error(error)), } } fn apply_generated_puzzle_candidates_to_session_snapshot( mut session: PuzzleAgentSessionRecord, target_level_id: &str, candidates: Vec, picture_reference: Option<&str>, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; let Some(target_index) = draft .levels .iter() .position(|level| level.level_id == target_level_id) .or_else(|| (!draft.levels.is_empty()).then_some(0)) else { return session; }; let mut candidates = candidates .into_iter() .take(1) .map(|mut candidate| { candidate.selected = true; candidate }) .collect::>(); let Some(selected) = candidates.first().cloned() else { return session; }; let level = &mut draft.levels[target_index]; level.candidates.clear(); level.candidates.append(&mut candidates); level.selected_candidate_id = Some(selected.candidate_id.clone()); level.cover_image_src = Some(selected.image_src.clone()); level.cover_asset_id = Some(selected.asset_id.clone()); if let Some(picture_reference) = picture_reference .map(str::trim) .filter(|value| !value.is_empty()) { level.picture_reference = Some(picture_reference.to_string()); } level.generation_status = "ready".to_string(); if target_index == 0 { sync_puzzle_primary_draft_fields_from_level(draft); } session.progress_percent = session.progress_percent.max(94); session.stage = "ready_to_publish".to_string(); session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); session.updated_at = format_timestamp_micros(updated_at_micros); session } fn apply_generated_puzzle_levels_to_session_snapshot( mut session: PuzzleAgentSessionRecord, levels: Vec, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; if levels.is_empty() { return session; } draft.levels = levels; sync_puzzle_primary_draft_fields_from_level(draft); session.updated_at = format_timestamp_micros(updated_at_micros); session } fn apply_generated_puzzle_first_level_name_to_session_snapshot( mut session: PuzzleAgentSessionRecord, target_level_id: &str, level_name: &str, previous_level_name: &str, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; let normalized_name = level_name.trim(); if normalized_name.is_empty() { return session; } let Some(target_index) = draft .levels .iter() .position(|level| level.level_id == target_level_id) .or_else(|| (!draft.levels.is_empty()).then_some(0)) else { return session; }; draft.levels[target_index].level_name = normalized_name.to_string(); let should_default_work_title = draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); if target_index == 0 && should_default_work_title { draft.work_title = normalized_name.to_string(); } sync_puzzle_primary_draft_fields_from_level(draft); session.updated_at = format_timestamp_micros(updated_at_micros); session } fn apply_generated_puzzle_initial_metadata_to_session_snapshot( mut session: PuzzleAgentSessionRecord, metadata: &PuzzleLevelNaming, previous_level_name: &str, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; apply_generated_puzzle_initial_metadata_to_draft( draft, metadata, previous_level_name, updated_at_micros, ); session.updated_at = format_timestamp_micros(updated_at_micros); session } fn apply_generated_puzzle_initial_metadata_to_draft( draft: &mut PuzzleResultDraftRecord, metadata: &PuzzleLevelNaming, previous_level_name: &str, _updated_at_micros: i64, ) { let should_default_work_title = draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); if should_default_work_title { draft.work_title = metadata.level_name.clone(); } if draft.work_description.trim().is_empty() && let Some(description) = metadata.work_description.as_ref() { draft.work_description = description.clone(); draft.summary = description.clone(); } if draft.theme_tags.is_empty() && metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { draft.theme_tags = metadata.work_tags.clone(); } sync_puzzle_primary_draft_fields_from_level(draft); } fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) { let Some(primary_level) = draft.levels.first() else { return; }; draft.level_name = primary_level.level_name.clone(); draft.candidates = primary_level.candidates.clone(); draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); draft.cover_image_src = primary_level.cover_image_src.clone(); draft.cover_asset_id = primary_level.cover_asset_id.clone(); draft.generation_status = primary_level.generation_status.clone(); draft.summary = draft.work_description.clone(); if draft.form_draft.is_some() { draft.form_draft = Some(PuzzleFormDraftRecord { work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()), work_description: (!draft.work_description.trim().is_empty()) .then_some(draft.work_description.clone()), picture_description: (!primary_level.picture_description.trim().is_empty()) .then_some(primary_level.picture_description.clone()), }); } } fn replace_puzzle_session_draft_snapshot( mut session: PuzzleAgentSessionRecord, draft: PuzzleResultDraftRecord, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { session.draft = Some(draft); session.updated_at = format_timestamp_micros(updated_at_micros); session } fn apply_generated_puzzle_ui_background_to_session_snapshot( mut session: PuzzleAgentSessionRecord, target_level_id: &str, prompt: String, image_src: String, image_object_key: Option, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; let Some(target_index) = draft .levels .iter() .position(|level| level.level_id == target_level_id) .or_else(|| (!draft.levels.is_empty()).then_some(0)) else { return session; }; let level = &mut draft.levels[target_index]; level.ui_background_prompt = Some(prompt); level.ui_background_image_src = Some(image_src); level.ui_background_image_object_key = image_object_key; if target_index == 0 { sync_puzzle_primary_draft_fields_from_level(draft); } session.progress_percent = session.progress_percent.max(96); session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string()); session.updated_at = format_timestamp_micros(updated_at_micros); session } mod tags; use tags::*; fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError { if error.code() == "UPSTREAM_ERROR" { let body_text = error.body_text(); return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图图片生成失败:{body_text}"), })); } error } fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool { error.status_code() == StatusCode::GATEWAY_TIMEOUT || is_puzzle_request_timeout_message(error.body_text().as_str()) } async fn generate_puzzle_image_candidates( state: &AppState, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, reference_image_src: Option<&str>, use_reference_image_edit: bool, image_model: Option<&str>, candidate_count: u32, candidate_start_index: usize, ) -> Result, AppError> { let total_started_at = Instant::now(); let count = candidate_count.clamp(1, 1); let resolved_model = resolve_puzzle_image_model(image_model); let http_client = build_puzzle_image_http_client(state, resolved_model)?; let has_reference_image = has_puzzle_reference_image(reference_image_src); let should_use_reference_image_edit = should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit); let actual_prompt = build_puzzle_vector_engine_generation_prompt( build_puzzle_image_prompt(level_name, prompt).as_str(), should_use_reference_image_edit, ); tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, prompt_chars = prompt.chars().count(), actual_prompt_chars = actual_prompt.chars().count(), has_reference_image, use_reference_image_edit = should_use_reference_image_edit, "拼图图片生成请求已准备" ); let reference_image_started_at = Instant::now(); let reference_image = match reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) .filter(|_| should_use_reference_image_edit) { Some(source) => { let resolved = resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, reference_mime = %resolved.mime_type, reference_bytes = resolved.bytes_len, elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, "拼图参考图解析完成" ); Some(resolved) } None => None, }; if !should_use_reference_image_edit { tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, has_reference_image, use_reference_image_edit = should_use_reference_image_edit, elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, "拼图参考图解析跳过" ); } // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let settings = require_puzzle_vector_engine_settings(state)?; let vector_engine_started_at = Instant::now(); let generated = if should_use_reference_image_edit { let reference_image = reference_image.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", "message": "AI 重绘需要提供参考图。", })) })?; let edit_result = create_puzzle_vector_engine_image_edit( &http_client, &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, reference_image, ) .await; match edit_result { Ok(generated) => Ok(generated), Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => { tracing::warn!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, reference_mime = %reference_image.mime_type, reference_bytes = reference_image.bytes_len, error = %error, "拼图参考图编辑接口超时,降级为带参考图的生成接口" ); create_puzzle_vector_engine_image_generation( &http_client, &settings, resolved_model, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, Some(reference_image), ) .await } Err(error) => Err(error), } } else { create_puzzle_vector_engine_image_generation( &http_client, &settings, resolved_model, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, None, ) .await } .map_err(map_puzzle_generation_endpoint_error)?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, generated_image_count = generated.images.len(), elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64, "拼图 VectorEngine 生图与下载完成" ); let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { let candidate_id = format!( "{session_id}-candidate-{}", candidate_start_index + index + 1 ); let downloaded_image = image.clone(); let persist_started_at = Instant::now(); let asset = persist_puzzle_generated_asset( state, owner_user_id, session_id, level_name, candidate_id.as_str(), generated.task_id.as_str(), image, current_utc_micros(), ) .await .map_err(map_puzzle_generation_endpoint_error)?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, candidate_id = %candidate_id, image_bytes = downloaded_image.bytes.len(), image_mime = %downloaded_image.mime_type, elapsed_ms = persist_started_at.elapsed().as_millis() as u64, "拼图生成图片已写入 OSS 与资产索引" ); items.push(GeneratedPuzzleImageCandidate { record: PuzzleGeneratedImageCandidateRecord { candidate_id, image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), actual_prompt: Some(actual_prompt.clone()), source_type: resolved_model.candidate_source_type().to_string(), // 单图生成结果总是直接成为当前正式图。 selected: index == 0, }, downloaded_image, }); } tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, candidate_count = items.len(), has_reference_image, elapsed_ms = total_started_at.elapsed().as_millis() as u64, "拼图图片候选生成完成" ); Ok(items) } async fn generate_puzzle_ui_background_image( state: &AppState, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, ) -> Result { let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, &settings, build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(), Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"), "9:16", 1, &[], "拼图 UI 背景图生成失败", ) .await?; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "拼图 UI 背景图生成失败:未返回图片", })) })?; persist_puzzle_ui_background_image( state, owner_user_id, session_id, level_name, generated.task_id.as_str(), image, ) .await } #[cfg(test)] fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt: &str) -> String { build_puzzle_ui_background_generation_prompt(level_name, prompt) } #[cfg(test)] mod tests { use super::*; #[test] fn puzzle_generated_image_size_is_square_1_1() { assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024"); } #[test] fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() { let body = build_puzzle_vector_engine_image_request_body( PuzzleImageModel::Gemini31FlashPreview, "一只猫在雨夜灯牌下回头。", PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, 4, None, ); assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE); assert_eq!(body["n"], 1); assert!(body.get("official_fallback").is_none()); assert!(body.get("image").is_none()); assert!( body["prompt"] .as_str() .unwrap_or_default() .contains("文字水印") ); } #[test] fn puzzle_vector_engine_generation_fallback_includes_reference_image() { let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); let mut cursor = std::io::Cursor::new(Vec::new()); image .write_to(&mut cursor, ImageFormat::Png) .expect("test image should encode"); let reference_image = PuzzleResolvedReferenceImage { mime_type: "image/png".to_string(), bytes_len: cursor.get_ref().len(), bytes: cursor.into_inner(), }; let body = build_puzzle_vector_engine_image_request_body( PuzzleImageModel::GptImage2, "参考图里的小猫做成拼图主图。", PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, 1, Some(&reference_image), ); let images = body["image"] .as_array() .expect("fallback generation should include reference image array"); assert_eq!(images.len(), 1); assert!( images[0] .as_str() .unwrap_or_default() .starts_with("data:image/png;base64,") ); } #[test] fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() { let settings = PuzzleVectorEngineSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), }; assert_eq!( puzzle_vector_engine_images_edit_url(&settings), "https://vector.example/v1/images/edits" ); } #[test] fn puzzle_vector_engine_edit_response_decodes_b64_image() { let images = puzzle_images_from_base64( "edit-1".to_string(), vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")], 1, ); assert_eq!(images.images.len(), 1); assert_eq!(images.images[0].mime_type, "image/png"); assert_eq!(images.images[0].extension, "png"); } #[test] fn puzzle_vector_engine_prompt_strongly_uses_reference_image() { let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true); assert!(prompt.contains("参考图作为第一优先级")); assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围")); assert!(prompt.contains("请生成雨夜猫街。")); } #[test] fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() { let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false); assert_eq!(prompt, "请生成雨夜猫街。"); } #[test] fn puzzle_reference_image_edit_requires_ai_redraw() { assert!(!should_use_puzzle_reference_image_edit(None, true)); assert!(!should_use_puzzle_reference_image_edit( Some("data:image/png;base64,abcd"), false )); assert!(should_use_puzzle_reference_image_edit( Some("data:image/png;base64,abcd"), true )); } #[test] fn puzzle_reference_image_sources_are_deduped_and_limited() { let sources = collect_puzzle_reference_image_sources( Some("data:image/png;base64,a"), &[ "data:image/png;base64,a".to_string(), "data:image/png;base64,b".to_string(), "data:image/png;base64,c".to_string(), "data:image/png;base64,d".to_string(), "data:image/png;base64,e".to_string(), "data:image/png;base64,f".to_string(), ], ); assert_eq!(sources.len(), 5); assert_eq!(sources[0], "data:image/png;base64,a"); assert_eq!(sources[1], "data:image/png;base64,b"); assert!(!sources.contains(&"data:image/png;base64,f".to_string())); } #[test] fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { let error = map_puzzle_vector_engine_request_error( "创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(), ); let response = error.into_response(); assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); } #[test] fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() { let error = map_puzzle_vector_engine_upstream_error( reqwest::StatusCode::GATEWAY_TIMEOUT, r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, "创建拼图 VectorEngine 图片编辑任务失败", ); let response = error.into_response(); assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); } #[test] fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() { let timeout_error = map_puzzle_vector_engine_upstream_error( reqwest::StatusCode::GATEWAY_TIMEOUT, r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, "创建拼图 VectorEngine 图片编辑任务失败", ); assert!(should_fallback_puzzle_reference_edit_to_generation( &timeout_error )); let auth_error = map_puzzle_vector_engine_upstream_error( reqwest::StatusCode::UNAUTHORIZED, r#"{"error":{"message":"invalid api key"}}"#, "创建拼图 VectorEngine 图片编辑任务失败", ); assert!(!should_fallback_puzzle_reference_edit_to_generation( &auth_error )); } #[test] fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() { let error = match reqwest::Client::new().get("http://[::1").build() { Ok(_) => panic!("invalid url should fail request build"), Err(error) => error, }; let app_error = map_puzzle_vector_engine_reqwest_error( "创建拼图 VectorEngine 图片编辑任务失败", "https://api.vectorengine.ai/v1/images/edits", error, ); let response = app_error.into_response(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } #[test] fn puzzle_compile_error_preserves_vector_engine_unavailable_status() { let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( "VECTOR_ENGINE_API_KEY 未配置".to_string(), )); let response = error.into_response(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); } #[tokio::test] async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() { let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( "APIMart 图片生成密钥未配置".to_string(), )); let response = error.into_response(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = response.into_body(); let bytes = axum::body::to_bytes(body, usize::MAX) .await .expect("body bytes should read"); let payload: Value = serde_json::from_slice(&bytes).expect("error response should be valid json"); assert_eq!( payload["error"]["details"]["provider"], Value::String(VECTOR_ENGINE_PROVIDER.to_string()) ); assert_eq!( payload["error"]["details"]["message"], Value::String("VectorEngine 图片生成密钥未配置".to_string()) ); } #[test] fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { let levels_json = serde_json::to_string(&vec![json!({ "level_id": "puzzle-level-1", "level_name": "雨夜猫街", "picture_description": "一只猫在雨夜灯牌下回头。", "candidates": [], "selected_candidate_id": null, "cover_image_src": null, "cover_asset_id": null, "generation_status": "idle", })]) .expect("levels json"); let payload = ExecutePuzzleAgentActionRequest { action: "generate_puzzle_images".to_string(), prompt_text: None, reference_image_src: None, reference_image_srcs: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), candidate_id: None, level_id: Some("puzzle-level-1".to_string()), work_title: Some("暖灯猫街作品".to_string()), work_description: Some("一套雨夜猫街主题拼图。".to_string()), picture_description: None, level_name: None, summary: Some("当前关卡画面。".to_string()), theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]), levels_json: Some(levels_json.clone()), }; let session = build_puzzle_session_snapshot_from_action_payload( "puzzle-session-1", &payload, Some(levels_json.as_str()), 1_713_686_401_234_567, ) .expect("fallback session"); let draft = session.draft.expect("draft"); assert_eq!(session.stage, "ready_to_publish"); assert_eq!(draft.work_title, "暖灯猫街作品"); assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]); assert_eq!(draft.levels[0].level_id, "puzzle-level-1"); assert_eq!( draft.levels[0].picture_description, "一只猫在雨夜灯牌下回头。" ); } #[test] fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { assert_eq!( parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#), Some("雨夜猫街".to_string()) ); assert_eq!( parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"), Some("暖灯猫街".to_string()) ); assert_eq!( parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#), Some("雨夜猫街".to_string()) ); assert_eq!( parse_puzzle_first_level_name_from_text(r#"{"levelNam"#), None ); } #[test] fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() { let naming = parse_puzzle_level_naming_from_text( r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#, ) .expect("naming should parse"); assert_eq!(naming.level_name, "雨夜猫街"); assert_eq!( naming.work_description.as_deref(), Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图") ); assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT); assert!(naming.work_tags.contains(&"雨夜".to_string())); assert!(naming.work_tags.contains(&"猫咪".to_string())); assert!(naming.work_tags.contains(&"灯牌".to_string())); assert_eq!( naming.ui_background_prompt.as_deref(), Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次") ); } #[test] fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() { let naming = parse_puzzle_level_naming_from_text( r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景,中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印,保留暖色灯光"}"#, ) .expect("naming should parse"); let prompt = naming .ui_background_prompt .as_deref() .expect("prompt should parse"); assert!(!prompt.contains("拼图槽")); assert!(!prompt.contains("棋盘")); assert!(!prompt.contains("HUD")); assert!(!prompt.contains("按钮")); assert!(!prompt.contains("文字")); assert!(!prompt.contains("水印")); } #[test] fn puzzle_first_level_name_fallback_uses_picture_keywords() { assert_eq!( build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"), "雨夜猫街" ); assert_eq!( build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"), "奇境初见" ); } #[test] fn puzzle_level_name_image_data_url_downsizes_generated_image() { let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); let mut cursor = std::io::Cursor::new(Vec::new()); image .write_to(&mut cursor, ImageFormat::Png) .expect("test image should encode"); let downloaded = PuzzleDownloadedImage { extension: "png".to_string(), mime_type: "image/png".to_string(), bytes: cursor.into_inner(), }; let data_url = build_puzzle_level_name_image_data_url(&downloaded) .expect("data url should be generated"); assert!(data_url.starts_with("data:image/png;base64,")); assert!(data_url.len() > "data:image/png;base64,".len()); } #[test] fn puzzle_first_level_name_snapshot_defaults_work_title() { let levels_json = serde_json::to_string(&vec![json!({ "level_id": "puzzle-level-1", "level_name": "猫画面", "picture_description": "一只猫在雨夜灯牌下回头。", "candidates": [], "selected_candidate_id": null, "cover_image_src": null, "cover_asset_id": null, "generation_status": "idle", })]) .expect("levels json"); let payload = ExecutePuzzleAgentActionRequest { action: "generate_puzzle_images".to_string(), prompt_text: None, reference_image_src: None, reference_image_srcs: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), candidate_id: None, level_id: Some("puzzle-level-1".to_string()), work_title: Some("猫画面".to_string()), work_description: None, picture_description: None, level_name: None, summary: None, theme_tags: Some(vec![]), levels_json: Some(levels_json.clone()), }; let session = build_puzzle_session_snapshot_from_action_payload( "puzzle-session-1", &payload, Some(levels_json.as_str()), 1_713_686_401_234_567, ) .expect("fallback session"); let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot( session, "puzzle-level-1", "雨夜猫街", "猫画面", 1_713_686_401_234_568, ); let draft = renamed.draft.expect("draft"); assert_eq!(draft.level_name, "雨夜猫街"); assert_eq!(draft.work_title, "雨夜猫街"); assert_eq!(draft.levels[0].level_name, "雨夜猫街"); } #[test] fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() { let mut session = PuzzleAgentSessionRecord { session_id: "puzzle-session-1".to_string(), seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(), current_turn: 1, progress_percent: 94, stage: "ready_to_publish".to_string(), anchor_pack: test_puzzle_anchor_pack_record(), draft: Some(test_puzzle_draft_record()), messages: Vec::new(), last_assistant_reply: None, published_profile_id: None, suggested_actions: Vec::new(), result_preview: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }; { let draft = session.draft.as_mut().expect("draft"); draft.work_title = "猫画面".to_string(); draft.work_description = String::new(); draft.summary = String::new(); draft.theme_tags = Vec::new(); } let metadata = PuzzleLevelNaming { level_name: "雨夜猫街".to_string(), work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()), work_tags: vec![ "插画".to_string(), "灯牌".to_string(), "街角".to_string(), "猫咪".to_string(), "暖色".to_string(), "雨夜".to_string(), ], ui_background_prompt: None, }; let session = apply_generated_puzzle_initial_metadata_to_session_snapshot( session, &metadata, "猫画面", 1_713_686_401_234_568, ); let draft = session.draft.expect("draft"); assert_eq!(draft.work_title, "雨夜猫街"); assert_eq!( draft.work_description, "在湿润灯牌与猫影之间完成一套雨夜街角拼图" ); assert_eq!(draft.summary, draft.work_description); assert_eq!(draft.theme_tags, metadata.work_tags); } #[test] fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() { let level = PuzzleDraftLevelResponse { level_id: "puzzle-level-1".to_string(), level_name: "雨夜猫街".to_string(), picture_description: "一只猫在雨夜灯牌下回头。".to_string(), picture_reference: None, ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, background_music: Some(CreationAudioAsset { task_id: "suno-task-1".to_string(), provider: "vector-engine-suno".to_string(), asset_object_id: Some("assetobj_1".to_string()), asset_kind: Some("puzzle_background_music".to_string()), audio_src: "/generated-puzzle-assets/audio.mp3".to_string(), prompt: Some("轻快拼图音乐".to_string()), title: Some("雨夜猫街背景音乐".to_string()), updated_at: Some("2026-05-11T00:00:00Z".to_string()), }), candidates: vec![], selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "ready".to_string(), }; let request_context = RequestContext::new( "test-request".to_string(), "PUT /api/runtime/puzzle/works/test".to_string(), Duration::ZERO, false, ); let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) .expect("levels should serialize"); let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); assert_eq!( payload[0]["background_music"]["audio_src"], Value::String("/generated-puzzle-assets/audio.mp3".to_string()) ); assert!(payload[0]["background_music"].get("audioSrc").is_none()); let records = parse_puzzle_level_records_from_module_json(&levels_json) .expect("levels should map back into records"); let music = records[0] .background_music .as_ref() .expect("background music should exist"); assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3"); assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music")); let response = map_puzzle_draft_level_response(records[0].clone()); assert_eq!( response .background_music .as_ref() .map(|asset| asset.audio_src.as_str()), Some("/generated-puzzle-assets/audio.mp3") ); } #[test] fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() { let level = PuzzleDraftLevelResponse { level_id: "puzzle-level-1".to_string(), level_name: "雨夜猫街".to_string(), picture_description: "一只猫在雨夜灯牌下回头。".to_string(), picture_reference: None, ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()), ui_background_image_src: Some( "/generated-puzzle-assets/session/ui/background.png".to_string(), ), ui_background_image_object_key: Some( "generated-puzzle-assets/session/ui/background.png".to_string(), ), background_music: None, candidates: vec![], selected_candidate_id: None, cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), cover_asset_id: Some("asset-1".to_string()), generation_status: "ready".to_string(), }; let request_context = RequestContext::new( "test-request".to_string(), "PUT /api/runtime/puzzle/works/test".to_string(), Duration::ZERO, false, ); let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) .expect("levels should serialize"); let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); assert_eq!( payload[0]["ui_background_prompt"], Value::String("雨夜猫街竖屏拼图UI背景".to_string()) ); assert!(payload[0].get("uiBackgroundPrompt").is_none()); let records = parse_puzzle_level_records_from_module_json(&levels_json) .expect("levels should map back into records"); assert_eq!( records[0].ui_background_image_src.as_deref(), Some("/generated-puzzle-assets/session/ui/background.png") ); let response = map_puzzle_draft_level_response(records[0].clone()); assert_eq!( response.ui_background_image_object_key.as_deref(), Some("generated-puzzle-assets/session/ui/background.png") ); } #[test] fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { let state = AppState::new(crate::config::AppConfig::default()).expect("state should build"); let level = PuzzleDraftLevelRecord { level_id: "puzzle-level-1".to_string(), level_name: "雨夜猫街".to_string(), picture_description: "一只猫在雨夜灯牌下回头。".to_string(), picture_reference: None, ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, background_music: None, candidates: vec![PuzzleGeneratedImageCandidateRecord { candidate_id: "candidate-1".to_string(), image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(), asset_id: "asset-1".to_string(), prompt: "雨夜猫街".to_string(), actual_prompt: None, source_type: "generated".to_string(), selected: true, }], selected_candidate_id: Some("candidate-1".to_string()), cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), cover_asset_id: Some("asset-1".to_string()), generation_status: "ready".to_string(), }; let response = map_puzzle_work_summary_response( &state, PuzzleWorkProfileRecord { work_id: "puzzle-work-1".to_string(), profile_id: "puzzle-profile-1".to_string(), owner_user_id: "user-1".to_string(), source_session_id: Some("puzzle-session-1".to_string()), author_display_name: "玩家".to_string(), work_title: "雨夜猫街".to_string(), work_description: "一只猫在雨夜灯牌下回头。".to_string(), level_name: "雨夜猫街".to_string(), summary: "一只猫在雨夜灯牌下回头。".to_string(), theme_tags: vec!["猫".to_string()], cover_image_src: None, cover_asset_id: None, publication_status: "draft".to_string(), updated_at: "2026-05-08T00:00:00.000Z".to_string(), published_at: None, play_count: 0, remix_count: 0, like_count: 0, recent_play_count_7d: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, publish_ready: false, anchor_pack: test_puzzle_anchor_pack_record(), levels: vec![level], }, ); assert_eq!(response.levels.len(), 1); assert_eq!(response.generation_status.as_deref(), Some("ready")); assert_eq!( response.levels[0].cover_image_src.as_deref(), Some("/generated-puzzle-assets/session/cover.png") ); assert_eq!( response.levels[0].candidates[0].image_src, "/generated-puzzle-assets/session/candidate-1.png" ); } #[test] fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() { let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景"); assert!(prompt.contains("9:16")); assert!(prompt.contains("纯背景图")); assert!(prompt.contains("不得出现拼图槽")); assert!(prompt.contains("默认拼图槽")); assert!(prompt.contains("文字")); } #[test] fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() { let mut draft = test_puzzle_draft_record(); draft.work_title = "模板作品名".to_string(); draft.work_description = "模板作品描述".to_string(); let mut target_level = draft.levels[0].clone(); target_level.level_name = "雨夜猫街".to_string(); let ai_prompt = "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"; target_level.ui_background_prompt = Some(ai_prompt.to_string()); let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level); assert_eq!(prompt, ai_prompt); assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER)); } #[test] fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() { let draft = test_puzzle_draft_record(); let target_level = draft.levels[0].clone(); let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level); assert!(prompt.contains("雨夜猫街")); assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER)); } #[test] fn puzzle_ui_background_initial_attach_updates_first_level_fields() { let draft = test_puzzle_draft_record(); let generated = GeneratedPuzzleUiBackgroundResponse { image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(), object_key: "generated-puzzle-assets/session/ui/background.png".to_string(), }; let mut levels = draft.levels.clone(); attach_puzzle_level_ui_background( &mut levels, "puzzle-level-1", "雨夜猫街移动端拼图UI背景".to_string(), generated, ); assert_eq!( levels[0].ui_background_prompt.as_deref(), Some("雨夜猫街移动端拼图UI背景") ); assert_eq!( levels[0].ui_background_image_src.as_deref(), Some("/generated-puzzle-assets/session/ui/background.png") ); assert_eq!( levels[0].ui_background_image_object_key.as_deref(), Some("generated-puzzle-assets/session/ui/background.png") ); } #[test] fn puzzle_initial_draft_assets_must_include_ui_background() { let mut draft = test_puzzle_draft_record(); let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) .expect_err("缺少自动生成资产时不能把草稿标记为完成"); assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY); assert!(missing_all.body_text().contains("UI背景图")); draft.levels[0].ui_background_image_src = Some("/generated-puzzle-assets/session/ui/background.png".to_string()); ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) .expect("UI 背景存在时即可完成自动草稿资源检查"); } fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord { let item = PuzzleAnchorItemRecord { key: "visualSubject".to_string(), label: "画面".to_string(), value: "雨夜猫街".to_string(), status: "confirmed".to_string(), }; PuzzleAnchorPackRecord { theme_promise: item.clone(), visual_subject: item.clone(), visual_mood: item.clone(), composition_hooks: item.clone(), tags_and_forbidden: item, } } fn test_puzzle_draft_record() -> PuzzleResultDraftRecord { let anchor_pack = test_puzzle_anchor_pack_record(); PuzzleResultDraftRecord { work_title: "雨夜猫街".to_string(), work_description: "一只猫在雨夜灯牌下回头。".to_string(), level_name: "猫画面".to_string(), summary: "一只猫在雨夜灯牌下回头。".to_string(), theme_tags: vec![], forbidden_directives: vec![], creator_intent: None, anchor_pack, candidates: vec![], selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), levels: vec![PuzzleDraftLevelRecord { level_id: "puzzle-level-1".to_string(), level_name: "猫画面".to_string(), picture_description: "一只猫在雨夜灯牌下回头。".to_string(), picture_reference: None, ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, background_music: None, candidates: vec![], selected_candidate_id: None, cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), }], form_draft: None, } } #[test] fn puzzle_primary_level_update_preserves_reference_for_regeneration() { let draft = test_puzzle_draft_record(); let mut target_level = draft.levels[0].clone(); target_level.level_name = "雨夜猫街".to_string(); let levels = build_puzzle_levels_with_primary_update( &draft, &target_level, Some("data:image/png;base64,abcd"), ); assert_eq!(levels[0].level_name, "雨夜猫街"); assert_eq!( levels[0].picture_reference.as_deref(), Some("data:image/png;base64,abcd") ); } #[test] fn puzzle_generated_fallback_snapshot_preserves_picture_reference() { let anchor_pack = test_puzzle_anchor_pack_record(); let session = PuzzleAgentSessionRecord { session_id: "puzzle-session-1".to_string(), seed_text: "雨夜猫街".to_string(), current_turn: 1, progress_percent: 0, stage: "draft_ready".to_string(), anchor_pack: anchor_pack.clone(), draft: Some(test_puzzle_draft_record()), messages: Vec::new(), last_assistant_reply: None, published_profile_id: None, suggested_actions: Vec::new(), result_preview: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }; let candidate = PuzzleGeneratedImageCandidateRecord { candidate_id: "puzzle-session-1-candidate-1".to_string(), image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(), asset_id: "puzzle-cover-1".to_string(), prompt: "雨夜猫街".to_string(), actual_prompt: Some("雨夜猫街".to_string()), source_type: "generated:gpt-image-2".to_string(), selected: true, }; let session = apply_generated_puzzle_candidates_to_session_snapshot( session, "puzzle-level-1", vec![candidate], Some("data:image/png;base64,abcd"), 1_713_686_401_234_568, ); let draft = session.draft.expect("draft"); assert_eq!( draft.levels[0].picture_reference.as_deref(), Some("data:image/png;base64,abcd") ); } #[test] fn freeze_boundary_sync_only_matches_freeze_invalid_operation() { let invalid_operation = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": "操作不合法", })); let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": "泥点余额不足", })); assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true)); assert!(!should_sync_puzzle_freeze_boundary( &invalid_operation, false )); assert!(!should_sync_puzzle_freeze_boundary(&other_error, true)); } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PuzzleImageModel { GptImage2, Gemini31FlashPreview, } impl PuzzleImageModel { fn provider_name(self) -> &'static str { VECTOR_ENGINE_PROVIDER } fn request_model_name(self) -> &'static str { VECTOR_ENGINE_GPT_IMAGE_2_MODEL } fn candidate_source_type(self) -> &'static str { match self { Self::GptImage2 => "generated:gpt-image-2", Self::Gemini31FlashPreview => "generated:nanobanana2", } } } struct PuzzleVectorEngineSettings { base_url: String, api_key: String, } struct PuzzleGeneratedImages { task_id: String, images: Vec, } struct PuzzleResolvedReferenceImage { mime_type: String, bytes_len: usize, bytes: Vec, } struct GeneratedPuzzleImageCandidate { record: PuzzleGeneratedImageCandidateRecord, downloaded_image: PuzzleDownloadedImage, } impl GeneratedPuzzleImageCandidate { fn into_record(self) -> PuzzleGeneratedImageCandidateRecord { self.record } } 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)] struct PuzzleDownloadedImage { extension: String, mime_type: String, bytes: Vec, } struct ParsedPuzzleImageDataUrl { mime_type: String, bytes: Vec, } struct GeneratedPuzzleAssetResponse { image_src: String, asset_id: String, } struct GeneratedPuzzleUiBackgroundResponse { image_src: String, object_key: String, } 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, } } 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(), }) } 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}"), })) }) } 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, } } 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 图片生成未返回图片地址", })), ) } 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 图片编辑未返回图片", })), ) } 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) } 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, ) } 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) )) } 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()) } 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) } 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 } 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() } 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) } 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}") } 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) } } 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) } } 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 }) } 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(), }) } 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(), }) } 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, }) } 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, }) } 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)) } 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()), ]) } 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()), ]) } 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}"), })) }) } 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, }) } 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) } 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 } fn extract_puzzle_b64_images(payload: &Value) -> Vec { let mut values = Vec::new(); collect_puzzle_strings_by_key(payload, "b64_json", &mut values); values } 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 } } 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, }) } 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() } 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); } } _ => {} } } 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); } } _ => {} } } 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() } 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(), } } fn puzzle_mime_to_extension(mime_type: &str) -> &str { match mime_type { "image/png" => "png", "image/webp" => "webp", "image/gif" => "gif", _ => "jpg", } } 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, })) } 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, })) } 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, })) } fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String { error .to_string() .split_whitespace() .collect::>() .join(" ") } 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 字段中的底层网络错误" } 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") } 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, })) } 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() } 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::() ) } fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } 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(), })) } 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 } } 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()) }