Files
Genarrative/server-rs/crates/api-server/src/puzzle/handlers.rs
高物 ae014ac881 Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2026-05-22 03:06:41 +08:00

2110 lines
80 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 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",
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: format!("onboarding-profile-{now}"),
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 (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,
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,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
now,
)
.await
}
.map_err(|error| {
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 {
generate_puzzle_image_candidates(
&state,
owner_user_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,
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,
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_puzzle_gallery()
.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()
.map(|item| map_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(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.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::new(
"puzzle",
payload.profile_id.clone(),
&authenticated,
"/api/runtime/puzzle/...",
)
.profile_id(payload.profile_id.clone())
.extra(json!({
"levelId": payload.level_id,
"runId": run.run_id,
})),
)
.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(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_puzzle_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn swap_puzzle_pieces(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SwapPuzzlePiecesRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.first_piece_id,
"firstPieceId",
)?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.second_piece_id,
"secondPieceId",
)?;
let run = state
.spacetime_client()
.swap_puzzle_pieces(PuzzleRunSwapRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
first_piece_id: payload.first_piece_id,
second_piece_id: payload.second_piece_id,
swapped_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn drag_puzzle_piece_or_group(
State(state): State<PuzzleApiState>,
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(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(authenticated): Extension<AuthenticatedAccessToken>,
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,
}
}
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: authenticated.claims().user_id().to_string(),
target_profile_id: payload.target_profile_id,
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(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.update_puzzle_run_pause(PuzzleRunPauseRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
paused: payload.paused,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}
pub async fn use_puzzle_runtime_prop(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.prop_kind,
"propKind",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let prop_kind = payload.prop_kind.trim().to_string();
let billing_asset_kind = match prop_kind.as_str() {
"hint" => "puzzle_prop_hint",
"reference" => "puzzle_prop_preview",
"freezeTime" | "freeze_time" => "puzzle_prop_freeze_time",
"extendTime" | "extend_time" => "puzzle_prop_extend_time",
_ => {
return Err(puzzle_bad_request(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
"unknown puzzle prop kind",
));
}
};
let 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(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SubmitPuzzleLeaderboardRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id: payload.profile_id,
grid_size: payload.grid_size,
elapsed_ms: payload.elapsed_ms.max(1_000),
nickname: payload.nickname.trim().to_string(),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await),
},
))
}