This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);

View File

@@ -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>,

View File

@@ -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>,

View File

@@ -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();