use axum::{ Json, body::Body, extract::{Extension, State}, http::{Request, StatusCode}, middleware::Next, response::Response, }; use serde_json::{Value, json}; #[cfg(test)] use module_runtime::build_creation_entry_config_response; use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, }; /// 中文注释:入口配置由 SpacetimeDB 表提供;api-server 只负责读取同一份配置并熔断运行态路由。 pub async fn get_creation_entry_config_handler( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let config = state.get_creation_entry_config().await.map_err(|error| { creation_entry_error_response( &request_context, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })), ) })?; Ok(json_success_body(Some(&request_context), config)) } /// 中文注释:api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。 pub async fn require_creation_entry_route_enabled( State(state): State, request: Request, next: Next, ) -> Response { let path = request.uri().path(); let route_id = resolve_creation_entry_route_id(path); if route_id.is_some() { let route_id = route_id.expect("route id should exist"); match state.is_creation_entry_route_enabled(route_id).await { Ok(true) => {} Ok(false) => { return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("该玩法入口暂不可用") .with_details(json!({ "reason": "creation_entry_disabled", "creationTypeId": route_id, })) .into(); } Err(error) => { return AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("读取玩法入口配置失败") .with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) .into(); } } } next.run(request).await } pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { let normalized = path.trim_end_matches('/'); if normalized.starts_with("/api/runtime/puzzle") { return Some("puzzle"); } if normalized.starts_with("/api/runtime/match3d") { return Some("match3d"); } if normalized.starts_with("/api/runtime/square-hole") { return Some("square-hole"); } if normalized.starts_with("/api/runtime/big-fish") { return Some("big-fish"); } if normalized.starts_with("/api/runtime/visual-novel") { return Some("visual-novel"); } if normalized.starts_with("/api/creation/visual-novel") { return Some("visual-novel"); } None } fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } #[cfg(test)] pub(crate) fn test_creation_entry_config_response() -> shared_contracts::creation_entry_config::CreationEntryConfigResponse { build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot { config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(), start_card: module_runtime::CreationEntryStartCardSnapshot { title: module_runtime::DEFAULT_CREATION_ENTRY_START_TITLE.to_string(), description: module_runtime::DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(), idle_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(), busy_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(), }, type_modal: module_runtime::CreationEntryTypeModalSnapshot { title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), }, creation_types: vec![ test_creation_type("rpg", false, true, 10), test_creation_type("big-fish", false, true, 20), test_creation_type("puzzle", true, true, 30), test_creation_type("match3d", true, true, 40), test_creation_type("square-hole", false, true, 50), test_creation_type("visual-novel", true, false, 60), test_creation_type("airp", true, false, 70), test_creation_type("creative-agent", false, true, 80), ], updated_at_micros: 0, }) } #[cfg(test)] fn test_creation_type( id: &str, visible: bool, open: bool, sort_order: i32, ) -> module_runtime::CreationEntryTypeSnapshot { module_runtime::CreationEntryTypeSnapshot { id: id.to_string(), title: id.to_string(), subtitle: "测试入口".to_string(), badge: "测试".to_string(), image_src: format!("/creation-type-references/{id}.webp"), visible, open, sort_order, updated_at_micros: 0, } } #[cfg(test)] mod tests { use super::*; #[test] fn resolves_runtime_paths_to_creation_type_ids() { assert_eq!( resolve_creation_entry_route_id("/api/runtime/puzzle/works"), Some("puzzle"), ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"), Some("match3d"), ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/square-hole/runs/run-1"), Some("square-hole"), ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/visual-novel/works"), Some("visual-novel"), ); assert_eq!( resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), Some("visual-novel"), ); assert_eq!(resolve_creation_entry_route_id("/healthz"), None); } }