use std::{ collections::BTreeMap, 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 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::{ PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus, PuzzleWorkProfile, resolve_puzzle_level_config, }; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, }; use serde_json::{Map, Value, json}; use shared_contracts::{ puzzle_agent::{ CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest, PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse, PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse, PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse, PuzzleCreatorIntentResponse, PuzzleDraftLevelResponse, PuzzleFormDraftResponse, PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse, PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest, }, puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse, }, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, http_error::AppError, 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}, }, puzzle_agent_turn::{ PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_puzzle_agent_turn, }, request_context::RequestContext, state::AppState, work_author::resolve_work_author_by_user_id, }; 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_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; 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 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, prompt_chars = payload .prompt_text .as_deref() .map(|value| value.chars().count()) .unwrap_or(0), has_reference_image = payload .reference_image_src .as_deref() .map(|value| !value.trim().is_empty()) .unwrap_or(false), "拼图 Agent action 开始执行" ); let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { 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 = execute_billable_asset_operation( &state, &owner_user_id, "puzzle_initial_image", &billing_asset_id, async { compile_puzzle_draft_with_initial_cover( &state, compile_session_id.clone(), owner_user_id.clone(), prompt_text, payload.reference_image_src.as_deref(), now, ) .await .map_err(map_puzzle_compile_error) }, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "compile_puzzle_draft", "完整拼图草稿", "已编译草稿、生成拼图图片并应用为正式图。", session, ) } "save_puzzle_form_draft" => { let seed_text = build_puzzle_form_seed_text_from_parts( payload.work_title.as_deref(), payload.work_description.as_deref(), 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) => { // 中文注释:Maincloud 旧 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( &state, &owner_user_id, "puzzle_generated_image", &billing_asset_id, async { let levels_json = levels_json?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(map_puzzle_client_error)?; 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 prompt = resolve_puzzle_level_image_prompt( payload.prompt_text.as_deref(), &target_level.picture_description, ); // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 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, payload.reference_image_src.as_deref(), candidate_count, candidate_start_index, ) .await .map_err(|message| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })) })?; let candidates_json = serde_json::to_string( &candidates .iter() .map(to_puzzle_generated_image_candidate) .collect::>(), ) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图候选图序列化失败:{error}"), })) })?; state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id, owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id), levels_json, candidates_json, saved_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) }); ( "generate_puzzle_images", "拼图图片生成", "已生成并替换当前拼图图片。", 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, Response> { 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), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryResponse { items: items .into_iter() .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) } 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, level_id: payload.level_id, started_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 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 advance_puzzle_next_level( 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() .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), 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 advance_local_puzzle_next_level( 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(), })), ) })?; let owner_user_id = authenticated.claims().user_id().to_string(); let run = build_local_next_puzzle_run(&state, payload, owner_user_id.as_str()) .await .map_err(|error| 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), }, )) } fn map_puzzle_agent_session_response( session: PuzzleAgentSessionRecord, ) -> PuzzleAgentSessionSnapshotResponse { PuzzleAgentSessionSnapshotResponse { session_id: session.session_id, seed_text: session.seed_text, current_turn: session.current_turn, progress_percent: session.progress_percent, stage: session.stage, anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack), draft: session.draft.map(map_puzzle_result_draft_response), messages: session .messages .into_iter() .map(map_puzzle_agent_message_response) .collect(), last_assistant_reply: session.last_assistant_reply, published_profile_id: session.published_profile_id, suggested_actions: session .suggested_actions .into_iter() .map(map_puzzle_suggested_action_response) .collect(), result_preview: session .result_preview .map(map_puzzle_result_preview_response), updated_at: session.updated_at, } } fn map_puzzle_anchor_pack_response( anchor_pack: PuzzleAnchorPackRecord, ) -> PuzzleAnchorPackResponse { PuzzleAnchorPackResponse { theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise), visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject), visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood), composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks), tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden), } } fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse { PuzzleAnchorItemResponse { key: anchor.key, label: anchor.label, value: anchor.value, status: anchor.status, } } fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse { PuzzleResultDraftResponse { work_title: draft.work_title, work_description: draft.work_description, level_name: draft.level_name, summary: draft.summary, theme_tags: draft.theme_tags, forbidden_directives: draft.forbidden_directives, creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response), anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack), candidates: draft .candidates .into_iter() .map(map_puzzle_generated_image_candidate_response) .collect(), selected_candidate_id: draft.selected_candidate_id, cover_image_src: draft.cover_image_src, cover_asset_id: draft.cover_asset_id, generation_status: draft.generation_status, levels: draft .levels .into_iter() .map(map_puzzle_draft_level_response) .collect(), form_draft: draft.form_draft.map(map_puzzle_form_draft_response), } } fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse { PuzzleFormDraftResponse { work_title: draft.work_title, work_description: draft.work_description, picture_description: draft.picture_description, } } fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse { PuzzleDraftLevelResponse { level_id: level.level_id, level_name: level.level_name, picture_description: level.picture_description, candidates: level .candidates .into_iter() .map(map_puzzle_generated_image_candidate_response) .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, } } fn map_puzzle_creator_intent_response( intent: PuzzleCreatorIntentRecord, ) -> PuzzleCreatorIntentResponse { PuzzleCreatorIntentResponse { source_mode: intent.source_mode, raw_messages_summary: intent.raw_messages_summary, theme_promise: intent.theme_promise, visual_subject: intent.visual_subject, visual_mood: intent.visual_mood, composition_hooks: intent.composition_hooks, theme_tags: intent.theme_tags, forbidden_directives: intent.forbidden_directives, } } fn map_puzzle_generated_image_candidate_response( candidate: PuzzleGeneratedImageCandidateRecord, ) -> PuzzleGeneratedImageCandidateResponse { PuzzleGeneratedImageCandidateResponse { 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, } } fn map_puzzle_agent_message_response( message: PuzzleAgentMessageRecord, ) -> PuzzleAgentMessageResponse { PuzzleAgentMessageResponse { id: message.message_id, role: message.role, kind: message.kind, text: message.text, created_at: message.created_at, } } fn map_puzzle_suggested_action_response( action: PuzzleAgentSuggestedActionRecord, ) -> PuzzleAgentSuggestedActionResponse { PuzzleAgentSuggestedActionResponse { id: action.action_id, action_type: action.action_type, label: action.label, } } fn map_puzzle_result_preview_response( preview: PuzzleResultPreviewRecord, ) -> PuzzleResultPreviewEnvelopeResponse { PuzzleResultPreviewEnvelopeResponse { draft: map_puzzle_result_draft_response(preview.draft), blockers: preview .blockers .into_iter() .map(map_puzzle_result_preview_blocker_response) .collect(), quality_findings: preview .quality_findings .into_iter() .map(map_puzzle_result_preview_finding_response) .collect(), publish_ready: preview.publish_ready, } } fn map_puzzle_result_preview_blocker_response( blocker: PuzzleResultPreviewBlockerRecord, ) -> PuzzleResultPreviewBlockerResponse { PuzzleResultPreviewBlockerResponse { id: blocker.blocker_id, code: blocker.code, message: blocker.message, } } fn map_puzzle_result_preview_finding_response( finding: PuzzleResultPreviewFindingRecord, ) -> PuzzleResultPreviewFindingResponse { PuzzleResultPreviewFindingResponse { id: finding.finding_id, severity: finding.severity, code: finding.code, message: finding.message, } } fn map_puzzle_work_summary_response( state: &AppState, item: PuzzleWorkProfileRecord, ) -> PuzzleWorkSummaryResponse { let author = resolve_work_author_by_user_id( state, &item.owner_user_id, Some(&item.author_display_name), None, ); PuzzleWorkSummaryResponse { work_id: item.work_id, profile_id: item.profile_id, owner_user_id: item.owner_user_id, source_session_id: item.source_session_id, author_display_name: author.display_name, work_title: item.work_title, work_description: item.work_description, level_name: item.level_name, summary: item.summary, theme_tags: item.theme_tags, cover_image_src: item.cover_image_src, cover_asset_id: item.cover_asset_id, publication_status: item.publication_status, updated_at: item.updated_at, published_at: item.published_at, play_count: item.play_count, remix_count: item.remix_count, like_count: item.like_count, recent_play_count_7d: item.recent_play_count_7d, point_incentive_total_half_points: item.point_incentive_total_half_points, point_incentive_claimed_points: item.point_incentive_claimed_points, point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0, point_incentive_claimable_points: item .point_incentive_total_half_points .saturating_div(2) .saturating_sub(item.point_incentive_claimed_points), publish_ready: item.publish_ready, levels: Vec::new(), } } fn map_puzzle_work_profile_response( state: &AppState, item: PuzzleWorkProfileRecord, ) -> PuzzleWorkProfileResponse { let mut summary = map_puzzle_work_summary_response(state, item.clone()); summary.levels = item .levels .into_iter() .map(map_puzzle_draft_level_response) .collect(); PuzzleWorkProfileResponse { summary, anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack), } } fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { PuzzleRunSnapshotResponse { run_id: run.run_id, entry_profile_id: run.entry_profile_id, cleared_level_count: run.cleared_level_count, current_level_index: run.current_level_index, current_grid_size: run.current_grid_size, played_profile_ids: run.played_profile_ids, previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_runtime_level_response), recommended_next_profile_id: run.recommended_next_profile_id, next_level_mode: run.next_level_mode, next_level_profile_id: run.next_level_profile_id, next_level_id: run.next_level_id, recommended_next_works: run .recommended_next_works .into_iter() .map(map_puzzle_recommended_next_work_response) .collect(), leaderboard_entries: run .leaderboard_entries .into_iter() .map(map_puzzle_leaderboard_entry_response) .collect(), } } fn map_puzzle_recommended_next_work_response( item: PuzzleRecommendedNextWorkRecord, ) -> PuzzleRecommendedNextWorkResponse { PuzzleRecommendedNextWorkResponse { profile_id: item.profile_id, level_name: item.level_name, author_display_name: item.author_display_name, theme_tags: item.theme_tags, cover_image_src: item.cover_image_src, similarity_score: item.similarity_score, } } async fn enrich_puzzle_run_author_name( state: &AppState, mut run: PuzzleRunRecord, ) -> PuzzleRunRecord { if let Some(level) = run.current_level.as_mut() { if let Ok(profile) = state .spacetime_client() .get_puzzle_gallery_detail(level.profile_id.clone()) .await { level.author_display_name = resolve_work_author_by_user_id( state, &profile.owner_user_id, Some(&profile.author_display_name), None, ) .display_name; } } run } fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRecord { PuzzleRunRecord { run_id: run.run_id, entry_profile_id: run.entry_profile_id, cleared_level_count: run.cleared_level_count, current_level_index: run.current_level_index, current_grid_size: run.current_grid_size, played_profile_ids: run.played_profile_ids, previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_level_request_record), recommended_next_profile_id: run.recommended_next_profile_id, next_level_mode: run.next_level_mode, next_level_profile_id: run.next_level_profile_id, next_level_id: run.next_level_id, recommended_next_works: run .recommended_next_works .into_iter() .map(map_puzzle_recommended_next_work_request_record) .collect(), leaderboard_entries: run .leaderboard_entries .into_iter() .map(map_puzzle_leaderboard_request_record) .collect(), } } fn map_puzzle_recommended_next_work_request_record( item: PuzzleRecommendedNextWorkResponse, ) -> PuzzleRecommendedNextWorkRecord { PuzzleRecommendedNextWorkRecord { profile_id: item.profile_id, level_name: item.level_name, author_display_name: item.author_display_name, theme_tags: item.theme_tags, cover_image_src: item.cover_image_src, similarity_score: item.similarity_score, } } fn map_puzzle_level_request_record( level: PuzzleRuntimeLevelSnapshotResponse, ) -> PuzzleRuntimeLevelRecord { PuzzleRuntimeLevelRecord { run_id: level.run_id, level_index: level.level_index, level_id: level.level_id, grid_size: level.grid_size, profile_id: level.profile_id, level_name: level.level_name, author_display_name: level.author_display_name, theme_tags: level.theme_tags, cover_image_src: level.cover_image_src, board: map_puzzle_board_request_record(level.board), status: level.status, started_at_ms: level.started_at_ms, cleared_at_ms: level.cleared_at_ms, elapsed_ms: level.elapsed_ms, time_limit_ms: level.time_limit_ms, remaining_ms: level.remaining_ms, paused_accumulated_ms: level.paused_accumulated_ms, pause_started_at_ms: level.pause_started_at_ms, freeze_accumulated_ms: level.freeze_accumulated_ms, freeze_started_at_ms: level.freeze_started_at_ms, freeze_until_ms: level.freeze_until_ms, leaderboard_entries: level .leaderboard_entries .into_iter() .map(map_puzzle_leaderboard_request_record) .collect(), } } fn map_puzzle_leaderboard_request_record( entry: PuzzleLeaderboardEntryResponse, ) -> PuzzleLeaderboardEntryRecord { PuzzleLeaderboardEntryRecord { rank: entry.rank, nickname: entry.nickname, elapsed_ms: entry.elapsed_ms, is_current_player: entry.is_current_player, } } fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> PuzzleBoardRecord { PuzzleBoardRecord { rows: board.rows, cols: board.cols, pieces: board .pieces .into_iter() .map(|piece| PuzzlePieceStateRecord { piece_id: piece.piece_id, correct_row: piece.correct_row, correct_col: piece.correct_col, current_row: piece.current_row, current_col: piece.current_col, merged_group_id: piece.merged_group_id, }) .collect(), merged_groups: board .merged_groups .into_iter() .map(|group| PuzzleMergedGroupRecord { group_id: group.group_id, piece_ids: group.piece_ids, occupied_cells: group .occupied_cells .into_iter() .map(|cell| PuzzleCellPositionRecord { row: cell.row, col: cell.col, }) .collect(), }) .collect(), selected_piece_id: board.selected_piece_id, all_tiles_resolved: board.all_tiles_resolved, } } fn map_puzzle_runtime_level_response( level: spacetime_client::PuzzleRuntimeLevelRecord, ) -> PuzzleRuntimeLevelSnapshotResponse { let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size); let time_limit_ms = if level.time_limit_ms == 0 { timer_defaults.time_limit_ms } else { level.time_limit_ms }; let remaining_ms = if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() { time_limit_ms } else { level.remaining_ms.min(time_limit_ms) }; PuzzleRuntimeLevelSnapshotResponse { run_id: level.run_id, level_index: level.level_index, level_id: level.level_id, grid_size: level.grid_size, profile_id: level.profile_id, level_name: level.level_name, author_display_name: level.author_display_name, theme_tags: level.theme_tags, cover_image_src: level.cover_image_src, board: map_puzzle_board_response(level.board), status: level.status, started_at_ms: level.started_at_ms, cleared_at_ms: level.cleared_at_ms, elapsed_ms: level.elapsed_ms, time_limit_ms, remaining_ms, paused_accumulated_ms: level.paused_accumulated_ms, pause_started_at_ms: level.pause_started_at_ms, freeze_accumulated_ms: level.freeze_accumulated_ms, freeze_started_at_ms: level.freeze_started_at_ms, freeze_until_ms: level.freeze_until_ms, leaderboard_entries: level .leaderboard_entries .into_iter() .map(map_puzzle_leaderboard_entry_response) .collect(), } } struct PuzzleRuntimeTimerResponseDefaults { time_limit_ms: u64, } fn build_puzzle_runtime_timer_response_defaults( level_index: u32, grid_size: u32, ) -> PuzzleRuntimeTimerResponseDefaults { let time_limit_ms = if level_index > 0 { module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index) } else { module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size) }; PuzzleRuntimeTimerResponseDefaults { time_limit_ms } } fn map_puzzle_leaderboard_entry_response( entry: PuzzleLeaderboardEntryRecord, ) -> PuzzleLeaderboardEntryResponse { PuzzleLeaderboardEntryResponse { rank: entry.rank, nickname: entry.nickname, elapsed_ms: entry.elapsed_ms, is_current_player: entry.is_current_player, } } fn map_puzzle_board_response( board: spacetime_client::PuzzleBoardRecord, ) -> PuzzleBoardSnapshotResponse { PuzzleBoardSnapshotResponse { rows: board.rows, cols: board.cols, pieces: board .pieces .into_iter() .map(|piece| PuzzlePieceStateResponse { piece_id: piece.piece_id, correct_row: piece.correct_row, correct_col: piece.correct_col, current_row: piece.current_row, current_col: piece.current_col, merged_group_id: piece.merged_group_id, }) .collect(), merged_groups: board .merged_groups .into_iter() .map(|group| PuzzleMergedGroupStateResponse { group_id: group.group_id, piece_ids: group.piece_ids, occupied_cells: group .occupied_cells .into_iter() .map(|cell| PuzzleCellPositionResponse { row: cell.row, col: cell.col, }) .collect(), }) .collect(), selected_piece_id: board.selected_piece_id, all_tiles_resolved: board.all_tiles_resolved, } } fn resolve_author_display_name( state: &AppState, authenticated: &AuthenticatedAccessToken, ) -> String { state .auth_user_service() .get_user_by_id(authenticated.claims().user_id()) .ok() .flatten() .map(|user| user.display_name) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "玩家".to_string()) } fn build_puzzle_welcome_text(seed_text: &str) -> String { if seed_text.trim().is_empty() { return "拼图创作信息已准备好。".to_string(); } "拼图创作信息已准备好。".to_string() } fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { title: payload .work_title .as_deref() .or(payload.seed_text.as_deref()), work_description: payload.work_description.as_deref(), picture_description: payload.picture_description.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( payload.work_title.as_deref(), payload.work_description.as_deref(), 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()); } // 中文注释:旧 Maincloud 缺自动保存 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, 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()) } 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, "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, "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}"), ) } 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>, now: i64, ) -> Result { let compiled_session = state .spacetime_client() .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) .await?; let draft = compiled_session .draft .clone() .ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?; let target_level = select_puzzle_level_for_api(&draft, None) .map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?; let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, &draft.summary, ); // 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。 let candidates = generate_puzzle_image_candidates( state, owner_user_id.as_str(), &compiled_session.session_id, &target_level.level_name, &image_prompt, reference_image_src, 1, target_level.candidates.len(), ) .await .map_err(SpacetimeClientError::Runtime)?; let selected_candidate_id = candidates .iter() .find(|candidate| candidate.selected) .or_else(|| candidates.first()) .map(|candidate| candidate.candidate_id.clone()) .ok_or_else(|| SpacetimeClientError::Runtime("拼图候选图生成结果为空".to_string()))?; let candidates_json = serde_json::to_string( &candidates .iter() .map(to_puzzle_generated_image_candidate) .collect::>(), ) .map_err(|error| SpacetimeClientError::Runtime(format!("拼图候选图序列化失败:{error}")))?; 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: None, candidates_json, saved_at_micros: current_utc_micros(), }) .await?; 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 } fn ensure_non_empty( request_context: &RequestContext, provider: &str, value: &str, field_name: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(puzzle_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": format!("{field_name} is required"), })), )); } Ok(()) } fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response { puzzle_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": message, })), ) } fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("当前模型不可用") || message.contains("生成失败") || message.contains("解析失败") || message.contains("缺少有效回复") => { StatusCode::BAD_GATEWAY } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool { is_freeze_time && error.body_text().contains("操作不合法") } fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool { matches!(error, SpacetimeClientError::Procedure(message) if message.contains("save_puzzle_form_draft") && (message.contains("No such procedure") || message.contains("不存在") || message.contains("does not exist") || message.contains("not found"))) } fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let message = error.to_string(); let provider = if message.contains("DashScope") || message.contains("dashscope") { "dashscope" } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { "puzzle-assets" } else { "spacetimedb" }; let status = if matches!(error, SpacetimeClientError::Runtime(_)) && (message.contains("生成") || message.contains("上游") || message.contains("DashScope") || message.contains("dashscope") || message.contains("参考图") || message.contains("图片") || message.contains("OSS") || message.contains("oss")) { StatusCode::BAD_GATEWAY } else { match &error { SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("当前模型不可用") || message.contains("生成失败") || message.contains("解析失败") || message.contains("缺少有效回复") => { StatusCode::BAD_GATEWAY } SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_GATEWAY, } }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": message, })) } fn puzzle_error_response( request_context: &RequestContext, provider: &str, error: AppError, ) -> Response { let mut response = error.into_response_with_context(Some(request_context)); response.headers_mut().insert( HeaderName::from_static("x-genarrative-provider"), header::HeaderValue::from_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static("puzzle")), ); response } fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result { Event::default() .event(event_name) .json_data(payload) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "sse", "message": format!("SSE payload 序列化失败:{error}"), })) }) } fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { match puzzle_sse_json_event(event_name, payload) { Ok(event) => event, Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()), } } fn puzzle_sse_error_event_message(message: String) -> Event { let payload = format!( "{{\"message\":{}}}", serde_json::to_string(&message) .unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string()) ); Event::default().event("error").data(payload) } fn map_puzzle_generation_app_error(error: AppError) -> String { let body_text = error.body_text(); if error.code() == "UPSTREAM_ERROR" { format!("拼图图片生成失败:{body_text}") } else { body_text } } 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>, candidate_count: u32, candidate_start_index: usize, ) -> Result, String> { let count = candidate_count.clamp(1, 1); let settings = require_puzzle_dashscope_settings(state).map_err(map_puzzle_generation_app_error)?; let http_client = build_puzzle_dashscope_http_client(&settings).map_err(map_puzzle_generation_app_error)?; let actual_prompt = build_puzzle_image_prompt(level_name, prompt); tracing::info!( provider = "dashscope", session_id, level_name, prompt_chars = prompt.chars().count(), actual_prompt_chars = actual_prompt.chars().count(), has_reference_image = reference_image_src .map(str::trim) .map(|value| !value.is_empty()) .unwrap_or(false), "拼图图片生成请求已准备" ); let reference_image = match reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) { Some(source) => Some( resolve_puzzle_reference_image_as_data_url(state, &http_client, source) .await .map_err(map_puzzle_generation_app_error)?, ), None => None, }; // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let generated = match reference_image.as_deref() { Some(reference_image) => { create_puzzle_image_to_image_generation( &http_client, &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_GENERATED_IMAGE_SIZE, count, reference_image, ) .await } None => { create_puzzle_text_to_image_generation( &http_client, &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_GENERATED_IMAGE_SIZE, count, ) .await } } .map_err(map_puzzle_generation_app_error)?; 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 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_app_error)?; items.push(PuzzleGeneratedImageCandidateResponse { candidate_id, image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), actual_prompt: Some(actual_prompt.clone()), source_type: "generated".to_string(), // 单图生成结果总是直接成为当前正式图。 selected: index == 0, }); } Ok(items .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()) } async fn build_local_next_puzzle_run( state: &AppState, payload: AdvanceLocalPuzzleNextLevelRequest, owner_user_id: &str, ) -> Result { let run = map_puzzle_run_request_record(payload.run); let current_level = run.current_level.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": "currentLevel is required", })) })?; if current_level.status != "cleared" { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": "current level is not cleared", })), ); } let source_session_id = payload.source_session_id.unwrap_or_default(); if let Some(next_run) = build_same_work_local_next_puzzle_run(state, &run, &source_session_id, owner_user_id) .await? { return Ok(next_run); } let current_work = fetch_local_current_work_detail(state, &run).await?; let similar_works = resolve_gallery_similar_puzzle_works(state, &run, current_work.as_ref()).await?; if !similar_works.is_empty() { return Ok(build_local_similar_works_handoff(run, similar_works)); } if source_session_id.trim().is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": "sourceSessionId is required when gallery has no next puzzle work", })), ); } let session = state .spacetime_client() .get_puzzle_agent_session(source_session_id, owner_user_id.to_string()) .await .map_err(map_puzzle_client_error)?; if let Some(candidate) = session .draft .as_ref() .and_then(|draft| pick_unused_puzzle_candidate(&draft.candidates, &run.played_profile_ids)) { return Ok(build_next_run_from_candidate(run, &session, candidate)); } let draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": "puzzle draft is required when gallery has no next puzzle work", })) })?; let candidates = generate_puzzle_image_candidates( state, owner_user_id, &session.session_id, &draft.level_name, &draft.summary, None, 1, draft.candidates.len(), ) .await .map_err(|message| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": message, })) })?; let candidates_json = serde_json::to_string( &candidates .iter() .map(to_puzzle_generated_image_candidate) .collect::>(), ) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": format!("拼图候选图序列化失败:{error}"), })) })?; let updated_session = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id, owner_user_id: owner_user_id.to_string(), level_id: None, levels_json: None, candidates_json, saved_at_micros: current_utc_micros(), }) .await .map_err(map_puzzle_client_error)?; let candidate = updated_session .draft .as_ref() .and_then(|draft| { draft .candidates .iter() .find(|candidate| !candidate.image_src.is_empty()) }) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": "现场生成后没有可用候选图", })) })?; Ok(build_next_run_from_candidate( run, &updated_session, candidate, )) } async fn build_same_work_local_next_puzzle_run( state: &AppState, run: &PuzzleRunRecord, source_session_id: &str, owner_user_id: &str, ) -> Result, AppError> { if !should_use_same_work_next_level(run) { return Ok(None); } if let Some(work) = fetch_local_current_work_detail(state, run).await? { if let Some(level) = select_local_next_level(&work.levels, run) { let next_after_level = select_next_level_after_level_id(&work.levels, level.level_id.as_str()) .map(|item| item.level_id.clone()); return Ok(Some(build_next_run_from_draft_level( run.clone(), level, Some(work.profile_id), work.author_display_name, work.theme_tags, next_after_level, ))); } } let normalized_session_id = source_session_id.trim(); if normalized_session_id.is_empty() { return Ok(None); } let session = state .spacetime_client() .get_puzzle_agent_session(normalized_session_id.to_string(), owner_user_id.to_string()) .await .map_err(map_puzzle_client_error)?; let Some(draft) = session.draft.as_ref() else { return Ok(None); }; if let Some(level) = select_local_next_level(&draft.levels, run) { let next_after_level = select_next_level_after_level_id(&draft.levels, level.level_id.as_str()) .map(|item| item.level_id.clone()); return Ok(Some(build_next_run_from_draft_level( run.clone(), level, Some(run.entry_profile_id.clone()), "当前草稿".to_string(), draft.theme_tags.clone(), next_after_level, ))); } Ok(None) } fn should_use_same_work_next_level(run: &PuzzleRunRecord) -> bool { run.next_level_mode == module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK || run .next_level_id .as_ref() .is_some_and(|value| !value.trim().is_empty()) } async fn fetch_local_current_work_detail( state: &AppState, run: &PuzzleRunRecord, ) -> Result, AppError> { let profile_id = run .next_level_profile_id .as_deref() .filter(|value| !value.trim().is_empty()) .or_else(|| { run.current_level .as_ref() .map(|level| level.profile_id.as_str()) .filter(|value| !value.trim().is_empty()) }) .unwrap_or(run.entry_profile_id.as_str()); match state .spacetime_client() .get_puzzle_gallery_detail(profile_id.to_string()) .await { Ok(work) => Ok(Some(work)), Err(SpacetimeClientError::Procedure(message)) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { Ok(None) } Err(error) => Err(map_puzzle_client_error(error)), } } async fn resolve_gallery_similar_puzzle_works( state: &AppState, run: &PuzzleRunRecord, current_work: Option<&PuzzleWorkProfileRecord>, ) -> Result, AppError> { let Some(current_profile) = build_recommendation_current_profile(run, current_work) else { return Ok(Vec::new()); }; let items = state .spacetime_client() .list_puzzle_gallery() .await .map_err(map_puzzle_client_error)?; let candidates = items .iter() .map(map_puzzle_work_profile_domain) .collect::>(); Ok(module_puzzle::select_next_profiles( ¤t_profile, &run.played_profile_ids, &candidates, 3, ) .into_iter() .map(|candidate| build_recommended_next_work_record(¤t_profile, candidate)) .collect()) } fn build_local_similar_works_handoff( mut run: PuzzleRunRecord, recommended_next_works: Vec, ) -> PuzzleRunRecord { let next_profile_id = recommended_next_works .first() .map(|item| item.profile_id.clone()); run.recommended_next_profile_id = next_profile_id.clone(); run.next_level_mode = module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string(); run.next_level_profile_id = next_profile_id; run.next_level_id = None; run.recommended_next_works = recommended_next_works; run } fn build_recommendation_current_profile( run: &PuzzleRunRecord, current_work: Option<&PuzzleWorkProfileRecord>, ) -> Option { if let Some(work) = current_work { return Some(map_puzzle_work_profile_domain(work)); } let level = run.current_level.as_ref()?; Some(PuzzleWorkProfile { work_id: format!("runtime-work-{}", level.profile_id), profile_id: level.profile_id.clone(), owner_user_id: String::new(), source_session_id: None, author_display_name: level.author_display_name.clone(), work_title: level.level_name.clone(), work_description: String::new(), level_name: level.level_name.clone(), summary: String::new(), theme_tags: level.theme_tags.clone(), cover_image_src: level.cover_image_src.clone(), cover_asset_id: None, levels: Vec::new(), publication_status: module_puzzle::PuzzlePublicationStatus::Published, updated_at_micros: 0, published_at_micros: 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: true, anchor_pack: module_puzzle::empty_anchor_pack(), }) } fn map_puzzle_work_profile_domain(item: &PuzzleWorkProfileRecord) -> PuzzleWorkProfile { PuzzleWorkProfile { work_id: item.work_id.clone(), profile_id: item.profile_id.clone(), owner_user_id: item.owner_user_id.clone(), source_session_id: item.source_session_id.clone(), author_display_name: item.author_display_name.clone(), work_title: item.work_title.clone(), work_description: item.work_description.clone(), level_name: item.level_name.clone(), summary: item.summary.clone(), theme_tags: item.theme_tags.clone(), cover_image_src: item.cover_image_src.clone(), cover_asset_id: item.cover_asset_id.clone(), levels: item .levels .iter() .map(map_puzzle_draft_level_domain) .collect(), publication_status: match item.publication_status.as_str() { "published" => module_puzzle::PuzzlePublicationStatus::Published, _ => module_puzzle::PuzzlePublicationStatus::Draft, }, updated_at_micros: parse_puzzle_record_timestamp_micros(&item.updated_at), published_at_micros: item .published_at .as_deref() .map(parse_puzzle_record_timestamp_micros), play_count: item.play_count, remix_count: item.remix_count, like_count: item.like_count, recent_play_count_7d: item.recent_play_count_7d, point_incentive_total_half_points: item.point_incentive_total_half_points, point_incentive_claimed_points: item.point_incentive_claimed_points, publish_ready: item.publish_ready, anchor_pack: module_puzzle::empty_anchor_pack(), } } fn map_puzzle_draft_level_domain( level: &PuzzleDraftLevelRecord, ) -> module_puzzle::PuzzleDraftLevel { module_puzzle::PuzzleDraftLevel { level_id: level.level_id.clone(), level_name: level.level_name.clone(), picture_description: level.picture_description.clone(), candidates: level .candidates .iter() .map(map_puzzle_generated_image_candidate_domain) .collect(), selected_candidate_id: level.selected_candidate_id.clone(), cover_image_src: level.cover_image_src.clone(), cover_asset_id: level.cover_asset_id.clone(), generation_status: level.generation_status.clone(), } } fn map_puzzle_generated_image_candidate_domain( candidate: &PuzzleGeneratedImageCandidateRecord, ) -> PuzzleGeneratedImageCandidate { 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, } } fn build_recommended_next_work_record( current_profile: &PuzzleWorkProfile, candidate: &PuzzleWorkProfile, ) -> PuzzleRecommendedNextWorkRecord { PuzzleRecommendedNextWorkRecord { profile_id: candidate.profile_id.clone(), level_name: candidate.level_name.clone(), author_display_name: candidate.author_display_name.clone(), theme_tags: candidate.theme_tags.clone(), cover_image_src: candidate.cover_image_src.clone(), similarity_score: module_puzzle::tag_similarity_score( ¤t_profile.theme_tags, &candidate.theme_tags, ), } } fn parse_puzzle_record_timestamp_micros(value: &str) -> i64 { let Some((seconds, rest)) = value.split_once('.') else { return 0; }; let micros = rest.strip_suffix('Z').unwrap_or(rest); let Ok(seconds) = seconds.parse::() else { return 0; }; let Ok(micros) = micros.parse::() else { return 0; }; seconds.saturating_mul(1_000_000).saturating_add(micros) } fn pick_unused_puzzle_candidate<'a>( candidates: &'a [PuzzleGeneratedImageCandidateRecord], played_profile_ids: &[String], ) -> Option<&'a PuzzleGeneratedImageCandidateRecord> { candidates.iter().find(|candidate| { !candidate.image_src.is_empty() && !played_profile_ids .iter() .any(|profile_id| profile_id.contains(&candidate.candidate_id)) }) } fn select_local_next_level<'a>( levels: &'a [PuzzleDraftLevelRecord], run: &PuzzleRunRecord, ) -> Option<&'a PuzzleDraftLevelRecord> { if levels.is_empty() { return None; } if let Some(next_level_id) = run .next_level_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { if let Some(level) = levels.iter().find(|level| level.level_id == next_level_id) { return Some(level); } } let current_level = run.current_level.as_ref()?; let matched_index = levels .iter() .position(|level| { level.cover_image_src == current_level.cover_image_src && level.level_name == current_level.level_name }) .or_else(|| { current_level .level_index .checked_sub(1) .and_then(|index| ((index as usize) < levels.len()).then_some(index as usize)) })?; levels.get(matched_index + 1) } fn select_next_level_after_level_id<'a>( levels: &'a [PuzzleDraftLevelRecord], level_id: &str, ) -> Option<&'a PuzzleDraftLevelRecord> { let matched_index = levels.iter().position(|level| level.level_id == level_id)?; levels.get(matched_index + 1) } fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option { level .cover_image_src .as_ref() .filter(|value| !value.trim().is_empty()) .cloned() .or_else(|| { level .selected_candidate_id .as_ref() .and_then(|candidate_id| { level .candidates .iter() .find(|candidate| candidate.candidate_id == *candidate_id) }) .map(|candidate| candidate.image_src.clone()) .filter(|value| !value.trim().is_empty()) }) .or_else(|| { level .candidates .iter() .find(|candidate| !candidate.image_src.trim().is_empty()) .map(|candidate| candidate.image_src.clone()) }) } fn build_next_run_from_candidate( run: PuzzleRunRecord, session: &PuzzleAgentSessionRecord, candidate: &PuzzleGeneratedImageCandidateRecord, ) -> PuzzleRunRecord { let draft = session.draft.as_ref(); let level_index = run.current_level_index + 1; build_next_run_from_parts( run, format!( "{}-{}-level-{}", session.session_id, candidate.candidate_id, level_index ), draft .map(|draft| format!("{} · 候选 {}", draft.level_name, level_index)) .unwrap_or_else(|| format!("候选拼图 {level_index}")), "当前草稿".to_string(), draft .map(|draft| draft.theme_tags.clone()) .unwrap_or_default(), Some(candidate.image_src.clone()), ) } fn build_next_run_from_draft_level( mut run: PuzzleRunRecord, level: &PuzzleDraftLevelRecord, profile_id: Option, author_display_name: String, theme_tags: Vec, next_after_level_id: Option, ) -> PuzzleRunRecord { // 中文注释:当前关卡 id 必须取本次选中的目标 level,避免旧 run 的空值或脏值影响后续同作品接续。 run.next_level_id = Some(level.level_id.clone()); let fallback_profile_id = run .current_level .as_ref() .map(|level| level.profile_id.clone()) .unwrap_or_else(|| level.level_id.clone()); build_next_run_from_parts_with_handoff( run, profile_id .filter(|value| !value.trim().is_empty()) .unwrap_or(fallback_profile_id), level.level_name.clone(), author_display_name, theme_tags, resolve_level_cover_image_src(level), next_after_level_id, ) } fn build_next_run_from_parts( run: PuzzleRunRecord, profile_id: String, level_name: String, author_display_name: String, theme_tags: Vec, cover_image_src: Option, ) -> PuzzleRunRecord { build_next_run_from_parts_with_handoff( run, profile_id, level_name, author_display_name, theme_tags, cover_image_src, None, ) } fn build_next_run_from_parts_with_handoff( run: PuzzleRunRecord, profile_id: String, level_name: String, author_display_name: String, theme_tags: Vec, cover_image_src: Option, next_after_level_id: Option, ) -> PuzzleRunRecord { let next_level_index = run.current_level_index + 1; let level_config = resolve_puzzle_level_config(next_level_index); let grid_size = level_config.grid_size; let time_limit_ms = level_config.time_limit_ms; let mut played_profile_ids = run.played_profile_ids.clone(); let current_level_id = run.next_level_id.clone(); if !played_profile_ids.contains(&profile_id) { played_profile_ids.push(profile_id.clone()); } let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index); PuzzleRunRecord { run_id: run.run_id.clone(), entry_profile_id: run.entry_profile_id, cleared_level_count: run.cleared_level_count, current_level_index: next_level_index, current_grid_size: grid_size, played_profile_ids, previous_level_tags: theme_tags.clone(), current_level: Some(PuzzleRuntimeLevelRecord { run_id: run.run_id, level_index: next_level_index, level_id: current_level_id, grid_size, profile_id: profile_id.clone(), level_name, author_display_name, theme_tags, cover_image_src, board, status: "playing".to_string(), started_at_ms: (current_utc_micros().max(0) as u64) / 1_000, cleared_at_ms: None, elapsed_ms: None, time_limit_ms, remaining_ms: time_limit_ms, paused_accumulated_ms: 0, pause_started_at_ms: None, freeze_accumulated_ms: 0, freeze_started_at_ms: None, freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, next_level_mode: next_after_level_id .as_ref() .map(|_| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string()) .unwrap_or_else(|| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()), next_level_profile_id: next_after_level_id.as_ref().map(|_| profile_id), next_level_id: next_after_level_id, recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), } } fn build_local_puzzle_board( grid_size: u32, run_id: &str, profile_id: &str, level_index: u32, ) -> PuzzleBoardRecord { let board = module_puzzle::build_initial_board_with_seed( grid_size, build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size), ) .unwrap_or_else(|_| { module_puzzle::build_initial_board_with_seed(3, 1) .expect("fallback puzzle board should use supported grid size") }); map_puzzle_board_snapshot_record(board) } fn build_local_puzzle_shuffle_seed( run_id: &str, profile_id: &str, level_index: u32, grid_size: u32, ) -> u64 { let mut hash = 0xcbf2_9ce4_8422_2325_u64; for byte in run_id .bytes() .chain(profile_id.bytes()) .chain(level_index.to_le_bytes()) .chain(grid_size.to_le_bytes()) { hash ^= u64::from(byte); hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } hash } fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord { PuzzleBoardRecord { rows: board.rows, cols: board.cols, pieces: board .pieces .into_iter() .map(|piece| PuzzlePieceStateRecord { piece_id: piece.piece_id, correct_row: piece.correct_row, correct_col: piece.correct_col, current_row: piece.current_row, current_col: piece.current_col, merged_group_id: piece.merged_group_id, }) .collect(), merged_groups: board .merged_groups .into_iter() .map(|group| PuzzleMergedGroupRecord { group_id: group.group_id, piece_ids: group.piece_ids, occupied_cells: group .occupied_cells .into_iter() .map(|cell| PuzzleCellPositionRecord { row: cell.row, col: cell.col, }) .collect(), }) .collect(), selected_piece_id: board.selected_piece_id, all_tiles_resolved: board.all_tiles_resolved, } } #[cfg(test)] mod tests { use super::*; fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> { board .pieces .iter() .map(|piece| (piece.current_row, piece.current_col)) .collect() } fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool { board.pieces.iter().any(|piece| { board.pieces.iter().any(|candidate| { piece.piece_id != candidate.piece_id && piece.current_row.abs_diff(candidate.current_row) + piece.current_col.abs_diff(candidate.current_col) == 1 && piece.correct_row.abs_diff(candidate.correct_row) + piece.correct_col.abs_diff(candidate.correct_col) == 1 }) }) } #[test] fn local_next_level_board_shuffle_changes_by_level() { let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2); let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3); assert_ne!(board_positions(&second), board_positions(&third)); assert!(!has_original_neighbor_pair(&second)); assert!(!has_original_neighbor_pair(&third)); } fn test_recommended_work(profile_id: &str, score: f32) -> PuzzleRecommendedNextWorkRecord { PuzzleRecommendedNextWorkRecord { profile_id: profile_id.to_string(), level_name: format!("{profile_id} 关"), author_display_name: "作者".to_string(), theme_tags: vec!["奇幻".to_string()], cover_image_src: Some(format!("/{profile_id}.png")), similarity_score: score, } } #[test] fn local_similar_works_handoff_keeps_cleared_run_for_user_choice() { let run = PuzzleRunRecord { run_id: "local-puzzle-run-a".to_string(), entry_profile_id: "profile-current".to_string(), cleared_level_count: 1, current_level_index: 1, current_grid_size: 3, played_profile_ids: vec!["profile-current".to_string()], previous_level_tags: vec!["奇幻".to_string()], current_level: Some(PuzzleRuntimeLevelRecord { run_id: "local-puzzle-run-a".to_string(), level_index: 1, level_id: Some("puzzle-level-1".to_string()), grid_size: 3, profile_id: "profile-current".to_string(), level_name: "当前拼图".to_string(), author_display_name: "当前作者".to_string(), theme_tags: vec!["奇幻".to_string()], cover_image_src: Some("/current.png".to_string()), board: build_local_puzzle_board(3, "local-puzzle-run-a", "profile-current", 1), status: "cleared".to_string(), started_at_ms: 1_000, cleared_at_ms: Some(2_000), elapsed_ms: Some(1_000), time_limit_ms: 300_000, remaining_ms: 0, paused_accumulated_ms: 0, pause_started_at_ms: None, freeze_accumulated_ms: 0, freeze_started_at_ms: None, freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, next_level_mode: module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(), next_level_profile_id: None, next_level_id: None, recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }; let next_run = build_local_similar_works_handoff( run, vec![ test_recommended_work("profile-a", 0.9), test_recommended_work("profile-b", 0.8), test_recommended_work("profile-c", 0.7), ], ); assert_eq!( next_run.next_level_mode, module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS ); assert_eq!( next_run.recommended_next_profile_id.as_deref(), Some("profile-a") ); assert_eq!(next_run.next_level_profile_id.as_deref(), Some("profile-a")); assert_eq!(next_run.next_level_id, None); assert_eq!(next_run.recommended_next_works.len(), 3); assert_eq!(next_run.current_level_index, 1); assert_eq!( next_run .current_level .as_ref() .map(|level| level.status.as_str()), Some("cleared") ); } #[test] fn puzzle_record_timestamp_parser_matches_shared_format() { assert_eq!( parse_puzzle_record_timestamp_micros("1713686401.234567Z"), 1_713_686_401_234_567 ); assert_eq!(parse_puzzle_record_timestamp_micros("bad-value"), 0); } #[test] fn puzzle_generated_image_size_is_square_1_1() { assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); } #[test] fn puzzle_text_to_image_request_places_negative_prompt_in_input_when_present() { let body = build_puzzle_text_to_image_request_body( "一只猫在雨夜灯牌下回头。", PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_GENERATED_IMAGE_SIZE, 3, ); assert_eq!(body["input"]["prompt"], "一只猫在雨夜灯牌下回头。"); assert_eq!( body["input"]["negative_prompt"], PUZZLE_DEFAULT_NEGATIVE_PROMPT ); assert!(body["parameters"].get("negative_prompt").is_none()); assert_eq!(body["parameters"]["size"], PUZZLE_GENERATED_IMAGE_SIZE); assert_eq!(body["parameters"]["n"], 1); } #[test] fn puzzle_dashscope_upstream_error_keeps_status_and_raw_excerpt() { let error = map_puzzle_dashscope_upstream_error( reqwest::StatusCode::BAD_REQUEST, r#"{"code":"InvalidParameter","message":"请求参数不合法"}"#, "创建拼图图片生成任务失败", ); assert_eq!(error.body_text(), "请求参数不合法"); let response = error.into_response(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } #[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)); } } struct PuzzleDashScopeSettings { base_url: String, api_key: String, reference_image_model: String, request_timeout_ms: u64, } struct PuzzleGeneratedImages { task_id: String, images: Vec, } struct PuzzleDownloadedImage { extension: String, mime_type: String, bytes: Vec, } struct ParsedPuzzleImageDataUrl { mime_type: String, bytes: Vec, } struct GeneratedPuzzleAssetResponse { image_src: String, asset_id: String, } fn require_puzzle_dashscope_settings( state: &AppState, ) -> Result { let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "dashscope", "reason": "DASHSCOPE_BASE_URL 未配置", })), ); } let api_key = state .config .dashscope_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": "dashscope", "reason": "DASHSCOPE_API_KEY 未配置", })) })?; Ok(PuzzleDashScopeSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), reference_image_model: state.config.dashscope_reference_image_model.clone(), request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), }) } fn build_puzzle_dashscope_http_client( settings: &PuzzleDashScopeSettings, ) -> Result { reqwest::Client::builder() .timeout(Duration::from_millis(settings.request_timeout_ms)) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "dashscope", "message": format!("构造拼图 DashScope 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_text_to_image_generation( http_client: &reqwest::Client, settings: &PuzzleDashScopeSettings, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, ) -> Result { let response = http_client .post(format!( "{}/services/aigc/text2image/image-synthesis", settings.base_url )) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(reqwest::header::CONTENT_TYPE, "application/json") .header("X-DashScope-Async", "enable") .json(&build_puzzle_text_to_image_request_body( prompt, negative_prompt, size, candidate_count, )) .send() .await .map_err(|error| { map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}")) })?; let status = response.status(); let response_text = response.text().await.map_err(|error| { map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}")) })?; if !status.is_success() { return Err(map_puzzle_dashscope_upstream_error( status, response_text.as_str(), "创建拼图图片生成任务失败", )); } let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图图片生成响应失败")?; let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "拼图图片生成任务未返回 task_id", })) })?; let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); while Instant::now() < deadline { let poll_response = http_client .get(format!("{}/tasks/{}", settings.base_url, task_id)) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .send() .await .map_err(|error| { map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}")) })?; let poll_status = poll_response.status(); let poll_text = poll_response.text().await.map_err(|error| { map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}")) })?; if !poll_status.is_success() { return Err(map_puzzle_dashscope_upstream_error( poll_status, poll_text.as_str(), "查询拼图图片生成任务失败", )); } let poll_payload = parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?; let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status") .unwrap_or_default() .trim() .to_string(); if task_status == "SUCCEEDED" { let image_urls = extract_puzzle_image_urls(&poll_payload); if image_urls.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "拼图图片生成成功但未返回图片地址", })), ); } let mut images = Vec::with_capacity(image_urls.len()); 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?); } return Ok(PuzzleGeneratedImages { task_id, images }); } if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { return Err(map_puzzle_dashscope_upstream_error( poll_status, poll_text.as_str(), "拼图图片生成任务失败", )); } sleep(Duration::from_secs(2)).await; } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "拼图图片生成超时或未返回图片地址", })), ) } fn build_puzzle_text_to_image_request_body( prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, ) -> Value { let parameters = Map::from_iter([ ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), ("prompt_extend".to_string(), Value::Bool(true)), ("watermark".to_string(), Value::Bool(false)), ]); let mut input = Map::from_iter([("prompt".to_string(), Value::String(prompt.to_string()))]); if !negative_prompt.trim().is_empty() { input.insert( "negative_prompt".to_string(), Value::String(negative_prompt.trim().to_string()), ); } json!({ "model": PUZZLE_TEXT_TO_IMAGE_MODEL, "input": input, "parameters": parameters, }) } 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) { return Ok(format!( "data:{};base64,{}", parsed.mime_type, BASE64_STANDARD.encode(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_dashscope_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_dashscope_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, })), ); } Ok(format!( "data:{};base64,{}", content_type, BASE64_STANDARD.encode(body) )) } async fn create_puzzle_image_to_image_generation( http_client: &reqwest::Client, settings: &PuzzleDashScopeSettings, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, reference_image: &str, ) -> Result { let mut content = vec![json!({ "image": reference_image })]; content.push(json!({ "text": prompt })); let mut parameters = Map::from_iter([ ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), ("prompt_extend".to_string(), Value::Bool(true)), ("watermark".to_string(), Value::Bool(false)), ]); if !negative_prompt.trim().is_empty() { parameters.insert( "negative_prompt".to_string(), Value::String(negative_prompt.trim().to_string()), ); } let response = http_client .post(format!( "{}/services/aigc/multimodal-generation/generation", settings.base_url )) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(reqwest::header::CONTENT_TYPE, "application/json") .json(&json!({ "model": settings.reference_image_model.as_str(), "input": { "messages": [ { "role": "user", "content": content, } ], }, "parameters": parameters, })) .send() .await .map_err(|error| { map_puzzle_dashscope_request_error(format!("创建拼图参考图生成任务失败:{error}")) })?; let status = response.status(); let response_text = response.text().await.map_err(|error| { map_puzzle_dashscope_request_error(format!("读取拼图参考图生成响应失败:{error}")) })?; if !status.is_success() { return Err(map_puzzle_dashscope_upstream_error( status, response_text.as_str(), "创建拼图参考图生成任务失败", )); } let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?; let image_urls = extract_puzzle_image_urls(&payload); if image_urls.is_empty() { let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "拼图参考图生成未返回 task_id 或图片地址", })) })?; return wait_puzzle_generated_images( http_client, settings, task_id.as_str(), candidate_count, "拼图参考图生成任务失败", ) .await; } 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: format!("puzzle-ref-{}", current_utc_micros()), images, }) } async fn wait_puzzle_generated_images( http_client: &reqwest::Client, settings: &PuzzleDashScopeSettings, task_id: &str, candidate_count: u32, failure_message: &str, ) -> Result { let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); while Instant::now() < deadline { let poll_response = http_client .get(format!("{}/tasks/{}", settings.base_url, task_id)) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .send() .await .map_err(|error| { map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}")) })?; let poll_status = poll_response.status(); let poll_text = poll_response.text().await.map_err(|error| { map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}")) })?; if !poll_status.is_success() { return Err(map_puzzle_dashscope_upstream_error( poll_status, poll_text.as_str(), "查询拼图图片生成任务失败", )); } let poll_payload = parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?; let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status") .unwrap_or_default() .trim() .to_string(); if task_status == "SUCCEEDED" { let image_urls = extract_puzzle_image_urls(&poll_payload); if image_urls.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "拼图图片生成成功但未返回图片地址", })), ); } let mut images = Vec::with_capacity(image_urls.len()); 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?); } return Ok(PuzzleGeneratedImages { task_id: task_id.to_string(), images, }); } if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { return Err(map_puzzle_dashscope_upstream_error( poll_status, poll_text.as_str(), failure_message, )); } sleep(Duration::from_secs(2)).await; } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": "拼图图片生成超时或未返回图片地址", })), ) } 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_dashscope_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_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}")) })?; if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "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 .map_err(map_puzzle_asset_spacetime_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 .map_err(map_puzzle_asset_spacetime_error)?; Ok(GeneratedPuzzleAssetResponse { image_src: put_result.legacy_public_path, asset_id, }) } 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 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": "dashscope", "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_task_id(payload: &Value) -> Option { find_first_puzzle_string_by_key(payload, "task_id") } 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 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 && let Some(text) = value.as_str() { results.push(text.to_string()); } collect_puzzle_strings_by_key(value, target_key, results); } } _ => {} } } 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_dashscope_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": message, })) } fn map_puzzle_dashscope_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); tracing::warn!( provider = "dashscope", upstream_status = upstream_status.as_u16(), message = %message, raw_excerpt = %raw_excerpt, "拼图 DashScope 上游请求失败" ); AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "upstreamStatus": upstream_status.as_u16(), "message": message, "rawExcerpt": raw_excerpt, })) } 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 { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": error.to_string(), })) } 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()) }