use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::Response, }; use serde_json::{Value, json}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; pub async fn create_jump_hop_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; validate_workspace_request(&request_context, &payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("jump-hop-session-"); let now = current_utc_micros(); let draft = build_jump_hop_draft(&payload); let session = JumpHopSessionSnapshotResponse { session_id, owner_user_id, status: JumpHopGenerationStatus::Draft, draft: Some(draft), created_at: format_timestamp_micros(now), updated_at: format_timestamp_micros(now), }; Ok(json_success_body( Some(&request_context), JumpHopSessionResponse { session: state .spacetime_client() .create_jump_hop_session(session) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?, }, )) } pub async fn get_jump_hop_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .get_jump_hop_session(session_id, owner_user_id) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopSessionResponse { session }, )) } pub async fn execute_jump_hop_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let response = state .spacetime_client() .execute_jump_hop_action(session_id, owner_user_id, payload) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), response)) } pub async fn publish_jump_hop_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .publish_jump_hop_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_CREATION_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopWorkMutationResponse { item: work }, )) } pub async fn get_jump_hop_runtime_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .get_jump_hop_runtime_work(profile_id) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopWorkDetailResponse { item: work }, )) } pub async fn start_jump_hop_run( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; let run = state .spacetime_client() .start_jump_hop_run(payload, authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopRunResponse { run }, )) } pub async fn jump_hop_run_jump( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .jump_hop_run_jump( run_id, authenticated.claims().user_id().to_string(), payload, ) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopJumpResponse { run }, )) } pub async fn restart_jump_hop_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .restart_jump_hop_run( run_id, authenticated.claims().user_id().to_string(), payload, ) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopRunResponse { run }, )) } pub async fn list_jump_hop_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let gallery = state .spacetime_client() .list_jump_hop_gallery() .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), gallery)) } pub async fn get_jump_hop_gallery_detail( State(state): State, Path(public_work_code): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?; let work = state .spacetime_client() .get_jump_hop_gallery_detail(public_work_code) .await .map_err(|error| { jump_hop_error_response( &request_context, JUMP_HOP_RUNTIME_PROVIDER, map_jump_hop_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), JumpHopGalleryDetailResponse { item: work }, )) } fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, work_title: payload.work_title.trim().to_string(), work_description: payload.work_description.trim().to_string(), theme_tags: normalize_tags(payload.theme_tags.clone()), difficulty: payload.difficulty.clone(), style_preset: payload.style_preset.clone(), character_prompt: payload.character_prompt.trim().to_string(), tile_prompt: payload.tile_prompt.trim().to_string(), end_mood_prompt: payload .end_mood_prompt .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), character_asset: None, tile_atlas_asset: None, tile_assets: Vec::new(), path: None, cover_composite: None, generation_status: JumpHopGenerationStatus::Draft, } } fn validate_workspace_request( request_context: &RequestContext, payload: &JumpHopWorkspaceCreateRequest, ) -> Result<(), Response> { ensure_non_empty(request_context, &payload.work_title, "workTitle")?; ensure_non_empty( request_context, &payload.character_prompt, "characterPrompt", )?; ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?; if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID { return Err(jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": JUMP_HOP_PROVIDER, "message": "templateId 必须为 jump-hop", })), )); } Ok(()) } fn ensure_non_empty( request_context: &RequestContext, value: &str, field: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(jump_hop_error_response( request_context, JUMP_HOP_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": JUMP_HOP_PROVIDER, "field": field, "message": format!("{field} 不能为空"), })), )); } Ok(()) } fn normalize_tags(tags: Vec) -> Vec { let mut normalized = Vec::new(); for tag in tags { let tag = tag.trim(); if tag.is_empty() || normalized.iter().any(|item| item == tag) { continue; } normalized.push(tag.to_string()); if normalized.len() >= 6 { break; } } normalized } fn jump_hop_json( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, Response> { payload.map_err(|error| { jump_hop_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": error.to_string(), })), ) }) } fn map_jump_hop_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("发布需要") || message.contains("不能为空") || message.contains("必须") => { StatusCode::BAD_REQUEST } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn jump_hop_error_response( request_context: &RequestContext, provider: &str, error: AppError, ) -> Response { let mut response = error.into_response_with_context(Some(request_context)); response.headers_mut().insert( HeaderName::from_static("x-genarrative-provider"), header::HeaderValue::from_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static("jump-hop")), ); response } fn current_utc_micros() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) .unwrap_or(0) }