2429 lines
84 KiB
Rust
2429 lines
84 KiB
Rust
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 module_assets::{
|
||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||
};
|
||
use module_puzzle::PuzzleGeneratedImageCandidate;
|
||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||
use serde_json::{Map, Value, json};
|
||
use shared_contracts::{
|
||
puzzle_agent::{
|
||
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
||
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
|
||
PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse,
|
||
PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse,
|
||
PuzzleCreatorIntentResponse, PuzzleGeneratedImageCandidateResponse,
|
||
PuzzleResultDraftResponse, PuzzleResultPreviewBlockerResponse,
|
||
PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse,
|
||
SendPuzzleAgentMessageRequest,
|
||
},
|
||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||
puzzle_runtime::{
|
||
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||
PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
||
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
||
StartPuzzleRunRequest, SwapPuzzlePiecesRequest,
|
||
},
|
||
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,
|
||
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
|
||
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
|
||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||
};
|
||
use std::convert::Infallible;
|
||
use tokio::time::sleep;
|
||
|
||
use crate::{
|
||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||
api_response::json_success_body,
|
||
auth::AuthenticatedAccessToken,
|
||
http_error::AppError,
|
||
puzzle_agent_turn::{
|
||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||
run_puzzle_agent_turn,
|
||
},
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
};
|
||
|
||
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_DEFAULT_NEGATIVE_PROMPT: &str =
|
||
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||
|
||
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 = payload.seed_text.unwrap_or_default().trim().to_string();
|
||
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),
|
||
},
|
||
|_| {},
|
||
)
|
||
.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,
|
||
},
|
||
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 (operation_type, phase_label, phase_detail, session) = match payload.action.trim() {
|
||
"compile_puzzle_draft" => {
|
||
let session = compile_puzzle_draft_with_initial_cover(
|
||
&state,
|
||
session_id.clone(),
|
||
owner_user_id.clone(),
|
||
now,
|
||
)
|
||
.await;
|
||
(
|
||
"compile_puzzle_draft",
|
||
"完整拼图草稿",
|
||
"已编译草稿、生成候选图并应用正式图片。",
|
||
session,
|
||
)
|
||
}
|
||
"generate_puzzle_images" => {
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||
.await;
|
||
let session = match session {
|
||
Ok(session) => {
|
||
let draft = session.draft.clone().ok_or_else(|| {
|
||
SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string())
|
||
});
|
||
match draft {
|
||
Ok(draft) => {
|
||
let prompt = payload
|
||
.prompt_text
|
||
.clone()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or_else(|| draft.summary.clone());
|
||
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
||
let candidate_start_index = draft.candidates.len();
|
||
let candidates = generate_puzzle_image_candidates(
|
||
&state,
|
||
owner_user_id.as_str(),
|
||
&session.session_id,
|
||
&draft.level_name,
|
||
&prompt,
|
||
candidate_count,
|
||
candidate_start_index,
|
||
)
|
||
.await
|
||
.map_err(SpacetimeClientError::Runtime);
|
||
match candidates {
|
||
Ok(candidates) => {
|
||
let candidates_json = serde_json::to_string(
|
||
&candidates
|
||
.iter()
|
||
.map(to_puzzle_generated_image_candidate)
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
.map_err(|error| {
|
||
SpacetimeClientError::Runtime(format!(
|
||
"拼图候选图序列化失败:{error}"
|
||
))
|
||
});
|
||
match candidates_json {
|
||
Ok(candidates_json) => {
|
||
state
|
||
.spacetime_client()
|
||
.save_puzzle_generated_images(
|
||
PuzzleGeneratedImagesSaveRecordInput {
|
||
session_id: session.session_id,
|
||
owner_user_id,
|
||
candidates_json,
|
||
saved_at_micros: now,
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
Err(error) => Err(error),
|
||
}
|
||
}
|
||
Err(error) => Err(error),
|
||
}
|
||
}
|
||
Err(error) => Err(error),
|
||
}
|
||
}
|
||
Err(error) => Err(error),
|
||
};
|
||
(
|
||
"generate_puzzle_images",
|
||
"候选图生成",
|
||
"已生成 2 张候选拼图图像。",
|
||
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(),
|
||
candidate_id,
|
||
selected_at_micros: now,
|
||
})
|
||
.await;
|
||
(
|
||
"select_puzzle_image",
|
||
"正式图确认",
|
||
"已应用正式拼图图片。",
|
||
session,
|
||
)
|
||
}
|
||
"publish_puzzle_work" => {
|
||
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
|
||
let profile = state
|
||
.spacetime_client()
|
||
.publish_puzzle_work(PuzzlePublishRecordInput {
|
||
session_id: session_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
// 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。
|
||
work_id,
|
||
profile_id,
|
||
author_display_name: resolve_author_display_name(&state, &authenticated),
|
||
level_name: payload.level_name.clone(),
|
||
summary: payload.summary.clone(),
|
||
theme_tags: payload.theme_tags.clone(),
|
||
published_at_micros: now,
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_error_response(
|
||
&request_context,
|
||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
map_puzzle_client_error(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.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),
|
||
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(map_puzzle_work_summary_response)
|
||
.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(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(),
|
||
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,
|
||
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(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(map_puzzle_work_summary_response)
|
||
.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(map_puzzle_work_summary_response)
|
||
.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_summary_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
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,
|
||
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(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
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(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
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(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn drag_puzzle_piece_or_group(
|
||
State(state): State<AppState>,
|
||
AxumPath(run_id): AxumPath<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<DragPuzzlePieceRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = payload.map_err(|error| {
|
||
puzzle_error_response(
|
||
&request_context,
|
||
PUZZLE_RUNTIME_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})?;
|
||
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
PUZZLE_RUNTIME_PROVIDER,
|
||
&payload.piece_id,
|
||
"pieceId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.drag_puzzle_piece_or_group(PuzzleRunDragRecordInput {
|
||
run_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
piece_id: payload.piece_id,
|
||
target_row: payload.target_row,
|
||
target_col: payload.target_col,
|
||
dragged_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_error_response(
|
||
&request_context,
|
||
PUZZLE_RUNTIME_PROVIDER,
|
||
map_puzzle_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleRunResponse {
|
||
run: map_puzzle_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
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(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
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(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
fn map_puzzle_agent_session_response(
|
||
session: PuzzleAgentSessionRecord,
|
||
) -> PuzzleAgentSessionSnapshotResponse {
|
||
PuzzleAgentSessionSnapshotResponse {
|
||
session_id: session.session_id,
|
||
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 {
|
||
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,
|
||
}
|
||
}
|
||
|
||
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(item: PuzzleWorkProfileRecord) -> PuzzleWorkSummaryResponse {
|
||
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: item.author_display_name,
|
||
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,
|
||
publish_ready: item.publish_ready,
|
||
}
|
||
}
|
||
|
||
fn map_puzzle_work_profile_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkProfileResponse {
|
||
PuzzleWorkProfileResponse {
|
||
summary: map_puzzle_work_summary_response(item.clone()),
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
fn map_puzzle_level_request_record(
|
||
level: PuzzleRuntimeLevelSnapshotResponse,
|
||
) -> PuzzleRuntimeLevelRecord {
|
||
PuzzleRuntimeLevelRecord {
|
||
run_id: level.run_id,
|
||
level_index: level.level_index,
|
||
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,
|
||
}
|
||
}
|
||
|
||
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 {
|
||
PuzzleRuntimeLevelSnapshotResponse {
|
||
run_id: level.run_id,
|
||
level_index: level.level_index,
|
||
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,
|
||
}
|
||
}
|
||
|
||
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_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,
|
||
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 candidates = generate_puzzle_image_candidates(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
&compiled_session.session_id,
|
||
&draft.level_name,
|
||
&draft.summary,
|
||
2,
|
||
draft.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(),
|
||
candidates_json,
|
||
saved_at_micros: current_utc_micros(),
|
||
})
|
||
.await?;
|
||
state
|
||
.spacetime_client()
|
||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||
session_id,
|
||
owner_user_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 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)
|
||
}
|
||
|
||
async fn generate_puzzle_image_candidates(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
level_name: &str,
|
||
prompt: &str,
|
||
candidate_count: u32,
|
||
candidate_start_index: usize,
|
||
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
||
let count = candidate_count.clamp(1, 2);
|
||
let settings =
|
||
require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?;
|
||
let http_client = build_puzzle_dashscope_http_client(&settings)
|
||
.map_err(|error| error.message().to_string())?;
|
||
let generated = create_puzzle_text_to_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||
"1024*1024",
|
||
count,
|
||
)
|
||
.await
|
||
.map_err(|error| error.message().to_string())?;
|
||
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(|error| error.message().to_string())?;
|
||
items.push(PuzzleGeneratedImageCandidateResponse {
|
||
candidate_id,
|
||
image_src: asset.image_src,
|
||
asset_id: asset.asset_id,
|
||
prompt: prompt.to_string(),
|
||
actual_prompt: Some(prompt.to_string()),
|
||
source_type: "generated".to_string(),
|
||
selected: candidate_start_index == 0 && 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",
|
||
})));
|
||
}
|
||
|
||
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
|
||
return Ok(build_next_run_from_puzzle_work(run, gallery_item));
|
||
}
|
||
|
||
let source_session_id = payload.source_session_id.unwrap_or_default();
|
||
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,
|
||
2,
|
||
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(),
|
||
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 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 build_next_run_from_puzzle_work(
|
||
run: PuzzleRunRecord,
|
||
item: PuzzleWorkProfileRecord,
|
||
) -> PuzzleRunRecord {
|
||
build_next_run_from_parts(
|
||
run,
|
||
item.profile_id,
|
||
item.level_name,
|
||
item.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_parts(
|
||
run: PuzzleRunRecord,
|
||
profile_id: String,
|
||
level_name: String,
|
||
author_display_name: String,
|
||
theme_tags: Vec<String>,
|
||
cover_image_src: 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 mut played_profile_ids = run.played_profile_ids.clone();
|
||
if !played_profile_ids.contains(&profile_id) {
|
||
played_profile_ids.push(profile_id.clone());
|
||
}
|
||
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,
|
||
grid_size,
|
||
profile_id,
|
||
level_name,
|
||
author_display_name,
|
||
theme_tags,
|
||
cover_image_src,
|
||
board: build_local_puzzle_board(grid_size),
|
||
status: "playing".to_string(),
|
||
}),
|
||
recommended_next_profile_id: None,
|
||
}
|
||
}
|
||
|
||
fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
|
||
let total = grid_size * grid_size;
|
||
let mut positions = (0..total)
|
||
.map(|index| PuzzleCellPositionRecord {
|
||
row: index / grid_size,
|
||
col: index % grid_size,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if !positions.is_empty() {
|
||
let first = positions.remove(0);
|
||
positions.push(first);
|
||
}
|
||
let pieces = (0..total)
|
||
.map(|index| {
|
||
let current = positions
|
||
.get(index as usize)
|
||
.cloned()
|
||
.unwrap_or(PuzzleCellPositionRecord {
|
||
row: index / grid_size,
|
||
col: index % grid_size,
|
||
});
|
||
PuzzlePieceStateRecord {
|
||
piece_id: format!("piece-{index}"),
|
||
correct_row: index / grid_size,
|
||
correct_col: index % grid_size,
|
||
current_row: current.row,
|
||
current_col: current.col,
|
||
merged_group_id: None,
|
||
}
|
||
})
|
||
.collect();
|
||
PuzzleBoardRecord {
|
||
rows: grid_size,
|
||
cols: grid_size,
|
||
pieces,
|
||
merged_groups: Vec::new(),
|
||
selected_piece_id: None,
|
||
all_tiles_resolved: false,
|
||
}
|
||
}
|
||
|
||
struct PuzzleDashScopeSettings {
|
||
base_url: String,
|
||
api_key: String,
|
||
request_timeout_ms: u64,
|
||
}
|
||
|
||
struct PuzzleGeneratedImages {
|
||
task_id: String,
|
||
images: Vec<PuzzleDownloadedImage>,
|
||
}
|
||
|
||
struct PuzzleDownloadedImage {
|
||
extension: String,
|
||
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(),
|
||
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 mut parameters = Map::from_iter([
|
||
("n".to_string(), json!(candidate_count.clamp(1, 2))),
|
||
("size".to_string(), Value::String(size.to_string())),
|
||
("prompt_extend".to_string(), Value::Bool(true)),
|
||
("watermark".to_string(), Value::Bool(false)),
|
||
]);
|
||
parameters.insert(
|
||
"negative_prompt".to_string(),
|
||
Value::String(negative_prompt.to_string()),
|
||
);
|
||
|
||
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(&json!({
|
||
"model": PUZZLE_TEXT_TO_IMAGE_MODEL,
|
||
"input": { "prompt": prompt },
|
||
"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(
|
||
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_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, 2) 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_text.as_str(),
|
||
"拼图图片生成任务失败",
|
||
));
|
||
}
|
||
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_image_prompt(level_name: &str, prompt: &str) -> String {
|
||
format!(
|
||
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
|
||
)
|
||
}
|
||
|
||
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 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(raw_text: &str, fallback_message: &str) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "dashscope",
|
||
"message": parse_puzzle_api_error_message(raw_text, fallback_message),
|
||
}))
|
||
}
|
||
|
||
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 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())
|
||
}
|