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).
2110 lines
80 KiB
Rust
2110 lines
80 KiB
Rust
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),
|
||
},
|
||
))
|
||
}
|