feat: move creation entry config to database
This commit is contained in:
@@ -50,6 +50,9 @@ use crate::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
},
|
||||
creation_agent_document_input::parse_creation_agent_document_input,
|
||||
creation_entry_config::{
|
||||
get_creation_entry_config_handler, require_creation_entry_route_enabled,
|
||||
},
|
||||
creative_agent::{
|
||||
cancel_creative_agent_session, confirm_creative_puzzle_template,
|
||||
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message,
|
||||
@@ -611,6 +614,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation-entry/config",
|
||||
get(get_creation_entry_config_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
@@ -1492,6 +1499,11 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/password/reset", post(reset_password))
|
||||
// 后端 runtime/API 路由读取入口配置做统一熔断,避免前端隐藏后后端仍可被直接访问。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_creation_entry_route_enabled,
|
||||
))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||
@@ -1952,6 +1964,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creation_entry_route_disabled_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_creation_entry_route_enabled("puzzle", false);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/runtime/puzzle/works")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(body["error"]["details"]["reason"], "creation_entry_disabled");
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_standard_envelope_when_requested() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
167
server-rs/crates/api-server/src/creation_entry_config.rs
Normal file
167
server-rs/crates/api-server/src/creation_entry_config.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
extract::{Extension, State},
|
||||
http::{Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
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");
|
||||
}
|
||||
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", true, true, 50),
|
||||
test_creation_type("visual-novel", true, true, 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("/healthz"), None);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ mod creation_agent_anchor_templates;
|
||||
mod creation_agent_chat;
|
||||
mod creation_agent_document_input;
|
||||
mod creation_agent_llm_turn;
|
||||
mod creation_entry_config;
|
||||
mod creative_agent;
|
||||
mod creative_agent_sse;
|
||||
mod custom_world;
|
||||
|
||||
@@ -11,6 +11,7 @@ use module_auth::{
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
#[cfg(test)]
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use platform_agent::MockLangChainRustAgentExecutor;
|
||||
@@ -41,6 +42,8 @@ pub struct AppState {
|
||||
auth_jwt_config: JwtConfig,
|
||||
admin_runtime: Option<AdminRuntime>,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
#[cfg(test)]
|
||||
test_creation_entry_config: Arc<Mutex<Option<CreationEntryConfigResponse>>>,
|
||||
oss_client: Option<OssClient>,
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
auth_store: InMemoryAuthStore,
|
||||
@@ -189,6 +192,10 @@ impl AppState {
|
||||
auth_jwt_config,
|
||||
admin_runtime,
|
||||
refresh_cookie_config,
|
||||
#[cfg(test)]
|
||||
test_creation_entry_config: Arc::new(Mutex::new(Some(
|
||||
crate::creation_entry_config::test_creation_entry_config_response(),
|
||||
))),
|
||||
oss_client,
|
||||
auth_store,
|
||||
password_entry_service,
|
||||
@@ -221,6 +228,68 @@ impl AppState {
|
||||
&self.refresh_cookie_config
|
||||
}
|
||||
|
||||
pub async fn get_creation_entry_config(
|
||||
&self,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
match self.spacetime_client.get_creation_entry_config().await {
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => Ok(self.read_test_creation_entry_config()),
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_creation_entry_route_enabled(
|
||||
&self,
|
||||
creation_type_id: &str,
|
||||
) -> Result<bool, SpacetimeClientError> {
|
||||
let config = self.get_creation_entry_config().await?;
|
||||
Ok(config
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == creation_type_id)
|
||||
.map(|item| item.visible && item.open)
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_creation_entry_route_enabled(
|
||||
&self,
|
||||
creation_type_id: impl AsRef<str>,
|
||||
enabled: bool,
|
||||
) {
|
||||
let creation_type_id = creation_type_id.as_ref();
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Some(item) = config
|
||||
.creation_types
|
||||
.iter_mut()
|
||||
.find(|item| item.id == creation_type_id)
|
||||
{
|
||||
item.visible = enabled;
|
||||
item.open = enabled;
|
||||
} else {
|
||||
config.creation_types.push(
|
||||
shared_contracts::creation_entry_config::CreationEntryTypeResponse {
|
||||
id: creation_type_id.to_string(),
|
||||
title: creation_type_id.to_string(),
|
||||
subtitle: String::new(),
|
||||
badge: String::new(),
|
||||
image_src: format!("/creation-type-references/{creation_type_id}.webp"),
|
||||
visible: enabled,
|
||||
open: enabled,
|
||||
sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX),
|
||||
updated_at_micros: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.cache_test_creation_entry_config(config);
|
||||
}
|
||||
|
||||
pub fn oss_client(&self) -> Option<&OssClient> {
|
||||
self.oss_client.as_ref()
|
||||
}
|
||||
@@ -488,6 +557,21 @@ impl AppState {
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppState {
|
||||
fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) {
|
||||
*self
|
||||
.test_creation_entry_config
|
||||
.lock()
|
||||
.expect("test creation entry config should lock") = Some(config);
|
||||
}
|
||||
|
||||
fn read_test_creation_entry_config(&self) -> CreationEntryConfigResponse {
|
||||
self.test_creation_entry_config
|
||||
.lock()
|
||||
.expect("test creation entry config should lock")
|
||||
.clone()
|
||||
.unwrap_or_else(crate::creation_entry_config::test_creation_entry_config_response)
|
||||
}
|
||||
|
||||
pub(crate) async fn seed_test_phone_user_with_password(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
|
||||
Reference in New Issue
Block a user