Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
6423 lines
234 KiB
Rust
6423 lines
234 KiB
Rust
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, PuzzleGalleryResponse},
|
||
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, 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,
|
||
},
|
||
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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CreatePuzzleAgentSessionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
payload: Result<Json<PuzzleOnboardingGenerateRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PuzzleOnboardingSaveRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(session_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(session_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendPuzzleAgentMessageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(session_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendPuzzleAgentMessageRequest>, JsonRejection>,
|
||
) -> Result<Response, 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 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::<String>();
|
||
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::<Event, Infallible>(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::<Event, Infallible>(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::<Event, Infallible>(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::<Event, Infallible>(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::<Event, Infallible>(puzzle_sse_json_event_or_error(
|
||
"session",
|
||
json!({ "session": session_response }),
|
||
));
|
||
yield Ok::<Event, Infallible>(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<AppState>,
|
||
AxumPath(session_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<ExecutePuzzleAgentActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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::<Vec<_>>(),
|
||
)
|
||
.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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(profile_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(profile_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PutPuzzleWorkRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(profile_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(profile_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
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),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleGalleryResponse {
|
||
items: items
|
||
.into_iter()
|
||
.map(|item| map_puzzle_work_summary_response(&state, item))
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_puzzle_gallery_detail(
|
||
State(state): State<AppState>,
|
||
AxumPath(profile_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(profile_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(profile_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StartPuzzleRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SwapPuzzlePiecesRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<DragPuzzlePieceRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<AdvancePuzzleNextLevelRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SubmitPuzzleLeaderboardRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<String, Response> {
|
||
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<String, Response> {
|
||
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<PuzzleDraftLevelRecord, AppError> {
|
||
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<Vec<PuzzleDraftLevelRecord>, AppError> {
|
||
let levels: Vec<module_puzzle::PuzzleDraftLevel> =
|
||
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<PuzzleAgentSessionRecord, AppError> {
|
||
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<PuzzleAgentSessionRecord, AppError> {
|
||
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<String, Response> {
|
||
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::<Vec<_>>(),
|
||
"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::<Vec<_>>();
|
||
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<Option<String>, String> {
|
||
let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else {
|
||
return Ok(None);
|
||
};
|
||
let levels: Vec<PuzzleDraftLevelResponse> =
|
||
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::<Vec<_>>(),
|
||
"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::<Vec<_>>();
|
||
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<String>,
|
||
work_tags: Vec<String>,
|
||
ui_background_prompt: Option<String>,
|
||
}
|
||
|
||
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<PuzzleLevelNaming> {
|
||
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<String> {
|
||
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<Vec<u8>> {
|
||
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<PuzzleLevelNaming> {
|
||
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::<Value>(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<String> {
|
||
parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name)
|
||
}
|
||
|
||
fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
|
||
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<String> {
|
||
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<String> {
|
||
let normalized = value
|
||
.trim()
|
||
.trim_matches(|ch: char| {
|
||
ch.is_ascii_punctuation()
|
||
|| matches!(
|
||
ch,
|
||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||
)
|
||
})
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
let description = normalized.chars().take(80).collect::<String>();
|
||
(description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description))
|
||
.then_some(description)
|
||
}
|
||
|
||
fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option<Vec<String>> {
|
||
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::<Vec<_>>(),
|
||
Value::String(text) => text
|
||
.split([',', ',', '、', '\n', '|', '/'])
|
||
.map(ToString::to_string)
|
||
.collect::<Vec<_>>(),
|
||
_ => 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<S>(
|
||
candidates: impl IntoIterator<Item = S>,
|
||
) -> Vec<String>
|
||
where
|
||
S: AsRef<str>,
|
||
{
|
||
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<String> {
|
||
let normalized = value
|
||
.trim()
|
||
.trim_matches(|ch: char| {
|
||
ch.is_ascii_punctuation()
|
||
|| matches!(
|
||
ch,
|
||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||
)
|
||
})
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
let filtered = normalized
|
||
.replace("拼图槽", "")
|
||
.replace("棋盘", "")
|
||
.replace("HUD", "")
|
||
.replace("按钮", "")
|
||
.replace("文字", "")
|
||
.replace("水印", "")
|
||
.replace("数字", "")
|
||
.replace("拼图碎片", "")
|
||
.replace("完整拼图图像", "")
|
||
.replace("教程浮层", "");
|
||
let prompt = filtered
|
||
.chars()
|
||
.take(160)
|
||
.collect::<String>()
|
||
.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<String> {
|
||
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::<String>();
|
||
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<PuzzleDraftLevelRecord> {
|
||
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::<Vec<_>>()
|
||
.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::<Vec<_>>()
|
||
.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<CreationAudioAsset, AppError> {
|
||
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<PuzzleAgentSessionRecord, AppError> {
|
||
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::<Vec<_>>(),
|
||
)
|
||
.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<PuzzleAgentSessionRecord, AppError> {
|
||
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<PuzzleGeneratedImageCandidateRecord>,
|
||
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::<Vec<_>>();
|
||
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<PuzzleDraftLevelRecord>,
|
||
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<String>,
|
||
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<Vec<GeneratedPuzzleImageCandidate>, 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<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||
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<PuzzleDownloadedImage>,
|
||
}
|
||
|
||
struct PuzzleResolvedReferenceImage {
|
||
mime_type: String,
|
||
bytes_len: usize,
|
||
bytes: Vec<u8>,
|
||
}
|
||
|
||
struct GeneratedPuzzleImageCandidate {
|
||
record: PuzzleGeneratedImageCandidateRecord,
|
||
downloaded_image: PuzzleDownloadedImage,
|
||
}
|
||
|
||
impl GeneratedPuzzleImageCandidate {
|
||
fn into_record(self) -> PuzzleGeneratedImageCandidateRecord {
|
||
self.record
|
||
}
|
||
}
|
||
|
||
trait GeneratedPuzzleImageCandidatesExt {
|
||
fn into_records(self) -> Vec<PuzzleGeneratedImageCandidateRecord>;
|
||
}
|
||
|
||
impl GeneratedPuzzleImageCandidatesExt for Vec<GeneratedPuzzleImageCandidate> {
|
||
fn into_records(self) -> Vec<PuzzleGeneratedImageCandidateRecord> {
|
||
self.into_iter()
|
||
.map(GeneratedPuzzleImageCandidate::into_record)
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct PuzzleDownloadedImage {
|
||
extension: String,
|
||
mime_type: String,
|
||
bytes: Vec<u8>,
|
||
}
|
||
|
||
struct ParsedPuzzleImageDataUrl {
|
||
mime_type: String,
|
||
bytes: Vec<u8>,
|
||
}
|
||
|
||
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<PuzzleVectorEngineSettings, AppError> {
|
||
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<reqwest::Client, AppError> {
|
||
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<PuzzleGeneratedImages, AppError> {
|
||
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<PuzzleGeneratedImages, AppError> {
|
||
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<String> {
|
||
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<Vec<u8>> {
|
||
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<String> {
|
||
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<String>,
|
||
candidate_count: u32,
|
||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||
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<PuzzleResolvedReferenceImage, AppError> {
|
||
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<PuzzleDownloadedImage, AppError> {
|
||
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<GeneratedPuzzleAssetResponse, AppError> {
|
||
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<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||
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<String, String> {
|
||
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<String, String> {
|
||
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<Value, AppError> {
|
||
serde_json::from_str::<Value>(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<ParsedPuzzleImageDataUrl> {
|
||
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<Vec<u8>> {
|
||
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<String> {
|
||
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<String> {
|
||
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<String>,
|
||
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<PuzzleDownloadedImage> {
|
||
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<String> {
|
||
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<String>) {
|
||
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<String>) {
|
||
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::<Vec<_>>()
|
||
.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::<Value>(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::<Vec<_>>().join(" ");
|
||
if normalized.chars().count() <= max_chars {
|
||
return normalized;
|
||
}
|
||
|
||
let keep_chars = max_chars.saturating_sub(3);
|
||
format!(
|
||
"{}...",
|
||
normalized.chars().take(keep_chars).collect::<String>()
|
||
)
|
||
}
|
||
|
||
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::<String>()
|
||
.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())
|
||
}
|