Files
Genarrative/server-rs/crates/api-server/src/puzzle/handlers.rs
2026-05-28 14:50:13 +08:00

2161 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub async fn create_puzzle_agent_session(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CreatePuzzleAgentSessionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": error.body_text(),
})),
)
})?;
let seed_text = build_puzzle_form_seed_text(&payload);
let session = state
.spacetime_client()
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
session_id: build_prefixed_uuid_id("puzzle-session-"),
owner_user_id: authenticated.claims().user_id().to_string(),
seed_text: seed_text.clone(),
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
welcome_message_text: build_puzzle_welcome_text(&seed_text),
created_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleAgentSessionResponse {
session: map_puzzle_agent_session_response(session),
},
))
}
pub async fn generate_puzzle_onboarding_work(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<PuzzleOnboardingGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": error.body_text(),
})),
)
})?;
let prompt_text = payload.prompt_text.trim().to_string();
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&prompt_text,
"promptText",
)?;
let now = current_utc_micros();
let session_id = build_prefixed_uuid_id("puzzle-onboarding-");
let onboarding_profile_id = format!("onboarding-profile-{now}");
let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
let tags =
generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await;
let candidates = generate_puzzle_image_candidates(
&state,
"onboarding-guest",
Some(onboarding_profile_id.as_str()),
session_id.as_str(),
naming.level_name.as_str(),
prompt_text.as_str(),
None,
false,
Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2),
1,
0,
)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_generation_endpoint_error(error),
)
})?
.into_records();
let selected = candidates.first().cloned().ok_or_else(|| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "新手引导拼图图片生成结果为空",
})),
)
})?;
let level = PuzzleDraftLevelRecord {
level_id: "onboarding-level-1".to_string(),
level_name: naming.level_name.clone(),
picture_description: prompt_text.clone(),
picture_reference: None,
ui_background_prompt: naming.ui_background_prompt.clone(),
ui_background_image_src: None,
ui_background_image_object_key: None,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: None,
candidates,
selected_candidate_id: Some(selected.candidate_id.clone()),
cover_image_src: Some(selected.image_src.clone()),
cover_asset_id: Some(selected.asset_id.clone()),
generation_status: "ready".to_string(),
};
let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack(
naming.level_name.as_str(),
level.picture_description.as_str(),
));
let item = PuzzleWorkProfileRecord {
work_id: format!("onboarding-work-{now}"),
profile_id: onboarding_profile_id,
owner_user_id: "onboarding-guest".to_string(),
source_session_id: None,
author_display_name: "陶泥儿主".to_string(),
work_title: naming.level_name.clone(),
work_description: prompt_text.clone(),
level_name: naming.level_name,
summary: prompt_text,
theme_tags: tags,
cover_image_src: level.cover_image_src.clone(),
cover_asset_id: level.cover_asset_id.clone(),
publication_status: "draft".to_string(),
updated_at: format_timestamp_micros(now),
published_at: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
anchor_pack,
publish_ready: true,
levels: vec![level.clone()],
};
Ok(json_success_body(
Some(&request_context),
PuzzleOnboardingGenerateResponse {
item: map_puzzle_work_profile_response(&state, item.clone()).summary,
level: map_puzzle_draft_level_response(level),
},
))
}
pub async fn save_puzzle_onboarding_work(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PuzzleOnboardingSaveRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_WORKS_PROVIDER,
"message": error.body_text(),
})),
)
})?;
let prompt_text = payload.prompt_text.trim().to_string();
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&prompt_text,
"promptText",
)?;
let first_level = payload.item.levels.first().cloned().ok_or_else(|| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_WORKS_PROVIDER,
"message": "新手引导拼图缺少可保存关卡",
})),
)
})?;
let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?;
let work_title = payload.item.work_title.trim();
let work_title = if work_title.is_empty() {
first_level.level_name.clone()
} else {
work_title.to_string()
};
let work_description = payload.item.work_description.trim();
let work_description = if work_description.is_empty() {
prompt_text.clone()
} else {
work_description.to_string()
};
let summary = payload.item.summary.trim();
let summary = if summary.is_empty() {
first_level.picture_description.clone()
} else {
summary.to_string()
};
let now = current_utc_micros();
let owner_user_id = authenticated.claims().user_id().to_string();
let session_id = build_prefixed_uuid_id("puzzle-session-");
state
.spacetime_client()
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
seed_text: prompt_text.clone(),
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
welcome_message_text: build_puzzle_welcome_text(&prompt_text),
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let item = state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
profile_id,
owner_user_id,
work_title,
work_description,
level_name: first_level.level_name,
summary,
theme_tags: payload.item.theme_tags,
cover_image_src: first_level.cover_image_src,
cover_asset_id: first_level.cover_asset_id,
levels_json: Some(levels_json),
updated_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorkMutationResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn get_puzzle_agent_session(
State(state): State<PuzzleApiState>,
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<PuzzleApiState>,
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.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<PuzzleApiState>,
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.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<PuzzleApiState>,
AxumPath(session_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<ExecutePuzzleAgentActionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let now = current_utc_micros();
let action = payload.action.trim().to_string();
let billing_asset_id = format!("{session_id}:{now}");
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
owner_user_id = %owner_user_id,
action = %action,
image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(),
prompt_chars = payload
.prompt_text
.as_deref()
.map(|value| value.chars().count())
.unwrap_or(0),
has_reference_image = has_puzzle_reference_images(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
),
"拼图 Agent action 开始执行"
);
let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| {
let state = state.clone();
let owner_user_id = owner_user_id.clone();
let error_message = error.body_text();
let session_id = compile_session_id.to_string();
let log_session_id = session_id.clone();
let log_owner_user_id = owner_user_id.clone();
async move {
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id,
owner_user_id,
error_message,
failed_at_micros: now,
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %log_session_id,
owner_user_id = %log_owner_user_id,
message = %error,
"拼图草稿失败态回写失败,继续返回原始错误"
);
}
}
};
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
let prompt_text = payload
.picture_description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| payload.prompt_text.as_deref());
let compile_session_id = match save_puzzle_form_payload_before_compile(
&state,
&request_context,
&session_id,
&owner_user_id,
&payload,
now,
)
.await
{
Ok(next_session_id) => next_session_id,
Err(response) => return Err(response),
};
let session = if ai_redraw {
execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
compile_puzzle_draft_with_initial_cover(
&state,
&request_context,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
payload.image_model.as_deref(),
now,
)
.await
},
)
.await
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
&request_context,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
now,
)
.await
};
let session = match session {
Ok(session) => Ok(session),
Err(error) => {
mark_puzzle_compile_failure(&error, &compile_session_id).await;
Err(puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
error,
))
}
};
(
"compile_puzzle_draft",
"首关拼图草稿",
if ai_redraw {
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
} else {
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
},
session,
)
}
"save_puzzle_form_draft" => {
let seed_text = build_puzzle_form_seed_text_from_parts(
None,
None,
payload
.picture_description
.as_deref()
.or(payload.prompt_text.as_deref()),
);
let save_result = state
.spacetime_client()
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
seed_text,
saved_at_micros: now,
})
.await;
let session = match save_result {
Ok(session) => Ok(session),
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
// 中文注释:旧 wasm 缺少该自动保存 procedure 时,返回当前 session避免填表页被非关键错误打断。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图表单自动保存 procedure 缺失,降级返回当前会话"
);
state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|fallback_error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(fallback_error),
)
})
}
Err(error) => Err(puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)),
};
(
"save_puzzle_form_draft",
"表单草稿保存",
"拼图表单草稿已保存。",
session,
)
}
"generate_puzzle_images" => {
let target_level_id = payload.level_id.clone();
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
let levels_json = levels_json?;
let session = get_puzzle_session_for_image_generation(
&state,
session_id.clone(),
owner_user_id.clone(),
&payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let mut target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let fallback_level_name = target_level.level_name.clone();
let prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
&draft.summary,
);
let should_auto_name_level = payload
.should_auto_name_level
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
let mut generated_naming = if should_auto_name_level {
let naming = generate_puzzle_first_level_name(
&state,
target_level.picture_description.as_str(),
)
.await;
target_level.level_name = naming.level_name.clone();
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
Some(naming)
} else {
None
};
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src =
reference_image_sources.first().map(String::as_str);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_start_index = target_level.candidates.len();
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let mut candidates = if should_use_uploaded_puzzle_image_directly(
primary_reference_image_src,
ai_redraw,
) {
vec![
create_uploaded_puzzle_image_candidate(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src.expect("checked reference image"),
candidate_start_index,
)
.await?,
]
} else {
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
Some(profile_id.as_str()),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src,
ai_redraw,
payload.image_model.as_deref(),
1,
candidate_start_index,
)
.await
.map_err(map_puzzle_generation_endpoint_error)?
};
if candidates.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}),
));
}
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
&state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
.filter(|_| should_auto_name_level)
{
target_level.level_name = refined_naming.level_name.clone();
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt =
refined_naming.ui_background_prompt.clone();
}
generated_naming = Some(refined_naming);
}
let generated_level_name = target_level.level_name.clone();
let mut updated_levels = build_puzzle_levels_with_primary_update(
&draft,
&target_level,
primary_reference_image_src,
);
for candidate in &mut candidates {
candidate.record.prompt = prompt.clone();
}
let selected_candidate = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}))
})?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
&state,
&request_context,
owner_user_id.as_str(),
&session.session_id,
&target_level,
&selected_candidate.downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&selected_candidate.record,
);
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let save_result = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
candidates_json,
saved_at_micros: now,
})
.await;
match save_result {
Ok(session) => Ok(session),
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
// 中文注释VectorEngine/OSS 已生成真实图片时SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let fallback_session =
replace_puzzle_session_draft_snapshot(session, draft, now);
let fallback_session = if should_auto_name_level {
apply_generated_puzzle_first_level_name_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
)
} else {
fallback_session
};
let mut fallback_session =
apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_levels_to_session_snapshot(
fallback_session,
updated_levels,
now,
),
target_level.level_id.as_str(),
candidates.into_records(),
primary_reference_image_src,
now,
);
if let Some(generated_naming) = generated_naming.as_ref() {
fallback_session =
apply_generated_puzzle_metadata_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
generated_naming,
fallback_level_name.as_str(),
now,
);
}
Ok(fallback_session)
}
Err(error) => Err(map_puzzle_client_error(error)),
}
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_images",
"拼图图片生成",
"已生成并替换当前拼图图片。",
session,
)
}
"generate_puzzle_ui_background" => {
let target_level_id = payload.level_id.clone();
let raw_prompt = payload
.prompt_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string();
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_ui_background_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
let levels_json = levels_json?;
let session = get_puzzle_session_for_image_generation(
&state,
session_id.clone(),
owner_user_id.clone(),
&payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let resolved_prompt = normalize_puzzle_ui_background_prompt(
raw_prompt.as_str(),
&draft,
&target_level,
);
let generated = generate_puzzle_ui_background_image(
&state,
&request_context,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
resolved_prompt.as_str(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
let save_result = state
.spacetime_client()
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json,
prompt: resolved_prompt.clone(),
image_src: generated.image_src.clone(),
image_object_key: Some(generated.object_key.clone()),
saved_at_micros: now,
})
.await;
match save_result {
Ok(session) => Ok(session),
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let fallback_session =
replace_puzzle_session_draft_snapshot(session, draft, now);
Ok(apply_generated_puzzle_ui_background_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
resolved_prompt,
generated.image_src,
Some(generated.object_key),
now,
))
}
Err(error) => Err(map_puzzle_client_error(error)),
}
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_ui_background",
"UI 背景图生成",
"已生成拼图 UI 背景图。",
session,
)
}
"generate_puzzle_tags" => {
let work_title = payload
.work_title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
puzzle_bad_request(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
"作品名称不能为空",
)
})?;
let work_description = payload
.work_description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
puzzle_bad_request(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
"作品描述不能为空",
)
})?;
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
})),
)
})?;
let generated_tags =
generate_puzzle_work_tags(&state, work_title, work_description).await;
let session = save_generated_puzzle_tags_to_session(
&state,
&session_id,
&owner_user_id,
&payload,
generated_tags,
levels_json,
now,
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_tags",
"作品标签生成",
"已生成 6 个作品标签。",
session,
)
}
"select_puzzle_image" => {
let candidate_id = payload
.candidate_id
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
puzzle_bad_request(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
"candidateId is required",
)
})?;
let session = state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: payload.level_id.clone(),
candidate_id,
selected_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
});
(
"select_puzzle_image",
"正式图确认",
"已应用正式拼图图片。",
session,
)
}
"publish_puzzle_work" => {
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": error,
})),
)
})?;
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
let author_display_name = resolve_author_display_name(&state, &authenticated);
let profile = execute_billable_asset_operation(
state.root_state(),
&owner_user_id,
"puzzle_publish_work",
&work_id,
async {
state
.spacetime_client()
.publish_puzzle_work(PuzzlePublishRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
work_id: work_id.clone(),
profile_id,
author_display_name,
work_title: payload.work_title.clone(),
work_description: payload.work_description.clone(),
level_name: payload.level_name.clone(),
summary: payload.summary.clone(),
theme_tags: payload.theme_tags.clone(),
levels_json,
published_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
})?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
return Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: profile.profile_id.clone(),
operation_type: "publish_puzzle_work".to_string(),
status: "completed".to_string(),
phase_label: "作品发布".to_string(),
phase_detail: "拼图作品已发布到广场。".to_string(),
progress: 100,
error: None,
},
session: map_puzzle_agent_session_response(session),
},
));
}
other => {
return Err(puzzle_bad_request(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
format!("action `{other}` is not supported").as_str(),
));
}
};
let session = session?;
Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: session.session_id.clone(),
operation_type: operation_type.to_string(),
status: "completed".to_string(),
phase_label: phase_label.to_string(),
phase_detail: phase_detail.to_string(),
progress: 100,
error: None,
},
session: map_puzzle_agent_session_response(session),
},
))
}
pub async fn get_puzzle_works(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_puzzle_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorksResponse {
items: items
.into_iter()
.map(|item| map_puzzle_work_summary_response(&state, item))
.collect(),
},
))
}
pub async fn get_puzzle_work_detail(
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.get_puzzle_work_detail(profile_id)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorkDetailResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn put_puzzle_work(
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PutPuzzleWorkRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_WORKS_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
profile_id,
owner_user_id: authenticated.claims().user_id().to_string(),
work_title: payload.work_title,
work_description: payload.work_description,
level_name: payload.level_name,
summary: payload.summary,
theme_tags: payload.theme_tags,
cover_image_src: payload.cover_image_src,
cover_asset_id: payload.cover_asset_id,
levels_json: Some(serialize_puzzle_levels_response(
&request_context,
&payload.levels,
)?),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorkMutationResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn delete_puzzle_work(
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let items = state
.spacetime_client()
.delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorksResponse {
items: items
.into_iter()
.map(|item| map_puzzle_work_summary_response(&state, item))
.collect(),
},
))
}
pub async fn claim_puzzle_work_point_incentive(
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput {
profile_id,
owner_user_id: authenticated.claims().user_id().to_string(),
claimed_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorkMutationResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn list_puzzle_gallery(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Response, Response> {
if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await {
crate::telemetry::record_puzzle_gallery_cache_hit();
return Ok(puzzle_gallery_cached_json(&request_context, response));
}
crate::telemetry::record_puzzle_gallery_cache_miss();
let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await;
if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await {
crate::telemetry::record_puzzle_gallery_cache_hit();
return Ok(puzzle_gallery_cached_json(&request_context, response));
}
let rebuild_started_at = std::time::Instant::now();
let items = state
.spacetime_client()
.list_public_work_gallery_entries()
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_GALLERY_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let response = build_puzzle_gallery_window_response(
items
.into_iter()
.filter(|item| item.source_type == "puzzle")
.map(|item| map_public_work_puzzle_gallery_card_response(&state, item))
.collect(),
);
let cached_response = state
.puzzle_gallery_cache()
.store_response(response)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_GALLERY_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_GALLERY_PROVIDER,
"message": format!("拼图广场缓存序列化失败:{error}"),
})),
)
})?;
crate::telemetry::record_puzzle_gallery_cache_rebuild(
rebuild_started_at.elapsed(),
cached_response.data_json_len(),
);
Ok(puzzle_gallery_cached_json(
&request_context,
cached_response,
))
}
pub async fn get_puzzle_gallery_detail(
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_GALLERY_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.get_puzzle_gallery_detail(profile_id)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_GALLERY_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleGalleryDetailResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn record_puzzle_gallery_like(
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_GALLERY_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.record_puzzle_work_like(PuzzleWorkLikeReportRecordInput {
profile_id,
user_id: authenticated.claims().user_id().to_string(),
liked_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_GALLERY_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleGalleryDetailResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn remix_puzzle_gallery_work(
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_GALLERY_PROVIDER,
&profile_id,
"profileId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.remix_puzzle_work(PuzzleWorkRemixRecordInput {
source_profile_id: profile_id,
target_owner_user_id: owner_user_id,
target_session_id: build_prefixed_uuid_id("puzzle-session-"),
target_profile_id: build_prefixed_uuid_id("puzzle-profile-"),
target_work_id: build_prefixed_uuid_id("puzzle-work-"),
author_display_name: resolve_author_display_name(&state, &authenticated),
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
remixed_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_GALLERY_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleAgentSessionResponse {
session: map_puzzle_agent_session_response(session),
},
))
}
pub async fn start_puzzle_run(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
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: principal.subject().to_string(),
profile_id: payload.profile_id.clone(),
level_id: payload.level_id.clone(),
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
record_puzzle_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::runtime_principal(
"puzzle",
payload.profile_id.clone(),
&principal,
"/api/runtime/puzzle/...",
)
.profile_id(payload.profile_id.clone())
.owner_user_id(principal.subject().to_string())
.extra(json!({
"levelId": payload.level_id,
"runId": run.run_id,
"principalKind": principal.kind().as_str(),
})),
)
.await;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn get_puzzle_run(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
) -> 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, principal.subject().to_string())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn swap_puzzle_pieces(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
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: principal.subject().to_string(),
first_piece_id: payload.first_piece_id,
second_piece_id: payload.second_piece_id,
swapped_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn drag_puzzle_piece_or_group(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
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: principal.subject().to_string(),
piece_id: payload.piece_id,
target_row: payload.target_row,
target_col: payload.target_col,
dragged_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn advance_puzzle_next_level(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<AdvancePuzzleNextLevelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let payload = match payload {
Ok(Json(payload)) => payload,
Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => {
AdvancePuzzleNextLevelRequest {
target_profile_id: None,
prefer_similar_work: false,
}
}
Err(error) => {
return Err(puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
));
}
};
let run = state
.spacetime_client()
.advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput {
run_id,
owner_user_id: principal.subject().to_string(),
target_profile_id: payload.target_profile_id,
prefer_similar_work: payload.prefer_similar_work,
advanced_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn update_puzzle_run_pause(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.update_puzzle_run_pause(PuzzleRunPauseRecordInput {
run_id,
owner_user_id: principal.subject().to_string(),
paused: payload.paused,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn use_puzzle_runtime_prop(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.prop_kind,
"propKind",
)?;
let owner_user_id = principal.subject().to_string();
let prop_kind = payload.prop_kind.trim().to_string();
let billing_asset_kind = match prop_kind.as_str() {
"hint" => "puzzle_prop_hint",
"reference" => "puzzle_prop_preview",
"freezeTime" | "freeze_time" => "puzzle_prop_freeze_time",
"extendTime" | "extend_time" => "puzzle_prop_extend_time",
_ => {
return Err(puzzle_bad_request(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
"unknown puzzle prop kind",
));
}
};
let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time");
let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros());
let reducer_owner_user_id = owner_user_id.clone();
let reducer_run_id = run_id.clone();
let fallback_run_id = run_id.clone();
let fallback_owner_user_id = owner_user_id.clone();
let run_result = execute_billable_asset_operation(
state.root_state(),
&owner_user_id,
billing_asset_kind,
billing_asset_id.as_str(),
async {
state
.spacetime_client()
.use_puzzle_runtime_prop(PuzzleRunPropRecordInput {
run_id: reducer_run_id,
owner_user_id: reducer_owner_user_id,
prop_kind,
used_at_micros: current_utc_micros(),
spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST,
})
.await
.map_err(map_puzzle_client_error)
},
)
.await;
let run = match run_result {
Ok(run) => run,
Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => {
// 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。
// 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。
state
.spacetime_client()
.get_puzzle_run(fallback_run_id, fallback_owner_user_id)
.await
.map_err(map_puzzle_client_error)
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error)
})?
}
Err(error) => {
return Err(puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
error,
));
}
};
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn submit_puzzle_leaderboard(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
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: principal.subject().to_string(),
profile_id: payload.profile_id,
grid_size: payload.grid_size,
elapsed_ms: payload.elapsed_ms.max(1_000),
nickname: payload.nickname.trim().to_string(),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}