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