use axum::{ Json, body::{Body, to_bytes}, extract::{Extension, Path, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::Response, }; use image::GenericImageView; use module_assets::{ AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, }; use shared_contracts::puzzle_clear::{ PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset, PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset, PuzzleClearNextLevelRequest, PuzzleClearPatternGroup, PuzzleClearRetryLevelRequest, PuzzleClearRunResponse, PuzzleClearSessionResponse, PuzzleClearSessionSnapshotResponse, PuzzleClearStartRunRequest, PuzzleClearSwapRequest, PuzzleClearTimeUpRequest, PuzzleClearWorkDetailResponse, PuzzleClearWorkMutationResponse, PuzzleClearWorksResponse, PuzzleClearWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::{ ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, }; use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, request_context::RequestContext, state::AppState, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const PUZZLE_CLEAR_PROVIDER: &str = "puzzle-clear"; const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation"; const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime"; const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear"; const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消"; pub(crate) const PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_clear_compile_draft"; const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs"; const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256; const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4; const PUZZLE_CLEAR_SHEET_ROWS: u32 = 6; const PUZZLE_CLEAR_SHEET_COLUMNS_USIZE: usize = 4; const PUZZLE_CLEAR_SHEET_ROWS_USIZE: usize = 6; const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10; const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10; const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = "."; const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL"; const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536"; const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024"; const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp"; const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4; const PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 58; const PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO: f32 = 0.018; const PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO: f32 = 0.045; const PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD: f32 = 0.34; const PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD: f32 = 0.66; const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 170; const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.92; const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0; const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0; const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插"; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PuzzleClearCompileDraftWorkerPayload { pub session_id: String, pub owner_user_id: String, pub author_display_name: String, pub payload: PuzzleClearActionRequest, } pub async fn create_puzzle_clear_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?; validate_workspace_request(&request_context, &payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_SESSION_ID_PREFIX); let now = current_utc_micros(); let draft = build_puzzle_clear_draft(&payload); let session = PuzzleClearSessionSnapshotResponse { session_id, owner_user_id, status: PuzzleClearGenerationStatus::Draft, draft: Some(draft), created_at: format_timestamp_micros(now), updated_at: format_timestamp_micros(now), }; Ok(json_success_body( Some(&request_context), PuzzleClearSessionResponse { session: state .spacetime_client() .create_puzzle_clear_session(session) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?, }, )) } pub async fn get_puzzle_clear_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let session = state .spacetime_client() .get_puzzle_clear_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearSessionResponse { session }, )) } pub async fn execute_puzzle_clear_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let Json(payload) = puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let author_display_name = authenticated .claims() .display_name .as_deref() .unwrap_or("拼消消玩家") .to_string(); let mut payload = payload; let should_queue_generation = matches!( payload.action_type, PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas ) && !state.config.external_generation_mode.is_inline(); if should_queue_generation { let mut queued_response = state .spacetime_client() .mark_puzzle_clear_generation_queued( session_id.clone(), owner_user_id.clone(), author_display_name.clone(), payload.clone(), ) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?; let queue_job = enqueue_puzzle_clear_compile_draft_job( &state, &request_context, &session_id, owner_user_id.as_str(), author_display_name.as_str(), payload, ) .await?; queued_response.queue_state = Some(map_puzzle_clear_queue_job_status(queue_job)); return Ok(json_success_body(Some(&request_context), queued_response)); } if let Err(response) = maybe_prepare_puzzle_clear_assets_inner( &state, &request_context, session_id.as_str(), owner_user_id.as_str(), &mut payload, ) .await { let (error_message, response) = extract_puzzle_clear_response_error_message(response).await; tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, session_id, error = %error_message, "拼消消素材生成失败,准备回写 failed 状态" ); if let Err(writeback_error) = state .spacetime_client() .mark_puzzle_clear_generation_failed( session_id.clone(), owner_user_id.clone(), author_display_name.clone(), payload.clone(), ) .await { tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, session_id, error = %writeback_error, "拼消消素材生成失败状态回写失败" ); } return Err(response); } let response = state .spacetime_client() .execute_puzzle_clear_action(session_id, owner_user_id, author_display_name, payload) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), response)) } async fn enqueue_puzzle_clear_compile_draft_job( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, author_display_name: &str, payload: PuzzleClearActionRequest, ) -> Result { let job_id = build_prefixed_uuid_id("task-"); let now_micros = current_utc_micros(); let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload { session_id: session_id.to_string(), owner_user_id: owner_user_id.to_string(), author_display_name: author_display_name.to_string(), payload, }) .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "message": format!("拼消消 worker 任务参数序列化失败:{error}"), })), ) })?; let price_mud_points = resolve_puzzle_clear_generation_points_cost(state).await; state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { dedupe_key: format!("puzzle-clear:compile-draft:{session_id}:{job_id}"), job_id, job_kind: PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND.to_string(), owner_user_id: owner_user_id.to_string(), source_module: "puzzle-clear".to_string(), source_entity_id: session_id.to_string(), request_label: "拼消消草稿生成".to_string(), request_payload_json, max_attempts: 1, available_at_micros: now_micros, created_at_micros: now_micros, price_mud_points, }) .await .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) }) } async fn resolve_puzzle_clear_generation_points_cost(state: &AppState) -> u64 { crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, PUZZLE_CLEAR_TEMPLATE_ID, u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), ) .await } fn map_puzzle_clear_queue_job_status( job: ExternalGenerationJobRecord, ) -> ExternalGenerationJobStatusRecord { ExternalGenerationJobStatusRecord { operation_id: job.job_id, status: ExternalGenerationJobStatus::Queued, phase_label: job.request_label, phase_detail: "排队中。".to_string(), progress: 8, error: job.last_error_message, updated_at_micros: job.updated_at_micros, } } pub(crate) async fn execute_puzzle_clear_compile_draft_worker_job( state: &AppState, request_context: &RequestContext, mut worker_payload: PuzzleClearCompileDraftWorkerPayload, ) -> Result { if let Err(response) = maybe_prepare_puzzle_clear_assets_inner( state, request_context, worker_payload.session_id.as_str(), worker_payload.owner_user_id.as_str(), &mut worker_payload.payload, ) .await { let (error_message, response) = extract_puzzle_clear_response_error_message(response).await; tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, session_id = worker_payload.session_id, error = %error_message, "拼消消 worker 素材生成失败,准备回写 failed 状态" ); if let Err(writeback_error) = state .spacetime_client() .mark_puzzle_clear_generation_failed( worker_payload.session_id.clone(), worker_payload.owner_user_id.clone(), worker_payload.author_display_name.clone(), worker_payload.payload.clone(), ) .await { tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, session_id = worker_payload.session_id, error = %writeback_error, "拼消消 worker 失败状态回写失败" ); } return Err(response); } let response = state .spacetime_client() .execute_puzzle_clear_action( worker_payload.session_id, worker_payload.owner_user_id, worker_payload.author_display_name, worker_payload.payload, ) .await .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(response.session) } pub async fn list_puzzle_clear_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let works = state .spacetime_client() .list_puzzle_clear_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearWorksResponse { items: works.into_iter().map(|work| work.summary).collect(), }, )) } pub async fn get_puzzle_clear_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let item = state .spacetime_client() .get_puzzle_clear_work_profile(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearWorkDetailResponse { item }, )) } pub async fn publish_puzzle_clear_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let item = state .spacetime_client() .publish_puzzle_clear_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_CREATION_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearWorkMutationResponse { item }, )) } pub async fn get_puzzle_clear_runtime_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let item = state .spacetime_client() .get_puzzle_clear_runtime_work(profile_id) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearWorkDetailResponse { item }, )) } pub async fn start_puzzle_clear_run( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; let owner_user_id = principal.subject().to_string(); let principal_kind = principal.kind().as_str(); let run = state .spacetime_client() .start_puzzle_clear_run(payload, owner_user_id) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; record_work_play_start_after_success( &state, &request_context, build_puzzle_clear_work_play_tracking_draft( &principal, run.profile_id.clone(), PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE, ) .owner_user_id(run.owner_user_id.clone()) .run_id(run.run_id.clone()) .profile_id(run.profile_id.clone()) .extra(json!({ "runStatus": run.status, "principalKind": principal_kind, "levelIndex": run.level_index, })), ) .await; Ok(json_success_body( Some(&request_context), PuzzleClearRunResponse { run }, )) } pub async fn get_puzzle_clear_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let run = state .spacetime_client() .get_puzzle_clear_run(run_id, principal.subject().to_string()) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearRunResponse { run }, )) } pub async fn swap_puzzle_clear_cards( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .swap_puzzle_clear_cards(run_id, principal.subject().to_string(), payload) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearRunResponse { run }, )) } pub async fn retry_puzzle_clear_level( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .retry_puzzle_clear_level(run_id, principal.subject().to_string(), payload) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearRunResponse { run }, )) } pub async fn advance_puzzle_clear_next_level( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .advance_puzzle_clear_next_level(run_id, principal.subject().to_string(), payload) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearRunResponse { run }, )) } pub async fn mark_puzzle_clear_level_time_up( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .mark_puzzle_clear_level_time_up(run_id, principal.subject().to_string(), payload) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearRunResponse { run }, )) } pub async fn list_puzzle_clear_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_puzzle_clear_gallery() .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), json!({ "items": items, "hasMore": false, "nextCursor": null, }), )) } pub async fn get_puzzle_clear_gallery_detail( State(state): State, Path(public_work_code): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?; let normalized_code = normalize_public_work_code(public_work_code.as_str()); let items = state .spacetime_client() .list_puzzle_clear_gallery() .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; let profile_id = items .into_iter() .find(|item| { normalize_public_work_code( build_puzzle_clear_public_work_code(&item.profile_id).as_str(), ) == normalized_code || normalize_public_work_code(item.profile_id.as_str()) == normalized_code }) .map(|item| item.profile_id) .ok_or_else(|| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": PUZZLE_CLEAR_PROVIDER, "message": "拼消消公开作品不存在", })), ) })?; let item = state .spacetime_client() .get_puzzle_clear_work_profile(profile_id, String::new()) .await .map_err(|error| { puzzle_clear_error_response( &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER, map_puzzle_clear_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleClearWorkDetailResponse { item }, )) } #[derive(Clone, Debug, PartialEq, Eq)] struct PuzzleClearAtlasCardSlice { group: PuzzleClearPatternGroup, task_id: Option, part_x: u32, part_y: u32, bytes: Vec, } #[derive(Clone, Copy, Debug)] struct PuzzleClearAtlasSheetSpec { sheet_id: &'static str, layout: [[&'static str; PUZZLE_CLEAR_SHEET_COLUMNS_USIZE]; PUZZLE_CLEAR_SHEET_ROWS_USIZE], layout_prompt: &'static str, } #[derive(Clone, Debug)] struct PuzzleClearGeneratedSheet { spec: PuzzleClearAtlasSheetSpec, prompt: String, task_id: String, image: DownloadedOpenAiImage, } async fn maybe_prepare_puzzle_clear_assets_inner( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &mut PuzzleClearActionRequest, ) -> Result<(), Response> { if !matches!( payload.action_type, PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas ) { return Ok(()); } if payload.atlas_asset.is_some() && payload .pattern_groups .as_ref() .is_some_and(|groups| !groups.is_empty()) && payload .card_assets .as_ref() .is_some_and(|cards| !cards.is_empty()) { return Ok(()); } let profile_id = payload .profile_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) .map(ToString::to_string) .unwrap_or_else(|| { build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX) }); payload.profile_id = Some(profile_id.clone()); if payload.generate_board_background.unwrap_or(false) && payload .board_background_asset .as_ref() .is_none_or(|asset| asset.image_src.trim().is_empty()) { let board_background_prompt = payload .board_background_prompt .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let theme_prompt = payload.theme_prompt.as_deref().unwrap_or_default(); let background_asset = generate_and_persist_puzzle_clear_board_background( state, request_context, owner_user_id, profile_id.as_str(), board_background_prompt.unwrap_or(theme_prompt), ) .await?; payload.board_background_asset = Some(background_asset); } else if let Some(asset) = payload.board_background_asset.clone() { if asset.image_src.trim_start().starts_with("data:image/") { payload.board_background_asset = Some( persist_puzzle_clear_data_url_asset( state, request_context, owner_user_id, profile_id.as_str(), "board-background-upload", asset.prompt.as_str(), asset.image_src.as_str(), 1024, 1536, ) .await?, ); } } let groups = planned_puzzle_clear_pattern_groups(); let groups_by_id = groups .iter() .cloned() .map(|group| (group.group_id.clone(), group)) .collect::>(); let sheet_specs = puzzle_clear_atlas_sheet_specs(); let settings = require_openai_image_settings(state) .map(|settings| { settings.with_external_api_audit_context( request_context, Some(owner_user_id.to_string()), Some(profile_id.clone()), ) }) .map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let http_client = build_openai_image_http_client(&settings).map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let mut generated_sheets = Vec::with_capacity(sheet_specs.len()); for sheet_spec in sheet_specs { let sheet_prompt = build_puzzle_clear_atlas_prompt( payload.theme_prompt.as_deref().unwrap_or_default(), &sheet_spec, ); let mut accepted_sheet = None; for attempt_index in 0..PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS { let failure_context = format!( "拼消消素材 {} 生成失败,第 {} 次", sheet_spec.sheet_id, attempt_index + 1 ); let generated = match create_openai_image_generation( &http_client, &settings, sheet_prompt.as_str(), Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT), PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, 1, &[], failure_context.as_str(), ) .await { Ok(generated) => generated, Err(error) if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS && is_retryable_puzzle_clear_sheet_generation_error(&error) => { tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, sheet_id = sheet_spec.sheet_id, attempt = attempt_index + 1, generation_error = %error.body_text(), "拼消消素材 sheet 生成遇到可重试上游错误,准备重试" ); continue; } Err(error) => { return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error, )); } }; let task_id = generated.task_id; let image = generated.images.into_iter().next().ok_or_else(|| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": format!("拼消消素材 {} 生成成功但未返回图片。", sheet_spec.sheet_id), })), ) })?; match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) { Ok(()) => { accepted_sheet = Some(PuzzleClearGeneratedSheet { spec: sheet_spec, prompt: sheet_prompt.clone(), task_id, image, }); break; } Err(error) if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS => { tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, sheet_id = sheet_spec.sheet_id, attempt = attempt_index + 1, quality_error = %error.body_text(), "拼消消素材 sheet 质量校验未通过,准备重试" ); } Err(error) => { tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, sheet_id = sheet_spec.sheet_id, attempt = attempt_index + 1, quality_error = %error.body_text(), "拼消消素材 sheet 质量校验最终未通过" ); return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error, )); } } } let Some(accepted_sheet) = accepted_sheet else { return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消素材 {} 多次生成后仍未得到可切图集。", sheet_spec.sheet_id), })), )); }; generated_sheets.push(accepted_sheet); } let mut slices = Vec::new(); for generated_sheet in &generated_sheets { slices.extend( slice_puzzle_clear_sheet( &generated_sheet.image, &generated_sheet.spec, &groups_by_id, generated_sheet.task_id.as_str(), ) .map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?, ); } let atlas_image = compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let atlas_prompt = generated_sheets .iter() .map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt)) .collect::>() .join("\n\n---\n\n"); let atlas_task_ids = generated_sheets .iter() .map(|sheet| sheet.task_id.as_str()) .collect::>() .join(","); let atlas_asset = persist_puzzle_clear_generated_image_asset( state, owner_user_id, profile_id.as_str(), "atlas", atlas_prompt.as_str(), Some(atlas_task_ids.as_str()), atlas_image, PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS, PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS, request_context, ) .await?; let mut card_assets = Vec::with_capacity(slices.len()); for slice in slices { let task_id = slice.task_id.clone(); card_assets.push( persist_puzzle_clear_card_slice( state, owner_user_id, profile_id.as_str(), task_id.as_deref(), slice, request_context, ) .await?, ); } payload.atlas_asset = Some(atlas_asset); payload.pattern_groups = Some(groups); payload.card_assets = Some(card_assets); tracing::info!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, session_id, profile_id, group_count = payload.pattern_groups.as_ref().map_or(0, Vec::len), card_count = payload.card_assets.as_ref().map_or(0, Vec::len), "拼消消素材 atlas 已生成、切片并写入资产索引" ); Ok(()) } async fn extract_puzzle_clear_response_error_message(response: Response) -> (String, Response) { let status = response.status(); let (parts, body) = response.into_parts(); let body_bytes = match to_bytes(body, 64 * 1024).await { Ok(bytes) => bytes, Err(_) => { let rebuilt = Response::from_parts(parts, Body::empty()); return (format!("拼消消素材生成失败:{status}"), rebuilt); } }; let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string(); let message = if body_text.is_empty() { format!("拼消消素材生成失败:{status}") } else if let Ok(body_json) = serde_json::from_str::(&body_text) { body_json .get("error") .and_then(|error| error.get("message")) .and_then(Value::as_str) .map(str::trim) .filter(|message| !message.is_empty()) .unwrap_or(body_text.as_str()) .to_string() } else { body_text }; let rebuilt = Response::from_parts(parts, Body::from(body_bytes)); (message, rebuilt) } fn planned_puzzle_clear_pattern_groups() -> Vec { module_puzzle_clear::plan_puzzle_clear_pattern_groups(PUZZLE_CLEAR_ATLAS_CELL_SIZE) .unwrap_or_default() .into_iter() .map(|group| PuzzleClearPatternGroup { group_id: group.group_id, shape: group.shape.as_str().to_string(), width: group.width, height: group.height, atlas_x: group.atlas_x, atlas_y: group.atlas_y, atlas_width: group.atlas_width, atlas_height: group.atlas_height, }) .collect() } fn puzzle_clear_atlas_sheet_specs() -> Vec { vec![ PuzzleClearAtlasSheetSpec { sheet_id: "sheet-01", layout: [ ["D01", "D01", "D01", "A02"], ["D01", "D01", "D01", "A02"], ["D02", "D02", "C01", "C01"], ["D02", "D02", "C01", "C01"], ["D02", "D02", "C02", "C02"], ["A01", "A01", "C02", "C02"], ], layout_prompt: concat!( "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", "第1行:D01 D01 D01 A02\n", "第2行:D01 D01 D01 A02\n", "第3行:D02 D02 C01 C01\n", "第4行:D02 D02 C01 C01\n", "第5行:D02 D02 C02 C02\n", "第6行:A01 A01 C02 C02\n\n", "A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" ), }, PuzzleClearAtlasSheetSpec { sheet_id: "sheet-02", layout: [ ["D03", "D03", "D03", "A04"], ["D03", "D03", "D03", "A04"], ["C03", "C03", "C04", "C04"], ["C03", "C03", "C04", "C04"], ["B01", "B01", "B01", "A06"], ["B03", "B03", "B03", "A06"], ], layout_prompt: concat!( "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", "第1行:D03 D03 D03 A04\n", "第2行:D03 D03 D03 A04\n", "第3行:C03 C03 C04 C04\n", "第4行:C03 C03 C04 C04\n", "第5行:B01 B01 B01 A06\n", "第6行:B03 B03 B03 A06\n\n", "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" ), }, PuzzleClearAtlasSheetSpec { sheet_id: "sheet-03", layout: [ ["B02", "B04", "A03", "A03"], ["B02", "B04", "A05", "A05"], ["B02", "B04", "A07", "A07"], ["B05", "B05", "B05", "A08"], ["A09", "A09", "A10", "A08"], ["A11", "A11", "A10", PUZZLE_CLEAR_SHEET_FILLER_CELL], ], layout_prompt: concat!( "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", "第1行:B02 B04 A03 A03\n", "第2行:B02 B04 A05 A05\n", "第3行:B02 B04 A07 A07\n", "第4行:B05 B05 B05 A08\n", "第5行:A09 A09 A10 A08\n", "第6行:A11 A11 A10 FILL\n\n", "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。FILL 是后台会丢弃的补位格,请画成主题一致但不参与玩法的小照片裁片,不要写字或编号。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" ), }, PuzzleClearAtlasSheetSpec { sheet_id: "sheet-04", layout: [ ["A12", "A13", "A13", "A14"], ["A12", "A15", "A15", "A14"], ["A16", "A17", "A17", "A18"], ["A16", "A19", "A19", "A18"], ["A20", "A21", "A21", "A22"], ["A20", "A23", "A23", "A22"], ], layout_prompt: concat!( "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", "第1行:A12 A13 A13 A14\n", "第2行:A12 A15 A15 A14\n", "第3行:A16 A17 A17 A18\n", "第4行:A16 A19 A19 A18\n", "第5行:A20 A21 A21 A22\n", "第6行:A20 A23 A23 A22\n\n", "A 表示 1x2 复合图案。相同编号表示同一视觉家族:横向 1x2 和纵向 1x2 要共享同一场景锚点,用色调、道具和背景线索互相呼应;每个 256 单元仍需完整可读,但不要做成彼此无关的随机独立小图。" ), }, ] } fn build_puzzle_clear_atlas_prompt( theme_prompt: &str, sheet_spec: &PuzzleClearAtlasSheetSpec, ) -> String { let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "梦幻物件".to_string()); format!( concat!( "生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n", "这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n", "这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域都要属于同一视觉家族,必须有明确背景、环境、道具、光影和构图线索,像从同一组丰富照片或插画中裁出的局部。\n\n", "每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;同组之间要共享同一场景锚点、主色和道具语言。禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n", "相同编号连续占据的格子表示同一视觉家族,不是随机独立小图。请把同编号区域画成一组可辨认的兄弟卡片,至少共享一个明显场景锚点(同一张桌子、同一窗景、同一庭院、同一篮子或同一器物系统);每个格子可以展示这个家族的不同局部、视角或连贯片段,但仍需完整可读,不能在单格内部再切出第二张图或第二个场景。\n\n", "同一张 sheet 内,不同编号必须使用不同视觉概念,并且拉开主色、场景和道具,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n", "每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n", "不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;FILL 补位格可以生成主题一致的小照片裁片,但后台会丢弃它,不要在 FILL 中写字、编号或画规则说明。\n\n", "图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n", "画风为高清、清爽、适合休闲消除游戏的丰富主题插画;颜色鲜明,边缘干净,不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n", "{layout_prompt}" ), subject = subject, layout_prompt = sheet_spec.layout_prompt ) } fn build_puzzle_clear_board_background_prompt(theme_prompt: &str) -> String { let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "拼消消".to_string()); format!( concat!( "生成拼消消中央背景底图,1:1 正方形,尺寸与中央棋盘一致,主题是「{subject}」。", "这张图不是棋盘装饰,而是玩家逐渐消除卡片后慢慢看见的目标画面;", "画面需要让玩家有探索、揭开底图全貌和追求目标完成的感受。", "请以主题为核心设计精致完整的主题场景或主题主视觉,主体清晰、细节丰富、色彩和氛围强绑定主题,强表现力,必须一眼体现主题。", "不要文字、水印、按钮、教程浮层或明显网格。" ), subject = subject ) } async fn generate_and_persist_puzzle_clear_board_background( state: &AppState, request_context: &RequestContext, owner_user_id: &str, profile_id: &str, theme_prompt: &str, ) -> Result { let prompt = build_puzzle_clear_board_background_prompt(theme_prompt); let settings = require_openai_image_settings(state) .map(|settings| { settings.with_external_api_audit_context( request_context, Some(owner_user_id.to_string()), Some(profile_id.to_string()), ) }) .map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let http_client = build_openai_image_http_client(&settings).map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let generated = create_openai_image_generation( &http_client, &settings, prompt.as_str(), Some("文字、水印、按钮、教程浮层、明显网格"), PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, 1, &[], "拼消消场地底图生成失败", ) .await .map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let task_id = generated.task_id; let image = generated.images.into_iter().next().ok_or_else(|| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "拼消消场地底图生成成功但未返回图片。", })), ) })?; persist_puzzle_clear_generated_image_asset( state, owner_user_id, profile_id, "board-background", prompt.as_str(), Some(task_id.as_str()), image, 1024, 1024, request_context, ) .await } async fn persist_puzzle_clear_data_url_asset( state: &AppState, request_context: &RequestContext, owner_user_id: &str, profile_id: &str, slot: &str, prompt: &str, data_url: &str, width: u32, height: u32, ) -> Result { let parsed = decode_generated_image_asset_data_url(data_url).map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "generated-image-assets", "message": format!("拼消消图片 Data URL 解析失败:{error:?}"), })), ) })?; let image = DownloadedOpenAiImage { extension: parsed.format.extension, mime_type: parsed.format.mime_type, bytes: parsed.bytes, }; persist_puzzle_clear_generated_image_asset( state, owner_user_id, profile_id, slot, prompt, None, image, width, height, request_context, ) .await } #[derive(Clone, Copy, Debug)] struct PuzzleClearSheetCellBounds { x0: u32, y0: u32, x1: u32, y1: u32, } impl PuzzleClearSheetCellBounds { fn width(self) -> u32 { self.x1.saturating_sub(self.x0).max(1) } fn height(self) -> u32 { self.y1.saturating_sub(self.y0).max(1) } fn area(self) -> u32 { self.width().saturating_mul(self.height()).max(1) } } #[derive(Clone, Copy, Debug)] struct PuzzleClearSheetCellQuality { foreground_ratio: f32, exposed_edge_count: usize, strongest_edge_ratio: f32, strongest_internal_seam_ratio: f32, } fn validate_puzzle_clear_sheet_quality( image: &DownloadedOpenAiImage, sheet_spec: &PuzzleClearAtlasSheetSpec, ) -> Result<(), AppError> { // 中文注释:生成图进入正式切片前先做像素级门禁,避免把明显错位的 sheet 持久化成卡牌资产。 let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消素材 {} 解码失败:{error}", sheet_spec.sheet_id), })) })?; let source_width = source.width(); let source_height = source.height(); if source_width < PUZZLE_CLEAR_SHEET_COLUMNS || source_height < PUZZLE_CLEAR_SHEET_ROWS { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消素材 {} 尺寸过小,无法做切片质量校验。", sheet_spec.sheet_id), })), ); } let mut findings = Vec::new(); let mut advisory_findings = Vec::new(); for row in 0..PUZZLE_CLEAR_SHEET_ROWS { for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS { let group_id = sheet_spec.layout[row as usize][col as usize]; let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height); let quality = analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds); let cell_label = format!("第{}行第{}列", row + 1, col + 1); if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL { if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO { findings.push(format!("{cell_label} 空白格有主体")); } continue; } if group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL { continue; } if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO { findings.push(format!("{cell_label} 主体过少")); } if quality.strongest_internal_seam_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD { findings.push(format!("{cell_label} 单格内部疑似拼接线")); } if quality.exposed_edge_count >= 2 && quality.strongest_edge_ratio > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD { advisory_findings.push(format!("{cell_label} 主体贴到不同图案边界")); } } } if !advisory_findings.is_empty() { tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, sheet_id = sheet_spec.sheet_id, quality_warning = %advisory_findings.join(";"), "拼消消素材 sheet 检测到边界接触,已作为提示继续切片" ); } if findings.is_empty() { return Ok(()); } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "reason": "invalid_puzzle_clear_sheet_quality", "message": format!( "拼消消素材 {} 不满足切片质量:{}。请重新生成图集。", sheet_spec.sheet_id, findings.join(";"), ), "findings": findings, })), ) } fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool { if !matches!( error.status_code(), StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT | StatusCode::TOO_MANY_REQUESTS ) { return false; } error .details() .and_then(|details| details.get("retryable")) .and_then(Value::as_bool) .unwrap_or(false) } fn analyze_puzzle_clear_sheet_cell_quality( source: &image::DynamicImage, sheet_spec: &PuzzleClearAtlasSheetSpec, row: u32, col: u32, bounds: PuzzleClearSheetCellBounds, ) -> PuzzleClearSheetCellQuality { let background = sample_puzzle_clear_sheet_cell_background(source, bounds); let width = bounds.width() as usize; let height = bounds.height() as usize; let mut mask = vec![0u8; width.saturating_mul(height)]; let mut foreground_pixels = 0u32; for local_y in 0..height { let y = bounds.y0 + local_y as u32; for local_x in 0..width { let x = bounds.x0 + local_x as u32; if is_puzzle_clear_sheet_foreground_pixel(source.get_pixel(x, y).0, background) { mask[local_y * width + local_x] = 1; foreground_pixels = foreground_pixels.saturating_add(1); } } } let (exposed_edge_count, strongest_edge_ratio) = measure_puzzle_clear_sheet_exposed_edges(&mask, width, height, sheet_spec, row, col); let strongest_internal_seam_ratio = measure_puzzle_clear_sheet_internal_seam(source, bounds); PuzzleClearSheetCellQuality { foreground_ratio: foreground_pixels as f32 / bounds.area() as f32, exposed_edge_count, strongest_edge_ratio, strongest_internal_seam_ratio, } } fn puzzle_clear_sheet_cell_bounds( row: u32, col: u32, source_width: u32, source_height: u32, ) -> PuzzleClearSheetCellBounds { let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS); let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS); let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS) .max(x0 + 1) .min(source_width); let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS) .max(y0 + 1) .min(source_height); PuzzleClearSheetCellBounds { x0, y0, x1, y1 } } fn is_puzzle_clear_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { if pixel[3] <= 24 { return false; } let alpha_diff = (pixel[3] as i32 - background[3] as i32).abs(); let color_diff = (pixel[0] as i32 - background[0] as i32).abs() + (pixel[1] as i32 - background[1] as i32).abs() + (pixel[2] as i32 - background[2] as i32).abs(); alpha_diff >= 48 || color_diff >= PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD } fn sample_puzzle_clear_sheet_cell_background( source: &image::DynamicImage, bounds: PuzzleClearSheetCellBounds, ) -> [u8; 4] { let sample_size = (bounds.width().min(bounds.height()) / 12).clamp(2, 8); let points = [ (bounds.x0, bounds.y0), (bounds.x1.saturating_sub(sample_size), bounds.y0), (bounds.x0, bounds.y1.saturating_sub(sample_size)), ( bounds.x1.saturating_sub(sample_size), bounds.y1.saturating_sub(sample_size), ), ]; let mut samples = Vec::new(); for (start_x, start_y) in points { let mut totals = [0u32; 4]; let mut count = 0u32; for y in start_y..start_y.saturating_add(sample_size).min(bounds.y1) { for x in start_x..start_x.saturating_add(sample_size).min(bounds.x1) { let pixel = source.get_pixel(x, y).0; totals[0] = totals[0].saturating_add(pixel[0] as u32); totals[1] = totals[1].saturating_add(pixel[1] as u32); totals[2] = totals[2].saturating_add(pixel[2] as u32); totals[3] = totals[3].saturating_add(pixel[3] as u32); count = count.saturating_add(1); } } if count > 0 { samples.push([ (totals[0] / count) as u8, (totals[1] / count) as u8, (totals[2] / count) as u8, (totals[3] / count) as u8, ]); } } samples .into_iter() .max_by_key(|sample| { let max_channel = sample[0].max(sample[1]).max(sample[2]) as u16; let min_channel = sample[0].min(sample[1]).min(sample[2]) as u16; let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; let saturation = max_channel.saturating_sub(min_channel); (luminance, u16::MAX.saturating_sub(saturation)) }) .unwrap_or([255, 255, 255, 255]) } fn measure_puzzle_clear_sheet_exposed_edges( mask: &[u8], width: usize, height: usize, sheet_spec: &PuzzleClearAtlasSheetSpec, row: u32, col: u32, ) -> (usize, f32) { if width == 0 || height == 0 || mask.len() < width.saturating_mul(height) { return (0, 0.0); } let band = (width.min(height) / 24).clamp(6, 12); let mut exposed_edges = 0usize; let mut strongest_ratio = 0.0f32; let edge_specs = [ ((-1i32, 0i32), 0usize, 0usize, width, band), ((1, 0), 0, height.saturating_sub(band), width, band), ((0, -1), 0, 0, band, height), ((0, 1), width.saturating_sub(band), 0, band, height), ]; for ((row_delta, col_delta), start_x, start_y, edge_width, edge_height) in edge_specs { if puzzle_clear_sheet_neighbor_is_same_group(sheet_spec, row, col, row_delta, col_delta) { continue; } let mut foreground = 0usize; let mut total = 0usize; for local_y in start_y..start_y.saturating_add(edge_height).min(height) { for local_x in start_x..start_x.saturating_add(edge_width).min(width) { total = total.saturating_add(1); if mask[local_y * width + local_x] != 0 { foreground = foreground.saturating_add(1); } } } if total == 0 { continue; } let ratio = foreground as f32 / total as f32; strongest_ratio = strongest_ratio.max(ratio); if ratio > PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD { exposed_edges = exposed_edges.saturating_add(1); } } (exposed_edges, strongest_ratio) } fn measure_puzzle_clear_sheet_internal_seam( source: &image::DynamicImage, bounds: PuzzleClearSheetCellBounds, ) -> f32 { let width = bounds.width(); let height = bounds.height(); if width < 48 || height < 48 { return 0.0; } let margin_x = (width / 8).clamp(18, 36); let margin_y = (height / 8).clamp(18, 36); let x_start = bounds.x0.saturating_add(margin_x).max(bounds.x0 + 1); let x_end = bounds.x1.saturating_sub(margin_x).max(x_start + 1); let y_start = bounds.y0.saturating_add(margin_y).max(bounds.y0 + 1); let y_end = bounds.y1.saturating_sub(margin_y).max(y_start + 1); let mut strongest = 0.0f32; for x in x_start..x_end { let mut strong = 0u32; let mut total = 0u32; for y in y_start..y_end { let left = source.get_pixel(x.saturating_sub(1), y).0; let right = source.get_pixel(x, y).0; if puzzle_clear_rgb_distance(left, right) >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD { strong = strong.saturating_add(1); } total = total.saturating_add(1); } if total > 0 { let line_ratio = strong as f32 / total as f32; if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD && puzzle_clear_sheet_internal_seam_has_flat_split( source, bounds, x, true, x_start, x_end, y_start, y_end, ) { strongest = strongest.max(line_ratio); } } } for y in y_start..y_end { let mut strong = 0u32; let mut total = 0u32; for x in x_start..x_end { let top = source.get_pixel(x, y.saturating_sub(1)).0; let bottom = source.get_pixel(x, y).0; if puzzle_clear_rgb_distance(top, bottom) >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD { strong = strong.saturating_add(1); } total = total.saturating_add(1); } if total > 0 { let line_ratio = strong as f32 / total as f32; if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD && puzzle_clear_sheet_internal_seam_has_flat_split( source, bounds, y, false, x_start, x_end, y_start, y_end, ) { strongest = strongest.max(line_ratio); } } } strongest } fn puzzle_clear_sheet_internal_seam_has_flat_split( source: &image::DynamicImage, bounds: PuzzleClearSheetCellBounds, split: u32, vertical: bool, x_start: u32, x_end: u32, y_start: u32, y_end: u32, ) -> bool { // 中文注释:富场景照片里常有窗框、桌沿、地平线等贯穿强边,只有两侧都近似人工平铺色块时才按拼贴硬失败。 let band = (bounds.width().min(bounds.height()) / 10).clamp(14, 28); let (first, second) = if vertical { let left_start = split.saturating_sub(band).max(x_start); let left_end = split.saturating_sub(2).max(left_start); let right_start = split.saturating_add(2).min(x_end); let right_end = split.saturating_add(band).min(x_end).max(right_start); ( puzzle_clear_rgb_stats_for_region(source, left_start, left_end, y_start, y_end), puzzle_clear_rgb_stats_for_region(source, right_start, right_end, y_start, y_end), ) } else { let top_start = split.saturating_sub(band).max(y_start); let top_end = split.saturating_sub(2).max(top_start); let bottom_start = split.saturating_add(2).min(y_end); let bottom_end = split.saturating_add(band).min(y_end).max(bottom_start); ( puzzle_clear_rgb_stats_for_region(source, x_start, x_end, top_start, top_end), puzzle_clear_rgb_stats_for_region(source, x_start, x_end, bottom_start, bottom_end), ) }; if first.count == 0 || second.count == 0 { return false; } let side_contrast = puzzle_clear_rgb_stats_distance(first, second); let side_texture = first.texture().max(second.texture()); side_contrast >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD && side_texture <= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX } #[derive(Clone, Copy, Debug, Default)] struct PuzzleClearRgbStats { count: u64, sum: [u64; 3], sum_square: [u64; 3], } impl PuzzleClearRgbStats { fn push(&mut self, pixel: [u8; 4]) { self.count = self.count.saturating_add(1); for (index, channel) in pixel.iter().take(3).enumerate() { let value = *channel as u64; self.sum[index] = self.sum[index].saturating_add(value); self.sum_square[index] = self.sum_square[index].saturating_add(value * value); } } fn mean_channel(self, index: usize) -> f32 { if self.count == 0 { return 0.0; } self.sum[index] as f32 / self.count as f32 } fn texture(self) -> f32 { if self.count == 0 { return f32::MAX; } let mut variance_sum = 0.0f32; for index in 0..3 { let mean = self.mean_channel(index); let mean_square = self.sum_square[index] as f32 / self.count as f32; variance_sum += (mean_square - mean * mean).max(0.0); } variance_sum.sqrt() } } fn puzzle_clear_rgb_stats_for_region( source: &image::DynamicImage, x0: u32, x1: u32, y0: u32, y1: u32, ) -> PuzzleClearRgbStats { let mut stats = PuzzleClearRgbStats::default(); if x0 >= x1 || y0 >= y1 { return stats; } for y in (y0..y1).step_by(4) { for x in (x0..x1).step_by(4) { stats.push(source.get_pixel(x, y).0); } } stats } fn puzzle_clear_rgb_stats_distance(left: PuzzleClearRgbStats, right: PuzzleClearRgbStats) -> f32 { (0..3) .map(|index| (left.mean_channel(index) - right.mean_channel(index)).abs()) .sum() } fn puzzle_clear_rgb_distance(left: [u8; 4], right: [u8; 4]) -> i32 { (left[0] as i32 - right[0] as i32).abs() + (left[1] as i32 - right[1] as i32).abs() + (left[2] as i32 - right[2] as i32).abs() } fn is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool { group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL } fn puzzle_clear_sheet_neighbor_is_same_group( sheet_spec: &PuzzleClearAtlasSheetSpec, row: u32, col: u32, row_delta: i32, col_delta: i32, ) -> bool { let current = sheet_spec.layout[row as usize][col as usize]; if is_puzzle_clear_sheet_discarded_cell(current) { return false; } let neighbor_row = row as i32 + row_delta; let neighbor_col = col as i32 + col_delta; if neighbor_row < 0 || neighbor_col < 0 || neighbor_row >= PUZZLE_CLEAR_SHEET_ROWS as i32 || neighbor_col >= PUZZLE_CLEAR_SHEET_COLUMNS as i32 { return false; } sheet_spec.layout[neighbor_row as usize][neighbor_col as usize] == current } fn slice_puzzle_clear_sheet( image: &DownloadedOpenAiImage, sheet_spec: &PuzzleClearAtlasSheetSpec, groups_by_id: &BTreeMap, task_id: &str, ) -> Result, AppError> { let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消素材 {} 解码失败:{error}", sheet_spec.sheet_id), })) })?; let source_width = source.width(); let source_height = source.height(); if source_width < 16 || source_height < 16 { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消素材 {} 尺寸过小,无法切割。", sheet_spec.sheet_id), })), ); } let mut slices = Vec::new(); let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new(); for (row, cells) in sheet_spec.layout.iter().enumerate() { for (col, group_id) in cells.iter().enumerate() { if is_puzzle_clear_sheet_discarded_cell(group_id) { continue; } cells_by_group .entry(*group_id) .or_default() .push((row as u32, col as u32)); } } for (group_id, cells) in cells_by_group { let group = groups_by_id.get(group_id).ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消素材布局引用了未知图案组:{group_id}"), })) })?; let min_row = cells.iter().map(|(row, _)| *row).min().unwrap_or(0); let min_col = cells.iter().map(|(_, col)| *col).min().unwrap_or(0); let expected_cell_count = (group.width * group.height) as usize; if cells.len() != expected_cell_count { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!( "拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。", sheet_spec.sheet_id, group_id, expected_cell_count, cells.len(), ), })), ); } for part_y in 0..group.height { for part_x in 0..group.width { let expected_cell = (min_row + part_y, min_col + part_x); if !cells.contains(&expected_cell) { return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!( "拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。", sheet_spec.sheet_id, group_id, expected_cell.0 + 1, expected_cell.1 + 1, ), }))); } } } for (row, col) in cells { let part_x = col.saturating_sub(min_col); let part_y = row.saturating_sub(min_row); let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS); let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS); let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS) .max(x0 + 1) .min(source_width); let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS) .max(y0 + 1) .min(source_height); let cropped = source.crop_imm(x0, y0, x1 - x0, y1 - y0).resize_exact( PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_CELL_SIZE, image::imageops::FilterType::Lanczos3, ); let mut cursor = std::io::Cursor::new(Vec::new()); cropped .write_to(&mut cursor, image::ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消素材 {} 切片失败:{error}", sheet_spec.sheet_id), })) })?; slices.push(PuzzleClearAtlasCardSlice { group: group.clone(), task_id: Some(task_id.to_string()), part_x, part_y, bytes: cursor.into_inner(), }); } } Ok(slices) } fn compose_puzzle_clear_final_atlas( slices: &[PuzzleClearAtlasCardSlice], groups_by_id: &BTreeMap, ) -> Result { let width = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS; let height = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS; let mut atlas = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 248, 234, 255])); for slice in slices { let group = groups_by_id.get(&slice.group.group_id).ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消最终 atlas 缺少图案组:{}", slice.group.group_id), })) })?; let piece = image::load_from_memory(slice.bytes.as_slice()) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消最终 atlas 切片解码失败:{error}"), })) })? .to_rgba8(); image::imageops::overlay( &mut atlas, &piece, i64::from(group.atlas_x + slice.part_x * PUZZLE_CLEAR_ATLAS_CELL_SIZE), i64::from(group.atlas_y + slice.part_y * PUZZLE_CLEAR_ATLAS_CELL_SIZE), ); } let mut cursor = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(atlas) .write_to(&mut cursor, image::ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!("拼消消最终 atlas 合成失败:{error}"), })) })?; Ok(DownloadedOpenAiImage { extension: "png".to_string(), mime_type: "image/png".to_string(), bytes: cursor.into_inner(), }) } fn scale_sheet_coord(value: u32, actual: u32, sheet_cells: u32) -> u32 { ((u64::from(value) * u64::from(actual)) / u64::from(sheet_cells)) as u32 } fn normalize_non_empty_str(value: &str) -> Option { let value = value.trim(); if value.is_empty() { None } else { Some(value.to_string()) } } async fn persist_puzzle_clear_card_slice( state: &AppState, owner_user_id: &str, profile_id: &str, task_id: Option<&str>, slice: PuzzleClearAtlasCardSlice, request_context: &RequestContext, ) -> Result { let card_id = format!( "{}-part-{}-{}", slice.group.group_id, slice.part_x, slice.part_y ); let prompt = format!( "拼消消素材切片 {} {}:{}", slice.group.group_id, slice.part_x, slice.part_y ); let image = DownloadedOpenAiImage { extension: "png".to_string(), mime_type: "image/png".to_string(), bytes: slice.bytes, }; let persisted = persist_puzzle_clear_generated_image_asset( state, owner_user_id, profile_id, format!("cards/{card_id}").as_str(), prompt.as_str(), task_id, image, PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_CELL_SIZE, request_context, ) .await?; Ok(PuzzleClearCardAsset { card_id, group_id: slice.group.group_id, shape: slice.group.shape, orientation: if slice.group.width >= slice.group.height { "horizontal".to_string() } else { "vertical".to_string() }, part_x: slice.part_x, part_y: slice.part_y, image_src: persisted.image_src, image_object_key: persisted.image_object_key, asset_object_id: persisted.asset_object_id, source_atlas_cell: format!("{}:{}:{}", persisted.asset_id, slice.part_x, slice.part_y), }) } #[allow(clippy::too_many_arguments)] async fn persist_puzzle_clear_generated_image_asset( state: &AppState, owner_user_id: &str, profile_id: &str, slot: &str, prompt: &str, task_id: Option<&str>, image: DownloadedOpenAiImage, width: u32, height: u32, request_context: &RequestContext, ) -> Result { let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str()); let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { prefix: LegacyAssetPrefix::PuzzleClearAssets, path_segments: vec![profile_id.to_string(), slot.to_string()], file_stem: "image".to_string(), image: GeneratedImageAssetDataUrl { format: image_format, bytes: image.bytes, }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(format!("puzzle-clear-{slot}")), owner_user_id: Some(owner_user_id.to_string()), entity_kind: Some("puzzle_clear_work".to_string()), entity_id: Some(profile_id.to_string()), slot: Some(slot.to_string()), provider: Some("vector-engine".to_string()), task_id: task_id.map(ToString::to_string), }, extra_metadata: BTreeMap::new(), }) .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "generated-image-assets", "message": format!("准备拼消消图片资产上传请求失败:{error:?}"), })), ) })?; let persisted_mime_type = prepared.format.mime_type.clone(); let oss_client = state.oss_client().ok_or_else(|| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, 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, prepared.request) .await .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": error.to_string(), })), ) })?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": error.to_string(), })), ) })?; let now_micros = current_utc_micros(); let asset_object_input = build_asset_object_upsert_input( generate_asset_object_id(now_micros), head.bucket, head.object_key.clone(), AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, head.etag, format!("puzzle-clear-{slot}"), task_id.map(ToString::to_string), Some(owner_user_id.to_string()), Some(profile_id.to_string()), Some(profile_id.to_string()), now_micros, ) .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "message": error.to_string(), })), ) })?; let asset_object = state .spacetime_client() .confirm_asset_object(asset_object_input) .await .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })), ) })?; let binding_input = build_asset_entity_binding_input( generate_asset_binding_id(now_micros), asset_object.asset_object_id.clone(), "puzzle_clear_work".to_string(), profile_id.to_string(), slot.to_string(), format!("puzzle-clear-{slot}"), Some(owner_user_id.to_string()), Some(profile_id.to_string()), now_micros, ) .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-entity-binding", "message": error.to_string(), })), ) })?; state .spacetime_client() .bind_asset_object_to_entity(binding_input) .await .map_err(|error| { puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })), ) })?; Ok(PuzzleClearImageAsset { asset_id: format!("{profile_id}-{}-{now_micros}", slot.replace('/', "-")), image_src: put_result.legacy_public_path, image_object_key: head.object_key, asset_object_id: asset_object.asset_object_id, generation_provider: "vector-engine".to_string(), prompt: prompt.to_string(), width, height, }) } fn build_puzzle_clear_draft( payload: &PuzzleClearWorkspaceCreateRequest, ) -> PuzzleClearDraftResponse { PuzzleClearDraftResponse { template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), profile_id: None, work_title: payload.work_title.trim().to_string(), work_description: payload.work_description.trim().to_string(), theme_prompt: payload.theme_prompt.trim().to_string(), board_background_prompt: payload.board_background_prompt.trim().to_string(), generate_board_background: payload.generate_board_background, board_background_asset: payload.board_background_asset.clone(), card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()), atlas_asset: None, pattern_groups: Vec::new(), card_assets: Vec::new(), generation_status: PuzzleClearGenerationStatus::Draft, } } fn validate_workspace_request( request_context: &RequestContext, payload: &PuzzleClearWorkspaceCreateRequest, ) -> Result<(), Response> { if payload.template_id.trim() != PUZZLE_CLEAR_TEMPLATE_ID { return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_CLEAR_PROVIDER, "message": "templateId 必须为 puzzle-clear", })), )); } ensure_non_empty(request_context, &payload.work_title, "workTitle")?; ensure_non_empty(request_context, &payload.theme_prompt, "themePrompt")?; Ok(()) } fn ensure_non_empty( request_context: &RequestContext, value: &str, field: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_CLEAR_PROVIDER, "field": field, "message": format!("{field} 不能为空"), })), )); } Ok(()) } fn puzzle_clear_json( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, Response> { payload.map_err(|error| { puzzle_clear_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": error.to_string(), })), ) }) } fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError { let message = error.to_string(); let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(value) if value.contains("不存在") || value.contains("not found") || value.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(value) if value.contains("发布需要") || value.contains("不能为空") || value.contains("必须") || value.contains("无权") => { StatusCode::BAD_REQUEST } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": message, })) } fn puzzle_clear_error_response( request_context: &RequestContext, provider: &str, error: AppError, ) -> Response { let mut response = error.into_response_with_context(Some(request_context)); response.headers_mut().insert( HeaderName::from_static("x-genarrative-provider"), header::HeaderValue::from_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static(PUZZLE_CLEAR_PROVIDER)), ); response } fn build_puzzle_clear_work_play_tracking_draft( principal: &RuntimePrincipal, work_id: impl Into, source_route: &'static str, ) -> WorkPlayTrackingDraft { WorkPlayTrackingDraft::runtime_principal("puzzle-clear", work_id, principal, source_route) } fn normalize_public_work_code(value: &str) -> String { value .chars() .filter(|character| character.is_ascii_alphanumeric()) .map(|character| character.to_ascii_uppercase()) .collect() } fn build_puzzle_clear_public_work_code(profile_id: &str) -> String { let normalized = normalize_public_work_code(profile_id); let fallback = if normalized.is_empty() { "00000000".to_string() } else { normalized }; let suffix = if fallback.len() >= 8 { fallback[fallback.len() - 8..].to_string() } else { format!("{fallback:0>8}") }; format!("PC-{suffix}") } #[cfg(test)] mod tests { use super::{ PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, PUZZLE_CLEAR_SHEET_FILLER_CELL, PUZZLE_CLEAR_SHEET_UNUSED_CELL, PuzzleClearAtlasSheetSpec, build_puzzle_clear_atlas_prompt, build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft, is_puzzle_clear_sheet_discarded_cell, is_retryable_puzzle_clear_sheet_generation_error, planned_puzzle_clear_pattern_groups, puzzle_clear_atlas_sheet_specs, validate_puzzle_clear_sheet_quality, }; use crate::http_error::AppError; use crate::openai_image_generation::DownloadedOpenAiImage; use axum::http::StatusCode; use image::{ImageFormat, Rgba, RgbaImage}; use serde_json::json; use shared_contracts::puzzle_clear::PuzzleClearWorkspaceCreateRequest; use std::io::Cursor; #[test] fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() { let sheet = puzzle_clear_atlas_sheet_specs() .into_iter() .next() .expect("sheet exists"); let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet); assert!(prompt.contains("主题是「水果」")); assert!(prompt.contains("竖版 1024x1536")); assert!(prompt.contains("4 列 x 6 行裁切")); assert!(prompt.contains("256x256 的正方形")); assert!(prompt.contains("完整的单场景照片裁片")); assert!(prompt.contains("照片式构图")); assert!(prompt.contains("主题微场景拼图卡")); assert!(prompt.contains("明确背景、环境、道具、光影和构图线索")); assert!(prompt.contains("每个 256x256 单元本身就是一张完整的单场景照片裁片")); assert!(prompt.contains("禁止在一个单元内部出现两张照片")); assert!(prompt.contains("内部竖切")); assert!(prompt.contains("内部横切")); assert!(prompt.contains("场景变化只能发生在 256 单元边界上")); assert!(prompt.contains("同一视觉家族")); assert!(prompt.contains("同一场景锚点")); assert!(prompt.contains("同一套连拍")); assert!(prompt.contains("彼此无关的随机独立小图")); assert!(prompt.contains("不能在单格内部再切出第二张图或第二个场景")); assert!(prompt.contains("不同编号必须使用不同视觉概念")); assert!(prompt.contains("不要把同一种主体换角度、换大小、换姿势后重复使用")); assert!(prompt.contains("果园、集市摊位、野餐布、果汁杯、厨房案板")); assert!(prompt.contains("可以包含主体局部、背景纹理、桌面、草地、天空")); assert!(prompt.contains("不要让小卡只有一个孤立主体加纯色背景")); assert!(prompt.contains("FILL 补位格可以生成主题一致的小照片裁片")); assert!(prompt.contains("后台会丢弃它")); assert!(prompt.contains("图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片")); assert!(prompt.contains("外轮廓框")); assert!(prompt.contains("贴纸边")); assert!(prompt.contains("圆角框")); assert!(prompt.contains("阴影框")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("白底商品图")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("孤立主体")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同品种重复")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同一物体多角度")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格内部拼接线")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格双图")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("照片拼贴")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("相册拼贴")); assert!(!prompt.contains("135 幅")); assert!(!prompt.contains("24 列 x 38 行")); assert!(!prompt.contains("卡牌小格")); assert!(!prompt.contains("卡牌排版图")); assert!(!prompt.contains("贴纸表")); } #[test] fn puzzle_clear_sheet_plan_matches_reduced_asset_strategy() { let sheets = puzzle_clear_atlas_sheet_specs(); let groups = planned_puzzle_clear_pattern_groups(); let occupied_sheet_cells = sheets .iter() .flat_map(|sheet| sheet.layout.iter().flatten()) .filter(|group_id| **group_id != PUZZLE_CLEAR_SHEET_UNUSED_CELL) .count(); let playable_sheet_cells = sheets .iter() .flat_map(|sheet| sheet.layout.iter().flatten()) .filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id)) .count(); let filler_sheet_cells = sheets .iter() .flat_map(|sheet| sheet.layout.iter().flatten()) .filter(|group_id| **group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL) .count(); let mut sheet_cells_by_group = std::collections::BTreeMap::<&str, u32>::new(); for group_id in sheets .iter() .flat_map(|sheet| sheet.layout.iter().flatten()) .filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id)) { *sheet_cells_by_group.entry(*group_id).or_default() += 1; } let group_cells = groups .iter() .map(|group| group.width * group.height) .sum::(); assert_eq!(sheets.len(), 4); assert_eq!(groups.len(), 35); assert_eq!(occupied_sheet_cells, 96); assert_eq!(playable_sheet_cells, 95); assert_eq!(filler_sheet_cells, 1); assert_eq!(group_cells, 95); assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256); assert_eq!(sheet_cells_by_group.len(), groups.len()); for group in &groups { assert_eq!( sheet_cells_by_group.get(group.group_id.as_str()).copied(), Some(group.width * group.height), ); } } #[test] fn puzzle_clear_sheet_quality_allows_edge_contact_as_advisory_warning() { let sheet = puzzle_clear_atlas_sheet_specs() .into_iter() .find(|sheet| sheet.sheet_id == "sheet-04") .expect("sheet exists"); let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); for row in 0..6u32 { for col in 0..4u32 { let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let color = Rgba([ 70u8.saturating_add((row * 23) as u8), 80u8.saturating_add((col * 31) as u8), 160, 255, ]); for y in base_y + 80..base_y + 176 { for x in base_x + 80..base_x + 176 { source.put_pixel(x, y, color); } } } } for y in 0..180u32 { for x in 0..180u32 { source.put_pixel(x, y, Rgba([215, 48, 62, 255])); } } let mut encoded = Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(source) .write_to(&mut encoded, ImageFormat::Png) .expect("test image should encode"); let image = DownloadedOpenAiImage { extension: "png".to_string(), mime_type: "image/png".to_string(), bytes: encoded.into_inner(), }; validate_puzzle_clear_sheet_quality(&image, &sheet) .expect("edge contact is advisory because generated sheets often touch borders"); } fn build_test_puzzle_clear_sheet_image_with_cell_pollution( row: u32, col: u32, ) -> DownloadedOpenAiImage { let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); for row in 0..6u32 { for col in 0..4u32 { let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let color = Rgba([ 70u8.saturating_add((row * 23) as u8), 80u8.saturating_add((col * 31) as u8), 160, 255, ]); for y in base_y + 80..base_y + 176 { for x in base_x + 80..base_x + 176 { source.put_pixel(x, y, color); } } } } let cell_y0 = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let cell_x0 = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; for y in cell_y0 + 40..cell_y0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { for x in cell_x0 + 40..cell_x0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { source.put_pixel(x, y, Rgba([215, 48, 62, 255])); } } let mut encoded = Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(source) .write_to(&mut encoded, ImageFormat::Png) .expect("test image should encode"); DownloadedOpenAiImage { extension: "png".to_string(), mime_type: "image/png".to_string(), bytes: encoded.into_inner(), } } #[test] fn puzzle_clear_sheet_quality_allows_filler_cell_pollution() { let sheet = puzzle_clear_atlas_sheet_specs() .into_iter() .find(|sheet| sheet.sheet_id == "sheet-03") .expect("sheet exists"); let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(5, 3); validate_puzzle_clear_sheet_quality(&image, &sheet) .expect("filler cell is generated only to stabilize the sheet and is discarded later"); } #[test] fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() { let sheet = PuzzleClearAtlasSheetSpec { sheet_id: "blank-test", layout: [ [PUZZLE_CLEAR_SHEET_UNUSED_CELL, "A01", "A01", "A02"], ["A03", "A03", "A04", "A04"], ["A05", "A05", "A06", "A06"], ["A07", "A07", "A08", "A08"], ["A09", "A09", "A10", "A10"], ["A11", "A11", "A12", "A12"], ], layout_prompt: "test", }; let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(0, 0); let error = validate_puzzle_clear_sheet_quality(&image, &sheet) .expect_err("blank cell pollution should be rejected"); assert!(error.body_text().contains("空白格有主体")); } #[test] fn puzzle_clear_sheet_quality_allows_textured_scene_divider() { let sheet = puzzle_clear_atlas_sheet_specs() .into_iter() .find(|sheet| sheet.sheet_id == "sheet-04") .expect("sheet exists"); let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); for row in 0..6u32 { for col in 0..4u32 { let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let color = Rgba([ 70u8.saturating_add((row * 23) as u8), 80u8.saturating_add((col * 31) as u8), 160, 255, ]); for y in base_y + 80..base_y + 176 { for x in base_x + 80..base_x + 176 { source.put_pixel(x, y, color); } } } } for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE { for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE { let noise = ((x * 17 + y * 31) % 120) as u8; let color = if x < PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 { Rgba([ 72u8.saturating_add(noise), 88u8.saturating_add(((x * 7 + y * 11) % 96) as u8), 112u8.saturating_add(((x * 5 + y * 13) % 72) as u8), 255, ]) } else { Rgba([ 104u8.saturating_add(((x * 19 + y * 3) % 92) as u8), 76u8.saturating_add(noise), 56u8.saturating_add(((x * 11 + y * 23) % 88) as u8), 255, ]) }; source.put_pixel(x, y, color); } source.put_pixel(PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2, y, Rgba([24, 24, 24, 255])); } let mut encoded = Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(source) .write_to(&mut encoded, ImageFormat::Png) .expect("test image should encode"); let image = DownloadedOpenAiImage { extension: "png".to_string(), mime_type: "image/png".to_string(), bytes: encoded.into_inner(), }; validate_puzzle_clear_sheet_quality(&image, &sheet) .expect("textured photo-like scene divider should not be rejected as collage"); } #[test] fn puzzle_clear_sheet_quality_rejects_internal_photo_seam() { let sheet = puzzle_clear_atlas_sheet_specs() .into_iter() .find(|sheet| sheet.sheet_id == "sheet-04") .expect("sheet exists"); let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); for row in 0..6u32 { for col in 0..4u32 { let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; let color = Rgba([ 70u8.saturating_add((row * 23) as u8), 80u8.saturating_add((col * 31) as u8), 160, 255, ]); for y in base_y + 80..base_y + 176 { for x in base_x + 80..base_x + 176 { source.put_pixel(x, y, color); } } } } for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE { for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 { source.put_pixel(x, y, Rgba([206, 46, 62, 255])); } for x in PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2..PUZZLE_CLEAR_ATLAS_CELL_SIZE { source.put_pixel(x, y, Rgba([38, 112, 218, 255])); } } let mut encoded = Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(source) .write_to(&mut encoded, ImageFormat::Png) .expect("test image should encode"); let image = DownloadedOpenAiImage { extension: "png".to_string(), mime_type: "image/png".to_string(), bytes: encoded.into_inner(), }; let error = validate_puzzle_clear_sheet_quality(&image, &sheet) .expect_err("internal photo seam should be rejected"); assert!(error.body_text().contains("单格内部疑似拼接线")); } #[test] fn puzzle_clear_sheet_generation_retries_only_retryable_upstream_errors() { let retryable_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "上游服务请求失败", "retryable": true, })); assert!(is_retryable_puzzle_clear_sheet_generation_error( &retryable_error )); let non_retryable_gateway = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "上游服务请求失败", "retryable": false, })); assert!(!is_retryable_puzzle_clear_sheet_generation_error( &non_retryable_gateway )); let bad_request = AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "vector-engine", "message": "请求参数不合法", "retryable": true, })); assert!(!is_retryable_puzzle_clear_sheet_generation_error( &bad_request )); } #[test] fn puzzle_clear_board_background_prompt_reveals_theme_goal() { let prompt = build_puzzle_clear_board_background_prompt("星港花园"); assert!(prompt.contains("星港花园")); assert!(prompt.contains("逐渐消除")); assert!(prompt.contains("底图全貌")); assert!(prompt.contains("探索")); assert!(prompt.contains("追求目标")); assert!(prompt.contains("尺寸与中央棋盘一致")); assert!(prompt.contains("精致")); assert!(prompt.contains("强表现力")); assert_eq!(PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, "1024x1024"); assert!(!prompt.contains("画面干净")); assert!(!prompt.contains("适合作为卡牌棋盘底图")); assert!(!prompt.contains("9:16")); } #[test] fn puzzle_clear_draft_uses_existing_card_back_placeholder() { let draft = build_puzzle_clear_draft(&PuzzleClearWorkspaceCreateRequest { template_id: "puzzle-clear".to_string(), work_title: "星港拼消消".to_string(), work_description: String::new(), theme_prompt: "星港".to_string(), board_background_prompt: String::new(), generate_board_background: true, board_background_asset: None, }); assert_eq!( draft.card_back_image_src.as_deref(), Some("/creation-type-references/puzzle.webp"), ); } } fn current_utc_micros() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) .unwrap_or(0) }