1395 lines
49 KiB
Rust
1395 lines
49 KiB
Rust
use std::{
|
|
env, fs,
|
|
path::{Path, PathBuf},
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Extension, Path as AxumPath, State, rejection::JsonRejection},
|
|
http::{HeaderName, StatusCode, header},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use serde_json::{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::{
|
|
DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
|
|
PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse,
|
|
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
|
SwapPuzzlePiecesRequest,
|
|
},
|
|
puzzle_works::{
|
|
PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse,
|
|
PuzzleWorkSummaryResponse, PuzzleWorksResponse, PutPuzzleWorkRequest,
|
|
},
|
|
};
|
|
use shared_kernel::build_prefixed_uuid_id;
|
|
use spacetime_client::{
|
|
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
|
|
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
|
PuzzleAnchorPackRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
|
|
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
|
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
|
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
|
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
|
|
SpacetimeClientError,
|
|
};
|
|
|
|
use crate::{
|
|
api_response::json_success_body,
|
|
auth::AuthenticatedAccessToken,
|
|
http_error::AppError,
|
|
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";
|
|
|
|
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 session = state
|
|
.spacetime_client()
|
|
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
|
|
session_id,
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
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),
|
|
)
|
|
})?;
|
|
|
|
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 session = state
|
|
.spacetime_client()
|
|
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
|
|
session_id,
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
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 session_response = map_puzzle_agent_session_response(session);
|
|
let reply_text = session_response
|
|
.last_assistant_reply
|
|
.clone()
|
|
.unwrap_or_else(|| "拼图锚点已更新。".to_string());
|
|
let mut sse_body = String::new();
|
|
append_sse_event(
|
|
&request_context,
|
|
&mut sse_body,
|
|
"reply_delta",
|
|
&json!({ "text": reply_text }),
|
|
)?;
|
|
append_sse_event(
|
|
&request_context,
|
|
&mut sse_body,
|
|
"session",
|
|
&json!({ "session": session_response }),
|
|
)?;
|
|
append_sse_event(&request_context, &mut sse_body, "done", &json!({ "ok": true }))?;
|
|
Ok(build_event_stream_response(sse_body))
|
|
}
|
|
|
|
pub async fn execute_puzzle_agent_action(
|
|
State(state): State<AppState>,
|
|
AxumPath(session_id): AxumPath<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<ExecutePuzzleAgentActionRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = payload.map_err(|error| {
|
|
puzzle_error_response(
|
|
&request_context,
|
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"message": error.body_text(),
|
|
})),
|
|
)
|
|
})?;
|
|
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
|
|
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let now = current_utc_micros();
|
|
|
|
let (operation_type, phase_label, phase_detail, session) = match payload.action.trim() {
|
|
"compile_puzzle_draft" => {
|
|
let session = state
|
|
.spacetime_client()
|
|
.compile_puzzle_agent_draft(session_id, owner_user_id, now)
|
|
.await;
|
|
(
|
|
"compile_puzzle_draft",
|
|
"结果页草稿",
|
|
"已根据当前锚点编译结果页草稿。",
|
|
session,
|
|
)
|
|
}
|
|
"generate_puzzle_images" => {
|
|
let session = state
|
|
.spacetime_client()
|
|
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
|
.await;
|
|
let session = match session {
|
|
Ok(session) => {
|
|
let draft = session.draft.clone().ok_or_else(|| {
|
|
SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string())
|
|
});
|
|
match draft {
|
|
Ok(draft) => {
|
|
let prompt = payload
|
|
.prompt_text
|
|
.clone()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| draft.summary.clone());
|
|
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
|
let candidates = build_placeholder_puzzle_candidates(
|
|
&session.session_id,
|
|
&draft.level_name,
|
|
&prompt,
|
|
candidate_count,
|
|
)
|
|
.map_err(SpacetimeClientError::Runtime);
|
|
match candidates {
|
|
Ok(candidates) => {
|
|
let candidates_json = serde_json::to_string(
|
|
&candidates
|
|
.iter()
|
|
.map(|candidate| {
|
|
json!({
|
|
"candidateId": candidate.candidate_id,
|
|
"imageSrc": candidate.image_src,
|
|
"assetId": candidate.asset_id,
|
|
"prompt": candidate.prompt,
|
|
"actualPrompt": candidate.actual_prompt,
|
|
"sourceType": candidate.source_type,
|
|
"selected": candidate.selected,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.map_err(|error| {
|
|
SpacetimeClientError::Runtime(format!(
|
|
"拼图候选图序列化失败:{error}"
|
|
))
|
|
});
|
|
match candidates_json {
|
|
Ok(candidates_json) => {
|
|
state
|
|
.spacetime_client()
|
|
.save_puzzle_generated_images(
|
|
PuzzleGeneratedImagesSaveRecordInput {
|
|
session_id: session.session_id,
|
|
owner_user_id,
|
|
candidates_json,
|
|
saved_at_micros: now,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
Err(error) => Err(error),
|
|
};
|
|
(
|
|
"generate_puzzle_images",
|
|
"候选图生成",
|
|
"已生成 2 张候选拼图图像。",
|
|
session,
|
|
)
|
|
}
|
|
"select_puzzle_image" => {
|
|
let candidate_id = payload
|
|
.candidate_id
|
|
.clone()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.ok_or_else(|| {
|
|
puzzle_bad_request(
|
|
&request_context,
|
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"candidateId is required",
|
|
)
|
|
})?;
|
|
let session = state
|
|
.spacetime_client()
|
|
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
|
session_id: session_id.clone(),
|
|
owner_user_id: owner_user_id.clone(),
|
|
candidate_id,
|
|
selected_at_micros: now,
|
|
})
|
|
.await;
|
|
(
|
|
"select_puzzle_image",
|
|
"正式图确认",
|
|
"已应用正式拼图图片。",
|
|
session,
|
|
)
|
|
}
|
|
"publish_puzzle_work" => {
|
|
let profile = state
|
|
.spacetime_client()
|
|
.publish_puzzle_work(PuzzlePublishRecordInput {
|
|
session_id: session_id.clone(),
|
|
owner_user_id: owner_user_id.clone(),
|
|
work_id: build_prefixed_uuid_id("puzzle-work-"),
|
|
profile_id: build_prefixed_uuid_id("puzzle-profile-"),
|
|
author_display_name: resolve_author_display_name(&state, &authenticated),
|
|
level_name: payload.level_name.clone(),
|
|
summary: payload.summary.clone(),
|
|
theme_tags: payload.theme_tags.clone(),
|
|
published_at_micros: now,
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
puzzle_error_response(
|
|
&request_context,
|
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
map_puzzle_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
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,
|
|
},
|
|
},
|
|
));
|
|
}
|
|
other => {
|
|
return Err(puzzle_bad_request(
|
|
&request_context,
|
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
format!("action `{other}` is not supported").as_str(),
|
|
));
|
|
}
|
|
};
|
|
|
|
let session = session.map_err(|error| {
|
|
puzzle_error_response(
|
|
&request_context,
|
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
map_puzzle_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
PuzzleAgentActionResponse {
|
|
operation: PuzzleAgentOperationResponse {
|
|
operation_id: session.session_id.clone(),
|
|
operation_type: operation_type.to_string(),
|
|
status: "completed".to_string(),
|
|
phase_label: phase_label.to_string(),
|
|
phase_detail: phase_detail.to_string(),
|
|
progress: 100,
|
|
error: None,
|
|
},
|
|
},
|
|
))
|
|
}
|
|
|
|
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 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),
|
|
},
|
|
))
|
|
}
|
|
|
|
fn map_puzzle_agent_session_response(
|
|
session: PuzzleAgentSessionRecord,
|
|
) -> PuzzleAgentSessionSnapshotResponse {
|
|
PuzzleAgentSessionSnapshotResponse {
|
|
session_id: session.session_id,
|
|
current_turn: session.current_turn,
|
|
progress_percent: session.progress_percent,
|
|
stage: session.stage,
|
|
anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack),
|
|
draft: session.draft.map(map_puzzle_result_draft_response),
|
|
messages: session
|
|
.messages
|
|
.into_iter()
|
|
.map(map_puzzle_agent_message_response)
|
|
.collect(),
|
|
last_assistant_reply: session.last_assistant_reply,
|
|
published_profile_id: session.published_profile_id,
|
|
suggested_actions: session
|
|
.suggested_actions
|
|
.into_iter()
|
|
.map(map_puzzle_suggested_action_response)
|
|
.collect(),
|
|
result_preview: session.result_preview.map(map_puzzle_result_preview_response),
|
|
updated_at: session.updated_at,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_anchor_pack_response(anchor_pack: PuzzleAnchorPackRecord) -> PuzzleAnchorPackResponse {
|
|
PuzzleAnchorPackResponse {
|
|
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
|
|
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
|
|
visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood),
|
|
composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks),
|
|
tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden),
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse {
|
|
PuzzleAnchorItemResponse {
|
|
key: anchor.key,
|
|
label: anchor.label,
|
|
value: anchor.value,
|
|
status: anchor.status,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse {
|
|
PuzzleResultDraftResponse {
|
|
level_name: draft.level_name,
|
|
summary: draft.summary,
|
|
theme_tags: draft.theme_tags,
|
|
forbidden_directives: draft.forbidden_directives,
|
|
creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response),
|
|
anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack),
|
|
candidates: draft
|
|
.candidates
|
|
.into_iter()
|
|
.map(map_puzzle_generated_image_candidate_response)
|
|
.collect(),
|
|
selected_candidate_id: draft.selected_candidate_id,
|
|
cover_image_src: draft.cover_image_src,
|
|
cover_asset_id: draft.cover_asset_id,
|
|
generation_status: draft.generation_status,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_creator_intent_response(
|
|
intent: PuzzleCreatorIntentRecord,
|
|
) -> PuzzleCreatorIntentResponse {
|
|
PuzzleCreatorIntentResponse {
|
|
source_mode: intent.source_mode,
|
|
raw_messages_summary: intent.raw_messages_summary,
|
|
theme_promise: intent.theme_promise,
|
|
visual_subject: intent.visual_subject,
|
|
visual_mood: intent.visual_mood,
|
|
composition_hooks: intent.composition_hooks,
|
|
theme_tags: intent.theme_tags,
|
|
forbidden_directives: intent.forbidden_directives,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_generated_image_candidate_response(
|
|
candidate: PuzzleGeneratedImageCandidateRecord,
|
|
) -> PuzzleGeneratedImageCandidateResponse {
|
|
PuzzleGeneratedImageCandidateResponse {
|
|
candidate_id: candidate.candidate_id,
|
|
image_src: candidate.image_src,
|
|
asset_id: candidate.asset_id,
|
|
prompt: candidate.prompt,
|
|
actual_prompt: candidate.actual_prompt,
|
|
source_type: candidate.source_type,
|
|
selected: candidate.selected,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_agent_message_response(
|
|
message: PuzzleAgentMessageRecord,
|
|
) -> PuzzleAgentMessageResponse {
|
|
PuzzleAgentMessageResponse {
|
|
id: message.message_id,
|
|
role: message.role,
|
|
kind: message.kind,
|
|
text: message.text,
|
|
created_at: message.created_at,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_suggested_action_response(
|
|
action: PuzzleAgentSuggestedActionRecord,
|
|
) -> PuzzleAgentSuggestedActionResponse {
|
|
PuzzleAgentSuggestedActionResponse {
|
|
id: action.action_id,
|
|
action_type: action.action_type,
|
|
label: action.label,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_result_preview_response(
|
|
preview: PuzzleResultPreviewRecord,
|
|
) -> PuzzleResultPreviewEnvelopeResponse {
|
|
PuzzleResultPreviewEnvelopeResponse {
|
|
draft: map_puzzle_result_draft_response(preview.draft),
|
|
blockers: preview
|
|
.blockers
|
|
.into_iter()
|
|
.map(map_puzzle_result_preview_blocker_response)
|
|
.collect(),
|
|
quality_findings: preview
|
|
.quality_findings
|
|
.into_iter()
|
|
.map(map_puzzle_result_preview_finding_response)
|
|
.collect(),
|
|
publish_ready: preview.publish_ready,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_result_preview_blocker_response(
|
|
blocker: PuzzleResultPreviewBlockerRecord,
|
|
) -> PuzzleResultPreviewBlockerResponse {
|
|
PuzzleResultPreviewBlockerResponse {
|
|
id: blocker.blocker_id,
|
|
code: blocker.code,
|
|
message: blocker.message,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_result_preview_finding_response(
|
|
finding: PuzzleResultPreviewFindingRecord,
|
|
) -> PuzzleResultPreviewFindingResponse {
|
|
PuzzleResultPreviewFindingResponse {
|
|
id: finding.finding_id,
|
|
severity: finding.severity,
|
|
code: finding.code,
|
|
message: finding.message,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkSummaryResponse {
|
|
PuzzleWorkSummaryResponse {
|
|
work_id: item.work_id,
|
|
profile_id: item.profile_id,
|
|
owner_user_id: item.owner_user_id,
|
|
source_session_id: item.source_session_id,
|
|
author_display_name: item.author_display_name,
|
|
level_name: item.level_name,
|
|
summary: item.summary,
|
|
theme_tags: item.theme_tags,
|
|
cover_image_src: item.cover_image_src,
|
|
cover_asset_id: item.cover_asset_id,
|
|
publication_status: item.publication_status,
|
|
updated_at: item.updated_at,
|
|
published_at: item.published_at,
|
|
play_count: item.play_count,
|
|
publish_ready: item.publish_ready,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_work_profile_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkProfileResponse {
|
|
PuzzleWorkProfileResponse {
|
|
summary: map_puzzle_work_summary_response(item.clone()),
|
|
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
|
PuzzleRunSnapshotResponse {
|
|
run_id: run.run_id,
|
|
entry_profile_id: run.entry_profile_id,
|
|
cleared_level_count: run.cleared_level_count,
|
|
current_level_index: run.current_level_index,
|
|
current_grid_size: run.current_grid_size,
|
|
played_profile_ids: run.played_profile_ids,
|
|
previous_level_tags: run.previous_level_tags,
|
|
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
|
recommended_next_profile_id: run.recommended_next_profile_id,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_runtime_level_response(
|
|
level: spacetime_client::PuzzleRuntimeLevelRecord,
|
|
) -> PuzzleRuntimeLevelSnapshotResponse {
|
|
PuzzleRuntimeLevelSnapshotResponse {
|
|
run_id: level.run_id,
|
|
level_index: level.level_index,
|
|
grid_size: level.grid_size,
|
|
profile_id: level.profile_id,
|
|
level_name: level.level_name,
|
|
author_display_name: level.author_display_name,
|
|
theme_tags: level.theme_tags,
|
|
cover_image_src: level.cover_image_src,
|
|
board: map_puzzle_board_response(level.board),
|
|
status: level.status,
|
|
}
|
|
}
|
|
|
|
fn map_puzzle_board_response(
|
|
board: spacetime_client::PuzzleBoardRecord,
|
|
) -> PuzzleBoardSnapshotResponse {
|
|
PuzzleBoardSnapshotResponse {
|
|
rows: board.rows,
|
|
cols: board.cols,
|
|
pieces: board
|
|
.pieces
|
|
.into_iter()
|
|
.map(|piece| PuzzlePieceStateResponse {
|
|
piece_id: piece.piece_id,
|
|
correct_row: piece.correct_row,
|
|
correct_col: piece.correct_col,
|
|
current_row: piece.current_row,
|
|
current_col: piece.current_col,
|
|
merged_group_id: piece.merged_group_id,
|
|
})
|
|
.collect(),
|
|
merged_groups: board
|
|
.merged_groups
|
|
.into_iter()
|
|
.map(|group| PuzzleMergedGroupStateResponse {
|
|
group_id: group.group_id,
|
|
piece_ids: group.piece_ids,
|
|
occupied_cells: group
|
|
.occupied_cells
|
|
.into_iter()
|
|
.map(|cell| PuzzleCellPositionResponse {
|
|
row: cell.row,
|
|
col: cell.col,
|
|
})
|
|
.collect(),
|
|
})
|
|
.collect(),
|
|
selected_piece_id: board.selected_piece_id,
|
|
all_tiles_resolved: board.all_tiles_resolved,
|
|
}
|
|
}
|
|
|
|
fn resolve_author_display_name(
|
|
state: &AppState,
|
|
authenticated: &AuthenticatedAccessToken,
|
|
) -> String {
|
|
state
|
|
.auth_user_service()
|
|
.get_user_by_id(authenticated.claims().user_id())
|
|
.ok()
|
|
.flatten()
|
|
.map(|user| user.display_name)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| "玩家".to_string())
|
|
}
|
|
|
|
fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
|
if seed_text.trim().is_empty() {
|
|
return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。"
|
|
.to_string();
|
|
}
|
|
|
|
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string()
|
|
}
|
|
|
|
fn 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
|
|
}
|
|
_ => 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 append_sse_event(
|
|
request_context: &RequestContext,
|
|
body: &mut String,
|
|
event_name: &str,
|
|
payload: &Value,
|
|
) -> Result<(), Response> {
|
|
let payload = serde_json::to_string(payload).map_err(|error| {
|
|
puzzle_error_response(
|
|
request_context,
|
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
"message": format!("SSE payload 序列化失败:{error}"),
|
|
})),
|
|
)
|
|
})?;
|
|
body.push_str("event: ");
|
|
body.push_str(event_name);
|
|
body.push('\n');
|
|
body.push_str("data: ");
|
|
body.push_str(&payload);
|
|
body.push_str("\n\n");
|
|
Ok(())
|
|
}
|
|
|
|
fn build_event_stream_response(body: String) -> Response {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
|
|
(header::CACHE_CONTROL, "no-cache, no-transform"),
|
|
(header::CONNECTION, "keep-alive"),
|
|
],
|
|
body,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn build_placeholder_puzzle_candidates(
|
|
session_id: &str,
|
|
level_name: &str,
|
|
prompt: &str,
|
|
candidate_count: u32,
|
|
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
|
let count = candidate_count.clamp(1, 2);
|
|
let mut items = Vec::with_capacity(count as usize);
|
|
|
|
for index in 0..count {
|
|
let asset = save_placeholder_puzzle_asset(
|
|
session_id,
|
|
level_name,
|
|
&format!("candidate-{}", index + 1),
|
|
"cover",
|
|
"1536*1536",
|
|
Some(prompt),
|
|
)
|
|
.map_err(|error| error.message().to_string())?;
|
|
items.push(PuzzleGeneratedImageCandidateResponse {
|
|
candidate_id: format!("{session_id}-candidate-{}", index + 1),
|
|
image_src: asset.image_src,
|
|
asset_id: asset.asset_id,
|
|
prompt: prompt.to_string(),
|
|
actual_prompt: Some(prompt.to_string()),
|
|
source_type: "generated".to_string(),
|
|
selected: 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())
|
|
}
|
|
|
|
struct GeneratedPuzzleAssetResponse {
|
|
image_src: String,
|
|
asset_id: String,
|
|
}
|
|
|
|
fn save_placeholder_puzzle_asset(
|
|
session_segment_seed: &str,
|
|
work_segment_seed: &str,
|
|
leaf_segment_seed: &str,
|
|
file_stem: &str,
|
|
size: &str,
|
|
prompt: Option<&str>,
|
|
) -> Result<GeneratedPuzzleAssetResponse, AppError> {
|
|
let asset_id = format!("{file_stem}-{}", current_utc_millis());
|
|
let relative_dir = PathBuf::from("generated-puzzle-covers")
|
|
.join(sanitize_path_segment(session_segment_seed, "session"))
|
|
.join(sanitize_path_segment(work_segment_seed, "puzzle"))
|
|
.join(sanitize_path_segment(leaf_segment_seed, "candidate"))
|
|
.join(&asset_id);
|
|
let output_dir = resolve_public_output_dir(&relative_dir)?;
|
|
fs::create_dir_all(&output_dir).map_err(io_error)?;
|
|
let file_name = format!("{file_stem}.svg");
|
|
let svg = build_puzzle_placeholder_svg(size, prompt.unwrap_or(file_stem));
|
|
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
|
|
|
|
Ok(GeneratedPuzzleAssetResponse {
|
|
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
|
|
asset_id,
|
|
})
|
|
}
|
|
|
|
fn build_puzzle_placeholder_svg(size: &str, label: &str) -> String {
|
|
let (width, height) = parse_size(size);
|
|
format!(
|
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
|
<defs>
|
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0%" stop-color="#201a0f"/>
|
|
<stop offset="50%" stop-color="#4a2c24"/>
|
|
<stop offset="100%" stop-color="#10243a"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#bg)"/>
|
|
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,244,214,0.12)"/>
|
|
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(115,194,255,0.14)"/>
|
|
<rect x="{frame_x}" y="{frame_y}" width="{frame_w}" height="{frame_h}" rx="{frame_r}" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.18)"/>
|
|
<text x="50%" y="47%" text-anchor="middle" fill="#f6e9cf" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
|
<text x="50%" y="57%" text-anchor="middle" fill="#d4e8ff" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Puzzle placeholder</text>
|
|
</svg>"##,
|
|
width = width,
|
|
height = height,
|
|
cx1 = width / 4,
|
|
cy1 = height / 3,
|
|
r1 = (width.min(height) / 5).max(42),
|
|
cx2 = width * 3 / 4,
|
|
cy2 = height / 4,
|
|
r2 = (width.min(height) / 7).max(30),
|
|
frame_x = width / 9,
|
|
frame_y = height / 9,
|
|
frame_w = width * 7 / 9,
|
|
frame_h = height * 7 / 9,
|
|
frame_r = (width.min(height) / 20).max(18),
|
|
font_main = (width.min(height) / 14).max(22),
|
|
font_sub = (width.min(height) / 30).max(12),
|
|
title = escape_svg_text(label),
|
|
)
|
|
}
|
|
|
|
fn parse_size(size: &str) -> (u32, u32) {
|
|
let mut parts = size.split('*');
|
|
let width = parts
|
|
.next()
|
|
.and_then(|value| value.trim().parse::<u32>().ok())
|
|
.filter(|value| *value > 0)
|
|
.unwrap_or(1536);
|
|
let height = parts
|
|
.next()
|
|
.and_then(|value| value.trim().parse::<u32>().ok())
|
|
.filter(|value| *value > 0)
|
|
.unwrap_or(1536);
|
|
(width, height)
|
|
}
|
|
|
|
fn escape_svg_text(value: &str) -> String {
|
|
value
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|
|
|
|
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 resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
|
|
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.ancestors()
|
|
.nth(3)
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.with_message("无法定位仓库根目录")
|
|
})?;
|
|
Ok(workspace_root.join("public").join(relative_dir))
|
|
}
|
|
|
|
fn io_error(error: std::io::Error) -> AppError {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
fn current_utc_millis() -> u128 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis()
|
|
}
|