use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_RETRY_BACKOFF_MS, LlmProvider, }; const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json"; const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 #[derive(Clone, Debug)] pub struct AppConfig { pub bind_host: String, pub bind_port: u16, pub log_filter: String, pub admin_username: Option, pub admin_password: Option, pub admin_token_ttl_seconds: u64, pub internal_api_secret: Option, pub jwt_issuer: String, pub jwt_secret: String, pub jwt_access_token_ttl_seconds: u64, pub refresh_cookie_name: String, pub refresh_cookie_path: String, pub refresh_cookie_secure: bool, pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, pub auth_store_path: PathBuf, pub dev_password_entry_auto_register_enabled: bool, pub sms_auth_enabled: bool, pub sms_auth_provider: String, pub sms_endpoint: String, pub sms_access_key_id: Option, pub sms_access_key_secret: Option, pub sms_sign_name: String, pub sms_template_code: String, pub sms_template_param_key: String, pub sms_country_code: String, pub sms_scheme_name: Option, pub sms_code_length: u8, pub sms_code_type: u8, pub sms_valid_time_seconds: u64, pub sms_interval_seconds: u64, pub sms_duplicate_policy: u8, pub sms_case_auth_policy: u8, pub sms_return_verify_code: bool, pub sms_mock_verify_code: String, pub wechat_auth_enabled: bool, pub wechat_auth_provider: String, pub wechat_app_id: Option, pub wechat_app_secret: Option, pub wechat_callback_path: String, pub wechat_redirect_path: String, pub wechat_authorize_endpoint: String, pub wechat_access_token_endpoint: String, pub wechat_user_info_endpoint: String, pub wechat_state_ttl_minutes: u32, pub wechat_mock_user_id: String, pub wechat_mock_union_id: Option, pub wechat_mock_display_name: String, pub wechat_mock_avatar_url: Option, pub oss_bucket: Option, pub oss_endpoint: Option, pub oss_access_key_id: Option, pub oss_access_key_secret: Option, pub oss_read_expire_seconds: u64, pub oss_post_expire_seconds: u64, pub oss_post_max_size_bytes: u64, pub oss_success_action_status: u16, pub spacetime_server_url: String, pub spacetime_database: String, pub spacetime_token: Option, pub spacetime_pool_size: u32, pub spacetime_procedure_timeout: Duration, pub llm_provider: LlmProvider, pub llm_base_url: String, pub llm_api_key: Option, pub llm_model: String, pub llm_request_timeout_ms: u64, pub llm_max_retries: u32, pub llm_retry_backoff_ms: u64, pub rpg_llm_web_search_enabled: bool, pub creation_agent_llm_web_search_enabled: bool, pub dashscope_base_url: String, pub dashscope_api_key: Option, pub dashscope_scene_image_model: String, 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, pub apimart_image_request_timeout_ms: u64, pub draft_asset_generation_max_concurrent_requests: usize, pub ark_character_video_base_url: String, pub ark_character_video_api_key: Option, pub ark_character_video_request_timeout_ms: u64, pub ark_character_video_model: String, pub character_animation_ffmpeg_path: String, pub character_animation_ffprobe_path: String, pub character_animation_frame_extract_timeout_ms: u64, pub slow_request_threshold_ms: u64, } impl Default for AppConfig { fn default() -> Self { Self { bind_host: "127.0.0.1".to_string(), bind_port: 3000, log_filter: "info,tower_http=info".to_string(), admin_username: None, admin_password: None, admin_token_ttl_seconds: 4 * 60 * 60, internal_api_secret: Some(DEFAULT_INTERNAL_API_SECRET.to_string()), jwt_issuer: "https://auth.genarrative.local".to_string(), jwt_secret: "genarrative-dev-secret".to_string(), jwt_access_token_ttl_seconds: 2 * 60 * 60, refresh_cookie_name: "genarrative_refresh_session".to_string(), refresh_cookie_path: "/api/auth".to_string(), refresh_cookie_secure: false, refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), sms_access_key_id: None, sms_access_key_secret: None, sms_sign_name: "速通互联验证码".to_string(), sms_template_code: "100001".to_string(), sms_template_param_key: "code".to_string(), sms_country_code: "86".to_string(), sms_scheme_name: None, sms_code_length: 6, sms_code_type: 1, sms_valid_time_seconds: 300, sms_interval_seconds: 60, sms_duplicate_policy: 1, sms_case_auth_policy: 1, sms_return_verify_code: false, sms_mock_verify_code: "123456".to_string(), wechat_auth_enabled: false, wechat_auth_provider: "mock".to_string(), wechat_app_id: None, wechat_app_secret: None, wechat_callback_path: "/api/auth/wechat/callback".to_string(), wechat_redirect_path: "/".to_string(), wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(), wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token" .to_string(), wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(), wechat_state_ttl_minutes: 15, wechat_mock_user_id: "wx-mock-user".to_string(), wechat_mock_union_id: Some("wx-mock-union".to_string()), wechat_mock_display_name: "微信旅人".to_string(), wechat_mock_avatar_url: None, oss_bucket: None, oss_endpoint: None, oss_access_key_id: None, oss_access_key_secret: None, oss_read_expire_seconds: 10 * 60, oss_post_expire_seconds: 10 * 60, oss_post_max_size_bytes: 20 * 1024 * 1024, oss_success_action_status: 200, spacetime_server_url: "http://127.0.0.1:3000".to_string(), spacetime_database: "genarrative-dev".to_string(), spacetime_token: None, spacetime_pool_size: 4, spacetime_procedure_timeout: Duration::from_secs(30), llm_provider: LlmProvider::Ark, llm_base_url: String::new(), llm_api_key: None, llm_model: String::new(), llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, rpg_llm_web_search_enabled: true, creation_agent_llm_web_search_enabled: true, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), dashscope_api_key: None, dashscope_scene_image_model: String::new(), 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, draft_asset_generation_max_concurrent_requests: 4, ark_character_video_base_url: String::new(), ark_character_video_api_key: None, ark_character_video_request_timeout_ms: 420_000, ark_character_video_model: String::new(), character_animation_ffmpeg_path: "ffmpeg".to_string(), character_animation_ffprobe_path: "ffprobe".to_string(), character_animation_frame_extract_timeout_ms: 120_000, slow_request_threshold_ms: 1_000, } } } impl AppConfig { pub fn from_env() -> Self { let mut config = Self::default(); if let Some(local_spacetime_database) = read_local_spacetime_database() { config.spacetime_database = local_spacetime_database; } if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") && !bind_host.trim().is_empty() { config.bind_host = bind_host; } if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") && let Ok(parsed_port) = bind_port.parse::() { config.bind_port = parsed_port; } if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") && !log_filter.trim().is_empty() { config.log_filter = log_filter; } config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]); config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]); if let Some(admin_token_ttl_seconds) = read_first_duration_seconds_env(&["GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS"]) { config.admin_token_ttl_seconds = admin_token_ttl_seconds; } config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]); if let Some(jwt_issuer) = read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"]) { config.jwt_issuer = jwt_issuer; } if let Some(jwt_secret) = read_first_non_empty_env(&["GENARRATIVE_JWT_SECRET", "JWT_SECRET"]) { config.jwt_secret = jwt_secret; } if let Some(ttl_seconds) = read_first_duration_seconds_env(&[ "GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS", "JWT_EXPIRES_IN", ]) { config.jwt_access_token_ttl_seconds = ttl_seconds; } if let Some(refresh_cookie_name) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_NAME"]) { config.refresh_cookie_name = refresh_cookie_name; } if let Some(refresh_cookie_path) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_PATH"]) { config.refresh_cookie_path = refresh_cookie_path; } if let Some(refresh_cookie_same_site) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_SAME_SITE"]) { config.refresh_cookie_same_site = refresh_cookie_same_site; } if let Some(refresh_cookie_secure) = read_first_bool_env(&["AUTH_REFRESH_COOKIE_SECURE"]) { config.refresh_cookie_secure = refresh_cookie_secure; } if let Some(refresh_session_ttl_days) = read_first_positive_u32_env(&["AUTH_REFRESH_SESSION_TTL_DAYS"]) { config.refresh_session_ttl_days = refresh_session_ttl_days; } if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) { config.auth_store_path = PathBuf::from(auth_store_path); } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"]) { config.dev_password_entry_auto_register_enabled = enabled; } if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { config.sms_auth_enabled = sms_auth_enabled; } if let Some(sms_auth_provider) = read_first_non_empty_env(&["SMS_AUTH_PROVIDER"]) { config.sms_auth_provider = sms_auth_provider; } if let Some(sms_endpoint) = read_first_non_empty_env(&["ALIYUN_SMS_ENDPOINT"]) { config.sms_endpoint = sms_endpoint; } config.sms_access_key_id = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_ID"]); config.sms_access_key_secret = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_SECRET"]); if let Some(sms_sign_name) = read_first_non_empty_env(&["ALIYUN_SMS_SIGN_NAME"]) { config.sms_sign_name = sms_sign_name; } if let Some(sms_template_code) = read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_CODE"]) { config.sms_template_code = sms_template_code; } if let Some(sms_template_param_key) = read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_PARAM_KEY"]) { config.sms_template_param_key = sms_template_param_key; } if let Some(sms_country_code) = read_first_non_empty_env(&["ALIYUN_SMS_COUNTRY_CODE"]) { config.sms_country_code = sms_country_code; } config.sms_scheme_name = read_first_non_empty_env(&["ALIYUN_SMS_SCHEME_NAME"]); if let Some(sms_code_length) = read_first_u8_env(&["ALIYUN_SMS_CODE_LENGTH"]) { config.sms_code_length = sms_code_length; } if let Some(sms_code_type) = read_first_u8_env(&["ALIYUN_SMS_CODE_TYPE"]) { config.sms_code_type = sms_code_type; } if let Some(sms_valid_time_seconds) = read_first_duration_seconds_env(&["ALIYUN_SMS_VALID_TIME_SECONDS"]) { config.sms_valid_time_seconds = sms_valid_time_seconds; } if let Some(sms_interval_seconds) = read_first_duration_seconds_env(&["ALIYUN_SMS_INTERVAL_SECONDS"]) { config.sms_interval_seconds = sms_interval_seconds; } if let Some(sms_duplicate_policy) = read_first_u8_env(&["ALIYUN_SMS_DUPLICATE_POLICY"]) { config.sms_duplicate_policy = sms_duplicate_policy; } if let Some(sms_case_auth_policy) = read_first_u8_env(&["ALIYUN_SMS_CASE_AUTH_POLICY"]) { config.sms_case_auth_policy = sms_case_auth_policy; } if let Some(sms_return_verify_code) = read_first_bool_env(&["ALIYUN_SMS_RETURN_VERIFY_CODE"]) { config.sms_return_verify_code = sms_return_verify_code; } if let Some(sms_mock_verify_code) = read_first_non_empty_env(&["SMS_AUTH_MOCK_VERIFY_CODE"]) { config.sms_mock_verify_code = sms_mock_verify_code; } if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) { config.wechat_auth_enabled = wechat_auth_enabled; } if let Some(wechat_auth_provider) = read_first_non_empty_env(&["WECHAT_AUTH_PROVIDER"]) { config.wechat_auth_provider = wechat_auth_provider; } config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]); config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]); if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) { config.wechat_callback_path = wechat_callback_path; } if let Some(wechat_redirect_path) = read_first_non_empty_env(&["WECHAT_REDIRECT_PATH"]) { config.wechat_redirect_path = wechat_redirect_path; } if let Some(wechat_authorize_endpoint) = read_first_non_empty_env(&["WECHAT_AUTHORIZE_ENDPOINT"]) { config.wechat_authorize_endpoint = wechat_authorize_endpoint; } if let Some(wechat_access_token_endpoint) = read_first_non_empty_env(&["WECHAT_ACCESS_TOKEN_ENDPOINT"]) { config.wechat_access_token_endpoint = wechat_access_token_endpoint; } if let Some(wechat_user_info_endpoint) = read_first_non_empty_env(&["WECHAT_USER_INFO_ENDPOINT"]) { config.wechat_user_info_endpoint = wechat_user_info_endpoint; } if let Some(wechat_state_ttl_minutes) = read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"]) { config.wechat_state_ttl_minutes = wechat_state_ttl_minutes; } if let Some(wechat_mock_user_id) = read_first_non_empty_env(&["WECHAT_MOCK_USER_ID"]) { config.wechat_mock_user_id = wechat_mock_user_id; } config.wechat_mock_union_id = read_first_non_empty_env(&["WECHAT_MOCK_UNION_ID"]); if let Some(wechat_mock_display_name) = read_first_non_empty_env(&["WECHAT_MOCK_DISPLAY_NAME"]) { config.wechat_mock_display_name = wechat_mock_display_name; } config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]); config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]); config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]); config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]); config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]); if let Some(oss_read_expire_seconds) = read_first_duration_seconds_env(&["ALIYUN_OSS_READ_EXPIRE_SECONDS"]) { config.oss_read_expire_seconds = oss_read_expire_seconds; } if let Some(oss_post_expire_seconds) = read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"]) { config.oss_post_expire_seconds = oss_post_expire_seconds; } if let Some(oss_post_max_size_bytes) = read_first_positive_u64_env(&["ALIYUN_OSS_POST_MAX_SIZE_BYTES"]) { config.oss_post_max_size_bytes = oss_post_max_size_bytes; } if let Some(oss_success_action_status) = read_first_positive_u16_env(&["ALIYUN_OSS_SUCCESS_ACTION_STATUS"]) { config.oss_success_action_status = oss_success_action_status; } if let Some(spacetime_server_url) = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"]) { config.spacetime_server_url = spacetime_server_url; } if let Some(spacetime_database) = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"]) { config.spacetime_database = spacetime_database; } config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]); if let Some(spacetime_pool_size) = read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"]) { config.spacetime_pool_size = spacetime_pool_size; } if let Some(spacetime_procedure_timeout_seconds) = read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"]) { config.spacetime_procedure_timeout = Duration::from_secs(spacetime_procedure_timeout_seconds); } if let Some(llm_provider) = read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"]) { config.llm_provider = llm_provider; } if let Some(llm_base_url) = read_first_non_empty_env(&["GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL"]) { config.llm_base_url = llm_base_url; } else if config.llm_provider == LlmProvider::Ark { config.llm_base_url = DEFAULT_ARK_BASE_URL.to_string(); } config.llm_api_key = read_first_non_empty_env(&["GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY"]); if let Some(llm_model) = read_first_non_empty_env(&["GENARRATIVE_LLM_MODEL", "LLM_MODEL", "VITE_LLM_MODEL"]) { config.llm_model = llm_model; } if let Some(llm_request_timeout_ms) = read_first_positive_u64_env(&[ "GENARRATIVE_LLM_REQUEST_TIMEOUT_MS", "LLM_REQUEST_TIMEOUT_MS", ]) { config.llm_request_timeout_ms = llm_request_timeout_ms; } if let Some(llm_max_retries) = read_first_u32_env(&["GENARRATIVE_LLM_MAX_RETRIES", "LLM_MAX_RETRIES"]) { config.llm_max_retries = llm_max_retries; } if let Some(llm_retry_backoff_ms) = read_first_u64_env(&["GENARRATIVE_LLM_RETRY_BACKOFF_MS", "LLM_RETRY_BACKOFF_MS"]) { config.llm_retry_backoff_ms = llm_retry_backoff_ms; } if let Some(rpg_llm_web_search_enabled) = read_first_bool_env(&[ "GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "RPG_LLM_WEB_SEARCH_ENABLED", ]) { config.rpg_llm_web_search_enabled = rpg_llm_web_search_enabled; } if let Some(creation_agent_llm_web_search_enabled) = read_first_bool_env(&[ "GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", ]) { config.creation_agent_llm_web_search_enabled = creation_agent_llm_web_search_enabled; } if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) { config.dashscope_base_url = dashscope_base_url; } config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]); if let Some(dashscope_scene_image_model) = read_first_non_empty_env(&["DASHSCOPE_SCENE_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"]) { config.dashscope_scene_image_model = dashscope_scene_image_model; } if let Some(dashscope_reference_image_model) = read_first_non_empty_env(&[ "DASHSCOPE_REFERENCE_IMAGE_MODEL", "DASHSCOPE_IMAGE_EDIT_MODEL", ]) { config.dashscope_reference_image_model = dashscope_reference_image_model; } if let Some(dashscope_cover_image_model) = read_first_non_empty_env(&["DASHSCOPE_COVER_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"]) { config.dashscope_cover_image_model = dashscope_cover_image_model; } if let Some(dashscope_image_request_timeout_ms) = read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"]) { 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; } if let Some(max_concurrent_requests) = read_first_usize_env(&[ "GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", "DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", ]) { config.draft_asset_generation_max_concurrent_requests = max_concurrent_requests; } if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[ "ARK_CHARACTER_VIDEO_BASE_URL", "ARK_BASE_URL", "GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL", ]) { config.ark_character_video_base_url = ark_character_video_base_url; } else if config.llm_provider == LlmProvider::Ark { config.ark_character_video_base_url = DEFAULT_ARK_BASE_URL.to_string(); } config.ark_character_video_api_key = read_first_non_empty_env(&[ "ARK_CHARACTER_VIDEO_API_KEY", "ARK_API_KEY", "GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", ]); if let Some(ark_character_video_request_timeout_ms) = read_first_positive_u64_env(&[ "ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS", "DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS", ]) { config.ark_character_video_request_timeout_ms = ark_character_video_request_timeout_ms; } if let Some(ark_character_video_model) = read_first_non_empty_env(&[ "ARK_CHARACTER_VIDEO_MODEL", "DASHSCOPE_CHARACTER_VIDEO_MODEL", ]) { config.ark_character_video_model = ark_character_video_model; } if let Some(character_animation_ffmpeg_path) = read_first_non_empty_env(&["CHARACTER_ANIMATION_FFMPEG_PATH"]) { config.character_animation_ffmpeg_path = character_animation_ffmpeg_path; } if let Some(character_animation_ffprobe_path) = read_first_non_empty_env(&["CHARACTER_ANIMATION_FFPROBE_PATH"]) { config.character_animation_ffprobe_path = character_animation_ffprobe_path; } if let Some(character_animation_frame_extract_timeout_ms) = read_first_positive_u64_env(&["CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS"]) { config.character_animation_frame_extract_timeout_ms = character_animation_frame_extract_timeout_ms; } if let Some(slow_request_threshold_ms) = read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"]) { config.slow_request_threshold_ms = slow_request_threshold_ms; } config } pub fn bind_socket_addr(&self) -> SocketAddr { let address = format!("{}:{}", self.bind_host, self.bind_port); address .parse() .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000))) } } fn read_first_non_empty_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key).ok().and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { return None; } Some(value) }) }) } fn read_local_spacetime_database() -> Option { let config_path = find_upward_file(SPACETIME_LOCAL_CONFIG_FILE)?; let raw_text = fs::read_to_string(config_path).ok()?; let parsed = serde_json::from_str::(&raw_text).ok()?; parsed .get("database") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn find_upward_file(file_name: &str) -> Option { let mut current_dir = env::current_dir().ok()?; loop { let candidate = current_dir.join(file_name); if candidate.is_file() { return Some(candidate); } if !current_dir.pop() { return None; } } } fn read_first_duration_seconds_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_duration_seconds(&value)) }) } fn read_first_bool_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value))) } fn read_first_llm_provider_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_llm_provider(&value)) }) } fn read_first_positive_u32_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_u32(&value)) }) } fn read_first_positive_u64_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_u64(&value)) }) } fn read_first_u32_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_u32(&value))) } fn read_first_u64_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value))) } fn read_first_usize_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_usize(&value)) }) } fn read_first_u8_env(keys: &[&str]) -> Option { keys.iter() .find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value))) } fn read_first_positive_u16_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) .ok() .and_then(|value| parse_positive_u16(&value)) }) } fn parse_duration_seconds(raw: &str) -> Option { let raw = raw.trim(); if raw.is_empty() { return None; } if let Ok(seconds) = raw.parse::() { return Some(seconds); } let (number, unit) = raw.split_at(raw.len().checked_sub(1)?); let unit = unit.to_ascii_lowercase(); let number = number.trim().parse::().ok()?; let multiplier = match unit.as_str() { "s" => 1, "m" => 60, "h" => 60 * 60, "d" => 24 * 60 * 60, _ => return None, }; number.checked_mul(multiplier) } fn parse_bool(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, } } fn parse_llm_provider(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "ark" => Some(LlmProvider::Ark), "dash_scope" | "dashscope" => Some(LlmProvider::DashScope), "openai_compatible" | "openai-compatible" | "openai" => Some(LlmProvider::OpenAiCompatible), _ => None, } } fn parse_positive_u32(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } fn parse_u32(raw: &str) -> Option { raw.trim().parse::().ok() } fn parse_positive_u64(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } fn parse_u64(raw: &str) -> Option { raw.trim().parse::().ok() } fn parse_positive_usize(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } fn parse_u8(raw: &str) -> Option { raw.trim().parse::().ok() } fn parse_positive_u16(raw: &str) -> Option { let value = raw.trim().parse::().ok()?; if value == 0 { return None; } Some(value) } #[cfg(test)] mod tests { use super::{AppConfig, LlmProvider}; use std::sync::{Mutex, OnceLock}; static ENV_LOCK: OnceLock> = OnceLock::new(); #[test] fn default_keeps_non_public_model_and_base_url_empty() { let config = AppConfig::default(); assert!(config.llm_model.is_empty()); assert!(config.llm_base_url.is_empty()); assert!(config.apimart_base_url.is_empty()); assert!(config.ark_character_video_base_url.is_empty()); assert!(config.ark_character_video_model.is_empty()); assert!(config.dashscope_scene_image_model.is_empty()); assert!(config.dashscope_reference_image_model.is_empty()); assert!(config.dashscope_cover_image_model.is_empty()); assert_eq!( config.dashscope_base_url, "https://dashscope.aliyuncs.com/api/v1" ); assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com"); assert_eq!( config.wechat_authorize_endpoint, "https://open.weixin.qq.com/connect/qrconnect" ); } #[test] fn from_env_reads_non_public_models_and_urls() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_LLM_PROVIDER"); std::env::remove_var("GENARRATIVE_LLM_BASE_URL"); std::env::remove_var("GENARRATIVE_LLM_MODEL"); std::env::remove_var("APIMART_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL"); std::env::remove_var("ARK_CHARACTER_VIDEO_BASE_URL"); std::env::remove_var("ARK_CHARACTER_VIDEO_MODEL"); std::env::set_var("GENARRATIVE_LLM_PROVIDER", "openai-compatible"); std::env::set_var( "GENARRATIVE_LLM_BASE_URL", "https://llm.internal.example/v1", ); std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model"); std::env::set_var("APIMART_BASE_URL", "https://image.internal.example/v1"); std::env::set_var("DASHSCOPE_SCENE_IMAGE_MODEL", "scene-model"); std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model"); std::env::set_var("DASHSCOPE_COVER_IMAGE_MODEL", "cover-model"); std::env::set_var( "ARK_CHARACTER_VIDEO_BASE_URL", "https://video.internal.example/v1", ); std::env::set_var("ARK_CHARACTER_VIDEO_MODEL", "video-model"); } let config = AppConfig::from_env(); 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://image.internal.example/v1"); assert_eq!(config.dashscope_scene_image_model, "scene-model"); assert_eq!(config.dashscope_reference_image_model, "reference-model"); assert_eq!(config.dashscope_cover_image_model, "cover-model"); assert_eq!( config.ark_character_video_base_url, "https://video.internal.example/v1" ); assert_eq!(config.ark_character_video_model, "video-model"); unsafe { std::env::remove_var("GENARRATIVE_LLM_PROVIDER"); std::env::remove_var("GENARRATIVE_LLM_BASE_URL"); std::env::remove_var("GENARRATIVE_LLM_MODEL"); std::env::remove_var("APIMART_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL"); std::env::remove_var("ARK_CHARACTER_VIDEO_BASE_URL"); std::env::remove_var("ARK_CHARACTER_VIDEO_MODEL"); } } #[test] fn from_env_reads_spacetime_pool_size() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); std::env::set_var("GENARRATIVE_SPACETIME_POOL_SIZE", "7"); } let config = AppConfig::from_env(); assert_eq!(config.spacetime_pool_size, 7); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); } } #[test] fn from_env_ignores_zero_spacetime_pool_size() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); std::env::set_var("GENARRATIVE_SPACETIME_POOL_SIZE", "0"); } let config = AppConfig::from_env(); assert_eq!(config.spacetime_pool_size, 4); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE"); } } #[test] fn from_env_reads_spacetime_procedure_timeout() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); std::env::set_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS", "45"); } let config = AppConfig::from_env(); assert_eq!(config.spacetime_procedure_timeout.as_secs(), 45); unsafe { std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); } } #[test] fn from_env_reads_rpg_llm_web_search_switch() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false"); } let config = AppConfig::from_env(); assert!(!config.rpg_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); } } #[test] fn from_env_reads_creation_agent_llm_web_search_switch() { let _guard = ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env lock should not poison"); unsafe { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false"); } let config = AppConfig::from_env(); assert!(!config.creation_agent_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); } } }