Files
Genarrative/server-rs/crates/api-server/src/creation_entry_config.rs
T

455 lines
16 KiB
Rust

use axum::{
Json,
body::Body,
extract::{Extension, State},
http::{Request, StatusCode},
middleware::Next,
response::Response,
};
use serde_json::{Value, json};
use module_runtime::build_creation_entry_config_response;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
pub use crate::modules::play_flow::resolve_creation_entry_route_id;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PublicWorkInteractionAction {
Like,
Remix,
}
impl PublicWorkInteractionAction {
fn as_str(self) -> &'static str {
match self {
Self::Like => "like",
Self::Remix => "remix",
}
}
}
/// 中文注释:入口配置由 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 路由熔断只拦新建创作入口,不限制已有作品读取、发布作品游玩或公开广场浏览。
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 async fn require_public_work_interaction_enabled(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
if let Some((source_type, action)) = resolve_public_work_interaction_route(path) {
match state
.is_public_work_interaction_enabled(source_type, action)
.await
{
Ok(true) => {}
Ok(false) => {
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("该作品互动暂不可用")
.with_details(json!({
"reason": "public_work_interaction_disabled",
"sourceType": source_type,
"action": action.as_str(),
}))
.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(crate) fn resolve_public_work_interaction_route(
path: &str,
) -> Option<(&'static str, PublicWorkInteractionAction)> {
let action = if path.ends_with("/like") {
PublicWorkInteractionAction::Like
} else if path.ends_with("/remix") {
PublicWorkInteractionAction::Remix
} else {
return None;
};
if path.starts_with("/api/runtime/custom-world-gallery/") {
return Some(("custom-world", action));
}
if path.starts_with("/api/runtime/big-fish/gallery/") {
return Some(("big-fish", action));
}
if path.starts_with("/api/runtime/puzzle/gallery/") {
return Some(("puzzle", action));
}
None
}
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
config: &CreationEntryConfigResponse,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
config
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.and_then(|item| item.unified_creation_spec.as_ref())
.map(|spec| u64::from(spec.mud_point_cost))
.filter(|cost| *cost > 0)
.unwrap_or(fallback_cost)
}
pub(crate) async fn resolve_creation_entry_mud_point_cost(
state: &AppState,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
match state.get_creation_entry_config().await {
Ok(config) => resolve_creation_entry_mud_point_cost_from_config(
&config,
creation_type_id,
fallback_cost,
),
Err(error) => {
tracing::warn!(
creation_type_id,
fallback_cost,
error = %error,
"读取创作入口泥点成本失败,回退到代码默认值"
);
fallback_cost
}
}
}
fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
/// 中文注释:本地 debug 兜底也来自后端领域默认种子,避免前端恢复硬编码入口配置。
pub(crate) fn default_creation_entry_config_response() -> 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(),
},
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(),
cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC
.to_string(),
prize_pool_mud_points:
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
public_work_interactions_json: Some(
module_runtime::default_public_work_interaction_config_json(),
),
})
}
#[cfg(test)]
pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigResponse {
default_creation_entry_config_response()
}
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
#[test]
fn resolves_new_creation_paths_to_creation_type_ids() {
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/puzzle-clear/sessions"),
Some("puzzle-clear"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/match3d/sessions"),
Some("match3d"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/square-hole/sessions"),
Some("square-hole"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"),
Some("big-fish"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id(
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"),
Some("bark-battle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
Some("wooden-fish"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
Some("baby-object-match"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"),
Some("baby-love-drawing"),
);
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn resolves_public_work_interaction_routes() {
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1/like"),
Some(("puzzle", PublicWorkInteractionAction::Like)),
);
assert_eq!(
resolve_public_work_interaction_route(
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
),
Some(("custom-world", PublicWorkInteractionAction::Remix)),
);
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1"),
None,
);
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/wooden-fish/runs/run-1"),
None,
);
}
#[test]
fn resolves_mud_point_cost_from_unified_creation_spec() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
let spec = puzzle
.unified_creation_spec
.as_mut()
.expect("puzzle unified spec should exist");
spec.mud_point_cost = 8;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
8,
);
}
#[test]
fn resolves_mud_point_cost_with_fallback_for_legacy_config() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
puzzle.unified_creation_spec = None;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
2,
);
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(
&config,
"missing-play",
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
),
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
);
}
#[test]
fn test_creation_entry_config_response_opens_bark_battle() {
let config = test_creation_entry_config_response();
let bark_battle = config
.creation_types
.iter()
.find(|item| item.id == "bark-battle")
.expect("test creation entry config should include bark-battle");
assert_eq!(bark_battle.title, "\u{6c6a}\u{6c6a}\u{58f0}\u{6d6a}");
assert!(bark_battle.visible);
assert!(bark_battle.open);
assert_eq!(bark_battle.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(
bark_battle.image_src,
"/creation-type-references/bark-battle.webp"
);
}
#[test]
fn test_creation_entry_config_response_updates_jump_hop_metadata() {
let config = test_creation_entry_config_response();
let jump_hop = config
.creation_types
.iter()
.find(|item| item.id == "jump-hop")
.expect("test creation entry config should include jump-hop");
assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
assert!(jump_hop.visible);
assert!(jump_hop.open);
assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(
jump_hop.subtitle,
"\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
);
assert_eq!(
jump_hop.image_src,
"/creation-type-references/jump-hop.webp"
);
}
#[test]
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
let config = test_creation_entry_config_response();
let baby_object_match = config
.creation_types
.iter()
.find(|item| item.id == "baby-object-match")
.expect("test creation entry config should include baby-object-match");
assert_eq!(baby_object_match.title, "\u{5b9d}\u{8d1d}\u{8bc6}\u{7269}");
assert!(baby_object_match.visible);
assert!(baby_object_match.open);
assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(baby_object_match.sort_order, 90);
assert_eq!(baby_object_match.category_id, "character");
assert_eq!(
baby_object_match.category_label,
"\u{89d2}\u{8272}\u{521b}\u{4f5c}"
);
assert_eq!(baby_object_match.category_sort_order, 40);
}
}