178 lines
6.2 KiB
Rust
178 lines
6.2 KiB
Rust
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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
request: Request<Body>,
|
||
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);
|
||
}
|
||
}
|