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 naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; let tags = generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; let candidates = generate_puzzle_image_candidates( &state, "onboarding-guest", session_id.as_str(), naming.level_name.as_str(), prompt_text.as_str(), None, false, Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2), 1, 0, ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_generation_endpoint_error(error), ) })? .into_records(); let selected = candidates.first().cloned().ok_or_else(|| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "新手引导拼图图片生成结果为空", })), ) })?; let level = PuzzleDraftLevelRecord { level_id: "onboarding-level-1".to_string(), level_name: naming.level_name.clone(), picture_description: prompt_text.clone(), picture_reference: None, ui_background_prompt: naming.ui_background_prompt.clone(), ui_background_image_src: None, ui_background_image_object_key: None, 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: format!("onboarding-profile-{now}"), owner_user_id: "onboarding-guest".to_string(), source_session_id: None, author_display_name: "陶泥儿主".to_string(), work_title: naming.level_name.clone(), work_description: prompt_text.clone(), level_name: naming.level_name, summary: prompt_text, theme_tags: tags, cover_image_src: level.cover_image_src.clone(), cover_asset_id: level.cover_asset_id.clone(), publication_status: "draft".to_string(), updated_at: format_timestamp_micros(now), published_at: None, play_count: 0, remix_count: 0, like_count: 0, recent_play_count_7d: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, anchor_pack, publish_ready: true, levels: vec![level.clone()], }; Ok(json_success_body( Some(&request_context), PuzzleOnboardingGenerateResponse { item: map_puzzle_work_profile_response(&state, item.clone()).summary, level: map_puzzle_draft_level_response(level), }, )) } pub async fn save_puzzle_onboarding_work( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": error.body_text(), })), ) })?; let prompt_text = payload.prompt_text.trim().to_string(); ensure_non_empty( &request_context, PUZZLE_WORKS_PROVIDER, &prompt_text, "promptText", )?; let first_level = payload.item.levels.first().cloned().ok_or_else(|| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": "新手引导拼图缺少可保存关卡", })), ) })?; let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?; let work_title = payload.item.work_title.trim(); let work_title = if work_title.is_empty() { first_level.level_name.clone() } else { work_title.to_string() }; let work_description = payload.item.work_description.trim(); let work_description = if work_description.is_empty() { prompt_text.clone() } else { work_description.to_string() }; let summary = payload.item.summary.trim(); let summary = if summary.is_empty() { first_level.picture_description.clone() } else { summary.to_string() }; let now = current_utc_micros(); let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("puzzle-session-"); state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), seed_text: prompt_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&prompt_text), created_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str()); let item = state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id, work_title, work_description, level_name: first_level.level_name, summary, theme_tags: payload.item.theme_tags, cover_image_src: first_level.cover_image_src, cover_asset_id: first_level.cover_asset_id, levels_json: Some(levels_json), updated_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn get_puzzle_agent_session( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn submit_puzzle_agent_message( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId", )?; let client_message_id = payload.client_message_id.trim().to_string(); let message_text = payload.text.trim().to_string(); if client_message_id.is_empty() || message_text.is_empty() { return Err(puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "clientMessageId and text are required", )); } let owner_user_id = authenticated.claims().user_id().to_string(); let submitted_session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), user_message_id: client_message_id, user_message_text: message_text, submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; let turn_result = run_puzzle_agent_turn( PuzzleAgentTurnRequest { llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), enable_web_search: state.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}:{now}"); tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, action = %action, image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), prompt_chars = payload .prompt_text .as_deref() .map(|value| value.chars().count()) .unwrap_or(0), has_reference_image = has_puzzle_reference_images( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), 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 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 session = if ai_redraw { execute_billable_asset_operation_with_cost( state.root_state(), &owner_user_id, "puzzle_initial_image", &billing_asset_id, PUZZLE_IMAGE_GENERATION_POINTS_COST, async { compile_puzzle_draft_with_initial_cover( &state, compile_session_id.clone(), owner_user_id.clone(), prompt_text, primary_reference_image_src, payload.image_model.as_deref(), now, ) .await }, ) .await } else { compile_puzzle_draft_with_uploaded_cover( &state, compile_session_id.clone(), owner_user_id.clone(), prompt_text, primary_reference_image_src, now, ) .await } .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "compile_puzzle_draft", "首关拼图草稿", if ai_redraw { "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。" } else { "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" }, session, ) } "save_puzzle_form_draft" => { let seed_text = build_puzzle_form_seed_text_from_parts( None, None, payload .picture_description .as_deref() .or(payload.prompt_text.as_deref()), ); let save_result = state .spacetime_client() .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), seed_text, saved_at_micros: now, }) .await; let session = match save_result { Ok(session) => Ok(session), Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { // 中文注释:旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, error = %error, "拼图表单自动保存 procedure 缺失,降级返回当前会话" ); state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|fallback_error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(fallback_error), ) }) } Err(error) => Err(puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), )), }; ( "save_puzzle_form_draft", "表单草稿保存", "拼图表单草稿已保存。", session, ) } "generate_puzzle_images" => { let target_level_id = payload.level_id.clone(); let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })) }); let session = execute_billable_asset_operation_with_cost( state.root_state(), &owner_user_id, "puzzle_generated_image", &billing_asset_id, PUZZLE_IMAGE_GENERATION_POINTS_COST, async { let levels_json = levels_json?; let session = get_puzzle_session_for_image_generation( &state, session_id.clone(), owner_user_id.clone(), &payload, levels_json.as_deref(), now, ) .await?; let mut draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; if let Some(levels_json) = levels_json.as_ref() { draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; } let mut target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; let fallback_level_name = target_level.level_name.clone(); let prompt = resolve_puzzle_level_image_prompt( payload.prompt_text.as_deref(), &target_level.picture_description, &draft.summary, ); let should_auto_name_level = payload .should_auto_name_level .unwrap_or_else(|| target_level.level_name.trim().is_empty()); let mut generated_naming = if should_auto_name_level { let naming = generate_puzzle_first_level_name( &state, target_level.picture_description.as_str(), ) .await; target_level.level_name = naming.level_name.clone(); target_level.ui_background_prompt = naming.ui_background_prompt.clone(); Some(naming) } else { None }; 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); // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 let candidate_start_index = target_level.candidates.len(); let ai_redraw = payload.ai_redraw.unwrap_or(true); let mut candidates = if should_use_uploaded_puzzle_image_directly( primary_reference_image_src, ai_redraw, ) { vec![ create_uploaded_puzzle_image_candidate( &state, owner_user_id.as_str(), &session.session_id, &target_level.level_name, &prompt, primary_reference_image_src.expect("checked reference image"), candidate_start_index, ) .await?, ] } else { generate_puzzle_image_candidates( &state, owner_user_id.as_str(), &session.session_id, &target_level.level_name, &prompt, primary_reference_image_src, ai_redraw, payload.image_model.as_deref(), 1, candidate_start_index, ) .await .map_err(map_puzzle_generation_endpoint_error)? }; if candidates.is_empty() { return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图候选图生成结果为空", }), )); } if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( &state, target_level.picture_description.as_str(), &candidates[0].downloaded_image, ) .await .filter(|_| should_auto_name_level) { target_level.level_name = refined_naming.level_name.clone(); if refined_naming.ui_background_prompt.is_some() { target_level.ui_background_prompt = refined_naming.ui_background_prompt.clone(); } generated_naming = Some(refined_naming); } let generated_level_name = target_level.level_name.clone(); let mut updated_levels = build_puzzle_levels_with_primary_update( &draft, &target_level, primary_reference_image_src, ); for candidate in &mut candidates { candidate.record.prompt = prompt.clone(); } let selected_candidate = candidates .iter() .find(|candidate| candidate.record.selected) .or_else(|| candidates.first()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图候选图生成结果为空", })) })?; let asset_bundle = generate_puzzle_level_asset_bundle_required( &state, owner_user_id.as_str(), &session.session_id, &target_level, &selected_candidate.downloaded_image, ) .await?; attach_puzzle_level_asset_bundle( &mut updated_levels, target_level.level_id.as_str(), asset_bundle, ); attach_selected_puzzle_candidate_to_levels( &mut updated_levels, target_level.level_id.as_str(), &selected_candidate.record, ); let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let candidates_json = serde_json::to_string( &candidates .iter() .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) .collect::>(), ) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图候选图序列化失败:{error}"), })) })?; let save_result = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json: levels_json_with_generated_name, candidates_json, saved_at_micros: now, }) .await; match save_result { Ok(session) => Ok(session), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { // 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session.session_id, owner_user_id = %owner_user_id, error = %error, "拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" ); let fallback_session = replace_puzzle_session_draft_snapshot(session, draft, now); let fallback_session = if should_auto_name_level { apply_generated_puzzle_first_level_name_to_session_snapshot( fallback_session, target_level.level_id.as_str(), generated_level_name.as_str(), fallback_level_name.as_str(), now, ) } else { fallback_session }; let mut fallback_session = apply_generated_puzzle_candidates_to_session_snapshot( apply_generated_puzzle_levels_to_session_snapshot( fallback_session, updated_levels, now, ), target_level.level_id.as_str(), candidates.into_records(), primary_reference_image_src, now, ); if let Some(generated_naming) = generated_naming.as_ref() { fallback_session = apply_generated_puzzle_metadata_to_session_snapshot( fallback_session, target_level.level_id.as_str(), generated_naming, fallback_level_name.as_str(), now, ); } Ok(fallback_session) } Err(error) => Err(map_puzzle_client_error(error)), } }, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "generate_puzzle_images", "拼图图片生成", "已生成并替换当前拼图图片。", session, ) } "generate_puzzle_ui_background" => { let target_level_id = payload.level_id.clone(); let raw_prompt = payload .prompt_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or_default() .to_string(); let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })) }); let session = execute_billable_asset_operation_with_cost( state.root_state(), &owner_user_id, "puzzle_ui_background_image", &billing_asset_id, PUZZLE_IMAGE_GENERATION_POINTS_COST, async { let levels_json = levels_json?; let session = get_puzzle_session_for_image_generation( &state, session_id.clone(), owner_user_id.clone(), &payload, levels_json.as_deref(), now, ) .await?; let mut draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; if let Some(levels_json) = levels_json.as_ref() { draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; } let target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; let resolved_prompt = normalize_puzzle_ui_background_prompt( raw_prompt.as_str(), &draft, &target_level, ); let generated = generate_puzzle_ui_background_image( &state, owner_user_id.as_str(), &session.session_id, &target_level.level_name, resolved_prompt.as_str(), ) .await .map_err(map_puzzle_generation_endpoint_error)?; let save_result = state .spacetime_client() .save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput { session_id: session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json, prompt: resolved_prompt.clone(), image_src: generated.image_src.clone(), image_object_key: Some(generated.object_key.clone()), saved_at_micros: now, }) .await; match save_result { Ok(session) => Ok(session), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session.session_id, owner_user_id = %owner_user_id, error = %error, "拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" ); let fallback_session = replace_puzzle_session_draft_snapshot(session, draft, now); Ok(apply_generated_puzzle_ui_background_to_session_snapshot( fallback_session, target_level.level_id.as_str(), resolved_prompt, generated.image_src, Some(generated.object_key), now, )) } Err(error) => Err(map_puzzle_client_error(error)), } }, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "generate_puzzle_ui_background", "UI 背景图生成", "已生成拼图 UI 背景图。", session, ) } "generate_puzzle_tags" => { let work_title = payload .work_title .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "作品名称不能为空", ) })?; let work_description = payload .work_description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "作品描述不能为空", ) })?; let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": message, })), ) })?; let generated_tags = generate_puzzle_work_tags(&state, work_title, work_description).await; let session = save_generated_puzzle_tags_to_session( &state, &session_id, &owner_user_id, &payload, generated_tags, levels_json, now, ) .await .map_err(|error| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "generate_puzzle_tags", "作品标签生成", "已生成 6 个作品标签。", session, ) } "select_puzzle_image" => { let candidate_id = payload .candidate_id .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "candidateId is required", ) })?; let session = state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: payload.level_id.clone(), candidate_id, selected_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) }); ( "select_puzzle_image", "正式图确认", "已应用正式拼图图片。", session, ) } "publish_puzzle_work" => { let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error, })), ) })?; let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let author_display_name = resolve_author_display_name(&state, &authenticated); let profile = execute_billable_asset_operation( state.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_puzzle_gallery() .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; let response = build_puzzle_gallery_window_response( items .into_iter() .map(|item| map_puzzle_gallery_card_response(&state, item)) .collect(), ); let cached_response = state .puzzle_gallery_cache() .store_response(response) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_GALLERY_PROVIDER, "message": format!("拼图广场缓存序列化失败:{error}"), })), ) })?; crate::telemetry::record_puzzle_gallery_cache_rebuild( rebuild_started_at.elapsed(), cached_response.data_json_len(), ); Ok(puzzle_gallery_cached_json( &request_context, cached_response, )) } pub async fn get_puzzle_gallery_detail( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_puzzle_gallery_detail(profile_id) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn record_puzzle_gallery_like( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { profile_id, user_id: authenticated.claims().user_id().to_string(), liked_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { item: map_puzzle_work_profile_response(&state, item), }, )) } pub async fn remix_puzzle_gallery_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .remix_puzzle_work(PuzzleWorkRemixRecordInput { source_profile_id: profile_id, target_owner_user_id: owner_user_id, target_session_id: build_prefixed_uuid_id("puzzle-session-"), target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), target_work_id: build_prefixed_uuid_id("puzzle-work-"), author_display_name: resolve_author_display_name(&state, &authenticated), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), remixed_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn start_puzzle_run( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.profile_id, "profileId", )?; let run = state .spacetime_client() .start_puzzle_run(PuzzleRunStartRecordInput { run_id: build_prefixed_uuid_id("puzzle-run-"), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: payload.profile_id.clone(), level_id: payload.level_id.clone(), started_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; record_puzzle_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( "puzzle", payload.profile_id.clone(), &authenticated, "/api/runtime/puzzle/...", ) .profile_id(payload.profile_id.clone()) .extra(json!({ "levelId": payload.level_id, "runId": run.run_id, })), ) .await; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn get_puzzle_run( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .get_puzzle_run(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn swap_puzzle_pieces( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.first_piece_id, "firstPieceId", )?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.second_piece_id, "secondPieceId", )?; let run = state .spacetime_client() .swap_puzzle_pieces(PuzzleRunSwapRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), first_piece_id: payload.first_piece_id, second_piece_id: payload.second_piece_id, swapped_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn drag_puzzle_piece_or_group( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.piece_id, "pieceId", )?; let run = state .spacetime_client() .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), piece_id: payload.piece_id, target_row: payload.target_row, target_col: payload.target_col, dragged_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn advance_puzzle_next_level( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let payload = match payload { Ok(Json(payload)) => payload, Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => { AdvancePuzzleNextLevelRequest { target_profile_id: None, } } Err(error) => { return Err(puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), )); } }; let run = state .spacetime_client() .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), target_profile_id: payload.target_profile_id, advanced_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn update_puzzle_run_pause( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .update_puzzle_run_pause(PuzzleRunPauseRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), paused: payload.paused, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } pub async fn use_puzzle_runtime_prop( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.prop_kind, "propKind", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let prop_kind = payload.prop_kind.trim().to_string(); let billing_asset_kind = match prop_kind.as_str() { "hint" => "puzzle_prop_hint", "reference" => "puzzle_prop_preview", "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", "extendTime" | "extend_time" => "puzzle_prop_extend_time", _ => { return Err(puzzle_bad_request( &request_context, PUZZLE_RUNTIME_PROVIDER, "unknown puzzle prop kind", )); } }; let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); let reducer_owner_user_id = owner_user_id.clone(); let reducer_run_id = run_id.clone(); let fallback_run_id = run_id.clone(); let fallback_owner_user_id = owner_user_id.clone(); let run_result = execute_billable_asset_operation( state.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(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), }, )) }