Files
Genarrative/server-rs/crates/api-server/src/puzzle.rs
2026-05-01 00:33:39 +08:00

4160 lines
144 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,
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 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::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde_json::{Map, Value, json};
use shared_contracts::{
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::{
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
UsePuzzleRuntimePropRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
},
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken,
http_error::AppError,
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},
},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_puzzle_agent_turn,
},
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
};
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_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
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 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,
prompt_chars = payload
.prompt_text
.as_deref()
.map(|value| value.chars().count())
.unwrap_or(0),
has_reference_image = payload
.reference_image_src
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false),
"拼图 Agent action 开始执行"
);
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
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 = execute_billable_asset_operation(
&state,
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
async {
compile_puzzle_draft_with_initial_cover(
&state,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
payload.reference_image_src.as_deref(),
now,
)
.await
.map_err(map_puzzle_compile_error)
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"compile_puzzle_draft",
"完整拼图草稿",
"已编译草稿、生成拼图图片并应用为正式图。",
session,
)
}
"save_puzzle_form_draft" => {
let seed_text = build_puzzle_form_seed_text_from_parts(
payload.work_title.as_deref(),
payload.work_description.as_deref(),
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) => {
// 中文注释Maincloud 旧 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(
&state,
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
async {
let levels_json = levels_json?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(map_puzzle_client_error)?;
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 prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 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,
payload.reference_image_src.as_deref(),
candidate_count,
candidate_start_index,
)
.await
.map_err(|message| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
})?;
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(to_puzzle_generated_image_candidate)
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id,
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id),
levels_json,
candidates_json,
saved_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)
});
(
"generate_puzzle_images",
"拼图图片生成",
"已生成并替换当前拼图图片。",
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 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,
level_id: payload.level_id,
started_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 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 advance_puzzle_next_level(
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()
.advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
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 billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros());
let reducer_owner_user_id = owner_user_id.clone();
let run = 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,
owner_user_id: reducer_owner_user_id,
prop_kind,
used_at_micros: current_utc_micros(),
})
.await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| 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 advance_local_puzzle_next_level(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<AdvanceLocalPuzzleNextLevelRequest>, 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(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let run = build_local_next_puzzle_run(&state, payload, owner_user_id.as_str())
.await
.map_err(|error| 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),
},
))
}
fn map_puzzle_agent_session_response(
session: PuzzleAgentSessionRecord,
) -> PuzzleAgentSessionSnapshotResponse {
PuzzleAgentSessionSnapshotResponse {
session_id: session.session_id,
seed_text: session.seed_text,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack),
draft: session.draft.map(map_puzzle_result_draft_response),
messages: session
.messages
.into_iter()
.map(map_puzzle_agent_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
suggested_actions: session
.suggested_actions
.into_iter()
.map(map_puzzle_suggested_action_response)
.collect(),
result_preview: session
.result_preview
.map(map_puzzle_result_preview_response),
updated_at: session.updated_at,
}
}
fn map_puzzle_anchor_pack_response(
anchor_pack: PuzzleAnchorPackRecord,
) -> PuzzleAnchorPackResponse {
PuzzleAnchorPackResponse {
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood),
composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks),
tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden),
}
}
fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse {
PuzzleAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse {
PuzzleResultDraftResponse {
work_title: draft.work_title,
work_description: draft.work_description,
level_name: draft.level_name,
summary: draft.summary,
theme_tags: draft.theme_tags,
forbidden_directives: draft.forbidden_directives,
creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response),
anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack),
candidates: draft
.candidates
.into_iter()
.map(map_puzzle_generated_image_candidate_response)
.collect(),
selected_candidate_id: draft.selected_candidate_id,
cover_image_src: draft.cover_image_src,
cover_asset_id: draft.cover_asset_id,
generation_status: draft.generation_status,
levels: draft
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect(),
form_draft: draft.form_draft.map(map_puzzle_form_draft_response),
}
}
fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse {
PuzzleFormDraftResponse {
work_title: draft.work_title,
work_description: draft.work_description,
picture_description: draft.picture_description,
}
}
fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse {
PuzzleDraftLevelResponse {
level_id: level.level_id,
level_name: level.level_name,
picture_description: level.picture_description,
candidates: level
.candidates
.into_iter()
.map(map_puzzle_generated_image_candidate_response)
.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,
}
}
fn map_puzzle_creator_intent_response(
intent: PuzzleCreatorIntentRecord,
) -> PuzzleCreatorIntentResponse {
PuzzleCreatorIntentResponse {
source_mode: intent.source_mode,
raw_messages_summary: intent.raw_messages_summary,
theme_promise: intent.theme_promise,
visual_subject: intent.visual_subject,
visual_mood: intent.visual_mood,
composition_hooks: intent.composition_hooks,
theme_tags: intent.theme_tags,
forbidden_directives: intent.forbidden_directives,
}
}
fn map_puzzle_generated_image_candidate_response(
candidate: PuzzleGeneratedImageCandidateRecord,
) -> PuzzleGeneratedImageCandidateResponse {
PuzzleGeneratedImageCandidateResponse {
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,
}
}
fn map_puzzle_agent_message_response(
message: PuzzleAgentMessageRecord,
) -> PuzzleAgentMessageResponse {
PuzzleAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
fn map_puzzle_suggested_action_response(
action: PuzzleAgentSuggestedActionRecord,
) -> PuzzleAgentSuggestedActionResponse {
PuzzleAgentSuggestedActionResponse {
id: action.action_id,
action_type: action.action_type,
label: action.label,
}
}
fn map_puzzle_result_preview_response(
preview: PuzzleResultPreviewRecord,
) -> PuzzleResultPreviewEnvelopeResponse {
PuzzleResultPreviewEnvelopeResponse {
draft: map_puzzle_result_draft_response(preview.draft),
blockers: preview
.blockers
.into_iter()
.map(map_puzzle_result_preview_blocker_response)
.collect(),
quality_findings: preview
.quality_findings
.into_iter()
.map(map_puzzle_result_preview_finding_response)
.collect(),
publish_ready: preview.publish_ready,
}
}
fn map_puzzle_result_preview_blocker_response(
blocker: PuzzleResultPreviewBlockerRecord,
) -> PuzzleResultPreviewBlockerResponse {
PuzzleResultPreviewBlockerResponse {
id: blocker.blocker_id,
code: blocker.code,
message: blocker.message,
}
}
fn map_puzzle_result_preview_finding_response(
finding: PuzzleResultPreviewFindingRecord,
) -> PuzzleResultPreviewFindingResponse {
PuzzleResultPreviewFindingResponse {
id: finding.finding_id,
severity: finding.severity,
code: finding.code,
message: finding.message,
}
}
fn map_puzzle_work_summary_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkSummaryResponse {
let author = resolve_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
PuzzleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
author_display_name: author.display_name,
work_title: item.work_title,
work_description: item.work_description,
level_name: item.level_name,
summary: item.summary,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
cover_asset_id: item.cover_asset_id,
publication_status: item.publication_status,
updated_at: item.updated_at,
published_at: item.published_at,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
publish_ready: item.publish_ready,
levels: Vec::new(),
}
}
fn map_puzzle_work_profile_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkProfileResponse {
let mut summary = map_puzzle_work_summary_response(state, item.clone());
summary.levels = item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect();
PuzzleWorkProfileResponse {
summary,
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
}
}
fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
PuzzleRunSnapshotResponse {
run_id: run.run_id,
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids: run.played_profile_ids,
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_runtime_level_response),
recommended_next_profile_id: run.recommended_next_profile_id,
next_level_mode: run.next_level_mode,
next_level_profile_id: run.next_level_profile_id,
next_level_id: run.next_level_id,
recommended_next_works: run
.recommended_next_works
.into_iter()
.map(map_puzzle_recommended_next_work_response)
.collect(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
fn map_puzzle_recommended_next_work_response(
item: PuzzleRecommendedNextWorkRecord,
) -> PuzzleRecommendedNextWorkResponse {
PuzzleRecommendedNextWorkResponse {
profile_id: item.profile_id,
level_name: item.level_name,
author_display_name: item.author_display_name,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
similarity_score: item.similarity_score,
}
}
async fn enrich_puzzle_run_author_name(
state: &AppState,
mut run: PuzzleRunRecord,
) -> PuzzleRunRecord {
if let Some(level) = run.current_level.as_mut() {
if let Ok(profile) = state
.spacetime_client()
.get_puzzle_gallery_detail(level.profile_id.clone())
.await
{
level.author_display_name = resolve_work_author_by_user_id(
state,
&profile.owner_user_id,
Some(&profile.author_display_name),
None,
)
.display_name;
}
}
run
}
fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRecord {
PuzzleRunRecord {
run_id: run.run_id,
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids: run.played_profile_ids,
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_level_request_record),
recommended_next_profile_id: run.recommended_next_profile_id,
next_level_mode: run.next_level_mode,
next_level_profile_id: run.next_level_profile_id,
next_level_id: run.next_level_id,
recommended_next_works: run
.recommended_next_works
.into_iter()
.map(map_puzzle_recommended_next_work_request_record)
.collect(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_recommended_next_work_request_record(
item: PuzzleRecommendedNextWorkResponse,
) -> PuzzleRecommendedNextWorkRecord {
PuzzleRecommendedNextWorkRecord {
profile_id: item.profile_id,
level_name: item.level_name,
author_display_name: item.author_display_name,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
similarity_score: item.similarity_score,
}
}
fn map_puzzle_level_request_record(
level: PuzzleRuntimeLevelSnapshotResponse,
) -> PuzzleRuntimeLevelRecord {
PuzzleRuntimeLevelRecord {
run_id: level.run_id,
level_index: level.level_index,
level_id: level.level_id,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
board: map_puzzle_board_request_record(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms: level.time_limit_ms,
remaining_ms: level.remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_leaderboard_request_record(
entry: PuzzleLeaderboardEntryResponse,
) -> PuzzleLeaderboardEntryRecord {
PuzzleLeaderboardEntryRecord {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.grid_size);
let time_limit_ms = if level.time_limit_ms == 0 {
timer_defaults.time_limit_ms
} else {
level.time_limit_ms
};
let remaining_ms =
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() {
time_limit_ms
} else {
level.remaining_ms.min(time_limit_ms)
};
PuzzleRuntimeLevelSnapshotResponse {
run_id: level.run_id,
level_index: level.level_index,
level_id: level.level_id,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms,
remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
struct PuzzleRuntimeTimerResponseDefaults {
time_limit_ms: u64,
}
fn build_puzzle_runtime_timer_response_defaults(
grid_size: u32,
) -> PuzzleRuntimeTimerResponseDefaults {
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
}
fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse {
PuzzleLeaderboardEntryResponse {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
fn map_puzzle_board_response(
board: spacetime_client::PuzzleBoardRecord,
) -> PuzzleBoardSnapshotResponse {
PuzzleBoardSnapshotResponse {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateResponse {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupStateResponse {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionResponse {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "拼图创作信息已准备好。".to_string();
}
"拼图创作信息已准备好。".to_string()
}
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: payload
.work_title
.as_deref()
.or(payload.seed_text.as_deref()),
work_description: payload.work_description.as_deref(),
picture_description: payload.picture_description.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(
payload.work_title.as_deref(),
payload.work_description.as_deref(),
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());
}
// 中文注释:旧 Maincloud 缺自动保存 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,
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())
}
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,
"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,
"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}"),
)
}
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>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
.await?;
let draft = compiled_session
.draft
.clone()
.ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?;
let target_level = select_puzzle_level_for_api(&draft, None)
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
&draft.summary,
);
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
let candidates = generate_puzzle_image_candidates(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&target_level.level_name,
&image_prompt,
reference_image_src,
1,
target_level.candidates.len(),
)
.await
.map_err(SpacetimeClientError::Runtime)?;
let selected_candidate_id = candidates
.iter()
.find(|candidate| candidate.selected)
.or_else(|| candidates.first())
.map(|candidate| candidate.candidate_id.clone())
.ok_or_else(|| SpacetimeClientError::Runtime("拼图候选图生成结果为空".to_string()))?;
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(to_puzzle_generated_image_candidate)
.collect::<Vec<_>>(),
)
.map_err(|error| SpacetimeClientError::Runtime(format!("拼图候选图序列化失败:{error}")))?;
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: None,
candidates_json,
saved_at_micros: current_utc_micros(),
})
.await?;
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
}
fn ensure_non_empty(
request_context: &RequestContext,
provider: &str,
value: &str,
field_name: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(puzzle_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": format!("{field_name} is required"),
})),
));
}
Ok(())
}
fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response {
puzzle_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": message,
})),
)
}
fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
matches!(error, SpacetimeClientError::Procedure(message) if
message.contains("save_puzzle_form_draft")
&& (message.contains("No such procedure")
|| message.contains("不存在")
|| message.contains("does not exist")
|| message.contains("not found")))
}
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
let provider = if message.contains("DashScope") || message.contains("dashscope") {
"dashscope"
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
"puzzle-assets"
} else {
"spacetimedb"
};
let status = if matches!(error, SpacetimeClientError::Runtime(_))
&& (message.contains("生成")
|| message.contains("上游")
|| message.contains("DashScope")
|| message.contains("dashscope")
|| message.contains("参考图")
|| message.contains("图片")
|| message.contains("OSS")
|| message.contains("oss"))
{
StatusCode::BAD_GATEWAY
} else {
match &error {
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
}
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": message,
}))
}
fn puzzle_error_response(
request_context: &RequestContext,
provider: &str,
error: AppError,
) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_str(provider)
.unwrap_or_else(|_| header::HeaderValue::from_static("puzzle")),
);
response
}
fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})
}
fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match puzzle_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
fn puzzle_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}
fn map_puzzle_generation_app_error(error: AppError) -> String {
let body_text = error.body_text();
if error.code() == "UPSTREAM_ERROR" {
format!("拼图图片生成失败:{body_text}")
} else {
body_text
}
}
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>,
candidate_count: u32,
candidate_start_index: usize,
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
let count = candidate_count.clamp(1, 1);
let settings =
require_puzzle_dashscope_settings(state).map_err(map_puzzle_generation_app_error)?;
let http_client =
build_puzzle_dashscope_http_client(&settings).map_err(map_puzzle_generation_app_error)?;
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
tracing::info!(
provider = "dashscope",
session_id,
level_name,
prompt_chars = prompt.chars().count(),
actual_prompt_chars = actual_prompt.chars().count(),
has_reference_image = reference_image_src
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false),
"拼图图片生成请求已准备"
);
let reference_image = match reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(source) => Some(
resolve_puzzle_reference_image_as_data_url(state, &http_client, source)
.await
.map_err(map_puzzle_generation_app_error)?,
),
None => None,
};
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与 DashScope 图生图都必须停留在 api-server。
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
let generated = match reference_image.as_deref() {
Some(reference_image) => {
create_puzzle_image_to_image_generation(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_GENERATED_IMAGE_SIZE,
count,
reference_image,
)
.await
}
None => {
create_puzzle_text_to_image_generation(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_GENERATED_IMAGE_SIZE,
count,
)
.await
}
}
.map_err(map_puzzle_generation_app_error)?;
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 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_app_error)?;
items.push(PuzzleGeneratedImageCandidateResponse {
candidate_id,
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
actual_prompt: Some(actual_prompt.clone()),
source_type: "generated".to_string(),
// 单图生成结果总是直接成为当前正式图。
selected: index == 0,
});
}
Ok(items
.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())
}
async fn build_local_next_puzzle_run(
state: &AppState,
payload: AdvanceLocalPuzzleNextLevelRequest,
owner_user_id: &str,
) -> Result<PuzzleRunRecord, AppError> {
let run = map_puzzle_run_request_record(payload.run);
let current_level = run.current_level.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "currentLevel is required",
}))
})?;
if current_level.status != "cleared" {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "current level is not cleared",
})),
);
}
let source_session_id = payload.source_session_id.unwrap_or_default();
if let Some(next_run) =
build_same_work_local_next_puzzle_run(state, &run, &source_session_id, owner_user_id)
.await?
{
return Ok(next_run);
}
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
return Ok(build_next_run_from_puzzle_work(state, run, gallery_item));
}
if source_session_id.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "sourceSessionId is required when gallery has no next puzzle work",
})),
);
}
let session = state
.spacetime_client()
.get_puzzle_agent_session(source_session_id, owner_user_id.to_string())
.await
.map_err(map_puzzle_client_error)?;
if let Some(candidate) = session
.draft
.as_ref()
.and_then(|draft| pick_unused_puzzle_candidate(&draft.candidates, &run.played_profile_ids))
{
return Ok(build_next_run_from_candidate(run, &session, candidate));
}
let draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "puzzle draft is required when gallery has no next puzzle work",
}))
})?;
let candidates = generate_puzzle_image_candidates(
state,
owner_user_id,
&session.session_id,
&draft.level_name,
&draft.summary,
None,
1,
draft.candidates.len(),
)
.await
.map_err(|message| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": message,
}))
})?;
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(to_puzzle_generated_image_candidate)
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let updated_session = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id,
owner_user_id: owner_user_id.to_string(),
level_id: None,
levels_json: None,
candidates_json,
saved_at_micros: current_utc_micros(),
})
.await
.map_err(map_puzzle_client_error)?;
let candidate = updated_session
.draft
.as_ref()
.and_then(|draft| {
draft
.candidates
.iter()
.find(|candidate| !candidate.image_src.is_empty())
})
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "现场生成后没有可用候选图",
}))
})?;
Ok(build_next_run_from_candidate(
run,
&updated_session,
candidate,
))
}
async fn build_same_work_local_next_puzzle_run(
state: &AppState,
run: &PuzzleRunRecord,
source_session_id: &str,
owner_user_id: &str,
) -> Result<Option<PuzzleRunRecord>, AppError> {
if !should_use_same_work_next_level(run) {
return Ok(None);
}
if let Some(work) = fetch_local_current_work_detail(state, run).await? {
if let Some(level) = select_local_next_level(&work.levels, run) {
let next_after_level =
select_next_level_after_level_id(&work.levels, level.level_id.as_str())
.map(|item| item.level_id.clone());
return Ok(Some(build_next_run_from_draft_level(
run.clone(),
level,
Some(work.profile_id),
work.author_display_name,
work.theme_tags,
next_after_level,
)));
}
}
let normalized_session_id = source_session_id.trim();
if normalized_session_id.is_empty() {
return Ok(None);
}
let session = state
.spacetime_client()
.get_puzzle_agent_session(normalized_session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_puzzle_client_error)?;
let Some(draft) = session.draft.as_ref() else {
return Ok(None);
};
if let Some(level) = select_local_next_level(&draft.levels, run) {
let next_after_level =
select_next_level_after_level_id(&draft.levels, level.level_id.as_str())
.map(|item| item.level_id.clone());
return Ok(Some(build_next_run_from_draft_level(
run.clone(),
level,
Some(run.entry_profile_id.clone()),
"当前草稿".to_string(),
draft.theme_tags.clone(),
next_after_level,
)));
}
Ok(None)
}
fn should_use_same_work_next_level(run: &PuzzleRunRecord) -> bool {
run.next_level_mode == module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK
|| run
.next_level_id
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
}
async fn fetch_local_current_work_detail(
state: &AppState,
run: &PuzzleRunRecord,
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
let profile_id = run
.next_level_profile_id
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
run.current_level
.as_ref()
.map(|level| level.profile_id.as_str())
.filter(|value| !value.trim().is_empty())
})
.unwrap_or(run.entry_profile_id.as_str());
match state
.spacetime_client()
.get_puzzle_gallery_detail(profile_id.to_string())
.await
{
Ok(work) => Ok(Some(work)),
Err(SpacetimeClientError::Procedure(message))
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
Ok(None)
}
Err(error) => Err(map_puzzle_client_error(error)),
}
}
async fn resolve_gallery_next_puzzle_work(
state: &AppState,
run: &PuzzleRunRecord,
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
let items = state
.spacetime_client()
.list_puzzle_gallery()
.await
.map_err(map_puzzle_client_error)?;
Ok(items.into_iter().find(|item| {
item.publication_status == "published"
&& item
.cover_image_src
.as_ref()
.is_some_and(|value| !value.is_empty())
&& !run.played_profile_ids.contains(&item.profile_id)
}))
}
fn pick_unused_puzzle_candidate<'a>(
candidates: &'a [PuzzleGeneratedImageCandidateRecord],
played_profile_ids: &[String],
) -> Option<&'a PuzzleGeneratedImageCandidateRecord> {
candidates.iter().find(|candidate| {
!candidate.image_src.is_empty()
&& !played_profile_ids
.iter()
.any(|profile_id| profile_id.contains(&candidate.candidate_id))
})
}
fn select_local_next_level<'a>(
levels: &'a [PuzzleDraftLevelRecord],
run: &PuzzleRunRecord,
) -> Option<&'a PuzzleDraftLevelRecord> {
if levels.is_empty() {
return None;
}
if let Some(next_level_id) = run
.next_level_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Some(level) = levels.iter().find(|level| level.level_id == next_level_id) {
return Some(level);
}
}
let current_level = run.current_level.as_ref()?;
let matched_index = levels
.iter()
.position(|level| {
level.cover_image_src == current_level.cover_image_src
&& level.level_name == current_level.level_name
})
.or_else(|| {
current_level
.level_index
.checked_sub(1)
.and_then(|index| ((index as usize) < levels.len()).then_some(index as usize))
})?;
levels.get(matched_index + 1)
}
fn select_next_level_after_level_id<'a>(
levels: &'a [PuzzleDraftLevelRecord],
level_id: &str,
) -> Option<&'a PuzzleDraftLevelRecord> {
let matched_index = levels.iter().position(|level| level.level_id == level_id)?;
levels.get(matched_index + 1)
}
fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option<String> {
level
.cover_image_src
.as_ref()
.filter(|value| !value.trim().is_empty())
.cloned()
.or_else(|| {
level
.selected_candidate_id
.as_ref()
.and_then(|candidate_id| {
level
.candidates
.iter()
.find(|candidate| candidate.candidate_id == *candidate_id)
})
.map(|candidate| candidate.image_src.clone())
.filter(|value| !value.trim().is_empty())
})
.or_else(|| {
level
.candidates
.iter()
.find(|candidate| !candidate.image_src.trim().is_empty())
.map(|candidate| candidate.image_src.clone())
})
}
fn build_next_run_from_puzzle_work(
state: &AppState,
run: PuzzleRunRecord,
item: PuzzleWorkProfileRecord,
) -> PuzzleRunRecord {
let author = resolve_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
build_next_run_from_parts(
run,
item.profile_id,
item.level_name,
author.display_name,
item.theme_tags,
item.cover_image_src,
)
}
fn build_next_run_from_candidate(
run: PuzzleRunRecord,
session: &PuzzleAgentSessionRecord,
candidate: &PuzzleGeneratedImageCandidateRecord,
) -> PuzzleRunRecord {
let draft = session.draft.as_ref();
let level_index = run.current_level_index + 1;
build_next_run_from_parts(
run,
format!(
"{}-{}-level-{}",
session.session_id, candidate.candidate_id, level_index
),
draft
.map(|draft| format!("{} · 候选 {}", draft.level_name, level_index))
.unwrap_or_else(|| format!("候选拼图 {level_index}")),
"当前草稿".to_string(),
draft
.map(|draft| draft.theme_tags.clone())
.unwrap_or_default(),
Some(candidate.image_src.clone()),
)
}
fn build_next_run_from_draft_level(
mut run: PuzzleRunRecord,
level: &PuzzleDraftLevelRecord,
profile_id: Option<String>,
author_display_name: String,
theme_tags: Vec<String>,
next_after_level_id: Option<String>,
) -> PuzzleRunRecord {
// 中文注释:当前关卡 id 必须取本次选中的目标 level避免旧 run 的空值或脏值影响后续同作品接续。
run.next_level_id = Some(level.level_id.clone());
let fallback_profile_id = run
.current_level
.as_ref()
.map(|level| level.profile_id.clone())
.unwrap_or_else(|| level.level_id.clone());
build_next_run_from_parts_with_handoff(
run,
profile_id
.filter(|value| !value.trim().is_empty())
.unwrap_or(fallback_profile_id),
level.level_name.clone(),
author_display_name,
theme_tags,
resolve_level_cover_image_src(level),
next_after_level_id,
)
}
fn build_next_run_from_parts(
run: PuzzleRunRecord,
profile_id: String,
level_name: String,
author_display_name: String,
theme_tags: Vec<String>,
cover_image_src: Option<String>,
) -> PuzzleRunRecord {
build_next_run_from_parts_with_handoff(
run,
profile_id,
level_name,
author_display_name,
theme_tags,
cover_image_src,
None,
)
}
fn build_next_run_from_parts_with_handoff(
run: PuzzleRunRecord,
profile_id: String,
level_name: String,
author_display_name: String,
theme_tags: Vec<String>,
cover_image_src: Option<String>,
next_after_level_id: Option<String>,
) -> PuzzleRunRecord {
let next_level_index = run.current_level_index + 1;
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
let mut played_profile_ids = run.played_profile_ids.clone();
let current_level_id = run.next_level_id.clone();
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
}
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
PuzzleRunRecord {
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: next_level_index,
current_grid_size: grid_size,
played_profile_ids,
previous_level_tags: theme_tags.clone(),
current_level: Some(PuzzleRuntimeLevelRecord {
run_id: run.run_id,
level_index: next_level_index,
level_id: current_level_id,
grid_size,
profile_id: profile_id.clone(),
level_name,
author_display_name,
theme_tags,
cover_image_src,
board,
status: "playing".to_string(),
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms,
remaining_ms: time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: next_after_level_id
.as_ref()
.map(|_| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string())
.unwrap_or_else(|| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()),
next_level_profile_id: next_after_level_id.as_ref().map(|_| profile_id),
next_level_id: next_after_level_id,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
}
}
fn build_local_puzzle_board(
grid_size: u32,
run_id: &str,
profile_id: &str,
level_index: u32,
) -> PuzzleBoardRecord {
let board = module_puzzle::build_initial_board_with_seed(
grid_size,
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
)
.unwrap_or_else(|_| {
module_puzzle::build_initial_board_with_seed(3, 1)
.expect("fallback puzzle board should use supported grid size")
});
map_puzzle_board_snapshot_record(board)
}
fn build_local_puzzle_shuffle_seed(
run_id: &str,
profile_id: &str,
level_index: u32,
grid_size: u32,
) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in run_id
.bytes()
.chain(profile_id.bytes())
.chain(level_index.to_le_bytes())
.chain(grid_size.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> {
board
.pieces
.iter()
.map(|piece| (piece.current_row, piece.current_col))
.collect()
}
fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool {
board.pieces.iter().any(|piece| {
board.pieces.iter().any(|candidate| {
piece.piece_id != candidate.piece_id
&& piece.current_row.abs_diff(candidate.current_row)
+ piece.current_col.abs_diff(candidate.current_col)
== 1
&& piece.correct_row.abs_diff(candidate.correct_row)
+ piece.correct_col.abs_diff(candidate.correct_col)
== 1
})
})
}
#[test]
fn local_next_level_board_shuffle_changes_by_level() {
let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2);
let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3);
assert_ne!(board_positions(&second), board_positions(&third));
assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third));
}
#[test]
fn puzzle_generated_image_size_is_square_1_1() {
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
}
#[test]
fn puzzle_text_to_image_request_places_negative_prompt_in_input_when_present() {
let body = build_puzzle_text_to_image_request_body(
"一只猫在雨夜灯牌下回头。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_GENERATED_IMAGE_SIZE,
3,
);
assert_eq!(body["input"]["prompt"], "一只猫在雨夜灯牌下回头。");
assert_eq!(
body["input"]["negative_prompt"],
PUZZLE_DEFAULT_NEGATIVE_PROMPT
);
assert!(body["parameters"].get("negative_prompt").is_none());
assert_eq!(body["parameters"]["size"], PUZZLE_GENERATED_IMAGE_SIZE);
assert_eq!(body["parameters"]["n"], 1);
}
#[test]
fn puzzle_dashscope_upstream_error_keeps_status_and_raw_excerpt() {
let error = map_puzzle_dashscope_upstream_error(
reqwest::StatusCode::BAD_REQUEST,
r#"{"code":"InvalidParameter","message":"请求参数不合法"}"#,
"创建拼图图片生成任务失败",
);
assert_eq!(error.body_text(), "请求参数不合法");
let response = error.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
}
struct PuzzleDashScopeSettings {
base_url: String,
api_key: String,
reference_image_model: String,
request_timeout_ms: u64,
}
struct PuzzleGeneratedImages {
task_id: String,
images: Vec<PuzzleDownloadedImage>,
}
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,
}
fn require_puzzle_dashscope_settings(
state: &AppState,
) -> Result<PuzzleDashScopeSettings, AppError> {
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.dashscope_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": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(PuzzleDashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
reference_image_model: state.config.dashscope_reference_image_model.clone(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
fn build_puzzle_dashscope_http_client(
settings: &PuzzleDashScopeSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "dashscope",
"message": format!("构造拼图 DashScope 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_text_to_image_generation(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
) -> Result<PuzzleGeneratedImages, AppError> {
let response = http_client
.post(format!(
"{}/services/aigc/text2image/image-synthesis",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&build_puzzle_text_to_image_request_body(
prompt,
negative_prompt,
size,
candidate_count,
))
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}"))
})?;
if !status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
status,
response_text.as_str(),
"创建拼图图片生成任务失败",
));
}
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图图片生成响应失败")?;
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成任务未返回 task_id",
}))
})?;
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
})?;
if !poll_status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
poll_status,
poll_text.as_str(),
"查询拼图图片生成任务失败",
));
}
let poll_payload =
parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_urls = extract_puzzle_image_urls(&poll_payload);
if image_urls.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成成功但未返回图片地址",
})),
);
}
let mut images = Vec::with_capacity(image_urls.len());
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?);
}
return Ok(PuzzleGeneratedImages { task_id, images });
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_puzzle_dashscope_upstream_error(
poll_status,
poll_text.as_str(),
"拼图图片生成任务失败",
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成超时或未返回图片地址",
})),
)
}
fn build_puzzle_text_to_image_request_body(
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
) -> Value {
let parameters = Map::from_iter([
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
let mut input = Map::from_iter([("prompt".to_string(), Value::String(prompt.to_string()))]);
if !negative_prompt.trim().is_empty() {
input.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.trim().to_string()),
);
}
json!({
"model": PUZZLE_TEXT_TO_IMAGE_MODEL,
"input": input,
"parameters": parameters,
})
}
async fn resolve_puzzle_reference_image_as_data_url(
state: &AppState,
http_client: &reqwest::Client,
source: &str,
) -> Result<String, 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) {
return Ok(format!(
"data:{};base64,{}",
parsed.mime_type,
BASE64_STANDARD.encode(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_dashscope_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_dashscope_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,
})),
);
}
Ok(format!(
"data:{};base64,{}",
content_type,
BASE64_STANDARD.encode(body)
))
}
async fn create_puzzle_image_to_image_generation(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: &str,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut content = vec![json!({ "image": reference_image })];
content.push(json!({ "text": prompt }));
let mut parameters = Map::from_iter([
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
if !negative_prompt.trim().is_empty() {
parameters.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.trim().to_string()),
);
}
let response = http_client
.post(format!(
"{}/services/aigc/multimodal-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.reference_image_model.as_str(),
"input": {
"messages": [
{
"role": "user",
"content": content,
}
],
},
"parameters": parameters,
}))
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("创建拼图参考图生成任务失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图参考图生成响应失败:{error}"))
})?;
if !status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
status,
response_text.as_str(),
"创建拼图参考图生成任务失败",
));
}
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?;
let image_urls = extract_puzzle_image_urls(&payload);
if image_urls.is_empty() {
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图参考图生成未返回 task_id 或图片地址",
}))
})?;
return wait_puzzle_generated_images(
http_client,
settings,
task_id.as_str(),
candidate_count,
"拼图参考图生成任务失败",
)
.await;
}
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: format!("puzzle-ref-{}", current_utc_micros()),
images,
})
}
async fn wait_puzzle_generated_images(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
task_id: &str,
candidate_count: u32,
failure_message: &str,
) -> Result<PuzzleGeneratedImages, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
})?;
if !poll_status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
poll_status,
poll_text.as_str(),
"查询拼图图片生成任务失败",
));
}
let poll_payload =
parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_urls = extract_puzzle_image_urls(&poll_payload);
if image_urls.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成成功但未返回图片地址",
})),
);
}
let mut images = Vec::with_capacity(image_urls.len());
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?);
}
return Ok(PuzzleGeneratedImages {
task_id: task_id.to_string(),
images,
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_puzzle_dashscope_upstream_error(
poll_status,
poll_text.as_str(),
failure_message,
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成超时或未返回图片地址",
})),
)
}
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_dashscope_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_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"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
.map_err(map_puzzle_asset_spacetime_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
.map_err(map_puzzle_asset_spacetime_error)?;
Ok(GeneratedPuzzleAssetResponse {
image_src: put_result.legacy_public_path,
asset_id,
})
}
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 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": "dashscope",
"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_task_id(payload: &Value) -> Option<String> {
find_first_puzzle_string_by_key(payload, "task_id")
}
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 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
&& let Some(text) = value.as_str()
{
results.push(text.to_string());
}
collect_puzzle_strings_by_key(value, target_key, results);
}
}
_ => {}
}
}
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_dashscope_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": message,
}))
}
fn map_puzzle_dashscope_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);
tracing::warn!(
provider = "dashscope",
upstream_status = upstream_status.as_u16(),
message = %message,
raw_excerpt = %raw_excerpt,
"拼图 DashScope 上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
}))
}
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 {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
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())
}