1
This commit is contained in:
@@ -92,11 +92,10 @@ use crate::{
|
||||
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
|
||||
generate_match3d_background_image_for_work, generate_match3d_cover_image,
|
||||
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
|
||||
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail,
|
||||
get_match3d_works, list_match3d_gallery, persist_match3d_generated_model,
|
||||
publish_match3d_work, put_match3d_audio_assets, put_match3d_work,
|
||||
restart_match3d_run, start_match3d_run, stop_match3d_run,
|
||||
stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
|
||||
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
|
||||
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
|
||||
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
},
|
||||
password_entry::password_entry,
|
||||
password_management::{change_password, reset_password},
|
||||
@@ -975,10 +974,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/item-assets",
|
||||
post(generate_match3d_item_assets_for_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(generate_match3d_item_assets_for_work).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/generated-models",
|
||||
|
||||
@@ -767,7 +767,9 @@ mod tests {
|
||||
assert!(super::is_supported_asset_history_kind("character_visual"));
|
||||
assert!(super::is_supported_asset_history_kind("scene_image"));
|
||||
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
|
||||
assert!(super::is_supported_asset_history_kind("match3d_cover_image"));
|
||||
assert!(super::is_supported_asset_history_kind(
|
||||
"match3d_cover_image"
|
||||
));
|
||||
assert!(super::is_supported_asset_history_kind("match3d_item_image"));
|
||||
assert!(super::is_supported_asset_history_kind(
|
||||
"square_hole_cover_image"
|
||||
|
||||
@@ -80,14 +80,16 @@ mod work_author;
|
||||
mod work_play_tracking;
|
||||
|
||||
use shared_logging::init_tracing;
|
||||
use std::{collections::HashSet, env, fs, io, panic, thread};
|
||||
use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||
use tracing::info;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
fn main() -> Result<(), io::Error> {
|
||||
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
|
||||
@@ -121,7 +123,7 @@ async fn run_server() -> Result<(), io::Error> {
|
||||
let bind_address = config.bind_socket_addr();
|
||||
let listener = TcpListener::bind(bind_address).await?;
|
||||
|
||||
let state = AppState::try_restore_auth_store_from_spacetime(config)
|
||||
let state = restore_app_state_for_startup(config)
|
||||
.await
|
||||
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
||||
let router = build_router(state);
|
||||
@@ -131,14 +133,47 @@ async fn run_server() -> Result<(), io::Error> {
|
||||
axum::serve(listener, router).await
|
||||
}
|
||||
|
||||
async fn restore_app_state_for_startup(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
let fallback_config = config.clone();
|
||||
match timeout(
|
||||
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
|
||||
AppState::try_restore_auth_store_from_spacetime(config),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(),
|
||||
"启动恢复认证快照超时,跳过远端恢复并继续启动 api-server"
|
||||
);
|
||||
AppState::new(fallback_config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_local_env_files() {
|
||||
let shell_env_keys = env::vars().map(|(key, _)| key).collect::<HashSet<_>>();
|
||||
let shell_env_keys = protected_env_keys_from(env::vars());
|
||||
|
||||
for path in [".env", ".env.local", ".env.secrets.local"] {
|
||||
load_env_file(path, &shell_env_keys);
|
||||
}
|
||||
}
|
||||
|
||||
fn protected_env_keys_from(vars: impl IntoIterator<Item = (String, String)>) -> HashSet<String> {
|
||||
vars.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_env_file(path: &str, shell_env_keys: &HashSet<String>) {
|
||||
let Ok(raw_text) = fs::read_to_string(path) else {
|
||||
return;
|
||||
@@ -193,7 +228,7 @@ fn is_valid_env_key(key: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_valid_env_key, strip_env_value};
|
||||
use super::{is_valid_env_key, protected_env_keys_from, strip_env_value};
|
||||
|
||||
#[test]
|
||||
fn strip_env_value_removes_wrapping_quotes() {
|
||||
@@ -218,4 +253,20 @@ mod tests {
|
||||
assert!(!is_valid_env_key("1_BAD"));
|
||||
assert!(!is_valid_env_key("BAD-KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_shell_env_does_not_protect_dotenv_value() {
|
||||
let protected = protected_env_keys_from([
|
||||
("ALIYUN_OSS_BUCKET".to_string(), "".to_string()),
|
||||
("ALIYUN_OSS_ENDPOINT".to_string(), " ".to_string()),
|
||||
(
|
||||
"ALIYUN_OSS_ACCESS_KEY_ID".to_string(),
|
||||
"configured".to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
assert!(!protected.contains("ALIYUN_OSS_BUCKET"));
|
||||
assert!(!protected.contains("ALIYUN_OSS_ENDPOINT"));
|
||||
assert!(protected.contains("ALIYUN_OSS_ACCESS_KEY_ID"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error as StdError,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -126,8 +127,6 @@ const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
|
||||
const PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH: &str =
|
||||
"public/ui-previews/puzzle-image-compact-ui-2026-05-08.png";
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
|
||||
|
||||
@@ -3372,7 +3371,7 @@ fn normalize_puzzle_ui_background_prompt(
|
||||
draft.work_description.trim(),
|
||||
target_level.picture_description.trim(),
|
||||
tags.as_str(),
|
||||
"移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰",
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素",
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -3391,7 +3390,7 @@ fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str)
|
||||
format!("当前拼图关卡名称:{level_name}。")
|
||||
};
|
||||
format!(
|
||||
"{title_clause}{prompt}\n严格参考输入图的构图关系:生成一张 9:16 竖屏拼图游戏 UI 背景图,中央必须预留清晰正方形拼图区,拼图区与外部 UI 背景必须有明确边界、描边或容器层次;拼图区之外可以生成与作品名称相关的氛围背景、顶部安全区和底部工具区背景,但不要画文字、按钮文字、数字、拼图碎片、完整拼图图像、教程浮层或水印。"
|
||||
"{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图,只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3533,28 +3532,36 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
})?;
|
||||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let fallback_level_name = target_level.level_name.clone();
|
||||
let generated_level_name =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
|
||||
target_level.level_name = generated_level_name.clone();
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
&draft.summary,
|
||||
);
|
||||
let image_level_name = if target_level.level_name.trim().is_empty() {
|
||||
build_fallback_puzzle_first_level_name(&target_level.picture_description)
|
||||
} else {
|
||||
target_level.level_name.clone()
|
||||
};
|
||||
// 中文注释:首图 prompt 只依赖画面描述,关卡名分支可以和生图分支并行;OSS 临时路径使用已有名或确定性兜底名。
|
||||
let level_name_future =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description);
|
||||
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
let candidates_future = generate_puzzle_image_candidates(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
&target_level.level_name,
|
||||
&image_level_name,
|
||||
&image_prompt,
|
||||
reference_image_src,
|
||||
true,
|
||||
image_model,
|
||||
1,
|
||||
target_level.candidates.len(),
|
||||
)
|
||||
.await?;
|
||||
);
|
||||
let (generated_level_name, candidates_result) =
|
||||
tokio::join!(level_name_future, candidates_future);
|
||||
target_level.level_name = generated_level_name.clone();
|
||||
let candidates = candidates_result?;
|
||||
let selected_candidate_id = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.record.selected)
|
||||
@@ -3580,30 +3587,31 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
if let Some(music) = try_generate_puzzle_background_music(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// 中文注释:音乐和 UI 背景都只依赖最终关卡名与草稿快照,名称确定后即可并行生成。
|
||||
let (music_result, ui_background_result) = tokio::join!(
|
||||
try_generate_puzzle_background_music(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
),
|
||||
try_generate_puzzle_initial_ui_background(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
),
|
||||
);
|
||||
if let Some(music) = music_result {
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
music,
|
||||
);
|
||||
}
|
||||
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Some((ui_prompt, ui_background)) = ui_background_result {
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
@@ -3736,14 +3744,16 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
})?;
|
||||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let fallback_level_name = target_level.level_name.clone();
|
||||
let generated_level_name =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
|
||||
target_level.level_name = generated_level_name.clone();
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
&draft.summary,
|
||||
);
|
||||
let image_level_name = if target_level.level_name.trim().is_empty() {
|
||||
build_fallback_puzzle_first_level_name(&target_level.picture_description)
|
||||
} else {
|
||||
target_level.level_name.clone()
|
||||
};
|
||||
// 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。
|
||||
let candidate_id = format!(
|
||||
"{}-candidate-{}",
|
||||
@@ -3755,44 +3765,60 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()),
|
||||
bytes: uploaded_image.bytes,
|
||||
};
|
||||
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
|
||||
let level_name_future =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description);
|
||||
let image_level_name_future = generate_puzzle_first_level_name_from_image(
|
||||
state,
|
||||
target_level.picture_description.as_str(),
|
||||
&uploaded_downloaded_image,
|
||||
)
|
||||
.await
|
||||
{
|
||||
target_level.level_name = refined_level_name;
|
||||
}
|
||||
);
|
||||
let persist_upload_future = persist_puzzle_generated_asset(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
image_level_name.as_str(),
|
||||
candidate_id.as_str(),
|
||||
"uploaded-direct",
|
||||
uploaded_downloaded_image.clone(),
|
||||
current_utc_micros(),
|
||||
);
|
||||
let (generated_level_name, refined_level_name, persisted_upload_result) = tokio::join!(
|
||||
level_name_future,
|
||||
image_level_name_future,
|
||||
persist_upload_future
|
||||
);
|
||||
target_level.level_name = refined_level_name.unwrap_or(generated_level_name.clone());
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let persisted_upload = persisted_upload_result?;
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
if let Some(music) = try_generate_puzzle_background_music(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// 中文注释:直用上传图时,名称分支和上传图落库完成后,再并行补齐音乐与 UI 背景。
|
||||
let (music_result, ui_background_result) = tokio::join!(
|
||||
try_generate_puzzle_background_music(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
),
|
||||
try_generate_puzzle_initial_ui_background(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
),
|
||||
);
|
||||
if let Some(music) = music_result {
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
music,
|
||||
);
|
||||
}
|
||||
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Some((ui_prompt, ui_background)) = ui_background_result {
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
@@ -3802,17 +3828,6 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
}
|
||||
let levels_json_with_generated_name =
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let persisted_upload = persist_puzzle_generated_asset(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
&target_level.level_name,
|
||||
candidate_id.as_str(),
|
||||
"uploaded-direct",
|
||||
uploaded_downloaded_image,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await?;
|
||||
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: candidate_id.clone(),
|
||||
image_src: persisted_upload.image_src,
|
||||
@@ -4769,15 +4784,14 @@ async fn generate_puzzle_ui_background_image(
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let reference_image = load_puzzle_ui_background_reference_data_url().await?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
|
||||
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、角色手指、模糊边界"),
|
||||
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
|
||||
"9:16",
|
||||
1,
|
||||
&[reference_image],
|
||||
&[],
|
||||
"拼图 UI 背景图生成失败",
|
||||
)
|
||||
.await?;
|
||||
@@ -4803,29 +4817,6 @@ fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt:
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt)
|
||||
}
|
||||
|
||||
async fn load_puzzle_ui_background_reference_data_url() -> Result<String, AppError> {
|
||||
let bytes = tokio::fs::read(PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("读取拼图 UI 背景参考图失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if bytes.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图 UI 背景参考图为空",
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -4914,6 +4905,32 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_request_error(
|
||||
"创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(),
|
||||
);
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
|
||||
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||
Ok(_) => panic!("invalid url should fail request build"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let app_error = map_puzzle_vector_engine_reqwest_error(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
"https://api.vectorengine.ai/v1/images/edits",
|
||||
error,
|
||||
);
|
||||
|
||||
let response = app_error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
|
||||
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||
@@ -5210,14 +5227,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_prompt_keeps_square_boundary_constraint() {
|
||||
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||
let prompt =
|
||||
build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
||||
|
||||
assert!(prompt.contains("9:16"));
|
||||
assert!(prompt.contains("中央必须预留清晰正方形拼图区"));
|
||||
assert!(prompt.contains("明确边界"));
|
||||
assert!(prompt.contains("不要画文字"));
|
||||
assert!(prompt.contains("纯背景图"));
|
||||
assert!(prompt.contains("不得出现拼图槽"));
|
||||
assert!(prompt.contains("默认拼图槽"));
|
||||
assert!(prompt.contains("文字"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5530,6 +5548,8 @@ fn build_puzzle_image_http_client(
|
||||
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
||||
// 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
@@ -5690,9 +5710,11 @@ async fn create_puzzle_vector_engine_image_edit(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败:{error}"
|
||||
))
|
||||
map_puzzle_vector_engine_reqwest_error(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
&request_url,
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
tracing::info!(
|
||||
@@ -6361,19 +6383,107 @@ fn puzzle_mime_to_extension(mime_type: &str) -> &str {
|
||||
}
|
||||
|
||||
fn map_puzzle_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "puzzle-image",
|
||||
"message": message,
|
||||
"timeout": is_timeout,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_puzzle_vector_engine_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message,
|
||||
"timeout": is_timeout,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_puzzle_vector_engine_reqwest_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
error: reqwest::Error,
|
||||
) -> AppError {
|
||||
let message = format!(
|
||||
"{context}:{}",
|
||||
normalize_puzzle_reqwest_error_message(&error)
|
||||
);
|
||||
let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str());
|
||||
let is_connect = error.is_connect();
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
let source = error.source().map(ToString::to_string).unwrap_or_default();
|
||||
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
timeout = is_timeout,
|
||||
connect = is_connect,
|
||||
request = error.is_request(),
|
||||
body = error.is_body(),
|
||||
source = %source,
|
||||
message = %message,
|
||||
"拼图 VectorEngine 请求发送失败"
|
||||
);
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message,
|
||||
"reason": resolve_puzzle_vector_engine_request_failure_reason(&error),
|
||||
"endpoint": request_url,
|
||||
"timeout": is_timeout,
|
||||
"connect": is_connect,
|
||||
"request": error.is_request(),
|
||||
"body": error.is_body(),
|
||||
"source": source,
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String {
|
||||
error
|
||||
.to_string()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn resolve_puzzle_vector_engine_request_failure_reason(error: &reqwest::Error) -> &'static str {
|
||||
if error.is_timeout() {
|
||||
return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
|
||||
}
|
||||
if error.is_connect() {
|
||||
return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置";
|
||||
}
|
||||
if error.is_body() {
|
||||
return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小";
|
||||
}
|
||||
"VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误"
|
||||
}
|
||||
|
||||
fn is_puzzle_request_timeout_message(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("timed out")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("operation timed out")
|
||||
|| lower.contains("deadline has elapsed")
|
||||
}
|
||||
|
||||
fn map_puzzle_vector_engine_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
|
||||
@@ -937,15 +937,41 @@ impl AdminRuntime {
|
||||
}
|
||||
|
||||
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
|
||||
let has_any_oss_field = config.oss_bucket.is_some()
|
||||
|| config.oss_endpoint.is_some()
|
||||
|| config.oss_access_key_id.is_some()
|
||||
|| config.oss_access_key_secret.is_some();
|
||||
let oss_fields = [
|
||||
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()),
|
||||
("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()),
|
||||
(
|
||||
"ALIYUN_OSS_ACCESS_KEY_ID",
|
||||
config.oss_access_key_id.as_deref(),
|
||||
),
|
||||
(
|
||||
"ALIYUN_OSS_ACCESS_KEY_SECRET",
|
||||
config.oss_access_key_secret.as_deref(),
|
||||
),
|
||||
];
|
||||
let has_any_oss_field = oss_fields
|
||||
.iter()
|
||||
.any(|(_, value)| value.is_some_and(|value| !value.trim().is_empty()));
|
||||
|
||||
if !has_any_oss_field {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let missing_fields = oss_fields
|
||||
.iter()
|
||||
.filter_map(|(name, value)| match value {
|
||||
Some(value) if !value.trim().is_empty() => None,
|
||||
_ => Some(*name),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !missing_fields.is_empty() {
|
||||
warn!(
|
||||
missing_fields = %missing_fields.join(","),
|
||||
"OSS 环境变量配置不完整,跳过 OSS 客户端初始化"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let oss_config = OssConfig::new(
|
||||
config.oss_bucket.clone().unwrap_or_default(),
|
||||
config.oss_endpoint.clone().unwrap_or_default(),
|
||||
@@ -1085,6 +1111,17 @@ mod tests {
|
||||
assert!(state.creative_agent_gpt5_client().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_skips_oss_client_when_oss_config_is_partial() {
|
||||
let mut config = AppConfig::default();
|
||||
config.oss_bucket = Some("genarrative-assets".to_string());
|
||||
config.oss_endpoint = Some("oss-cn-hangzhou.aliyuncs.com".to_string());
|
||||
|
||||
let state = AppState::new(config).expect("state should build with partial oss config");
|
||||
|
||||
assert!(state.oss_client().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -37,7 +37,8 @@ const SUNO_TAGS_MAX_CHARS: usize = 160;
|
||||
const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
|
||||
const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5;
|
||||
const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024;
|
||||
const CREATION_AUDIO_POINTS_COST: u64 = 10;
|
||||
const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5;
|
||||
const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct VectorEngineAudioSettings {
|
||||
@@ -260,13 +261,8 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let normalized_prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
|
||||
let task = create_sound_effect_task_response(
|
||||
state,
|
||||
normalized_prompt.clone(),
|
||||
duration,
|
||||
seed,
|
||||
)
|
||||
.await?;
|
||||
let task =
|
||||
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
@@ -284,9 +280,9 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated.audio_src.ok_or_else(|| {
|
||||
vector_engine_bad_gateway("音效生成完成但缺少播放地址")
|
||||
})?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
@@ -309,11 +305,8 @@ pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
model: Option<String>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let normalized_prompt = normalize_limited_text_allow_empty(
|
||||
&prompt,
|
||||
"prompt",
|
||||
SUNO_PROMPT_MAX_CHARS,
|
||||
)?;
|
||||
let normalized_prompt =
|
||||
normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
|
||||
let normalized_title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
|
||||
let task = create_background_music_task_response(
|
||||
state,
|
||||
@@ -340,9 +333,9 @@ pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated.audio_src.ok_or_else(|| {
|
||||
vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址")
|
||||
})?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
@@ -623,12 +616,13 @@ async fn publish_generated_audio_asset(
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?;
|
||||
let billing_asset_kind = target.asset_kind.clone();
|
||||
let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target);
|
||||
let points_cost = resolve_creation_audio_points_cost(slot, &target);
|
||||
let persisted = execute_billable_asset_operation_with_cost(
|
||||
state,
|
||||
owner_user_id,
|
||||
billing_asset_kind.as_str(),
|
||||
billing_asset_id.as_str(),
|
||||
CREATION_AUDIO_POINTS_COST,
|
||||
points_cost,
|
||||
async {
|
||||
let audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?;
|
||||
persist_generated_audio_asset(
|
||||
@@ -673,7 +667,12 @@ async fn wait_for_generated_audio_asset(
|
||||
target.clone(),
|
||||
)
|
||||
.await?;
|
||||
if response.audio_src.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
|
||||
if response
|
||||
.audio_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
latest_status = response.status;
|
||||
@@ -704,6 +703,16 @@ fn build_audio_billing_asset_id(
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_creation_audio_points_cost(
|
||||
slot: AudioAssetSlot,
|
||||
_target: &AudioAssetBindingTarget,
|
||||
) -> u64 {
|
||||
match slot {
|
||||
AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST,
|
||||
AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_audio_task_payload(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
@@ -1442,6 +1451,28 @@ mod tests {
|
||||
assert!(is_failed_task_status("failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_audio_billing_uses_lower_cost_for_background_music() {
|
||||
let target = AudioAssetBindingTarget {
|
||||
entity_kind: "puzzle_work".to_string(),
|
||||
entity_id: "puzzle-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "puzzle_background_music".to_string(),
|
||||
profile_id: Some("puzzle-profile-1".to_string()),
|
||||
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
storage_scope: "puzzle_work".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_prompt_length() {
|
||||
let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1);
|
||||
|
||||
@@ -113,6 +113,12 @@ pub struct Match3DGeneratedBackgroundAssetResponse {
|
||||
pub image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container_image_object_key: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
|
||||
@@ -18,6 +18,21 @@ pub struct PutMatch3DWorkRequest {
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateMatch3DWorkTagsRequest {
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateMatch3DWorkTagsResponse {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PutMatch3DAudioAssetsRequest {
|
||||
@@ -134,6 +149,12 @@ pub struct Match3DGeneratedBackgroundAssetResponse {
|
||||
pub image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container_image_object_key: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
|
||||
@@ -15,12 +15,12 @@ pub use mapper::{
|
||||
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord,
|
||||
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
|
||||
BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CreationEntryConfigRecord,
|
||||
CustomWorldAgentActionExecuteRecord,
|
||||
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
|
||||
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
||||
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput,
|
||||
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput,
|
||||
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
|
||||
CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
|
||||
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
|
||||
CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput,
|
||||
@@ -42,18 +42,17 @@ pub use mapper::{
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
||||
SquareHoleAgentMessageFinalizeRecordInput, SquareHoleAgentMessageRecord,
|
||||
SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput,
|
||||
SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord,
|
||||
@@ -421,28 +420,32 @@ impl SpacetimeClient {
|
||||
let connect_sender = Arc::new(Mutex::new(Some(sender)));
|
||||
let broken_flag = broken.clone();
|
||||
let disconnect_sender = connect_sender.clone();
|
||||
let connection = tokio::task::spawn_blocking(move || {
|
||||
DbConnection::builder()
|
||||
.with_uri(config.server_url)
|
||||
.with_database_name(config.database)
|
||||
.with_token(config.token)
|
||||
.on_connect(move |_, _, _| {
|
||||
send_connect_once(&connect_sender, Ok(()));
|
||||
})
|
||||
.on_disconnect(move |_, error| {
|
||||
broken_flag.store(true, Ordering::SeqCst);
|
||||
let message = error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| "SpacetimeDB 连接已断开".to_string());
|
||||
send_connect_once(
|
||||
&disconnect_sender,
|
||||
Err(SpacetimeClientError::Procedure(message)),
|
||||
);
|
||||
})
|
||||
.build()
|
||||
.map_err(|error| SpacetimeClientError::Build(error.to_string()))
|
||||
})
|
||||
let connection = timeout(
|
||||
self.config.procedure_timeout,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
DbConnection::builder()
|
||||
.with_uri(config.server_url)
|
||||
.with_database_name(config.database)
|
||||
.with_token(config.token)
|
||||
.on_connect(move |_, _, _| {
|
||||
send_connect_once(&connect_sender, Ok(()));
|
||||
})
|
||||
.on_disconnect(move |_, error| {
|
||||
broken_flag.store(true, Ordering::SeqCst);
|
||||
let message = error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| "SpacetimeDB 连接已断开".to_string());
|
||||
send_connect_once(
|
||||
&disconnect_sender,
|
||||
Err(SpacetimeClientError::Procedure(message)),
|
||||
);
|
||||
})
|
||||
.build()
|
||||
.map_err(|error| SpacetimeClientError::Build(error.to_string()))
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| SpacetimeClientError::Timeout)?
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??;
|
||||
|
||||
let runner = connection.run_threaded();
|
||||
|
||||
Reference in New Issue
Block a user