diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e2e05bd8..05ed0fe5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -71,6 +71,8 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对同一请求最多发送 3 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。 +拼图入口直创的 `compile_puzzle_draft` 是长耗时链路:后端会先快速编译草稿并返回 `image_refining` / `generating` 快照,然后在 api-server 后台任务中完成首图、UI 资产、OSS 持久化、作品投影、计费退款和失败态回写。生产排查小程序 `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499`、`upstream_status=-`,说明客户端或 WebView 先断开;此时不应再把长 POST 是否返回作为生成成败依据,而应继续按实际 `session_id` 查后台任务日志、VectorEngine provider 日志、`external_api_call_failure` 和后续 GET 轮询结果。同一用户可能先轮询旧的 `puzzle-session-*`,随后 POST 新建实际生成 session;必须用 action POST 的 `request_id` 和 `/api/runtime/puzzle/agent/sessions//actions` 路径对齐真实失败请求,避免被前端显示的“来源草稿”误导。 + 查看本地 Rust / SpacetimeDB 日志: ```bash diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index b4fe7b41..be4b8cb0 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,5 +1,6 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, HashSet}, + sync::{Mutex, OnceLock}, time::{Instant, SystemTime, UNIX_EPOCH}, }; @@ -130,6 +131,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024"; const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; +static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock>> = OnceLock::new(); + +fn puzzle_background_compile_tasks() -> &'static Mutex> { + PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn try_register_puzzle_background_compile_task(session_id: &str) -> bool { + match puzzle_background_compile_tasks().lock() { + Ok(mut tasks) => tasks.insert(session_id.to_string()), + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + error = %error, + "拼图后台生成任务注册表锁已损坏,允许本次任务继续" + ); + true + } + } +} + +fn unregister_puzzle_background_compile_task(session_id: &str) { + match puzzle_background_compile_tasks().lock() { + Ok(mut tasks) => { + tasks.remove(session_id); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + error = %error, + "拼图后台生成任务注册表解锁失败,忽略清理" + ); + } + } +} + +fn has_puzzle_cover_image_src(value: &Option) -> bool { + value + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + +fn mark_puzzle_initial_generation_started_snapshot( + mut session: PuzzleAgentSessionRecord, +) -> PuzzleAgentSessionRecord { + session.stage = "image_refining".to_string(); + session.progress_percent = session.progress_percent.max(88); + if let Some(draft) = session.draft.as_mut() { + let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src); + if let Some(primary_level) = draft.levels.first_mut() { + if !has_puzzle_cover_image_src(&primary_level.cover_image_src) { + primary_level.generation_status = "generating".to_string(); + } + draft.generation_status = primary_level.generation_status.clone(); + draft.candidates = primary_level.candidates.clone(); + draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); + draft.cover_image_src = primary_level.cover_image_src.clone(); + draft.cover_asset_id = primary_level.cover_asset_id.clone(); + } else if draft_needs_cover { + draft.generation_status = "generating".to_string(); + } + } + session +} + pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String { format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0) } diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 276a29f5..43bc146d 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -1177,21 +1177,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( .or_else(|| levels.first()) } -pub(crate) async fn compile_puzzle_draft_with_initial_cover( +pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session( state: &PuzzleApiState, request_context: &RequestContext, - session_id: String, + compiled_session: PuzzleAgentSessionRecord, owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, image_model: Option<&str>, now: i64, ) -> Result { - let compiled_session = state - .spacetime_client() - .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) - .await - .map_err(map_puzzle_compile_error)?; let draft = compiled_session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, @@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( match state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { - session_id, + session_id: compiled_session.session_id.clone(), owner_user_id, level_id: Some(target_level.level_id), candidate_id: selected_candidate_id, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index afd6f3cf..873495f7 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action( session_id, owner_user_id, error_message, - failed_at_micros: now, + failed_at_micros: current_utc_micros(), }) .await; if let Err(error) = result { @@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action( 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, - &request_context, + if !try_register_puzzle_background_compile_task(&compile_session_id) { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compile_session_id, + owner_user_id = %owner_user_id, + "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话" + ); + 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(map_puzzle_client_error) + } else { + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft( compile_session_id.clone(), owner_user_id.clone(), - prompt_text, - primary_reference_image_src, - payload.image_model.as_deref(), now, ) .await - }, - ) - .await + .map_err(map_puzzle_compile_error); + match compiled_session { + Ok(compiled_session) => { + let response_session = + mark_puzzle_initial_generation_started_snapshot( + compiled_session.clone(), + ); + let background_state = state.clone(); + let background_request_context = request_context.clone(); + let background_session_id = compile_session_id.clone(); + let background_owner_user_id = owner_user_id.clone(); + let background_prompt_text = prompt_text.map(str::to_string); + let background_reference_image_src = + primary_reference_image_src.map(str::to_string); + let background_image_model = payload.image_model.clone(); + let background_billing_asset_id = + format!("{background_session_id}:compile_puzzle_draft"); + tokio::spawn(async move { + let operation_owner_user_id = + background_owner_user_id.clone(); + let background_root_state = + background_state.root_state().clone(); + let operation_state = background_state.clone(); + let result = execute_billable_asset_operation_with_cost( + &background_root_state, + &background_owner_user_id, + "puzzle_initial_image", + &background_billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async move { + generate_puzzle_initial_cover_from_compiled_session( + &operation_state, + &background_request_context, + compiled_session, + operation_owner_user_id, + background_prompt_text.as_deref(), + background_reference_image_src.as_deref(), + background_image_model.as_deref(), + current_utc_micros(), + ) + .await + }, + ) + .await; + match result { + Ok(session) => { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %background_owner_user_id, + "拼图首图后台生成任务完成" + ); + } + Err(error) => { + let error_message = error.body_text(); + let failure_result = background_state + .spacetime_client() + .mark_puzzle_draft_generation_failed( + PuzzleDraftCompileFailureRecordInput { + session_id: background_session_id.clone(), + owner_user_id: background_owner_user_id + .clone(), + error_message: error_message.clone(), + failed_at_micros: current_utc_micros(), + }, + ) + .await; + if let Err(mark_error) = failure_result { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %background_session_id, + owner_user_id = %background_owner_user_id, + message = %mark_error, + "拼图首图后台生成失败态回写失败" + ); + } + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %background_session_id, + owner_user_id = %background_owner_user_id, + message = %error_message, + "拼图首图后台生成任务失败" + ); + } + } + unregister_puzzle_background_compile_task( + &background_session_id, + ); + }); + Ok(response_session) + } + Err(error) => { + unregister_puzzle_background_compile_task(&compile_session_id); + Err(error) + } + } + } } else { compile_puzzle_draft_with_uploaded_cover( &state, @@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action( "compile_puzzle_draft", "首关拼图草稿", if ai_redraw { - "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。" + "已编译首关草稿,并启动首关画面和 UI 资产后台生成。" } else { "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" }, diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index 86512e7d..b5b902b9 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -980,6 +980,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { ); } +#[test] +fn puzzle_compile_started_snapshot_marks_primary_level_generating() { + let mut session = PuzzleAgentSessionRecord { + session_id: "puzzle-session-1".to_string(), + seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(), + current_turn: 1, + progress_percent: 88, + stage: "draft_ready".to_string(), + anchor_pack: test_puzzle_anchor_pack_record(), + draft: Some(test_puzzle_draft_record()), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + { + let draft = session.draft.as_mut().expect("draft"); + draft.generation_status = "idle".to_string(); + draft.levels[0].generation_status = "idle".to_string(); + draft.levels[0].cover_image_src = None; + draft.levels[0].cover_asset_id = None; + } + + let session = mark_puzzle_initial_generation_started_snapshot(session); + let draft = session.draft.expect("draft"); + + assert_eq!(session.stage, "image_refining"); + assert_eq!(draft.generation_status, "generating"); + assert_eq!(draft.levels[0].generation_status, "generating"); + assert!(draft.cover_image_src.is_none()); + assert!(draft.levels[0].cover_image_src.is_none()); +} + #[test] fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() { let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景"); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 86e77d3c..3c789f71 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -67,6 +67,7 @@ import type { PuzzleAgentSessionSnapshot, SendPuzzleAgentMessageRequest, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState'; import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate'; import type { PuzzleRunSnapshot, @@ -6251,7 +6252,7 @@ export function PlatformEntryFlowShellImpl({ sessionController.setCreationTypeError(errorMessage); setPuzzleCreationError(errorMessage); }, - onActionComplete: async ({ payload, response, setSession }) => { + onActionComplete: async ({ payload, response, session, setSession }) => { setPuzzleOperation(response.operation); setSession(response.session); const formPayload = buildPuzzleFormPayloadFromAction(payload); @@ -6275,6 +6276,47 @@ export function PlatformEntryFlowShellImpl({ if (payload.action === 'compile_puzzle_draft') { const openResult = selectionStageRef.current === 'puzzle-generating'; + if (!isPuzzleCompileActionReady(response.session)) { + const nextPayload = + formPayload ?? buildPuzzleFormPayloadFromSession(response.session); + const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload( + nextPayload, + response.session, + ); + const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState( + puzzleGenerationState ?? fallbackGenerationState, + response.session, + ); + activePuzzleGenerationSessionIdRef.current = response.session.sessionId; + setSelectionStage('puzzle-generating'); + markDraftGenerating('puzzle', [ + response.session.sessionId, + buildPuzzleResultWorkId(response.session.sessionId), + response.session.publishedProfileId, + buildPuzzleResultProfileId(response.session.sessionId), + ]); + markPendingDraftGenerating( + 'puzzle', + response.session.sessionId, + buildPendingPuzzleDraftMetadata(nextPayload), + ); + setPuzzleGenerationState(nextGenerationState); + setPuzzleBackgroundCompileTasks((current) => { + const next = { ...current }; + if (session.sessionId !== response.session.sessionId) { + delete next[session.sessionId]; + } + next[response.session.sessionId] = { + session: response.session, + payload: nextPayload, + generationState: nextGenerationState, + error: null, + }; + return next; + }); + void refreshPuzzleShelf(); + return { openResult: false }; + } setPuzzleGenerationState((current) => current ? resolveFinishedMiniGameDraftGenerationState(current, 'ready', { @@ -7169,6 +7211,22 @@ export function PlatformEntryFlowShellImpl({ return; } + if (hasRecoverableGeneratedPuzzleDraft(latestSession)) { + const payload = + puzzleGenerationViewPayload ?? + buildPuzzleFormPayloadFromSession(latestSession); + const generationState = + puzzleGenerationViewState ?? + createPuzzleDraftGenerationStateFromPayload(payload, latestSession); + await recoverCompletedPuzzleDraftGeneration({ + sessionId: latestSession.sessionId, + payload, + generationState, + setSession: setPuzzleSession, + }); + return; + } + setPuzzleSession(latestSession); setPuzzleBackgroundCompileTasks((current) => { const task = current[activePuzzleGenerationSessionId]; @@ -7212,6 +7270,9 @@ export function PlatformEntryFlowShellImpl({ }; }, [ activePuzzleGenerationSessionId, + puzzleGenerationViewPayload, + puzzleGenerationViewState, + recoverCompletedPuzzleDraftGeneration, shouldPollPuzzleGenerationSession, setPuzzleSession, ]); diff --git a/src/components/platform-entry/puzzleDraftGenerationState.test.ts b/src/components/platform-entry/puzzleDraftGenerationState.test.ts new file mode 100644 index 00000000..dd4a955c --- /dev/null +++ b/src/components/platform-entry/puzzleDraftGenerationState.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; + +describe('isPuzzleCompileActionReady', () => { + it('keeps compile action generating until the draft has a cover image', () => { + const session = { + sessionId: 'puzzle-session-1', + draft: { + coverImageSrc: null, + levels: [ + { + generationStatus: 'generating', + coverImageSrc: null, + }, + ], + }, + } as PuzzleAgentSessionSnapshot; + + expect(isPuzzleCompileActionReady(session)).toBe(false); + }); + + it('treats compile action as ready after the selected cover exists', () => { + const session = { + sessionId: 'puzzle-session-1', + draft: { + coverImageSrc: '/generated-puzzle-assets/session/cover.png', + levels: [ + { + generationStatus: 'ready', + coverImageSrc: '/generated-puzzle-assets/session/cover.png', + }, + ], + }, + } as PuzzleAgentSessionSnapshot; + + expect(isPuzzleCompileActionReady(session)).toBe(true); + }); +}); diff --git a/src/components/platform-entry/puzzleDraftGenerationState.ts b/src/components/platform-entry/puzzleDraftGenerationState.ts new file mode 100644 index 00000000..f00ee282 --- /dev/null +++ b/src/components/platform-entry/puzzleDraftGenerationState.ts @@ -0,0 +1,20 @@ +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; + +function hasText(value: string | null | undefined) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function isPuzzleCompileActionReady( + session: PuzzleAgentSessionSnapshot, +) { + const draft = session.draft; + if (!draft) { + return false; + } + if (hasText(draft.coverImageSrc)) { + return true; + } + return ( + draft.levels?.some((level) => hasText(level.coverImageSrc)) === true + ); +}