455 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|