use std::{ collections::BTreeMap, time::{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, OssSignedGetObjectUrlRequest}; use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; use serde_json::{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::{ ExternalGenerationJobEnqueueRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleLevelGenerationFailureRecordInput, 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, RuntimePrincipal}, external_generation_worker::{ PUZZLE_COMPILE_DRAFT_JOB_KIND, PUZZLE_GENERATE_IMAGES_JOB_KIND, PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND, }, generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, http_error::AppError, llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL}, openai_image_generation::{ DownloadedOpenAiImage, 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, PuzzleApiState}, wechat::subscribe_message::{ GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, send_generation_result_subscribe_message_after_completion, }, work_author::resolve_puzzle_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_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"; #[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 = 6 * 1024 * 1024; const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素"; const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024"; const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; fn build_puzzle_background_compile_task_id(session_id: &str) -> String { format!("puzzle_initial_background:{session_id}") } fn build_puzzle_background_compile_claim_id(task_id: &str, request_id: &str) -> String { format!("{task_id}:{request_id}") } async fn release_claimed_puzzle_background_compile_task( state: &PuzzleApiState, task_id: &str, claim_id: &str, session_id: &str, owner_user_id: &str, ) { let result = state .spacetime_client() .release_puzzle_background_compile_task(PuzzleBackgroundCompileTaskReleaseRecordInput { task_id: task_id.to_string(), claim_id: claim_id.to_string(), session_id: session_id.to_string(), owner_user_id: owner_user_id.to_string(), }) .await; match result { Ok(true) => {} Ok(false) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, task_id, claim_id, session_id, owner_user_id, "拼图首图后台生成任务释放未命中当前 claim" ); } Err(error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, task_id, claim_id, session_id, owner_user_id, error = %error, "拼图首图后台生成任务释放失败" ); } } } pub(crate) fn spawn_release_claimed_puzzle_background_compile_task( state: PuzzleApiState, task_id: String, claim_id: String, session_id: String, owner_user_id: String, ) { tokio::spawn(async move { release_claimed_puzzle_background_compile_task( &state, &task_id, &claim_id, &session_id, &owner_user_id, ) .await; }); } fn has_puzzle_cover_image_src(value: &Option) -> bool { value .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) } fn mark_puzzle_initial_generation_started_snapshot( mut session: PuzzleAgentSessionRecord, ) -> PuzzleAgentSessionRecord { session.stage = "image_refining".to_string(); session.progress_percent = session.progress_percent.max(88); if let Some(draft) = session.draft.as_mut() { let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src); if let Some(primary_level) = draft.levels.first_mut() { if !has_puzzle_cover_image_src(&primary_level.cover_image_src) { primary_level.generation_status = "generating".to_string(); } draft.generation_status = primary_level.generation_status.clone(); draft.candidates = primary_level.candidates.clone(); draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); draft.cover_image_src = primary_level.cover_image_src.clone(); draft.cover_asset_id = primary_level.cover_asset_id.clone(); } else if draft_needs_cover { draft.generation_status = "generating".to_string(); } } session } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ExternalGenerationWriteLeaseGuard { pub(crate) job_id: Option, pub(crate) worker_id: Option, pub(crate) lease_token: Option, } impl ExternalGenerationWriteLeaseGuard { pub(crate) fn inline() -> Self { Self { job_id: None, worker_id: None, lease_token: None, } } pub(crate) fn from_claimed_job(job_id: String, worker_id: String, lease_token: String) -> Self { Self { job_id: Some(job_id), worker_id: Some(worker_id), lease_token: Some(lease_token), } } } #[derive(Debug)] pub(crate) struct PuzzleExternalGenerationWorkerError { error: AppError, should_fail_queue_job: bool, } impl PuzzleExternalGenerationWorkerError { pub(crate) fn with_failure_state_written(error: AppError) -> Self { Self { error, should_fail_queue_job: true, } } pub(crate) fn with_failure_state_pending(error: AppError) -> Self { Self { error, should_fail_queue_job: false, } } pub(crate) fn body_text(&self) -> String { self.error.body_text() } pub(crate) fn into_app_error(self) -> AppError { self.error } pub(crate) fn should_fail_queue_job(&self) -> bool { self.should_fail_queue_job } } pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String { format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0) } pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize) -> String { format!( "参考图过大,请压缩后再上传(当前 {},最多 6MB)。", format_puzzle_reference_image_upload_bytes(actual_bytes) ) } const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字"; const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。"; const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容"; mod handlers; pub(crate) use self::handlers::*; mod mappers; use self::mappers::*; mod draft; pub(crate) use self::draft::*; mod tags; use self::tags::*; mod generation; mod vector_engine; pub(crate) use self::generation::*; use self::vector_engine::*; #[cfg(test)] mod tests;