use super::*; pub async fn create_puzzle_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; let seed_text = build_puzzle_form_seed_text(&payload); let session = state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id("puzzle-session-"), owner_user_id: authenticated.claims().user_id().to_string(), seed_text: seed_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&seed_text), created_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn generate_puzzle_onboarding_work( State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; let prompt_text = payload.prompt_text.trim().to_string(); ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &prompt_text, "promptText", )?; let now = current_utc_micros(); let session_id = build_prefixed_uuid_id("puzzle-onboarding-"); let onboarding_profile_id = format!("onboarding-profile-{now}"); let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; let tags = generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; let candidates = generate_puzzle_image_candidates( &state, "onboarding-guest", Some(onboarding_profile_id.as_str()), session_id.as_str(), naming.level_name.as_str(), prompt_text.as_str(), None, false, Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2), 1, 0, ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_generation_endpoint_error(error), ) })? .into_records(); let selected = candidates.first().cloned().ok_or_else(|| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "新手引导拼图图片生成结果为空", })), ) })?; let level = PuzzleDraftLevelRecord { level_id: "onboarding-level-1".to_string(), level_name: naming.level_name.clone(), picture_description: prompt_text.clone(), picture_reference: None, ui_background_prompt: naming.ui_background_prompt.clone(), ui_background_image_src: None, ui_background_image_object_key: None, level_scene_image_src: None, level_scene_image_object_key: None, ui_spritesheet_image_src: None, ui_spritesheet_image_object_key: None, level_background_image_src: None, level_background_image_object_key: None, background_music: None, candidates, selected_candidate_id: Some(selected.candidate_id.clone()), cover_image_src: Some(selected.image_src.clone()), cover_asset_id: Some(selected.asset_id.clone()), generation_status: "ready".to_string(), }; let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack( naming.level_name.as_str(), level.picture_description.as_str(), )); let item = PuzzleWorkProfileRecord { work_id: format!("onboarding-work-{now}"), profile_id: onboarding_profile_id, owner_user_id: "onboarding-guest".to_string(), source_session_id: None, author_display_name: "陶泥儿主".to_string(), work_title: naming.level_name.clone(), work_description: prompt_text.clone(), level_name: naming.level_name, summary: prompt_text, theme_tags: tags, cover_image_src: level.cover_image_src.clone(), cover_asset_id: level.cover_asset_id.clone(), publication_status: "draft".to_string(), updated_at: format_timestamp_micros(now), published_at: None, play_count: 0, remix_count: 0, like_count: 0, recent_play_count_7d: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, anchor_pack, publish_ready: true, levels: vec![level.clone()], }; Ok(json_success_body( Some(&request_context), PuzzleOnboardingGenerateResponse { item: map_puzzle_work_profile_response(&state, item.clone()).summary, level: map_puzzle_draft_level_response(level), }, )) } pub async fn save_puzzle_onboarding_work( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": error.body_text(), })), ) })?; let prompt_text = payload.prompt_text.trim().to_string(); ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &prompt_text, "promptText", )?; let first_level = payload.item.levels.first().cloned().ok_or_else(|| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": "新手引导拼图缺少可保存关卡", })), ) })?; let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?; let work_title = payload.item.work_title.trim(); let work_title = if work_title.is_empty() { first_level.level_name.clone() } else { work_title.to_string() }; let work_description = payload.item.work_description.trim(); let work_description = if work_description.is_empty() { prompt_text.clone() } else { work_description.to_string() }; let summary = payload.item.summary.trim(); let summary = if summary.is_empty() { first_level.picture_description.clone() } else { summary.to_string() }; let now = current_utc_micros(); let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("puzzle-session-"); state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), seed_text: prompt_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&prompt_text), created_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str()); let item = state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id, work_title, work_description, level_name: first_level.level_name, summary, theme_tags: payload.item.theme_tags, cover_image_src: first_level.cover_image_src, cover_asset_id: first_level.cover_asset_id, levels_json: Some(levels_json), updated_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn get_puzzle_agent_session( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn submit_puzzle_agent_message( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let client_message_id = payload.client_message_id.trim().to_string(); let message_text = payload.text.trim().to_string(); if client_message_id.is_empty() || message_text.is_empty() { return Err(puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "clientMessageId and text are required", )); } let owner_user_id = authenticated.claims().user_id().to_string(); let submitted_session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), user_message_id: client_message_id, user_message_text: message_text, submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; let turn_result = run_puzzle_agent_turn( PuzzleAgentTurnRequest { llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), enable_web_search: state.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.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, action, request_context.request_id()); tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, action = %action, image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), prompt_chars = payload .prompt_text .as_deref() .map(|value| value.chars().count()) .unwrap_or(0), has_reference_image = has_puzzle_reference_images( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), payload.reference_image_asset_object_id.as_deref(), payload.reference_image_asset_object_ids.as_slice(), ), "拼图 Agent action 开始执行" ); let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let ai_redraw = payload.ai_redraw.unwrap_or(true); let puzzle_draft_generation_points_cost = if ai_redraw { crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state.root_state(), "puzzle", PUZZLE_IMAGE_GENERATION_POINTS_COST, ) .await } else { 0 }; let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), payload.reference_image_asset_object_id.as_deref(), payload.reference_image_asset_object_ids.as_slice(), ); let primary_reference_image_src = reference_image_sources.first().map(String::as_str); let prompt_text = payload .picture_description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| payload.prompt_text.as_deref()); let compile_session_id = match save_puzzle_form_payload_before_compile( &state, &request_context, &session_id, &owner_user_id, &payload, now, ) .await { Ok(next_session_id) => next_session_id, Err(response) => return Err(response), }; let worker_payload = PuzzleCompileDraftWorkerPayload { session_id: compile_session_id.clone(), owner_user_id: owner_user_id.clone(), billing_asset_id: billing_asset_id.clone(), ai_redraw, billing_points_cost: puzzle_draft_generation_points_cost, prompt_text: prompt_text.map(ToOwned::to_owned), reference_image_src: primary_reference_image_src.map(ToOwned::to_owned), image_model: payload.image_model.clone(), requested_at_micros: now, background_task_id: None, background_claim_id: None, }; let worker_payload = if ai_redraw { let background_task_id = build_puzzle_background_compile_task_id(&compile_session_id); let background_claim_id = build_puzzle_background_compile_claim_id( &background_task_id, request_context.request_id(), ); let claim_result = state .spacetime_client() .claim_puzzle_background_compile_task( PuzzleBackgroundCompileTaskClaimRecordInput { task_id: background_task_id.clone(), claim_id: background_claim_id.clone(), session_id: compile_session_id.clone(), owner_user_id: owner_user_id.clone(), claimed_at_micros: current_utc_micros(), }, ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; if !claim_result { tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compile_session_id, owner_user_id = %owner_user_id, task_id = %background_task_id, "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话" ); let session = state .spacetime_client() .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) .await .map(mark_puzzle_initial_generation_started_snapshot) .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: background_task_id, operation_type: "compile_puzzle_draft".to_string(), status: "running".to_string(), phase_label: "首关拼图草稿".to_string(), phase_detail: "首关草稿生成已在后台处理中。".to_string(), progress: session.progress_percent.max(10), error: None, }, session: map_puzzle_agent_session_response(session), }, )); } PuzzleCompileDraftWorkerPayload { background_task_id: Some(background_task_id), background_claim_id: Some(background_claim_id), ..worker_payload } } else { worker_payload }; if state .root_state() .config .external_generation_mode .is_inline() { tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compile_session_id, owner_user_id = %owner_user_id, external_generation_mode = state.root_state().config.external_generation_mode.as_str(), "拼图首关草稿生成使用 inline 模式同步执行" ); let session = execute_puzzle_compile_draft_worker_job( &state, &request_context, worker_payload.clone(), ExternalGenerationWriteLeaseGuard::inline(), ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error.into_app_error(), ) })?; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: build_prefixed_uuid_id("extgen-inline-"), operation_type: "compile_puzzle_draft".to_string(), status: "completed".to_string(), phase_label: "首关拼图草稿".to_string(), phase_detail: if ai_redraw { "首关草稿生成已完成。".to_string() } else { "首关草稿编译已完成。".to_string() }, progress: 100, error: None, }, session: map_puzzle_agent_session_response(session), }, )); } let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { if let (Some(task_id), Some(claim_id)) = ( worker_payload.background_task_id.as_deref(), worker_payload.background_claim_id.as_deref(), ) { spawn_release_claimed_puzzle_background_compile_task( state.clone(), task_id.to_string(), claim_id.to_string(), compile_session_id.clone(), owner_user_id.clone(), ); } 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": format!("拼图生成任务参数序列化失败:{error}"), })), ) })?; let external_generation_job_id = build_prefixed_uuid_id("extgen-"); let job = state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { job_id: external_generation_job_id.clone(), dedupe_key: format!( "puzzle:compile_puzzle_draft:{compile_session_id}:{external_generation_job_id}" ), job_kind: PUZZLE_COMPILE_DRAFT_JOB_KIND.to_string(), owner_user_id: owner_user_id.clone(), source_module: "puzzle".to_string(), source_entity_id: compile_session_id.clone(), request_label: "拼图首关草稿生成".to_string(), request_payload_json, max_attempts: 1, available_at_micros: now, created_at_micros: now, }) .await .map_err(|error| { if let (Some(task_id), Some(claim_id)) = ( worker_payload.background_task_id.as_deref(), worker_payload.background_claim_id.as_deref(), ) { spawn_release_claimed_puzzle_background_compile_task( state.clone(), task_id.to_string(), claim_id.to_string(), compile_session_id.clone(), owner_user_id.clone(), ); } puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; let session = state .spacetime_client() .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) .await .map(mark_puzzle_initial_generation_started_snapshot) .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; let (status, progress) = match job.status.as_str() { "completed" => ("completed", 100), "running" => ("running", session.progress_percent.max(10)), "failed" => ("failed", session.progress_percent), _ => ("queued", session.progress_percent.max(5)), }; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: job.job_id, operation_type: "compile_puzzle_draft".to_string(), status: status.to_string(), phase_label: "首关拼图草稿".to_string(), phase_detail: if ai_redraw { "首关草稿生成已进入后台队列。".to_string() } else { "首关草稿编译已进入后台队列。".to_string() }, progress, error: job.last_error_message, }, session: map_puzzle_agent_session_response(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) => { // 中文注释:旧 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| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })), ) })?; let worker_payload = PuzzleGenerateImagesWorkerPayload { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), billing_asset_id: billing_asset_id.clone(), level_id: target_level_id.clone(), prompt_text: payload.prompt_text.clone(), reference_image_src: payload.reference_image_src.clone(), reference_image_srcs: payload.reference_image_srcs.clone(), reference_image_asset_object_id: payload.reference_image_asset_object_id.clone(), reference_image_asset_object_ids: payload.reference_image_asset_object_ids.clone(), image_model: payload.image_model.clone(), ai_redraw: payload.ai_redraw, should_auto_name_level: payload.should_auto_name_level, work_title: payload.work_title.clone(), work_description: payload.work_description.clone(), picture_description: payload.picture_description.clone(), summary: payload.summary.clone(), theme_tags: payload.theme_tags.clone(), levels_json, requested_at_micros: now, }; if state .root_state() .config .external_generation_mode .is_inline() { tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, external_generation_mode = state.root_state().config.external_generation_mode.as_str(), "拼图关卡图片生成使用 inline 模式同步执行" ); let session = execute_puzzle_generate_images_worker_job( &state, &request_context, worker_payload, ExternalGenerationWriteLeaseGuard::inline(), ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error.into_app_error(), ) })?; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: build_prefixed_uuid_id("extgen-inline-"), operation_type: "generate_puzzle_images".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), }, )); } let request_payload_json = serde_json::to_string(&worker_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": format!("拼图关卡图片生成任务参数序列化失败:{error}"), })), ) })?; let external_generation_job_id = build_prefixed_uuid_id("extgen-"); let source_entity_id = target_level_id .as_deref() .map(|level_id| format!("{session_id}:{level_id}")) .unwrap_or_else(|| session_id.clone()); let job = state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { job_id: external_generation_job_id.clone(), dedupe_key: format!( "puzzle:generate_puzzle_images:{session_id}:{external_generation_job_id}" ), job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(), owner_user_id: owner_user_id.clone(), source_module: "puzzle".to_string(), source_entity_id, request_label: "拼图关卡图片生成".to_string(), request_payload_json, max_attempts: 1, available_at_micros: now, created_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(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), ) })?; let (status, progress) = match job.status.as_str() { "completed" => ("completed", 100), "running" => ("running", 35), "failed" => ("failed", 0), _ => ("queued", 8), }; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: job.job_id, operation_type: "generate_puzzle_images".to_string(), status: status.to_string(), phase_label: "拼图图片生成".to_string(), phase_detail: "关卡图片生成已进入后台队列。".to_string(), progress, error: job.last_error_message, }, session: map_puzzle_agent_session_response(session), }, )); } "generate_puzzle_ui_background" => { 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| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })), ) })?; let worker_payload = PuzzleGenerateUiBackgroundWorkerPayload { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), billing_asset_id: billing_asset_id.clone(), level_id: target_level_id.clone(), prompt_text: payload.prompt_text.clone(), levels_json, requested_at_micros: now, }; if state .root_state() .config .external_generation_mode .is_inline() { tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, external_generation_mode = state.root_state().config.external_generation_mode.as_str(), "拼图 UI 背景图生成使用 inline 模式同步执行" ); let session = execute_puzzle_generate_ui_background_worker_job( &state, &request_context, worker_payload, ExternalGenerationWriteLeaseGuard::inline(), ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error.into_app_error(), ) })?; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: build_prefixed_uuid_id("extgen-inline-"), operation_type: "generate_puzzle_ui_background".to_string(), status: "completed".to_string(), phase_label: "UI 背景图生成".to_string(), phase_detail: "拼图 UI 背景图生成已完成。".to_string(), progress: 100, error: None, }, session: map_puzzle_agent_session_response(session), }, )); } let request_payload_json = serde_json::to_string(&worker_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": format!("拼图 UI 背景图生成任务参数序列化失败:{error}"), })), ) })?; let external_generation_job_id = build_prefixed_uuid_id("extgen-"); let source_entity_id = target_level_id .as_deref() .map(|level_id| format!("{session_id}:{level_id}")) .unwrap_or_else(|| session_id.clone()); let job = state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { job_id: external_generation_job_id.clone(), dedupe_key: format!( "puzzle:generate_puzzle_ui_background:{session_id}:{external_generation_job_id}" ), job_kind: PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND.to_string(), owner_user_id: owner_user_id.clone(), source_module: "puzzle".to_string(), source_entity_id, request_label: "拼图 UI 背景图生成".to_string(), request_payload_json, max_attempts: 1, available_at_micros: now, created_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(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), ) })?; let (status, progress) = match job.status.as_str() { "completed" => ("completed", 100), "running" => ("running", session.progress_percent.max(55)), "failed" => ("failed", session.progress_percent), _ => ("queued", session.progress_percent.max(12)), }; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: job.job_id, operation_type: "generate_puzzle_ui_background".to_string(), status: status.to_string(), phase_label: "UI 背景图生成".to_string(), phase_detail: "拼图 UI 背景图生成已进入后台队列。".to_string(), progress, error: job.last_error_message, }, session: map_puzzle_agent_session_response(session), }, )); } "generate_puzzle_tags" => { let work_title = payload .work_title .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "作品名称不能为空", ) })?; let work_description = payload .work_description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "作品描述不能为空", ) })?; let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })), ) })?; let generated_tags = generate_puzzle_work_tags(&state, work_title, work_description).await; let session = save_generated_puzzle_tags_to_session( &state, &session_id, &owner_user_id, &payload, generated_tags, levels_json, now, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "generate_puzzle_tags", "作品标签生成", "已生成 6 个作品标签。", session, ) } "select_puzzle_image" => { let candidate_id = payload .candidate_id .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "candidateId is required", ) })?; let session = state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: payload.level_id.clone(), candidate_id, selected_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) }); ( "select_puzzle_image", "正式图确认", "已应用正式拼图图片。", session, ) } "publish_puzzle_work" => { let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error, })), ) })?; let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let author_display_name = resolve_author_display_name(&state, &authenticated); let profile = execute_billable_asset_operation( state.root_state(), &owner_user_id, "puzzle_publish_work", &work_id, async { state .spacetime_client() .publish_puzzle_work(PuzzlePublishRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 work_id: work_id.clone(), profile_id, author_display_name, work_title: payload.work_title.clone(), work_description: payload.work_description.clone(), level_name: payload.level_name.clone(), summary: payload.summary.clone(), theme_tags: payload.theme_tags.clone(), levels_json, published_at_micros: now, }) .await .map_err(map_puzzle_client_error) }, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) })?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: profile.profile_id.clone(), operation_type: "publish_puzzle_work".to_string(), status: "completed".to_string(), phase_label: "作品发布".to_string(), phase_detail: "拼图作品已发布到广场。".to_string(), progress: 100, error: None, }, session: map_puzzle_agent_session_response(session), }, )); } other => { return Err(puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, format!("action `{other}` is not supported").as_str(), )); } }; let session = session?; Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: session.session_id.clone(), operation_type: operation_type.to_string(), status: "completed".to_string(), phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), progress: 100, error: None, }, session: map_puzzle_agent_session_response(session), }, )) } pub async fn get_puzzle_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_puzzle_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorksResponse { items: items .into_iter() .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) } pub async fn get_puzzle_work_detail( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(_authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_puzzle_work_detail(profile_id) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn put_puzzle_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), work_title: payload.work_title, work_description: payload.work_description, level_name: payload.level_name, summary: payload.summary, theme_tags: payload.theme_tags, cover_image_src: payload.cover_image_src, cover_asset_id: payload.cover_asset_id, levels_json: Some(serialize_puzzle_levels_response( &request_context, &payload.levels, )?), updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn delete_puzzle_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let items = state .spacetime_client() .delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorksResponse { items: items .into_iter() .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) } pub async fn claim_puzzle_work_point_incentive( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), claimed_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn list_puzzle_gallery( State(state): State, Extension(request_context): Extension, ) -> Result { if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { crate::telemetry::record_puzzle_gallery_cache_hit(); return Ok(puzzle_gallery_cached_json(&request_context, response)); } crate::telemetry::record_puzzle_gallery_cache_miss(); let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await; if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { crate::telemetry::record_puzzle_gallery_cache_hit(); return Ok(puzzle_gallery_cached_json(&request_context, response)); } let rebuild_started_at = std::time::Instant::now(); let items = state .spacetime_client() .list_public_work_gallery_entries() .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; let response = build_puzzle_gallery_window_response( items .into_iter() .filter(|item| item.source_type == "puzzle") .map(|item| map_public_work_puzzle_gallery_card_response(&state, item)) .collect(), ); let cached_response = state .puzzle_gallery_cache() .store_response(response) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_GALLERY_PROVIDER, "message": format!("拼图广场缓存序列化失败:{error}"), })), ) })?; crate::telemetry::record_puzzle_gallery_cache_rebuild( rebuild_started_at.elapsed(), cached_response.data_json_len(), ); Ok(puzzle_gallery_cached_json( &request_context, cached_response, )) } pub async fn get_puzzle_gallery_detail( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_puzzle_gallery_detail(profile_id) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn record_puzzle_gallery_like( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { profile_id, user_id: authenticated.claims().user_id().to_string(), liked_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn remix_puzzle_gallery_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .remix_puzzle_work(PuzzleWorkRemixRecordInput { source_profile_id: profile_id, target_owner_user_id: owner_user_id, target_session_id: build_prefixed_uuid_id("puzzle-session-"), target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), target_work_id: build_prefixed_uuid_id("puzzle-work-"), author_display_name: resolve_author_display_name(&state, &authenticated), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), remixed_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn start_puzzle_run( State(state): State, Extension(request_context): Extension, Extension(principal): 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: principal.subject().to_string(), profile_id: payload.profile_id.clone(), level_id: payload.level_id.clone(), started_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; record_puzzle_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::runtime_principal( "puzzle", payload.profile_id.clone(), &principal, "/api/runtime/puzzle/...", ) .profile_id(payload.profile_id.clone()) .owner_user_id(principal.subject().to_string()) .extra(json!({ "levelId": payload.level_id, "runId": run.run_id, "principalKind": principal.kind().as_str(), })), ) .await; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn get_puzzle_run( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .get_puzzle_run(run_id, principal.subject().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(principal): 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: principal.subject().to_string(), first_piece_id: payload.first_piece_id, second_piece_id: payload.second_piece_id, swapped_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn drag_puzzle_piece_or_group( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.piece_id, "pieceId", )?; let run = state .spacetime_client() .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { run_id, owner_user_id: principal.subject().to_string(), piece_id: payload.piece_id, target_row: payload.target_row, target_col: payload.target_col, dragged_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn advance_puzzle_next_level( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let payload = match payload { Ok(Json(payload)) => payload, Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => { AdvancePuzzleNextLevelRequest { target_profile_id: None, prefer_similar_work: false, } } Err(error) => { return Err(puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), )); } }; let run = state .spacetime_client() .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { run_id, owner_user_id: principal.subject().to_string(), target_profile_id: payload.target_profile_id, prefer_similar_work: payload.prefer_similar_work, 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(principal): 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: principal.subject().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(principal): 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 = principal.subject().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, request_context.request_id()); 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.root_state(), &owner_user_id, billing_asset_kind, billing_asset_id.as_str(), async { state .spacetime_client() .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { run_id: reducer_run_id, owner_user_id: reducer_owner_user_id, prop_kind, used_at_micros: current_utc_micros(), spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST, }) .await .map_err(map_puzzle_client_error) }, ) .await; let run = match run_result { Ok(run) => run, Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => { // 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。 // 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。 state .spacetime_client() .get_puzzle_run(fallback_run_id, fallback_owner_user_id) .await .map_err(map_puzzle_client_error) .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error) })? } Err(error) => { return Err(puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, error, )); } }; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn submit_puzzle_leaderboard( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(principal): 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: principal.subject().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), }, )) }