2992 lines
103 KiB
Rust
2992 lines
103 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 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};
|
||
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, PuzzleGeneratedImageCandidateResponse,
|
||
PuzzleResultDraftResponse, PuzzleResultPreviewBlockerResponse,
|
||
PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse,
|
||
SendPuzzleAgentMessageRequest,
|
||
},
|
||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||
puzzle_runtime::{
|
||
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||
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,
|
||
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
|
||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||
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,
|
||
asset_billing::execute_billable_asset_operation,
|
||
auth::AuthenticatedAccessToken,
|
||
http_error::AppError,
|
||
prompt::puzzle_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,
|
||
};
|
||
|
||
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";
|
||
|
||
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),
|
||
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}");
|
||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||
"compile_puzzle_draft" => {
|
||
let session = execute_billable_asset_operation(
|
||
&state,
|
||
&owner_user_id,
|
||
"puzzle_initial_image",
|
||
&billing_asset_id,
|
||
async {
|
||
compile_puzzle_draft_with_initial_cover(
|
||
&state,
|
||
session_id.clone(),
|
||
owner_user_id.clone(),
|
||
now,
|
||
)
|
||
.await
|
||
.map_err(map_puzzle_client_error)
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||
});
|
||
(
|
||
"compile_puzzle_draft",
|
||
"完整拼图草稿",
|
||
"已编译草稿、生成拼图图片并应用为正式图。",
|
||
session,
|
||
)
|
||
}
|
||
"generate_puzzle_images" => {
|
||
let session = execute_billable_asset_operation(
|
||
&state,
|
||
&owner_user_id,
|
||
"puzzle_generated_image",
|
||
&billing_asset_id,
|
||
async {
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||
.await
|
||
.map_err(map_puzzle_client_error)?;
|
||
let draft = session.draft.clone().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图结果页草稿尚未生成",
|
||
}))
|
||
})?;
|
||
let prompt = payload
|
||
.prompt_text
|
||
.clone()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or_else(|| draft.summary.clone());
|
||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||
let candidate_count = 1;
|
||
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,
|
||
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(),
|
||
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(),
|
||
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 (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,
|
||
level_name: payload.level_name.clone(),
|
||
summary: payload.summary.clone(),
|
||
theme_tags: payload.theme_tags.clone(),
|
||
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(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),
|
||
},
|
||
))
|
||
}
|
||
|
||
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(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,
|
||
leaderboard_entries: run
|
||
.leaderboard_entries
|
||
.into_iter()
|
||
.map(map_puzzle_leaderboard_entry_response)
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
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,
|
||
leaderboard_entries: run
|
||
.leaderboard_entries
|
||
.into_iter()
|
||
.map(map_puzzle_leaderboard_request_record)
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
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,
|
||
started_at_ms: level.started_at_ms,
|
||
cleared_at_ms: level.cleared_at_ms,
|
||
elapsed_ms: level.elapsed_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 {
|
||
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,
|
||
started_at_ms: level.started_at_ms,
|
||
cleared_at_ms: level.cleared_at_ms,
|
||
elapsed_ms: level.elapsed_ms,
|
||
leaderboard_entries: level
|
||
.leaderboard_entries
|
||
.into_iter()
|
||
.map(map_puzzle_leaderboard_entry_response)
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
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_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,
|
||
None,
|
||
1,
|
||
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,
|
||
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(|error| error.message().to_string())?;
|
||
let http_client = build_puzzle_dashscope_http_client(&settings)
|
||
.map_err(|error| error.message().to_string())?;
|
||
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
|
||
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(|error| error.message().to_string())?,
|
||
),
|
||
None => None,
|
||
};
|
||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。
|
||
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,
|
||
"1024*1024",
|
||
count,
|
||
reference_image,
|
||
)
|
||
.await
|
||
}
|
||
None => {
|
||
create_puzzle_text_to_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
actual_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(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",
|
||
})),
|
||
);
|
||
}
|
||
|
||
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,
|
||
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(),
|
||
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());
|
||
}
|
||
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,
|
||
grid_size,
|
||
profile_id,
|
||
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,
|
||
leaderboard_entries: Vec::new(),
|
||
}),
|
||
recommended_next_profile_id: None,
|
||
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));
|
||
}
|
||
}
|
||
|
||
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 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)),
|
||
]);
|
||
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, 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_text.as_str(),
|
||
"拼图图片生成任务失败",
|
||
));
|
||
}
|
||
sleep(Duration::from_secs(2)).await;
|
||
}
|
||
|
||
Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "dashscope",
|
||
"message": "拼图图片生成超时或未返回图片地址",
|
||
})),
|
||
)
|
||
}
|
||
|
||
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 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": {
|
||
"n": candidate_count.clamp(1, 1),
|
||
"size": size,
|
||
"negative_prompt": negative_prompt,
|
||
"prompt_extend": true,
|
||
"watermark": false,
|
||
},
|
||
}))
|
||
.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 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_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_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(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())
|
||
}
|