Files
Genarrative/server-rs/crates/api-server/src/puzzle.rs
高物 a45e358e83 Add generationStatus and match3d/runtime fixes
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.
2026-05-16 22:59:02 +08:00

6423 lines
234 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}