合并 master 并保留外部生成 worker 模式

合入 master 的生产健康巡检、JumpHop 和 SpacetimeDB 更新
保留外部生成 worker、队列/内联模式与 lease guard 口径
合并 Server-Provision 工具复用、health patrol 和外部生成 worker systemd 配置
补齐 SpacetimeDB 生成绑定并通过本地检查
This commit is contained in:
2026-06-10 21:26:53 +08:00
93 changed files with 7872 additions and 2244 deletions

View File

@@ -26,7 +26,8 @@ use shared_contracts::admin::{
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
};
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
@@ -212,14 +213,7 @@ pub async fn admin_get_creation_entry_config(
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
},
build_admin_creation_entry_config_response(config),
))
}
@@ -237,14 +231,7 @@ pub async fn admin_upsert_creation_entry_config(
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
},
build_admin_creation_entry_config_response(config),
))
}
@@ -268,14 +255,45 @@ pub async fn admin_upsert_creation_entry_event_banners_config(
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
},
build_admin_creation_entry_config_response(config),
))
}
/// 保存公开作品详情页点赞 / 改造能力配置。
pub async fn admin_upsert_public_work_interaction_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertPublicWorkInteractionConfigRequest>,
) -> Result<Json<Value>, AppError> {
let snapshots = payload
.public_work_interactions
.into_iter()
.map(
|entry| module_runtime::PublicWorkInteractionConfigSnapshot {
source_type: entry.source_type,
like_enabled: entry.like_enabled,
remix_enabled: entry.remix_enabled,
like_disabled_message: entry.like_disabled_message,
remix_disabled_message: entry.remix_disabled_message,
},
)
.collect::<Vec<_>>();
let public_work_interactions_json =
module_runtime::encode_public_work_interaction_config_snapshots(&snapshots)
.and_then(|json| module_runtime::normalize_public_work_interaction_config_json(&json))
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
let config = state
.upsert_public_work_interaction_config(
module_runtime::PublicWorkInteractionConfigAdminUpsertInput {
public_work_interactions_json,
},
)
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
build_admin_creation_entry_config_response(config),
))
}
@@ -313,6 +331,20 @@ pub async fn admin_update_work_visibility(
))
}
fn build_admin_creation_entry_config_response(
config: shared_contracts::creation_entry_config::CreationEntryConfigResponse,
) -> AdminCreationEntryConfigResponse {
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
public_work_interactions: config.public_work_interactions,
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
}
}
fn map_admin_creation_entry_type_config(
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
) -> AdminCreationEntryTypeConfigPayload {

View File

@@ -542,8 +542,8 @@ mod tests {
#[tokio::test]
async fn create_ai_task_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -605,8 +605,8 @@ mod tests {
#[tokio::test]
async fn start_ai_task_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -652,8 +652,8 @@ mod tests {
#[tokio::test]
async fn ai_task_mutation_routes_return_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
for route in ai_task_mutation_route_cases() {
@@ -763,21 +763,20 @@ mod tests {
(status, payload)
}
async fn seed_authenticated_state() -> AppState {
async fn seed_authenticated_state() -> (AppState, String) {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
let user_id = state
.seed_test_phone_user_with_password("13800138100", "secret123")
.await
.id;
state
(state, user_id)
}
fn issue_access_token(state: &AppState) -> String {
fn issue_access_token(state: &AppState, user_id: &str) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"),
user_id: user_id.to_string(),
session_id: state.seed_test_refresh_session_for_user_id(user_id, "sess_ai_tasks"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -15,32 +15,19 @@ use tower_http::{
use tracing::{Level, Span, error, info_span};
use crate::{
auth::{AuthenticatedAccessToken, require_bearer_auth},
auth::AuthenticatedAccessToken,
backpressure::limit_concurrent_requests,
creation_entry_config::require_creation_entry_route_enabled,
creation_entry_config::{
require_creation_entry_route_enabled, require_public_work_interaction_enabled,
},
error_middleware::normalize_error_response,
http_error::AppError,
modules,
request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
runtime_inventory::get_runtime_inventory_state,
state::{AppState, BackpressureState},
telemetry::record_http_observability,
tracking::record_route_tracking_event_after_success,
vector_engine_audio_generation::{
create_background_music_task, create_sound_effect_task,
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
publish_background_music_asset, publish_sound_effect_asset,
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
},
visual_novel::{
compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work,
execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session,
get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history,
list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run,
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
submit_visual_novel_message, update_visual_novel_work,
},
wechat::pay::{
handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify,
handle_wechat_virtual_payment_notify,
@@ -57,19 +44,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::profile::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::platform::router(state.clone()))
.merge(modules::story::router(state.clone()))
.merge(modules::edutainment::router(state.clone()))
.merge(modules::custom_world::router(state.clone()))
.merge(modules::big_fish::router(state.clone()))
.merge(modules::bark_battle::router(state.clone()))
.merge(modules::match3d::router(state.clone()))
.merge(modules::square_hole::router(state.clone()))
.merge(modules::jump_hop::router(state.clone()))
.merge(modules::wooden_fish::router(state.clone()))
.merge(modules::public_work::router(state.clone()))
.merge(modules::puzzle_clear::router(state.clone()))
.merge(modules::puzzle::router(state.clone()))
.merge(visual_novel_router(state.clone()))
.merge(modules::play_flow::router(state.clone()))
.route(
"/api/profile/recharge/wechat/notify",
post(handle_wechat_pay_notify),
@@ -79,18 +54,15 @@ pub fn build_router(state: AppState) -> Router {
get(handle_wechat_virtual_payment_message_push_verify)
.post(handle_wechat_virtual_payment_notify),
)
.route(
"/api/runtime/sessions/{runtime_session_id}/inventory",
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
// 后端创作/运行态 API 路由只按 open 做熔断visible 仅控制创作页入口展示。
.layer(middleware::from_fn_with_state(
state.clone(),
require_creation_entry_route_enabled,
))
.layer(middleware::from_fn_with_state(
state.clone(),
require_public_work_interaction_enabled,
))
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
.layer(middleware::from_fn_with_state(
BackpressureState::from_ref(&state),
@@ -290,166 +262,6 @@ async fn record_api_tracking_after_success(
response
}
fn visual_novel_router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/visual-novel/sessions",
post(create_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}",
get(get_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages",
post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages/stream",
post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/actions",
post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/compile",
post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works",
get(list_visual_novel_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}",
get(get_visual_novel_work)
.put(update_visual_novel_work)
.patch(update_visual_novel_work)
.delete(delete_visual_novel_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}/publish",
post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/audio/background-music",
post(create_visual_novel_background_music_task).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/visual-novel/audio/background-music/{task_id}/asset",
post(publish_visual_novel_background_music_asset).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/visual-novel/audio/sound-effect",
post(create_visual_novel_sound_effect_task).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/visual-novel/audio/sound-effect/{task_id}/asset",
post(publish_visual_novel_sound_effect_asset).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/audio/background-music",
post(create_background_music_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/background-music/{task_id}/asset",
post(publish_background_music_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/sound-effect",
post(create_sound_effect_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/sound-effect/{task_id}/asset",
post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/gallery",
get(list_visual_novel_gallery),
)
.route(
"/api/runtime/visual-novel/works/{profile_id}/runs",
post(start_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}",
get(get_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/actions/stream",
post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/history",
get(list_visual_novel_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/regenerate",
post(regenerate_visual_novel_run)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}
#[cfg(test)]
mod tests {
use axum::{
@@ -463,6 +275,7 @@ mod tests {
};
use reqwest::Client;
use serde_json::Value;
use spacetime_client::{SpacetimeClientHealthSnapshot, SpacetimeClientStage};
use time::OffsetDateTime;
use tokio::net::TcpListener;
use tower::ServiceExt;
@@ -783,6 +596,37 @@ mod tests {
);
}
#[tokio::test]
async fn disabled_public_work_like_returns_service_unavailable() {
let state = AppState::new(AppConfig::default()).expect("state should build");
state.set_test_public_work_interaction_enabled(
"puzzle",
crate::creation_entry_config::PublicWorkInteractionAction::Like,
false,
);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/puzzle/gallery/profile-1/like")
.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"],
"public_work_interaction_disabled"
);
assert_eq!(body["error"]["details"]["sourceType"], "puzzle");
assert_eq!(body["error"]["details"]["action"], "like");
}
#[tokio::test]
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -918,6 +762,45 @@ mod tests {
);
}
#[tokio::test]
async fn readyz_reports_spacetime_health_stage() {
let state = AppState::new(AppConfig::default()).expect("state should build");
state.set_test_spacetime_health(SpacetimeClientHealthSnapshot {
ok: false,
stage: SpacetimeClientStage::ProcedureResult,
checked_at_micros: 1_713_680_000_000_000,
elapsed_ms: 2_000,
timeout_ms: 2_000,
error: Some("SpacetimeDB procedure 调用超时".to_string()),
last_success_at_micros: Some(1_713_679_999_000_000),
last_error: Some("SpacetimeDB procedure 调用超时".to_string()),
});
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/readyz")
.header("x-request-id", "req-ready-spacetime")
.body(Body::empty())
.expect("readyz request should build"),
)
.await
.expect("readyz request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = read_json_response(response).await;
assert_eq!(body["error"]["details"]["reason"], "spacetime_unhealthy");
assert_eq!(
body["error"]["details"]["spacetime"]["stage"],
"procedure_result"
);
assert_eq!(
body["error"]["details"]["spacetime"]["timeoutMs"],
Value::from(2_000)
);
}
#[tokio::test]
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
let app = build_internal_creative_agent_app();
@@ -4382,6 +4265,62 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
/// 中文注释:验证后台作品互动配置保存后回到同一份入口配置响应。
#[tokio::test]
async fn admin_public_work_interactions_route_saves_form_payload() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let admin_token = read_admin_access_token(app.clone()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/creation-entry/config/interactions")
.header("authorization", format!("Bearer {admin_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"publicWorkInteractions": [
{
"sourceType": "puzzle",
"likeEnabled": false,
"remixEnabled": true,
"likeDisabledMessage": "拼图点赞维护中。",
"remixDisabledMessage": "拼图作品改造暂不可用。"
}
]
})
.to_string(),
))
.expect("interactions request should build"),
)
.await
.expect("interactions request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("interactions body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("interactions payload should be json");
let puzzle = payload["publicWorkInteractions"]
.as_array()
.expect("interactions should be array")
.iter()
.find(|item| item["sourceType"] == "puzzle")
.expect("puzzle interaction should exist");
assert_eq!(puzzle["likeEnabled"], false);
assert_eq!(puzzle["remixEnabled"], true);
assert_eq!(puzzle["likeDisabledMessage"], "拼图点赞维护中。");
}
#[tokio::test]
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
let mut config = AppConfig::default();

View File

@@ -12,6 +12,7 @@ use platform_speech::{
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
const DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS: u64 = 2;
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
@@ -124,6 +125,7 @@ pub struct AppConfig {
pub spacetime_token: Option<String>,
pub spacetime_pool_size: u32,
pub spacetime_procedure_timeout: Duration,
pub spacetime_health_check_timeout: Duration,
pub llm_provider: LlmProvider,
pub llm_base_url: String,
pub llm_api_key: Option<String>,
@@ -139,9 +141,10 @@ pub struct AppConfig {
pub dashscope_reference_image_model: String,
pub dashscope_cover_image_model: String,
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
// 中文注释Apimart 已于 2026-06 弃用LLM 文本调用统一迁移到 VectorEngine同时支持 Chat Completions / Responses 协议)。
// pub apimart_base_url: String,
// pub apimart_api_key: Option<String>,
// pub apimart_image_request_timeout_ms: u64,
pub vector_engine_base_url: String,
pub vector_engine_api_key: Option<String>,
pub vector_engine_image_request_timeout_ms: u64,
@@ -331,6 +334,9 @@ impl Default for AppConfig {
spacetime_token: None,
spacetime_pool_size: 4,
spacetime_procedure_timeout: Duration::from_secs(30),
spacetime_health_check_timeout: Duration::from_secs(
DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS,
),
llm_provider: LlmProvider::Ark,
llm_base_url: String::new(),
llm_api_key: None,
@@ -349,9 +355,9 @@ impl Default for AppConfig {
dashscope_reference_image_model: String::new(),
dashscope_cover_image_model: String::new(),
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: String::new(),
apimart_api_key: None,
apimart_image_request_timeout_ms: 180_000,
// apimart_base_url: String::new(),
// apimart_api_key: None,
// apimart_image_request_timeout_ms: 180_000,
vector_engine_base_url: String::new(),
vector_engine_api_key: None,
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
@@ -788,6 +794,12 @@ impl AppConfig {
config.spacetime_procedure_timeout =
Duration::from_secs(spacetime_procedure_timeout_seconds);
}
if let Some(spacetime_health_check_timeout_seconds) =
read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS"])
{
config.spacetime_health_check_timeout =
Duration::from_secs(spacetime_health_check_timeout_seconds);
}
if let Some(llm_provider) =
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
@@ -876,17 +888,17 @@ impl AppConfig {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) {
config.apimart_base_url = apimart_base_url;
}
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
}
// 中文注释Apimart 已于 2026-06 弃用LLM 文本调用统一迁移到 VectorEngine。
// 保留以下历史加载代码,后续删除:
// if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) {
// config.apimart_base_url = apimart_base_url;
// }
// config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
// if let Some(apimart_image_request_timeout_ms) =
// read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
// {
// config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
// }
if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"])
{
@@ -1331,7 +1343,7 @@ mod tests {
assert!(config.llm_model.is_empty());
assert!(config.llm_base_url.is_empty());
assert!(config.apimart_base_url.is_empty());
// assert!(config.apimart_base_url.is_empty());
assert!(config.vector_engine_base_url.is_empty());
assert!(config.ark_character_video_base_url.is_empty());
assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2");
@@ -1498,11 +1510,11 @@ mod tests {
assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible);
assert_eq!(config.llm_base_url, "https://llm.internal.example/v1");
assert_eq!(config.llm_model, "internal-text-model");
assert_eq!(
config.apimart_base_url,
"https://responses.internal.example/v1"
);
assert_eq!(config.apimart_image_request_timeout_ms, 190_000);
// assert_eq!(
// config.apimart_base_url,
// "https://responses.internal.example/v1"
// );
// assert_eq!(config.apimart_image_request_timeout_ms, 190_000);
assert_eq!(
config.vector_engine_base_url,
"https://vector.internal.example"
@@ -1822,6 +1834,26 @@ mod tests {
}
}
#[test]
fn from_env_reads_spacetime_health_check_timeout() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS");
std::env::set_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS", "3");
}
let config = AppConfig::from_env();
assert_eq!(config.spacetime_health_check_timeout.as_secs(), 3);
unsafe {
std::env::remove_var("GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS");
}
}
#[test]
fn default_keeps_structured_llm_web_search_disabled() {
let config = AppConfig::default();

View File

@@ -11,11 +11,27 @@ 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>,
@@ -70,59 +86,65 @@ pub async fn require_creation_entry_route_enabled(
next.run(request).await
}
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
let normalized = path.trim_end_matches('/');
if normalized == "/api/runtime/puzzle/agent/sessions"
|| normalized == "/api/runtime/puzzle/onboarding/generate"
{
return Some("puzzle");
/// 中文注释:公开作品互动配置只拦点赞 / 改造动作,不影响作品详情读取和正式游玩。
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();
}
}
}
if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") {
return Some("puzzle");
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 normalized == "/api/runtime/big-fish/agent/sessions" {
return Some("big-fish");
if path.starts_with("/api/runtime/big-fish/gallery/") {
return Some(("big-fish", action));
}
if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") {
return Some("big-fish");
}
if normalized == "/api/runtime/custom-world/agent/sessions"
|| normalized == "/api/runtime/custom-world/profile"
{
return Some("rpg");
}
if normalized.starts_with("/api/runtime/custom-world-gallery/")
&& normalized.ends_with("/remix")
{
return Some("rpg");
}
if normalized == "/api/creation/match3d/sessions" {
return Some("match3d");
}
if normalized == "/api/creation/square-hole/sessions" {
return Some("square-hole");
}
if normalized == "/api/creation/bark-battle/drafts" {
return Some("bark-battle");
}
if normalized == "/api/creation/wooden-fish/sessions" {
return Some("wooden-fish");
}
if normalized == "/api/creation/jump-hop/sessions" {
return Some("jump-hop");
}
if normalized == "/api/creation/puzzle-clear/sessions" {
return Some("puzzle-clear");
}
if normalized == "/api/creation/visual-novel/sessions" {
return Some("visual-novel");
}
if normalized == "/api/creation/edutainment/baby-object-match/assets" {
return Some("baby-object-match");
}
if normalized == "/api/creation/edutainment/baby-love-drawing/magic" {
return Some("baby-love-drawing");
if path.starts_with("/api/runtime/puzzle/gallery/") {
return Some(("puzzle", action));
}
None
}
@@ -197,6 +219,9 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
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(),
),
})
}
@@ -297,6 +322,28 @@ mod tests {
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();

View File

@@ -10,6 +10,7 @@ use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
use spacetime_client::SpacetimeClientHealthSnapshot;
pub async fn health_check(Extension(request_context): Extension<RequestContext>) -> Json<Value> {
json_success_body(
@@ -25,23 +26,49 @@ pub async fn readiness_check(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Response {
if state.is_ready() {
if !state.is_ready() {
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("api-server 正在退出,不再接收新流量")
.with_details(json!({
"reason": "api_server_draining",
"ready": false,
}))
.into_response_with_context(Some(&request_context));
}
let spacetime_health = state.spacetime_health_check().await;
if spacetime_health.ok {
return json_success_body(
Some(&request_context),
json!({
"ok": true,
"ready": true,
"service": "genarrative-api-server",
"spacetime": spacetime_health_to_json(&spacetime_health),
}),
)
.into_response();
}
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("api-server 正在退出,不再接收新流量")
.with_message("SpacetimeDB 连接健康检查失败,api-server 暂不接收新流量")
.with_details(json!({
"reason": "api_server_draining",
"reason": "spacetime_unhealthy",
"ready": false,
"spacetime": spacetime_health_to_json(&spacetime_health),
}))
.into_response_with_context(Some(&request_context))
}
fn spacetime_health_to_json(snapshot: &SpacetimeClientHealthSnapshot) -> Value {
json!({
"ok": snapshot.ok,
"stage": snapshot.stage.as_str(),
"checkedAtMicros": snapshot.checked_at_micros,
"elapsedMs": snapshot.elapsed_ms,
"timeoutMs": snapshot.timeout_ms,
"error": snapshot.error,
"lastSuccessAtMicros": snapshot.last_success_at_micros,
"lastError": snapshot.last_error,
})
}

File diff suppressed because one or more lines are too long

View File

@@ -10,10 +10,12 @@ pub mod internal;
pub mod jump_hop;
pub mod match3d;
pub mod platform;
pub mod play_flow;
pub mod profile;
pub mod public_work;
pub mod puzzle;
pub mod puzzle_clear;
pub mod square_hole;
pub mod story;
pub mod visual_novel;
pub mod wooden_fish;

View File

@@ -9,7 +9,7 @@ use crate::{
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
admin_login, admin_me, admin_overview, admin_update_work_visibility,
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
require_admin_auth,
admin_upsert_public_work_interaction_config, require_admin_auth,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
@@ -81,6 +81,12 @@ pub fn router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/creation-entry/config/interactions",
post(admin_upsert_public_work_interaction_config).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/works/visibility",
get(admin_list_work_visibility)

View File

@@ -6,7 +6,7 @@ use axum::{
use crate::{
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_history, get_asset_read_bytes, get_asset_read_url,
create_sts_upload_credentials, get_asset_read_bytes, get_asset_read_url,
},
auth::require_bearer_auth,
state::AppState,
@@ -44,11 +44,4 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route("/api/assets/read-bytes", get(get_asset_read_bytes))
.route(
"/api/assets/history",
get(get_asset_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -1,40 +1,11 @@
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
Router, middleware,
routing::{get, post},
};
use crate::{
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
},
auth::require_bearer_auth,
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
import_character_animation_video, list_character_animation_templates,
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
save_character_workflow_cache,
},
character_visual_assets::{
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,
hyper3d_generation::{
get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model,
submit_hyper3d_text_to_model,
},
llm::proxy_llm_chat_completions,
runtime_chat::stream_runtime_npc_chat_turn,
runtime_chat_plain::{
generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary,
stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue,
stream_runtime_npc_recruit_dialogue,
},
runtime_save::{delete_runtime_snapshot, get_runtime_snapshot, put_runtime_snapshot},
runtime_settings::{get_runtime_settings, put_runtime_settings},
state::AppState,
volcengine_speech::{
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
@@ -42,8 +13,6 @@ use crate::{
},
};
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
@@ -81,213 +50,4 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/character/suggestions",
post(generate_runtime_character_chat_suggestions).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/chat/character/summary",
post(generate_runtime_character_chat_summary).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/chat/character/reply/stream",
post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/dialogue/stream",
post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/turn/stream",
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/recruit/stream",
post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creation-agent/document-inputs/parse",
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks",
post(create_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/start",
post(start_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/chunks",
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/references",
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/complete",
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/fail",
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/cancel",
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/character-visual/generate",
post(generate_character_visual),
)
.route(
"/api/assets/character-visual/jobs/{task_id}",
get(get_character_visual_job),
)
.route(
"/api/assets/character-visual/publish",
post(publish_character_visual),
)
.route(
"/api/assets/character-animation/generate",
post(generate_character_animation),
)
.route(
"/api/assets/character-animation/jobs/{task_id}",
get(get_character_animation_job),
)
.route(
"/api/assets/character-animation/publish",
post(publish_character_animation),
)
.route(
"/api/assets/character-animation/import-video",
post(import_character_animation_video),
)
.route(
"/api/assets/character-animation/templates",
get(list_character_animation_templates),
)
.route(
"/api/assets/character-workflow-cache",
post(save_character_workflow_cache),
)
.route(
"/api/assets/character-workflow-cache/{character_id}",
get(get_character_workflow_cache),
)
.route(
"/api/runtime/custom-world/asset-studio/role/{character_id}/workflow",
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
)
.route(
"/api/assets/hyper3d/text-to-model",
post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/image-to-model",
post(submit_hyper3d_image_to_model)
.layer(DefaultBodyLimit::max(
HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/status",
post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/download",
post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation-entry/config",
get(get_creation_entry_config_handler),
)
.route(
"/api/runtime/settings",
get(get_runtime_settings)
.put(put_runtime_settings)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/save/snapshot",
get(get_runtime_snapshot)
.put(put_runtime_snapshot)
.delete(delete_runtime_snapshot)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,18 +6,13 @@ use axum::{
use crate::{
auth::require_bearer_auth,
profile_identity::update_profile_identity,
runtime_browse_history::{
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
},
runtime_profile::{
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
redeem_profile_reward_code, stream_wechat_profile_recharge_order_events,
submit_profile_feedback,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center,
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
stream_wechat_profile_recharge_order_events, submit_profile_feedback,
},
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
state::AppState,
};
@@ -30,16 +25,6 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
@@ -131,25 +116,4 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,183 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_auth,
state::AppState,
vector_engine_audio_generation::{
create_background_music_task, create_sound_effect_task,
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
publish_background_music_asset, publish_sound_effect_asset,
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
},
visual_novel::{
compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work,
execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session,
get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history,
list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run,
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
submit_visual_novel_message, update_visual_novel_work,
},
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/visual-novel/sessions",
post(create_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}",
get(get_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages",
post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages/stream",
post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/actions",
post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/compile",
post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works",
get(list_visual_novel_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}",
get(get_visual_novel_work)
.put(update_visual_novel_work)
.patch(update_visual_novel_work)
.delete(delete_visual_novel_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}/publish",
post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/audio/background-music",
post(create_visual_novel_background_music_task).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/visual-novel/audio/background-music/{task_id}/asset",
post(publish_visual_novel_background_music_asset).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/visual-novel/audio/sound-effect",
post(create_visual_novel_sound_effect_task).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/visual-novel/audio/sound-effect/{task_id}/asset",
post(publish_visual_novel_sound_effect_asset).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/audio/background-music",
post(create_background_music_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/background-music/{task_id}/asset",
post(publish_background_music_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/sound-effect",
post(create_sound_effect_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/audio/sound-effect/{task_id}/asset",
post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/gallery",
get(list_visual_novel_gallery),
)
.route(
"/api/runtime/visual-novel/works/{profile_id}/runs",
post(start_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}",
get(get_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/actions/stream",
post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/history",
get(list_visual_novel_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/regenerate",
post(regenerate_visual_novel_run)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}

View File

@@ -270,8 +270,8 @@ mod tests {
#[tokio::test]
async fn runtime_browse_history_rejects_blank_required_fields() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -316,8 +316,8 @@ mod tests {
#[tokio::test]
async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway()
{
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -361,23 +361,21 @@ mod tests {
);
}
async fn seed_authenticated_state() -> AppState {
async fn seed_authenticated_state() -> (AppState, String) {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
let user_id = state
.seed_test_phone_user_with_password("13800138102", "secret123")
.await
.id;
state
(state, user_id)
}
fn issue_access_token(state: &AppState) -> String {
fn issue_access_token(state: &AppState, user_id: &str) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_browse_history",
),
user_id: user_id.to_string(),
session_id: state
.seed_test_refresh_session_for_user_id(user_id, "sess_runtime_browse_history"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -347,8 +347,8 @@ mod tests {
#[tokio::test]
async fn runtime_snapshot_checkpoint_rejects_legacy_full_snapshot_upload() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -379,8 +379,8 @@ mod tests {
#[tokio::test]
async fn runtime_snapshot_checkpoint_requires_existing_server_snapshot() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -407,9 +407,9 @@ mod tests {
#[tokio::test]
async fn runtime_snapshot_checkpoint_rejects_session_mismatch() {
let state = seed_authenticated_state().await;
seed_runtime_snapshot(&state, "runtime-server", "adventure").await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
seed_runtime_snapshot(&state, user_id.as_str(), "runtime-server", "adventure").await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -436,9 +436,9 @@ mod tests {
#[tokio::test]
async fn runtime_snapshot_checkpoint_uses_persisted_server_snapshot() {
let state = seed_authenticated_state().await;
seed_runtime_snapshot(&state, "runtime-main", "adventure").await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
seed_runtime_snapshot(&state, user_id.as_str(), "runtime-main", "adventure").await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -509,8 +509,8 @@ mod tests {
#[tokio::test]
async fn resume_profile_save_archive_rejects_blank_world_key() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -529,21 +529,26 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
async fn seed_authenticated_state() -> AppState {
async fn seed_authenticated_state() -> (AppState, String) {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
let user_id = state
.seed_test_phone_user_with_password("13800138105", "secret123")
.await
.id;
state
(state, user_id)
}
async fn seed_runtime_snapshot(state: &AppState, session_id: &str, bottom_tab: &str) {
async fn seed_runtime_snapshot(
state: &AppState,
user_id: &str,
session_id: &str,
bottom_tab: &str,
) {
let now = OffsetDateTime::now_utc();
let now_micros = shared_kernel::offset_datetime_to_unix_micros(now);
state
.put_runtime_snapshot_record(
"user_00000001".to_string(),
user_id.to_string(),
now_micros - 2_000_000,
bottom_tab.to_string(),
json!({
@@ -571,12 +576,12 @@ mod tests {
.expect("runtime snapshot should seed");
}
fn issue_access_token(state: &AppState) -> String {
fn issue_access_token(state: &AppState, user_id: &str) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
user_id: user_id.to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"),
.seed_test_refresh_session_for_user_id(user_id, "sess_runtime_save"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -184,8 +184,8 @@ mod tests {
#[tokio::test]
async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -221,8 +221,8 @@ mod tests {
#[tokio::test]
async fn runtime_settings_rejects_invalid_theme_with_envelope() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let response = app
@@ -266,8 +266,8 @@ mod tests {
#[tokio::test]
#[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module验证 PUT/GET settings 主链"]
async fn runtime_settings_round_trip_against_local_spacetimedb() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let (state, user_id) = seed_authenticated_state().await;
let token = issue_access_token(&state, user_id.as_str());
let app = build_router(state);
let put_response = app
@@ -337,23 +337,21 @@ mod tests {
assert_eq!(get_payload["data"]["musicVolume"], json!(1.0));
}
async fn seed_authenticated_state() -> AppState {
async fn seed_authenticated_state() -> (AppState, String) {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
let user_id = state
.seed_test_phone_user_with_password("13800138106", "secret123")
.await
.id;
state
(state, user_id)
}
fn issue_access_token(state: &AppState) -> String {
fn issue_access_token(state: &AppState, user_id: &str) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state.seed_test_refresh_session_for_user_id(
"user_00000001",
"sess_runtime_settings",
),
user_id: user_id.to_string(),
session_id: state
.seed_test_refresh_session_for_user_id(user_id, "sess_runtime_settings"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,

View File

@@ -31,7 +31,9 @@ use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use spacetime_client::{
SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot,
};
use time::OffsetDateTime;
use tokio::sync::{Semaphore, broadcast};
use tracing::{info, warn};
@@ -242,6 +244,8 @@ pub struct AppStateInner {
refresh_cookie_config: RefreshCookieConfig,
#[cfg(test)]
test_creation_entry_config: Arc<Mutex<Option<CreationEntryConfigResponse>>>,
#[cfg(test)]
test_spacetime_health: Arc<Mutex<Option<SpacetimeClientHealthSnapshot>>>,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
@@ -418,6 +422,10 @@ impl AppState {
test_creation_entry_config: Arc::new(Mutex::new(Some(
crate::creation_entry_config::test_creation_entry_config_response(),
))),
#[cfg(test)]
test_spacetime_health: Arc::new(Mutex::new(Some(
SpacetimeClientHealthSnapshot::healthy_for_test(),
))),
oss_client,
auth_store,
password_entry_service,
@@ -467,6 +475,30 @@ impl AppState {
self.ready.store(false, Ordering::Release);
}
pub async fn spacetime_health_check(&self) -> SpacetimeClientHealthSnapshot {
#[cfg(test)]
if let Some(snapshot) = self
.test_spacetime_health
.lock()
.expect("test spacetime health should lock")
.clone()
{
return snapshot;
}
self.spacetime_client
.health_check(self.config.spacetime_health_check_timeout)
.await
}
#[cfg(test)]
pub(crate) fn set_test_spacetime_health(&self, snapshot: SpacetimeClientHealthSnapshot) {
*self
.test_spacetime_health
.lock()
.expect("test spacetime health should lock") = Some(snapshot);
}
pub async fn upsert_creation_entry_type_config(
&self,
input: module_runtime::CreationEntryTypeAdminUpsertInput,
@@ -527,6 +559,44 @@ impl AppState {
}
}
/// 通过 SpacetimeDB 保存公开作品互动配置,并同步测试缓存。
pub async fn upsert_public_work_interaction_config(
&self,
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
#[cfg(test)]
let test_interactions_json = input.public_work_interactions_json.clone();
match self
.spacetime_client
.upsert_public_work_interaction_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => {
let mut config = self.read_test_creation_entry_config();
if let Ok(interactions) =
module_runtime::decode_public_work_interaction_config_snapshots(
test_interactions_json.as_str(),
)
{
config.public_work_interactions = interactions
.into_iter()
.map(module_runtime::build_public_work_interaction_config_response)
.collect();
self.cache_test_creation_entry_config(config.clone());
}
Ok(config)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
@@ -587,6 +657,53 @@ impl AppState {
.unwrap_or(true))
}
pub async fn is_public_work_interaction_enabled(
&self,
source_type: &str,
action: crate::creation_entry_config::PublicWorkInteractionAction,
) -> Result<bool, SpacetimeClientError> {
let config = self.get_creation_entry_config().await?;
Ok(config
.public_work_interactions
.iter()
.find(|item| item.source_type == source_type)
.map(|item| match action {
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
item.like_enabled
}
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
item.remix_enabled
}
})
.unwrap_or(true))
}
#[cfg(test)]
pub(crate) fn set_test_public_work_interaction_enabled(
&self,
source_type: impl AsRef<str>,
action: crate::creation_entry_config::PublicWorkInteractionAction,
enabled: bool,
) {
let source_type = source_type.as_ref();
let mut config = self.read_test_creation_entry_config();
if let Some(item) = config
.public_work_interactions
.iter_mut()
.find(|item| item.source_type == source_type)
{
match action {
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
item.like_enabled = enabled;
}
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
item.remix_enabled = enabled;
}
}
}
self.cache_test_creation_entry_config(config);
}
#[cfg(test)]
pub(crate) fn set_test_creation_entry_route_enabled(
&self,
@@ -1378,8 +1495,9 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
fn build_creative_agent_gpt5_client(
config: &AppConfig,
) -> Result<Option<LlmClient>, AppStateInitError> {
// 中文注释Apimart 已于 2026-06 弃用LLM 文本调用统一迁移到 VectorEngine。
let Some(api_key) = config
.apimart_api_key
.vector_engine_api_key
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
@@ -1387,9 +1505,15 @@ fn build_creative_agent_gpt5_client(
return Ok(None);
};
let base_url = if config.vector_engine_base_url.ends_with("/v1") {
config.vector_engine_base_url.clone()
} else {
format!("{}/v1", config.vector_engine_base_url.trim_end_matches('/'))
};
let llm_config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
config.apimart_base_url.clone(),
base_url,
api_key.to_string(),
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
config.llm_request_timeout_ms,
@@ -1512,11 +1636,11 @@ mod tests {
}
#[test]
fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() {
fn app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings() {
let mut config = AppConfig::default();
config.llm_api_key = None;
config.apimart_base_url = "https://api.apimart.test/v1".to_string();
config.apimart_api_key = Some("apimart-key".to_string());
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
config.vector_engine_api_key = Some("ve-key".to_string());
let state = AppState::new(config).expect("state should build");
let client = state
@@ -1529,7 +1653,7 @@ mod tests {
);
assert_eq!(
client.config().responses_url(),
"https://api.apimart.test/v1/responses"
"https://api.vectorengine.test/v1/responses"
);
assert!(client.config().official_fallback());
}

View File

@@ -6,7 +6,9 @@ use crate::{
};
const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0;
const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.008;
const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.004;
const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO: f32 = 0.72;
const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO: f32 = 0.52;
pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath {
let config = difficulty_config(difficulty);
@@ -94,12 +96,11 @@ pub fn apply_jump(
);
let landed_x = current.x + unit_x * jump_distance;
let landed_y = current.y + unit_y * jump_distance;
let landing_error = (landed_x - target.x).hypot(landed_y - target.y);
let target_landing_radius = target.landing_radius;
let landed_on_target = is_landing_inside_platform_footprint(target, landed_x, landed_y);
let mut next = run.clone();
next.path = path;
let result = if landing_error <= target_landing_radius {
let result = if landed_on_target {
JumpHopJumpResultKind::Hit
} else {
JumpHopJumpResultKind::Miss
@@ -128,6 +129,42 @@ pub fn apply_jump(
Ok(next)
}
fn is_landing_inside_platform_footprint(
platform: &JumpHopPlatform,
landed_x: f32,
landed_y: f32,
) -> bool {
let half_width = (platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO).max(0.0);
let half_height = (platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO).max(0.0);
let error_x = landed_x - platform.x;
let error_y = landed_y - platform.y;
error_x.abs() <= half_width && error_y.abs() <= half_height
}
fn normalize_jump_direction(
drag_vector_x: Option<f32>,
drag_vector_y: Option<f32>,
fallback_x: f32,
fallback_y: f32,
) -> (f32, f32) {
let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else {
return (fallback_x, fallback_y);
};
let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else {
return (fallback_x, fallback_y);
};
// 前端提交屏幕拖拽向量x 轴同向y 轴向下为正;真实起跳反向弹出,世界 y 向上为正。
let jump_x = -drag_x;
let jump_y = drag_y;
let length = jump_x.hypot(jump_y);
if length < 0.0001 {
(fallback_x, fallback_y)
} else {
(jump_x / length, jump_y / length)
}
}
pub fn restart_run(
run: &JumpHopRunSnapshot,
next_run_id: String,
@@ -250,30 +287,6 @@ fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHop
path
}
fn normalize_jump_direction(
drag_vector_x: Option<f32>,
drag_vector_y: Option<f32>,
fallback_x: f32,
fallback_y: f32,
) -> (f32, f32) {
let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else {
return (fallback_x, fallback_y);
};
let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else {
return (fallback_x, fallback_y);
};
// 前端提交的是屏幕拖拽向量x 轴同向y 轴向下为正。
// 真实起跳需要“反向弹出”,同时把屏幕 y 翻回世界坐标的向上为正。
let jump_x = -drag_x;
let jump_y = drag_y;
let length = jump_x.hypot(jump_y);
if length < 0.0001 {
(fallback_x, fallback_y)
} else {
(jump_x / length, jump_y / length)
}
}
fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
match difficulty {
JumpHopDifficulty::Easy => DifficultyConfig {
@@ -353,8 +366,8 @@ impl DeterministicRng {
#[cfg(test)]
mod tests {
use crate::{
JumpHopDifficulty, JumpHopJumpResultKind, JumpHopRunStatus, apply_jump,
generate_jump_hop_path, restart_run, start_run,
JumpHopDifficulty, JumpHopJumpResultKind, JumpHopPlatform, JumpHopRunStatus,
JumpHopTileType, apply_jump, generate_jump_hop_path, restart_run, start_run,
};
#[test]
@@ -371,16 +384,17 @@ mod tests {
}
#[test]
fn difficulty_charge_to_distance_ratio_is_doubled() {
fn difficulty_charge_to_distance_ratio_is_reduced_for_long_press() {
let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy);
let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard);
let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced);
let challenge = generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge);
let challenge =
generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge);
assert_eq!(easy.scoring.charge_to_distance_ratio, 0.008);
assert_eq!(standard.scoring.charge_to_distance_ratio, 0.008);
assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.008);
assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.008);
assert_eq!(easy.scoring.charge_to_distance_ratio, 0.004);
assert_eq!(standard.scoring.charge_to_distance_ratio, 0.004);
assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.004);
assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.004);
}
#[test]
@@ -454,7 +468,7 @@ mod tests {
None,
200,
)
.expect("jump should resolve");
.expect("jump should resolve");
assert_eq!(miss.status, JumpHopRunStatus::Failed);
assert_eq!(
miss.last_jump.as_ref().unwrap().result,
@@ -463,7 +477,7 @@ mod tests {
}
#[test]
fn jump_resolution_uses_screen_drag_y_axis_for_forward_jump_direction() {
fn jump_resolution_uses_client_drag_direction_for_landing() {
let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy);
let run = start_run(
"run-screen-axis".to_string(),
@@ -478,21 +492,74 @@ mod tests {
let target_distance = (target.x - current.x).hypot(target.y - current.y);
let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32;
let result = apply_jump(
&run,
charge as f32,
Some(-(target.x - current.x)),
Some(target.y - current.y),
200,
)
.expect("jump should resolve");
let result = apply_jump(&run, charge as f32, Some(999.0), Some(-999.0), 200)
.expect("jump should resolve");
assert_eq!(result.status, JumpHopRunStatus::Playing);
assert_eq!(result.status, JumpHopRunStatus::Failed);
assert_eq!(
result.last_jump.as_ref().unwrap().result,
JumpHopJumpResultKind::Hit
JumpHopJumpResultKind::Miss
);
}
#[test]
fn jump_resolution_falls_back_to_next_center_when_drag_direction_missing() {
let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy);
let run = start_run(
"run-screen-axis".to_string(),
"user-screen-axis".to_string(),
"profile-screen-axis".to_string(),
path,
100,
)
.expect("run should start");
let current = &run.path.platforms[0];
let target = &run.path.platforms[1];
let target_distance = (target.x - current.x).hypot(target.y - current.y);
let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32;
let result = apply_jump(&run, charge as f32, None, None, 200).expect("jump should resolve");
let last_jump = result.last_jump.as_ref().expect("last jump should exist");
assert_eq!(result.status, JumpHopRunStatus::Playing);
assert_eq!(last_jump.result, JumpHopJumpResultKind::Hit);
assert_eq!(result.current_platform_index, 1);
assert!((last_jump.landed_x - target.x).abs() < target.landing_radius);
assert!((last_jump.landed_y - target.y).abs() < target.landing_radius);
}
#[test]
fn jump_resolution_uses_visual_top_face_footprint_instead_of_landing_radius() {
let mut path = generate_jump_hop_path("seed-footprint", JumpHopDifficulty::Easy);
path.platforms[0] = test_platform("p0", 0.0, 0.0, 1.2, 1.0);
path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 0.6);
path.scoring.max_charge_ms = 600;
let run = start_run(
"run-footprint".to_string(),
"user-footprint".to_string(),
"profile-footprint".to_string(),
path,
100,
)
.expect("run should start");
let edge_hit_charge = 1.6 / run.path.scoring.charge_to_distance_ratio;
let edge_hit =
apply_jump(&run, edge_hit_charge, None, None, 200).expect("jump should resolve");
let last_hit = edge_hit.last_jump.as_ref().expect("last jump should exist");
assert_eq!(edge_hit.status, JumpHopRunStatus::Playing);
assert_eq!(last_hit.result, JumpHopJumpResultKind::Hit);
assert!(last_hit.landed_x > 1.5);
assert!(last_hit.landed_x <= 1.72);
let outside_charge = 1.8 / run.path.scoring.charge_to_distance_ratio;
let outside =
apply_jump(&run, outside_charge, None, None, 200).expect("jump should resolve");
assert_eq!(outside.status, JumpHopRunStatus::Failed);
assert_eq!(
outside.last_jump.as_ref().unwrap().result,
JumpHopJumpResultKind::Miss
);
}
#[test]
@@ -551,4 +618,18 @@ mod tests {
assert!(run.path.platforms.len() >= 12);
assert!(run.finished_at_ms.is_none());
}
fn test_platform(id: &str, x: f32, y: f32, width: f32, height: f32) -> JumpHopPlatform {
JumpHopPlatform {
platform_id: id.to_string(),
tile_type: JumpHopTileType::Normal,
x,
y,
width,
height,
landing_radius: 0.2,
perfect_radius: 0.1,
score_value: 1,
}
}
}

View File

@@ -11,7 +11,7 @@ use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse, PublicWorkInteractionConfigResponse,
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
};
@@ -27,6 +27,9 @@ pub fn build_creation_entry_config_response(
.first()
.cloned()
.unwrap_or_else(|| build_creation_entry_event_banner_response(snapshot.event_banner));
let public_work_interactions = resolve_public_work_interaction_config_responses(
snapshot.public_work_interactions_json.as_deref(),
);
CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
@@ -41,6 +44,7 @@ pub fn build_creation_entry_config_response(
},
event_banner,
event_banners,
public_work_interactions,
creation_types: snapshot
.creation_types
.into_iter()
@@ -69,6 +73,223 @@ pub fn build_creation_entry_config_response(
}
}
/// 返回公开作品点赞 / 改造默认矩阵,保持历史前端硬编码能力不变。
pub fn default_public_work_interaction_config_snapshots() -> Vec<PublicWorkInteractionConfigSnapshot>
{
vec![
public_work_interaction_config(
"custom-world",
true,
true,
"RPG 作品暂不支持点赞。",
"RPG 作品暂不支持改造。",
),
public_work_interaction_config(
"big-fish",
true,
true,
"摸鱼点赞暂不可用。",
"摸鱼作品改造暂不可用。",
),
public_work_interaction_config(
"puzzle",
true,
true,
"拼图点赞暂不可用。",
"拼图作品改造暂不可用。",
),
public_work_interaction_config(
"puzzle-clear",
false,
false,
"拼消消点赞将在后续版本开放。",
"拼消消作品改造将在后续版本开放。",
),
public_work_interaction_config(
"jump-hop",
false,
false,
"作品类型 jump-hop 暂不支持点赞。",
"跳一跳作品改造将在后续版本开放。",
),
public_work_interaction_config(
"wooden-fish",
false,
false,
"作品类型 wooden-fish 暂不支持点赞。",
"敲木鱼作品改造将在后续版本开放。",
),
public_work_interaction_config(
"match3d",
false,
false,
"作品类型 match3d 暂不支持点赞。",
"抓大鹅作品改造将在后续版本开放。",
),
public_work_interaction_config(
"square-hole",
false,
false,
"方洞挑战点赞将在后续版本开放。",
"方洞挑战作品改造将在后续版本开放。",
),
public_work_interaction_config(
"visual-novel",
false,
false,
"视觉小说点赞将在后续版本开放。",
"视觉小说作品改造将在后续版本开放。",
),
public_work_interaction_config(
"bark-battle",
false,
false,
"汪汪声浪点赞将在后续版本开放。",
"汪汪声浪作品改造将在后续版本开放。",
),
public_work_interaction_config(
"edutainment",
false,
false,
"宝贝识物点赞将在后续版本开放。",
"宝贝识物作品改造将在创作链路接入后开放。",
),
]
}
fn public_work_interaction_config(
source_type: &str,
like_enabled: bool,
remix_enabled: bool,
like_disabled_message: &str,
remix_disabled_message: &str,
) -> PublicWorkInteractionConfigSnapshot {
PublicWorkInteractionConfigSnapshot {
source_type: source_type.to_string(),
like_enabled,
remix_enabled,
like_disabled_message: like_disabled_message.to_string(),
remix_disabled_message: remix_disabled_message.to_string(),
}
}
/// 生成默认公开作品互动配置 JSON供 SpacetimeDB 表字段持久化。
pub fn default_public_work_interaction_config_json() -> String {
encode_public_work_interaction_config_snapshots(
&default_public_work_interaction_config_snapshots(),
)
.unwrap_or_else(|_| "[]".to_string())
}
/// 校验并归一后台公开作品互动配置 JSON。
pub fn normalize_public_work_interaction_config_json(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(default_public_work_interaction_config_json());
}
let configs = decode_public_work_interaction_config_snapshots(trimmed)?;
encode_public_work_interaction_config_snapshots(&configs)
}
/// 解析公开作品互动配置 JSON并补齐缺失 sourceType 的默认项。
pub fn decode_public_work_interaction_config_snapshots(
input: &str,
) -> Result<Vec<PublicWorkInteractionConfigSnapshot>, String> {
let raw_entries = serde_json::from_str::<Vec<PublicWorkInteractionConfigResponse>>(input)
.map_err(|error| format!("作品互动配置 JSON 非法:{error}"))?;
if raw_entries.len() > PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT {
return Err(format!(
"作品互动配置最多允许 {}",
PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT
));
}
let defaults = default_public_work_interaction_config_snapshots();
let default_by_source = defaults
.iter()
.map(|item| (item.source_type.clone(), item.clone()))
.collect::<BTreeMap<_, _>>();
let mut overrides = BTreeMap::<String, PublicWorkInteractionConfigSnapshot>::new();
for (index, entry) in raw_entries.into_iter().enumerate() {
let source_type = entry.source_type.trim().to_string();
let Some(default_entry) = default_by_source.get(&source_type) else {
return Err(format!("{} 条作品类型非法:{}", index + 1, source_type));
};
if overrides.contains_key(&source_type) {
return Err(format!("作品互动配置 sourceType 重复:{source_type}"));
}
overrides.insert(
source_type.clone(),
PublicWorkInteractionConfigSnapshot {
source_type,
like_enabled: entry.like_enabled,
remix_enabled: entry.remix_enabled,
like_disabled_message: normalize_interaction_message(
entry.like_disabled_message,
&default_entry.like_disabled_message,
),
remix_disabled_message: normalize_interaction_message(
entry.remix_disabled_message,
&default_entry.remix_disabled_message,
),
},
);
}
Ok(defaults
.into_iter()
.map(|item| overrides.remove(&item.source_type).unwrap_or(item))
.collect())
}
fn normalize_interaction_message(value: String, fallback: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
/// 把公开作品互动领域快照编码为稳定 JSON。
pub fn encode_public_work_interaction_config_snapshots(
configs: &[PublicWorkInteractionConfigSnapshot],
) -> Result<String, String> {
let responses = configs
.iter()
.cloned()
.map(build_public_work_interaction_config_response)
.collect::<Vec<_>>();
serde_json::to_string_pretty(&responses)
.map_err(|error| format!("作品互动配置 JSON 序列化失败:{error}"))
}
/// 根据持久化 JSON 得到前台可消费的公开作品互动矩阵。
pub fn resolve_public_work_interaction_config_responses(
public_work_interactions_json: Option<&str>,
) -> Vec<PublicWorkInteractionConfigResponse> {
public_work_interactions_json
.and_then(|raw| decode_public_work_interaction_config_snapshots(raw).ok())
.unwrap_or_else(default_public_work_interaction_config_snapshots)
.into_iter()
.map(build_public_work_interaction_config_response)
.collect()
}
pub fn build_public_work_interaction_config_response(
config: PublicWorkInteractionConfigSnapshot,
) -> PublicWorkInteractionConfigResponse {
PublicWorkInteractionConfigResponse {
source_type: config.source_type,
like_enabled: config.like_enabled,
remix_enabled: config.remix_enabled,
like_disabled_message: config.like_disabled_message,
remix_disabled_message: config.remix_disabled_message,
}
}
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
pub fn default_creation_entry_event_banner_snapshots() -> Vec<CreationEntryEventBannerSnapshot> {
vec![CreationEntryEventBannerSnapshot {

View File

@@ -65,6 +65,8 @@ pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
pub const CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT: usize = 8;
/// 单条 HTML 公告的代码大小上限,避免后台误贴超大片段拖慢入口页。
pub const CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES: usize = 12_000;
/// 公开作品互动配置最多允许覆盖的 sourceType 数量。
pub const PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT: usize = 32;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -96,6 +98,17 @@ pub struct CreationEntryEventBannerSnapshot {
pub html_code: Option<String>,
}
/// 单类公开作品互动配置,控制作品详情页点赞 / 改造入口与后端动作熔断。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicWorkInteractionConfigSnapshot {
pub source_type: String,
pub like_enabled: bool,
pub remix_enabled: bool,
pub like_disabled_message: String,
pub remix_disabled_message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryTypeSnapshot {
@@ -126,6 +139,8 @@ pub struct CreationEntryConfigSnapshot {
pub event_banners_json: Option<String>,
pub creation_types: Vec<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
/// 公开作品点赞 / 改造能力 JSON 配置;旧库为空时由应用层兜底。
pub public_work_interactions_json: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -153,6 +168,14 @@ pub struct CreationEntryEventBannersAdminUpsertInput {
pub event_banners_json: String,
}
/// 后台保存公开作品互动能力表单序列化结果的领域输入。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicWorkInteractionConfigAdminUpsertInput {
/// 持久化字段沿用 JSON 字符串,内容由后台表单生成。
pub public_work_interactions_json: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryConfigProcedureResult {

View File

@@ -531,6 +531,7 @@ mod tests {
),
}],
updated_at_micros: 1,
public_work_interactions_json: Some(default_public_work_interaction_config_json()),
});
let puzzle = response
.creation_types
@@ -547,6 +548,37 @@ mod tests {
);
}
#[test]
fn public_work_interaction_config_defaults_and_overrides() {
let defaults = resolve_public_work_interaction_config_responses(None);
let puzzle = defaults
.iter()
.find(|item| item.source_type == "puzzle")
.expect("puzzle interaction should exist");
assert!(puzzle.like_enabled);
assert!(puzzle.remix_enabled);
let normalized = normalize_public_work_interaction_config_json(
r#"[{
"sourceType": "puzzle",
"likeEnabled": false,
"remixEnabled": true,
"likeDisabledMessage": "拼图点赞维护中。",
"remixDisabledMessage": ""
}]"#,
)
.expect("interaction config should normalize");
let resolved = resolve_public_work_interaction_config_responses(Some(&normalized));
let puzzle = resolved
.iter()
.find(|item| item.source_type == "puzzle")
.expect("puzzle interaction should exist");
assert!(!puzzle.like_enabled);
assert_eq!(puzzle.like_disabled_message, "拼图点赞维护中。");
assert_eq!(puzzle.remix_disabled_message, "拼图作品改造暂不可用。");
}
#[test]
fn normalized_clamps_music_volume_into_valid_range() {
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);

View File

@@ -18,6 +18,7 @@ use super::{
},
response::handle_vector_engine_response,
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
util::truncate_raw,
};
pub async fn create_vector_engine_image_generation(
@@ -66,7 +67,25 @@ pub async fn create_vector_engine_image_generation(
)
.await
{
Ok(response) => break response,
Ok(response) => {
if should_retry_vector_engine_upstream_status(response.status, attempt) {
retry_vector_engine_upstream_status_after_delay(
"generation",
request_url.as_str(),
attempt,
response.status,
response.body.as_str(),
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_images.len()),
Some(&request_body),
)
.await;
attempt += 1;
continue;
}
break response;
}
Err(error) => {
if should_retry_vector_engine_curl_send_error(&error, attempt) {
retry_vector_engine_send_after_delay(
@@ -75,7 +94,7 @@ pub async fn create_vector_engine_image_generation(
"request_send",
attempt,
error.is_timeout(),
error.is_connect(),
error.is_connect() || error.is_transient_transport(),
true,
false,
error.to_string().as_str(),
@@ -220,7 +239,25 @@ pub async fn create_vector_engine_image_edit_with_references(
)
.await
{
Ok(response) => break response,
Ok(response) => {
if should_retry_vector_engine_upstream_status(response.status, attempt) {
retry_vector_engine_upstream_status_after_delay(
"edit",
request_url.as_str(),
attempt,
response.status,
response.body.as_str(),
started_at.elapsed().as_millis() as u64,
Some(prompt.chars().count()),
Some(reference_image_count),
Some(&request_params),
)
.await;
attempt += 1;
continue;
}
break response;
}
Err(error) => {
if should_retry_vector_engine_curl_send_error(&error, attempt) {
retry_vector_engine_send_after_delay(
@@ -229,7 +266,7 @@ pub async fn create_vector_engine_image_edit_with_references(
"request_send",
attempt,
error.is_timeout(),
error.is_connect(),
error.is_connect() || error.is_transient_transport(),
true,
false,
error.to_string().as_str(),
@@ -290,7 +327,12 @@ fn should_retry_vector_engine_curl_send_error(
error: &super::curl_transport::VectorEngineCurlError,
attempt: u32,
) -> bool {
attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect())
attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS
&& (error.is_timeout() || error.is_connect() || error.is_transient_transport())
}
fn should_retry_vector_engine_upstream_status(status: u16, attempt: u32) -> bool {
attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (status == 408 || status == 429 || status >= 500)
}
async fn retry_vector_engine_send_after_delay(
@@ -334,6 +376,40 @@ async fn retry_vector_engine_send_after_delay(
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
async fn retry_vector_engine_upstream_status_after_delay(
request_kind: &'static str,
request_url: &str,
attempt: u32,
status: u16,
raw_body: &str,
elapsed_ms: u64,
prompt_chars: Option<usize>,
reference_image_count: Option<usize>,
request_params: Option<&serde_json::Value>,
) {
let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms());
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
request_kind,
failure_stage = "upstream_status",
attempt,
max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS,
retry_delay_ms = delay_ms,
status,
retryable = true,
elapsed_ms,
prompt_chars,
reference_image_count,
raw_excerpt = %truncate_raw(raw_body),
request_params = %request_params
.map(|value| value.to_string())
.unwrap_or_default(),
"VectorEngine 图片上游状态可重试,准备重试"
);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
fn vector_engine_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 {
let exponential_factor = 1_u64 << attempt.saturating_sub(1).min(10);
let bounded_jitter_ms = jitter_ms.min(VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS);
@@ -357,6 +433,33 @@ mod tests {
assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5);
}
#[test]
fn vector_engine_send_retry_policy_treats_ssl_reset_as_transient_transport() {
let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(35));
assert!(error.is_transient_transport());
assert!(should_retry_vector_engine_curl_send_error(&error, 1));
assert!(!should_retry_vector_engine_curl_send_error(&error, 5));
}
#[test]
fn vector_engine_send_retry_policy_treats_recv_eof_as_transient_transport() {
let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(56));
assert!(error.is_transient_transport());
assert!(should_retry_vector_engine_curl_send_error(&error, 1));
assert!(!should_retry_vector_engine_curl_send_error(&error, 5));
}
#[test]
fn vector_engine_send_retry_policy_treats_upstream_502_as_retryable() {
assert!(should_retry_vector_engine_upstream_status(502, 1));
assert!(should_retry_vector_engine_upstream_status(429, 1));
assert!(should_retry_vector_engine_upstream_status(408, 1));
assert!(!should_retry_vector_engine_upstream_status(400, 1));
assert!(!should_retry_vector_engine_upstream_status(502, 5));
}
#[test]
fn vector_engine_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() {
assert_eq!(vector_engine_send_retry_delay_ms(1, 0), 500);

View File

@@ -45,6 +45,25 @@ impl VectorEngineCurlError {
Self::Form(_) | Self::WorkerJoin(_) => false,
}
}
pub(crate) fn is_transient_transport(&self) -> bool {
match self {
Self::Curl(error) => {
let message = error.to_string().to_ascii_lowercase();
error.is_ssl_connect_error()
|| error.is_recv_error()
|| error.is_send_error()
|| message.contains("connection reset")
|| message.contains("recv failure")
|| message.contains("receive failure")
|| message.contains("receiving data")
|| message.contains("unexpected eof")
|| message.contains("send failure")
|| message.contains("broken pipe")
}
Self::Form(_) | Self::WorkerJoin(_) => false,
}
}
}
impl fmt::Display for VectorEngineCurlError {
@@ -136,7 +155,7 @@ pub(crate) fn map_curl_error(
request_params: Option<&Value>,
) -> PlatformImageError {
let is_timeout = error.is_timeout();
let is_connect = error.is_connect();
let is_connect = error.is_connect() || error.is_transient_transport();
let source = error.to_string();
let message = format!("{context}{source}");
let audit = build_failure_audit(

View File

@@ -1,8 +1,8 @@
use platform_image::vector_engine::{
GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
create_vector_engine_image_edit, vector_engine_images_edit_url,
vector_engine_images_generation_url,
create_vector_engine_image_edit, create_vector_engine_image_generation,
vector_engine_images_edit_url, vector_engine_images_generation_url,
};
use std::{
sync::{
@@ -109,3 +109,72 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() {
assert_eq!(request_count.load(Ordering::SeqCst), 2);
server.abort();
}
#[tokio::test]
async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("mock server should bind");
let server_addr = listener
.local_addr()
.expect("mock server address should be readable");
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_for_server = Arc::clone(&request_count);
let server = tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst);
tokio::spawn(async move {
let mut buffer = [0_u8; 4096];
let _ = stream.read(&mut buffer).await;
if request_index == 0 {
let body = "<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center></body></html>";
let response = format!(
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes()).await;
return;
}
let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes()).await;
});
}
});
let settings = VectorEngineImageSettings {
base_url: format!("http://{server_addr}/v1"),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
};
let http_client =
build_vector_engine_image_http_client(&settings).expect("client should build");
let generated = create_vector_engine_image_generation(
&http_client,
&settings,
"测试提示词",
None,
"1024x1024",
1,
&[],
"测试 VectorEngine 图片生成失败",
)
.await
.expect("second attempt should return generated image");
assert_eq!(generated.images.len(), 1);
assert_eq!(generated.images[0].mime_type, "image/png");
assert_eq!(request_count.load(Ordering::SeqCst), 2);
server.abort();
}

View File

@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
use crate::creation_entry_config::{
CreationEntryEventBannerResponse, PublicWorkInteractionConfigResponse,
UnifiedCreationSpecResponse,
};
// 管理后台协议统一收口在 shared-contracts避免页面脚本和 Rust handler 各自手拼字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -20,6 +23,8 @@ pub struct AdminCreationEntryConfigResponse {
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
/// 底部加号创作入口页的后台公告列表。
pub event_banners: Vec<CreationEntryEventBannerResponse>,
/// 公开作品详情页点赞 / 改造能力配置。
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
}
/// 后台单个创作入口开关配置。
@@ -69,6 +74,13 @@ pub struct AdminUpsertCreationEntryEventBannersRequest {
pub event_banners_json: String,
}
/// 后台保存公开作品点赞 / 改造能力配置请求。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertPublicWorkInteractionConfigRequest {
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
}
/// 后台作品可见性列表项。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]

View File

@@ -12,6 +12,7 @@ pub struct CreationEntryConfigResponse {
pub type_modal: CreationEntryTypeModalResponse,
pub event_banner: CreationEntryEventBannerResponse,
pub event_banners: Vec<CreationEntryEventBannerResponse>,
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
pub creation_types: Vec<CreationEntryTypeResponse>,
}
@@ -57,6 +58,20 @@ pub fn default_creation_entry_event_banner_render_mode() -> String {
"structured".to_string()
}
/// 单类公开作品互动能力配置。
///
/// 后台可以关闭已接入的点赞 / 改造能力;未接入后端动作的玩法即使误开,
/// 前端仍会按实际能力矩阵返回不可用提示。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkInteractionConfigResponse {
pub source_type: String,
pub like_enabled: bool,
pub remix_enabled: bool,
pub like_disabled_message: String,
pub remix_disabled_message: String,
}
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -547,6 +562,13 @@ mod tests {
render_mode: "html".to_string(),
html_code: Some("<section>ok</section>".to_string()),
}],
public_work_interactions: vec![PublicWorkInteractionConfigResponse {
source_type: "puzzle".to_string(),
like_enabled: true,
remix_enabled: true,
like_disabled_message: "拼图点赞暂不可用。".to_string(),
remix_disabled_message: "拼图作品改造暂不可用。".to_string(),
}],
creation_types: Vec::new(),
};
let value = serde_json::to_value(response).expect("response should serialize");
@@ -558,5 +580,6 @@ mod tests {
);
assert!(value.get("event_banner").is_none());
assert!(value.get("eventBanner").is_some());
assert_eq!(value["publicWorkInteractions"][0]["sourceType"], "puzzle");
}
}

View File

@@ -166,6 +166,45 @@ pub struct JumpHopTileAsset {
pub visual_height: u32,
pub top_surface_radius: f32,
pub landing_radius: f32,
#[serde(default)]
pub face_assets: Option<JumpHopTileFaceAssets>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum JumpHopTileFaceKey {
Top,
Front,
Right,
Back,
Left,
Bottom,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopTileFaceAsset {
pub face: JumpHopTileFaceKey,
pub asset_id: String,
pub image_src: String,
pub image_object_key: String,
pub asset_object_id: String,
pub generation_provider: String,
pub prompt: String,
pub width: u32,
pub height: u32,
pub source_atlas_cell: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopTileFaceAssets {
pub top: JumpHopTileFaceAsset,
pub front: JumpHopTileFaceAsset,
pub right: JumpHopTileFaceAsset,
pub back: JumpHopTileFaceAsset,
pub left: JumpHopTileFaceAsset,
pub bottom: JumpHopTileFaceAsset,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -472,9 +472,9 @@ fn validate_jump_hop_runtime_ready(
}
validate_jump_hop_default_character_ready(work)?;
validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
if work.tile_assets.len() < 25 {
if work.tile_assets.len() < 18 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop runtime 需要 25 个地块资产",
"jump-hop runtime 需要 18 个地块资产",
));
}
for (index, asset) in work.tile_assets.iter().enumerate() {
@@ -760,12 +760,12 @@ fn build_compile_input(
draft.default_character = Some(default_jump_hop_default_character());
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed(
"jump-hop compile-draft 缺少真实地图集资产,请先由 api-server 生成并持久化 asset_object",
"jump-hop compile-draft 缺少真实地板贴图图集资产,请先由 api-server 生成并持久化 asset_object",
)
})?;
let tile_assets = if draft.tile_assets.len() < 25 {
let tile_assets = if draft.tile_assets.len() < 18 {
return Err(SpacetimeClientError::validation_failed(
"jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object",
"jump-hop compile-draft 需要 18 个真实地块资产,请先由 api-server 生成并持久化 asset_object",
));
} else {
draft.tile_assets.clone()
@@ -877,7 +877,7 @@ fn default_draft() -> JumpHopDraftResponse {
style_preset: JumpHopStylePreset::MinimalBlocks,
default_character: Some(default_jump_hop_default_character()),
character_prompt: "内置默认 3D 角色".to_string(),
tile_prompt: "跳一跳主题的正面30度视角主题物体图集物体本身作为跳跃落点".to_string(),
tile_prompt: "跳一跳主题的3D立方体主题身份方块包装图集".to_string(),
end_mood_prompt: None,
character_asset: None,
tile_atlas_asset: None,
@@ -993,7 +993,7 @@ mod tests {
const NOW_MICROS: i64 = 1_763_456_789_000_000;
#[test]
fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character()
fn jump_hop_action_compile_draft_builds_compile_input_with_18_tile_assets_and_builtin_character()
{
let session = session_with_draft(draft_without_character_asset());
let payload = action(JumpHopActionType::CompileDraft);
@@ -1027,9 +1027,9 @@ mod tests {
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("old-tile-25-object")
.contains("old-tile-18-object")
);
assert_eq!(draft.tile_assets.len(), 25);
assert_eq!(draft.tile_assets.len(), 18);
assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready);
}
@@ -1039,7 +1039,7 @@ mod tests {
let mut payload = action(JumpHopActionType::RegenerateTiles);
payload.tile_prompt = Some("新的地块提示词".to_string());
payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS));
payload.tile_assets = Some(tile_assets("new", 25));
payload.tile_assets = Some(tile_assets("new", 18));
let (plan, _draft) =
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
@@ -1081,7 +1081,7 @@ mod tests {
.tile_assets_json
.as_deref()
.unwrap_or("")
.contains("new-tile-25-object")
.contains("new-tile-18-object")
);
}
@@ -1195,7 +1195,7 @@ mod tests {
JumpHopDraftResponse {
profile_id: None,
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
tile_assets: tile_assets("old", 25),
tile_assets: tile_assets("old", 18),
..base_draft()
}
}
@@ -1205,7 +1205,7 @@ mod tests {
profile_id: Some(PROFILE_ID.to_string()),
character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")),
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
tile_assets: tile_assets("old", 25),
tile_assets: tile_assets("old", 18),
path: Some(sample_jump_hop_path()),
cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()),
generation_status: JumpHopGenerationStatus::Ready,
@@ -1242,13 +1242,14 @@ mod tests {
index + 1
),
asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1),
source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1),
atlas_row: Some(index as u32 / 5 + 1),
atlas_col: Some(index as u32 % 5 + 1),
source_atlas_cell: format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1),
atlas_row: Some(index as u32 / 3 + 1),
atlas_col: Some(index as u32 % 3 + 1),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
face_assets: None,
})
.collect()
}

View File

@@ -137,7 +137,7 @@ use std::{
sync::atomic::{AtomicBool, Ordering},
sync::{Arc, Mutex},
thread::JoinHandle,
time::Duration,
time::{Duration, Instant},
};
use module_ai::{
@@ -246,6 +246,7 @@ use tokio::{
sync::{OwnedSemaphorePermit, RwLock, Semaphore, oneshot},
time::timeout,
};
use tracing::warn;
use crate::module_bindings::*;
@@ -258,6 +259,60 @@ pub struct SpacetimeClientConfig {
pub procedure_timeout: Duration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpacetimeClientStage {
Ready,
PoolAcquire,
ConnectBuild,
ConnectHandshake,
ReadModelSubscribe,
ProcedureResult,
ReducerResult,
ReadCache,
}
impl SpacetimeClientStage {
pub fn as_str(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::PoolAcquire => "pool_acquire",
Self::ConnectBuild => "connect_build",
Self::ConnectHandshake => "connect_handshake",
Self::ReadModelSubscribe => "read_model_subscribe",
Self::ProcedureResult => "procedure_result",
Self::ReducerResult => "reducer_result",
Self::ReadCache => "read_cache",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SpacetimeClientHealthSnapshot {
pub ok: bool,
pub stage: SpacetimeClientStage,
pub checked_at_micros: i64,
pub elapsed_ms: u64,
pub timeout_ms: u64,
pub error: Option<String>,
pub last_success_at_micros: Option<i64>,
pub last_error: Option<String>,
}
impl SpacetimeClientHealthSnapshot {
pub fn healthy_for_test() -> Self {
Self {
ok: true,
stage: SpacetimeClientStage::Ready,
checked_at_micros: current_unix_micros(),
elapsed_ms: 0,
timeout_ms: 0,
error: None,
last_success_at_micros: Some(current_unix_micros()),
last_error: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthStoreSnapshotRecord {
pub snapshot_json: Option<String>,
@@ -275,6 +330,7 @@ pub struct AuthStoreSnapshotImportRecord {
pub struct SpacetimeClient {
config: SpacetimeClientConfig,
pool: Arc<SpacetimeConnectionPool>,
health_state: Arc<RwLock<SpacetimeClientHealthState>>,
creation_entry_config_cache: Arc<RwLock<Option<CreationEntryConfigRecord>>>,
custom_world_gallery_legacy_sync_attempted: Arc<AtomicBool>,
}
@@ -301,6 +357,24 @@ struct SpacetimeConnectionPool {
permits: Arc<Semaphore>,
}
#[derive(Debug, Default)]
struct SpacetimeClientHealthState {
last_success_at_micros: Option<i64>,
last_error: Option<String>,
}
#[derive(Debug)]
struct SpacetimeStageError {
stage: SpacetimeClientStage,
error: SpacetimeClientError,
}
impl SpacetimeStageError {
fn new(stage: SpacetimeClientStage, error: SpacetimeClientError) -> Self {
Self { stage, error }
}
}
struct PooledConnectionSlot {
connection: Option<PooledConnection>,
in_use: bool,
@@ -346,6 +420,7 @@ impl SpacetimeClient {
Self {
config,
pool,
health_state: Arc::new(RwLock::new(SpacetimeClientHealthState::default())),
creation_entry_config_cache: Arc::new(RwLock::new(None)),
custom_world_gallery_legacy_sync_attempted: Arc::new(AtomicBool::new(false)),
}
@@ -359,29 +434,58 @@ impl SpacetimeClient {
where
T: Send + 'static,
{
let started_at = Instant::now();
let metrics_guard = telemetry::begin_procedure(procedure);
let (sender, receiver) = oneshot::channel();
let result_sender = Arc::new(Mutex::new(Some(sender)));
let final_result = match self.acquire_connection().await {
let final_result = match self
.acquire_connection_with_timeout(self.config.procedure_timeout)
.await
{
Ok(lease) => {
let result = if let Some(connection) = lease.connection.as_ref() {
let (result, failed_stage) = if let Some(connection) = lease.connection.as_ref() {
call(&connection.connection, result_sender.clone());
match timeout(self.config.procedure_timeout, receiver).await {
Ok(inner) => match inner {
Ok(value) => value,
Err(_) => Err(SpacetimeClientError::ConnectDropped),
let stage = SpacetimeClientStage::ProcedureResult;
(
match timeout(self.config.procedure_timeout, receiver).await {
Ok(inner) => match inner {
Ok(value) => value,
Err(_) => Err(SpacetimeClientError::ConnectDropped),
},
Err(_) => Err(Self::resolve_timeout_error(Some(connection), stage)),
},
Err(_) => Err(Self::resolve_timeout_error(Some(connection))),
}
stage,
)
} else {
Err(SpacetimeClientError::Runtime(
"SpacetimeDB 连接租约缺少连接".to_string(),
))
(
Err(SpacetimeClientError::Runtime(
"SpacetimeDB 连接租约缺少连接".to_string(),
)),
SpacetimeClientStage::ProcedureResult,
)
};
self.release_connection(lease).await;
if let Err(error) = &result {
log_spacetime_client_failure(
"procedure",
procedure,
failed_stage,
started_at,
error,
);
}
result
}
Err(error) => Err(error),
Err(error) => {
log_spacetime_client_failure(
"procedure",
procedure,
error.stage,
started_at,
&error.error,
);
Err(error.error)
}
};
metrics_guard.finish(&final_result);
@@ -393,29 +497,58 @@ impl SpacetimeClient {
procedure: &'static str,
call: impl FnOnce(&DbConnection, ReducerResultSender) + Send + 'static,
) -> Result<(), SpacetimeClientError> {
let started_at = Instant::now();
let metrics_guard = telemetry::begin_procedure(procedure);
let (sender, receiver) = oneshot::channel();
let result_sender = Arc::new(Mutex::new(Some(sender)));
let final_result = match self.acquire_connection().await {
let final_result = match self
.acquire_connection_with_timeout(self.config.procedure_timeout)
.await
{
Ok(lease) => {
let result = if let Some(connection) = lease.connection.as_ref() {
let (result, failed_stage) = if let Some(connection) = lease.connection.as_ref() {
call(&connection.connection, result_sender.clone());
match timeout(self.config.procedure_timeout, receiver).await {
Ok(inner) => match inner {
Ok(value) => value,
Err(_) => Err(SpacetimeClientError::ConnectDropped),
let stage = SpacetimeClientStage::ReducerResult;
(
match timeout(self.config.procedure_timeout, receiver).await {
Ok(inner) => match inner {
Ok(value) => value,
Err(_) => Err(SpacetimeClientError::ConnectDropped),
},
Err(_) => Err(Self::resolve_timeout_error(Some(connection), stage)),
},
Err(_) => Err(Self::resolve_timeout_error(Some(connection))),
}
stage,
)
} else {
Err(SpacetimeClientError::Runtime(
"SpacetimeDB 连接租约缺少连接".to_string(),
))
(
Err(SpacetimeClientError::Runtime(
"SpacetimeDB 连接租约缺少连接".to_string(),
)),
SpacetimeClientStage::ReducerResult,
)
};
self.release_connection(lease).await;
if let Err(error) = &result {
log_spacetime_client_failure(
"reducer",
procedure,
failed_stage,
started_at,
error,
);
}
result
}
Err(error) => Err(error),
Err(error) => {
log_spacetime_client_failure(
"reducer",
procedure,
error.stage,
started_at,
&error.error,
);
Err(error.error)
}
};
metrics_guard.finish(&final_result);
@@ -430,11 +563,22 @@ impl SpacetimeClient {
where
T: Send + 'static,
{
let started_at = Instant::now();
let metrics_guard = telemetry::begin_read(read_name);
let lease = match self.acquire_connection().await {
let lease = match self
.acquire_connection_with_timeout(self.config.procedure_timeout)
.await
{
Ok(lease) => lease,
Err(error) => {
let final_result = Err(error);
log_spacetime_client_failure(
"read",
read_name,
error.stage,
started_at,
&error.error,
);
let final_result = Err(error.error);
metrics_guard.finish(&final_result);
return final_result;
}
@@ -448,6 +592,15 @@ impl SpacetimeClient {
};
self.release_connection(lease).await;
if let Err(error) = &final_result {
log_spacetime_client_failure(
"read",
read_name,
SpacetimeClientStage::ReadCache,
started_at,
error,
);
}
metrics_guard.finish(&final_result);
final_result
}
@@ -460,14 +613,75 @@ impl SpacetimeClient {
self.creation_entry_config_cache.read().await.clone()
}
async fn acquire_connection(&self) -> Result<PooledConnectionLease, SpacetimeClientError> {
let permit = timeout(
self.config.procedure_timeout,
self.pool.permits.clone().acquire_owned(),
)
.await
.map_err(|_| SpacetimeClientError::Timeout)?
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?;
pub async fn health_check(&self, probe_timeout: Duration) -> SpacetimeClientHealthSnapshot {
let timeout = if probe_timeout.is_zero() {
DEFAULT_PROCEDURE_TIMEOUT
} else {
probe_timeout
};
let started_at = Instant::now();
let checked_at_micros = current_unix_micros();
let result = self.acquire_connection_with_timeout(timeout).await;
match result {
Ok(lease) => {
self.release_connection(lease).await;
let mut health_state = self.health_state.write().await;
health_state.last_success_at_micros = Some(checked_at_micros);
health_state.last_error = None;
SpacetimeClientHealthSnapshot {
ok: true,
stage: SpacetimeClientStage::Ready,
checked_at_micros,
elapsed_ms: duration_millis_u64(started_at.elapsed()),
timeout_ms: duration_millis_u64(timeout),
error: None,
last_success_at_micros: health_state.last_success_at_micros,
last_error: health_state.last_error.clone(),
}
}
Err(error) => {
log_spacetime_client_failure(
"health_check",
"spacetime_connection",
error.stage,
started_at,
&error.error,
);
let mut health_state = self.health_state.write().await;
let error_message = error.error.to_string();
health_state.last_error = Some(error_message.clone());
SpacetimeClientHealthSnapshot {
ok: false,
stage: error.stage,
checked_at_micros,
elapsed_ms: duration_millis_u64(started_at.elapsed()),
timeout_ms: duration_millis_u64(timeout),
error: Some(error_message),
last_success_at_micros: health_state.last_success_at_micros,
last_error: health_state.last_error.clone(),
}
}
}
}
async fn acquire_connection_with_timeout(
&self,
operation_timeout: Duration,
) -> Result<PooledConnectionLease, SpacetimeStageError> {
let permit = timeout(operation_timeout, self.pool.permits.clone().acquire_owned())
.await
.map_err(|_| {
SpacetimeStageError::new(
SpacetimeClientStage::PoolAcquire,
SpacetimeClientError::Timeout,
)
})?
.map_err(|error| {
SpacetimeStageError::new(
SpacetimeClientStage::PoolAcquire,
SpacetimeClientError::Runtime(error.to_string()),
)
})?;
loop {
for (slot_index, slot) in self.pool.slots.iter().enumerate() {
@@ -485,7 +699,7 @@ impl SpacetimeClient {
let connection = if let Some(connection) = reusable_connection {
connection
} else {
match self.build_pooled_connection().await {
match self.build_pooled_connection(operation_timeout).await {
Ok(connection) => connection,
Err(error) => {
let mut slot_guard = self.pool.slots[slot_index].lock().await;
@@ -507,7 +721,10 @@ impl SpacetimeClient {
}
}
async fn build_pooled_connection(&self) -> Result<PooledConnection, SpacetimeClientError> {
async fn build_pooled_connection(
&self,
operation_timeout: Duration,
) -> Result<PooledConnection, SpacetimeStageError> {
let config = self.config.clone();
let broken = Arc::new(AtomicBool::new(false));
let (sender, receiver) = oneshot::channel::<Result<(), SpacetimeClientError>>();
@@ -515,7 +732,7 @@ impl SpacetimeClient {
let broken_flag = broken.clone();
let disconnect_sender = connect_sender.clone();
let connection = timeout(
self.config.procedure_timeout,
operation_timeout,
tokio::task::spawn_blocking(move || {
DbConnection::builder()
.with_uri(config.server_url)
@@ -539,17 +756,41 @@ impl SpacetimeClient {
}),
)
.await
.map_err(|_| SpacetimeClientError::Timeout)?
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??;
.map_err(|_| {
SpacetimeStageError::new(
SpacetimeClientStage::ConnectBuild,
SpacetimeClientError::Timeout,
)
})?
.map_err(|error| {
SpacetimeStageError::new(
SpacetimeClientStage::ConnectBuild,
SpacetimeClientError::Runtime(error.to_string()),
)
})?
.map_err(|error| SpacetimeStageError::new(SpacetimeClientStage::ConnectBuild, error))?;
let runner = connection.run_threaded();
timeout(self.config.procedure_timeout, receiver)
timeout(operation_timeout, receiver)
.await
.map_err(|_| SpacetimeClientError::Timeout)?
.map_err(|_| SpacetimeClientError::ConnectDropped)??;
.map_err(|_| {
SpacetimeStageError::new(
SpacetimeClientStage::ConnectHandshake,
SpacetimeClientError::Timeout,
)
})?
.map_err(|_| {
SpacetimeStageError::new(
SpacetimeClientStage::ConnectHandshake,
SpacetimeClientError::ConnectDropped,
)
})?
.map_err(|error| {
SpacetimeStageError::new(SpacetimeClientStage::ConnectHandshake, error)
})?;
let read_model_subscriptions = self
.subscribe_cached_read_models(&connection, broken.clone())
.subscribe_cached_read_models(&connection, broken.clone(), operation_timeout)
.await?;
Ok(PooledConnection {
@@ -564,7 +805,8 @@ impl SpacetimeClient {
&self,
connection: &DbConnection,
broken: Arc<AtomicBool>,
) -> Result<Vec<SubscriptionHandle>, SpacetimeClientError> {
operation_timeout: Duration,
) -> Result<Vec<SubscriptionHandle>, SpacetimeStageError> {
let mut subscriptions = Vec::new();
for query in [
"SELECT * FROM public_work_gallery_entry",
@@ -581,7 +823,13 @@ impl SpacetimeClient {
"SELECT * FROM big_fish_gallery_view",
] {
let subscription = self
.subscribe_cached_read_model_query(connection, broken.clone(), query, true)
.subscribe_cached_read_model_query(
connection,
broken.clone(),
query,
true,
operation_timeout,
)
.await?;
subscriptions.push(subscription);
}
@@ -602,7 +850,13 @@ impl SpacetimeClient {
"SELECT * FROM asset_object",
] {
if let Ok(subscription) = self
.subscribe_cached_read_model_query(connection, broken.clone(), query, false)
.subscribe_cached_read_model_query(
connection,
broken.clone(),
query,
false,
operation_timeout,
)
.await
{
subscriptions.push(subscription);
@@ -618,7 +872,8 @@ impl SpacetimeClient {
broken: Arc<AtomicBool>,
query: &'static str,
mark_broken_on_error: bool,
) -> Result<SubscriptionHandle, SpacetimeClientError> {
operation_timeout: Duration,
) -> Result<SubscriptionHandle, SpacetimeStageError> {
let (sender, receiver) = oneshot::channel::<Result<(), SpacetimeClientError>>();
let applied_sender = Arc::new(Mutex::new(Some(sender)));
let on_applied_sender = applied_sender.clone();
@@ -640,10 +895,23 @@ impl SpacetimeClient {
})
.subscribe(query);
timeout(self.config.procedure_timeout, receiver)
timeout(operation_timeout, receiver)
.await
.map_err(|_| SpacetimeClientError::Timeout)?
.map_err(|_| SpacetimeClientError::ConnectDropped)??;
.map_err(|_| {
SpacetimeStageError::new(
SpacetimeClientStage::ReadModelSubscribe,
SpacetimeClientError::Timeout,
)
})?
.map_err(|_| {
SpacetimeStageError::new(
SpacetimeClientStage::ReadModelSubscribe,
SpacetimeClientError::ConnectDropped,
)
})?
.map_err(|error| {
SpacetimeStageError::new(SpacetimeClientStage::ReadModelSubscribe, error)
})?;
Ok(subscription)
}
@@ -663,7 +931,10 @@ impl SpacetimeClient {
}
// 超时后必须统一归还租约;若连接已先一步断开则回传断线,否则标记坏连接并回传超时。
fn resolve_timeout_error(connection: Option<&PooledConnection>) -> SpacetimeClientError {
fn resolve_timeout_error(
connection: Option<&PooledConnection>,
_stage: SpacetimeClientStage,
) -> SpacetimeClientError {
if let Some(connection) = connection {
if connection.is_broken() {
return SpacetimeClientError::ConnectDropped;
@@ -686,6 +957,27 @@ fn current_public_work_day() -> i64 {
current_unix_micros().div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS)
}
fn duration_millis_u64(duration: Duration) -> u64 {
duration.as_millis().min(u64::MAX as u128) as u64
}
fn log_spacetime_client_failure(
operation_kind: &'static str,
operation_name: &'static str,
stage: SpacetimeClientStage,
started_at: Instant,
error: &SpacetimeClientError,
) {
warn!(
operation_kind,
operation_name,
spacetime_stage = stage.as_str(),
elapsed_ms = duration_millis_u64(started_at.elapsed()),
error = %error,
"SpacetimeDB client operation failed"
);
}
fn public_work_recent_play_counts(
connection: &DbConnection,
source_type: &str,

View File

@@ -8,9 +8,9 @@ pub use shared_contracts::jump_hop::{
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
JumpHopTileFaceAsset, JumpHopTileFaceAssets, JumpHopTileFaceKey, JumpHopTileType,
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse,
JumpHopWorkSummaryResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
};
pub(crate) fn map_jump_hop_agent_session_procedure_result(
@@ -267,6 +267,33 @@ fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset {
visual_height: snapshot.visual_height,
top_surface_radius: snapshot.top_surface_radius,
landing_radius: snapshot.landing_radius,
face_assets: snapshot.face_assets.map(map_tile_face_assets),
}
}
fn map_tile_face_assets(snapshot: JumpHopTileFaceAssetsSnapshot) -> JumpHopTileFaceAssets {
JumpHopTileFaceAssets {
top: map_tile_face_asset(snapshot.top),
front: map_tile_face_asset(snapshot.front),
right: map_tile_face_asset(snapshot.right),
back: map_tile_face_asset(snapshot.back),
left: map_tile_face_asset(snapshot.left),
bottom: map_tile_face_asset(snapshot.bottom),
}
}
fn map_tile_face_asset(snapshot: JumpHopTileFaceAssetSnapshot) -> JumpHopTileFaceAsset {
JumpHopTileFaceAsset {
face: parse_tile_face_key(&snapshot.face),
asset_id: snapshot.asset_id,
image_src: snapshot.image_src,
image_object_key: snapshot.image_object_key,
asset_object_id: snapshot.asset_object_id,
generation_provider: snapshot.generation_provider,
prompt: snapshot.prompt,
width: snapshot.width,
height: snapshot.height,
source_atlas_cell: snapshot.source_atlas_cell,
}
}
@@ -405,6 +432,17 @@ fn parse_tile_type(value: &str) -> JumpHopTileType {
}
}
fn parse_tile_face_key(value: &str) -> JumpHopTileFaceKey {
match value {
"front" => JumpHopTileFaceKey::Front,
"right" => JumpHopTileFaceKey::Right,
"back" => JumpHopTileFaceKey::Back,
"left" => JumpHopTileFaceKey::Left,
"bottom" => JumpHopTileFaceKey::Bottom,
_ => JumpHopTileFaceKey::Top,
}
}
fn parse_domain_tile_type(value: crate::module_bindings::JumpHopTileType) -> JumpHopTileType {
match value {
crate::module_bindings::JumpHopTileType::Start => JumpHopTileType::Start,

View File

@@ -30,6 +30,17 @@ impl From<module_runtime::CreationEntryEventBannersAdminUpsertInput>
}
}
/// 将业务层公开作品互动配置保存输入转换为 SpacetimeDB 生成绑定类型。
impl From<module_runtime::PublicWorkInteractionConfigAdminUpsertInput>
for PublicWorkInteractionConfigAdminUpsertInput
{
fn from(input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput) -> Self {
Self {
public_work_interactions_json: input.public_work_interactions_json,
}
}
}
impl From<module_runtime::AdminWorkVisibilityListInput> for AdminWorkVisibilityListInput {
fn from(input: module_runtime::AdminWorkVisibilityListInput) -> Self {
Self {
@@ -323,6 +334,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
})
.collect(),
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
public_work_interactions_json: header.public_work_interactions_json,
},
)
}
@@ -376,6 +388,7 @@ fn map_creation_entry_config_snapshot(
})
.collect(),
updated_at_micros: snapshot.updated_at_micros,
public_work_interactions_json: snapshot.public_work_interactions_json,
}
}
@@ -432,6 +445,7 @@ mod tests {
event_starts_at_text: None,
event_ends_at_text: None,
event_banners_json: None,
public_work_interactions_json: None,
}
}
@@ -514,6 +528,7 @@ mod tests {
unified_creation_spec_json: None,
}],
updated_at_micros: 1_000_000,
public_work_interactions_json: None,
});
let jump_hop = record

View File

@@ -476,6 +476,8 @@ pub mod jump_hop_runtime_run_row_type;
pub mod jump_hop_runtime_run_table;
pub mod jump_hop_scoring_type;
pub mod jump_hop_tile_asset_snapshot_type;
pub mod jump_hop_tile_face_asset_snapshot_type;
pub mod jump_hop_tile_face_assets_snapshot_type;
pub mod jump_hop_tile_type_type;
pub mod jump_hop_work_delete_input_type;
pub mod jump_hop_work_get_input_type;
@@ -603,6 +605,7 @@ pub mod public_work_detail_entry_table;
pub mod public_work_detail_entry_type;
pub mod public_work_gallery_entry_table;
pub mod public_work_gallery_entry_type;
pub mod public_work_interaction_config_admin_upsert_input_type;
pub mod public_work_like_table;
pub mod public_work_like_type;
pub mod public_work_play_daily_stat_table;
@@ -1036,6 +1039,7 @@ pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_and_return_procedure;
pub mod upsert_npc_state_reducer;
pub mod upsert_platform_browse_history_and_return_procedure;
pub mod upsert_public_work_interaction_config_procedure;
pub mod upsert_runtime_setting_and_return_procedure;
pub mod upsert_runtime_snapshot_and_return_procedure;
pub mod upsert_visual_novel_run_snapshot_procedure;
@@ -1596,6 +1600,8 @@ pub use jump_hop_runtime_run_row_type::JumpHopRuntimeRunRow;
pub use jump_hop_runtime_run_table::*;
pub use jump_hop_scoring_type::JumpHopScoring;
pub use jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot;
pub use jump_hop_tile_face_asset_snapshot_type::JumpHopTileFaceAssetSnapshot;
pub use jump_hop_tile_face_assets_snapshot_type::JumpHopTileFaceAssetsSnapshot;
pub use jump_hop_tile_type_type::JumpHopTileType;
pub use jump_hop_work_delete_input_type::JumpHopWorkDeleteInput;
pub use jump_hop_work_get_input_type::JumpHopWorkGetInput;
@@ -1723,6 +1729,7 @@ pub use public_work_detail_entry_table::*;
pub use public_work_detail_entry_type::PublicWorkDetailEntry;
pub use public_work_gallery_entry_table::*;
pub use public_work_gallery_entry_type::PublicWorkGalleryEntry;
pub use public_work_interaction_config_admin_upsert_input_type::PublicWorkInteractionConfigAdminUpsertInput;
pub use public_work_like_table::*;
pub use public_work_like_type::PublicWorkLike;
pub use public_work_play_daily_stat_table::*;
@@ -2156,6 +2163,7 @@ pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile;
pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return;
pub use upsert_npc_state_reducer::upsert_npc_state;
pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return;
pub use upsert_public_work_interaction_config_procedure::upsert_public_work_interaction_config;
pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return;
pub use upsert_runtime_snapshot_and_return_procedure::upsert_runtime_snapshot_and_return;
pub use upsert_visual_novel_run_snapshot_procedure::upsert_visual_novel_run_snapshot;

View File

@@ -19,6 +19,7 @@ pub struct CreationEntryConfigSnapshot {
pub event_banners_json: Option<String>,
pub creation_types: Vec<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
pub public_work_interactions_json: Option<String>,
}
impl __sdk::InModule for CreationEntryConfigSnapshot {

View File

@@ -22,6 +22,7 @@ pub struct CreationEntryConfig {
pub event_starts_at_text: Option<String>,
pub event_ends_at_text: Option<String>,
pub event_banners_json: Option<String>,
pub public_work_interactions_json: Option<String>,
}
impl __sdk::InModule for CreationEntryConfig {
@@ -47,6 +48,8 @@ pub struct CreationEntryConfigCols {
pub event_starts_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_ends_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_banners_json: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub public_work_interactions_json:
__sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
}
impl __sdk::__query_builder::HasCols for CreationEntryConfig {
@@ -77,6 +80,10 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig {
),
event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"),
event_banners_json: __sdk::__query_builder::Col::new(table_name, "event_banners_json"),
public_work_interactions_json: __sdk::__query_builder::Col::new(
table_name,
"public_work_interactions_json",
),
}
}
}

View File

@@ -4,6 +4,8 @@
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::jump_hop_tile_face_assets_snapshot_type::JumpHopTileFaceAssetsSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct JumpHopTileAssetSnapshot {
@@ -19,6 +21,7 @@ pub struct JumpHopTileAssetSnapshot {
pub visual_height: u32,
pub top_surface_radius: f32,
pub landing_radius: f32,
pub face_assets: Option<JumpHopTileFaceAssetsSnapshot>,
}
impl __sdk::InModule for JumpHopTileAssetSnapshot {

View File

@@ -0,0 +1,24 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct JumpHopTileFaceAssetSnapshot {
pub face: String,
pub asset_id: String,
pub image_src: String,
pub image_object_key: String,
pub asset_object_id: String,
pub generation_provider: String,
pub prompt: String,
pub width: u32,
pub height: u32,
pub source_atlas_cell: String,
}
impl __sdk::InModule for JumpHopTileFaceAssetSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::jump_hop_tile_face_asset_snapshot_type::JumpHopTileFaceAssetSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct JumpHopTileFaceAssetsSnapshot {
pub top: JumpHopTileFaceAssetSnapshot,
pub front: JumpHopTileFaceAssetSnapshot,
pub right: JumpHopTileFaceAssetSnapshot,
pub back: JumpHopTileFaceAssetSnapshot,
pub left: JumpHopTileFaceAssetSnapshot,
pub bottom: JumpHopTileFaceAssetSnapshot,
}
impl __sdk::InModule for JumpHopTileFaceAssetsSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct PublicWorkInteractionConfigAdminUpsertInput {
pub public_work_interactions_json: String,
}
impl __sdk::InModule for PublicWorkInteractionConfigAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult;
use super::public_work_interaction_config_admin_upsert_input_type::PublicWorkInteractionConfigAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct UpsertPublicWorkInteractionConfigArgs {
pub input: PublicWorkInteractionConfigAdminUpsertInput,
}
impl __sdk::InModule for UpsertPublicWorkInteractionConfigArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `upsert_public_work_interaction_config`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait upsert_public_work_interaction_config {
fn upsert_public_work_interaction_config(
&self,
input: PublicWorkInteractionConfigAdminUpsertInput,
) {
self.upsert_public_work_interaction_config_then(input, |_, _| {});
}
fn upsert_public_work_interaction_config_then(
&self,
input: PublicWorkInteractionConfigAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl upsert_public_work_interaction_config for super::RemoteProcedures {
fn upsert_public_work_interaction_config_then(
&self,
input: PublicWorkInteractionConfigAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>(
"upsert_public_work_interaction_config",
UpsertPublicWorkInteractionConfigArgs { input },
__callback,
);
}
}

View File

@@ -115,6 +115,34 @@ impl SpacetimeClient {
Ok(config)
}
/// 调用 SpacetimeDB procedure 保存公开作品互动配置并刷新缓存。
pub async fn upsert_public_work_interaction_config(
&self,
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
let procedure_input: PublicWorkInteractionConfigAdminUpsertInput = input.into();
let config = self
.call_after_connect(
"upsert_public_work_interaction_config",
move |connection, sender| {
connection
.procedures()
.upsert_public_work_interaction_config_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_creation_entry_config_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await?;
self.cache_creation_entry_config(config.clone()).await;
Ok(config)
}
pub async fn admin_list_work_visibility(
&self,
admin_user_id: String,

View File

@@ -1311,7 +1311,7 @@ fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot {
difficulty: JumpHopDifficulty::Standard.as_str().to_string(),
style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(),
character_prompt: "内置默认 3D 角色".to_string(),
tile_prompt: format!("{seed}主题的正面30度视角主题物体图集物体本身作为跳跃落点"),
tile_prompt: format!("{seed}主题的3D立方体主题身份方块包装图集"),
end_mood_prompt: String::new(),
}
}

View File

@@ -232,6 +232,34 @@ pub struct JumpHopTileAssetSnapshot {
pub visual_height: u32,
pub top_surface_radius: f32,
pub landing_radius: f32,
#[serde(default)]
pub face_assets: Option<JumpHopTileFaceAssetsSnapshot>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopTileFaceAssetSnapshot {
pub face: String,
pub asset_id: String,
pub image_src: String,
pub image_object_key: String,
pub asset_object_id: String,
pub generation_provider: String,
pub prompt: String,
pub width: u32,
pub height: u32,
pub source_atlas_cell: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct JumpHopTileFaceAssetsSnapshot {
pub top: JumpHopTileFaceAssetSnapshot,
pub front: JumpHopTileFaceAssetSnapshot,
pub right: JumpHopTileFaceAssetSnapshot,
pub back: JumpHopTileFaceAssetSnapshot,
pub left: JumpHopTileFaceAssetSnapshot,
pub bottom: JumpHopTileFaceAssetSnapshot,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]

View File

@@ -1194,6 +1194,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("event_banners_json".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("public_work_interactions_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "creation_entry_type_config" {

View File

@@ -26,6 +26,9 @@ pub struct CreationEntryConfig {
/// 底部加号创作入口页的多 banner JSON 配置,旧单条字段仅用于兼容。
#[default(None::<String>)]
pub(crate) event_banners_json: Option<String>,
/// 公开作品点赞 / 改造能力配置,旧库为空时由读取层按默认矩阵兜底。
#[default(None::<String>)]
pub(crate) public_work_interactions_json: Option<String>,
}
#[spacetimedb::table(
@@ -109,6 +112,26 @@ pub fn upsert_creation_entry_event_banners_config(
}
}
#[spacetimedb::procedure]
/// 后台保存公开作品点赞 / 改造能力配置的过程入口。
pub fn upsert_public_work_interaction_config(
ctx: &mut ProcedureContext,
input: PublicWorkInteractionConfigAdminUpsertInput,
) -> CreationEntryConfigProcedureResult {
match ctx.try_with_tx(|tx| upsert_public_work_interaction_config_in_tx(tx, input.clone())) {
Ok(record) => CreationEntryConfigProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => CreationEntryConfigProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn upsert_creation_entry_type_config_in_tx(
ctx: &ReducerContext,
input: CreationEntryTypeAdminUpsertInput,
@@ -171,6 +194,33 @@ fn upsert_creation_entry_event_banners_config_in_tx(
get_or_seed_creation_entry_config_snapshot(ctx)
}
/// 在事务内归一化公开作品互动配置 JSON 并更新全局入口配置表头。
fn upsert_public_work_interaction_config_in_tx(
ctx: &ReducerContext,
input: PublicWorkInteractionConfigAdminUpsertInput,
) -> Result<CreationEntryConfigSnapshot, String> {
seed_creation_entry_config_if_missing(ctx);
let now = ctx.timestamp;
let config_id = CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string();
let Some(header) = ctx.db.creation_entry_config().config_id().find(&config_id) else {
return Err("创作入口配置初始化失败".to_string());
};
let public_work_interactions_json =
module_runtime::normalize_public_work_interaction_config_json(
&input.public_work_interactions_json,
)?;
ctx.db
.creation_entry_config()
.config_id()
.update(CreationEntryConfig {
updated_at: now,
public_work_interactions_json: Some(public_work_interactions_json),
..header
});
get_or_seed_creation_entry_config_snapshot(ctx)
}
fn get_or_seed_creation_entry_config_snapshot(
ctx: &ReducerContext,
) -> Result<CreationEntryConfigSnapshot, String> {
@@ -247,6 +297,7 @@ fn get_or_seed_creation_entry_config_snapshot(
event_banners_json: header.event_banners_json,
creation_types,
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
public_work_interactions_json: header.public_work_interactions_json,
})
}
@@ -276,6 +327,9 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()),
event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()),
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
public_work_interactions_json: Some(
module_runtime::default_public_work_interaction_config_json(),
),
});
}