//! SaaS 服务器配置 use serde::{Deserialize, Serialize}; use std::path::PathBuf; use secrecy::SecretString; /// 当前期望的配置版本 const CURRENT_CONFIG_VERSION: u32 = 1; /// SaaS 服务器完整配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaaSConfig { /// Configuration schema version #[serde(default = "default_config_version")] pub config_version: u32, pub server: ServerConfig, pub database: DatabaseConfig, pub auth: AuthConfig, pub relay: RelayConfig, #[serde(default)] pub rate_limit: RateLimitConfig, #[serde(default)] pub scheduler: SchedulerConfig, #[serde(default)] pub payment: PaymentConfig, } /// Scheduler 定时任务配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulerConfig { #[serde(default)] pub jobs: Vec, } /// 单个定时任务配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobConfig { pub name: String, /// 间隔时间,支持 "5m", "1h", "24h", "30s" 格式 pub interval: String, /// 对应的 Worker 名称 pub task: String, /// 传递给 Worker 的参数(JSON 格式) #[serde(default)] pub args: Option, /// 是否在启动时立即执行 #[serde(default)] pub run_on_start: bool, } impl Default for SchedulerConfig { fn default() -> Self { Self { jobs: Vec::new() } } } /// 服务器配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { #[serde(default = "default_host")] pub host: String, #[serde(default = "default_port")] pub port: u16, #[serde(default)] pub cors_origins: Vec, /// 可信反向代理 IP 列表。仅对来自这些 IP 的请求解析 X-Forwarded-For 头。 /// 生产环境应为 Nginx/Caddy 的实际 IP,如 ["127.0.0.1", "10.0.0.1"] #[serde(default)] pub trusted_proxies: Vec, } /// 数据库配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DatabaseConfig { #[serde(default = "default_db_url")] pub url: String, /// 连接池最大连接数 #[serde(default = "default_max_connections")] pub max_connections: u32, /// 连接池最小连接数 #[serde(default = "default_min_connections")] pub min_connections: u32, /// 获取连接超时 (秒) #[serde(default = "default_acquire_timeout")] pub acquire_timeout_secs: u64, /// 空闲连接回收超时 (秒) #[serde(default = "default_idle_timeout")] pub idle_timeout_secs: u64, /// 连接最大生命周期 (秒) #[serde(default = "default_max_lifetime")] pub max_lifetime_secs: u64, /// Worker 并发上限 (Semaphore permits) #[serde(default = "default_worker_concurrency")] pub worker_concurrency: usize, /// 限流事件批量 flush 间隔 (秒) #[serde(default = "default_rate_limit_batch_interval")] pub rate_limit_batch_interval_secs: u64, /// 限流事件批量 flush 最大条目数 #[serde(default = "default_rate_limit_batch_max")] pub rate_limit_batch_max_size: usize, } /// 认证配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthConfig { #[serde(default = "default_jwt_hours")] pub jwt_expiration_hours: i64, #[serde(default = "default_totp_issuer")] pub totp_issuer: String, /// Refresh Token 有效期 (小时), 默认 168 小时 = 7 天 #[serde(default = "default_refresh_hours")] pub refresh_token_hours: i64, } /// 中转服务配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayConfig { #[serde(default = "default_max_queue")] pub max_queue_size: usize, /// 每个 Provider 最大并发请求数 (预留,当前由 max_queue_size 控制) #[serde(default = "default_max_concurrent")] pub max_concurrent_per_provider: usize, /// 批量窗口间隔 (预留,用于请求合并优化) #[serde(default = "default_batch_window")] pub batch_window_ms: u64, #[serde(default = "default_retry_delay")] pub retry_delay_ms: u64, #[serde(default = "default_max_attempts")] pub max_attempts: u32, } fn default_config_version() -> u32 { 1 } fn default_host() -> String { "0.0.0.0".into() } fn default_port() -> u16 { 8080 } fn default_db_url() -> String { "postgres://localhost:5432/zclaw".into() } fn default_jwt_hours() -> i64 { 24 } fn default_totp_issuer() -> String { "ZCLAW SaaS".into() } fn default_refresh_hours() -> i64 { 168 } fn default_max_connections() -> u32 { 100 } fn default_min_connections() -> u32 { 5 } fn default_acquire_timeout() -> u64 { 8 } fn default_idle_timeout() -> u64 { 180 } fn default_max_lifetime() -> u64 { 900 } fn default_worker_concurrency() -> usize { 20 } fn default_rate_limit_batch_interval() -> u64 { 5 } fn default_rate_limit_batch_max() -> usize { 500 } fn default_max_queue() -> usize { 1000 } fn default_max_concurrent() -> usize { 5 } fn default_batch_window() -> u64 { 50 } fn default_retry_delay() -> u64 { 1000 } fn default_max_attempts() -> u32 { 3 } /// 速率限制配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RateLimitConfig { /// 每分钟最大请求数 (滑动窗口) #[serde(default = "default_rpm")] pub requests_per_minute: u32, /// 突发允许的额外请求数 #[serde(default = "default_burst")] pub burst: u32, } fn default_rpm() -> u32 { 60 } fn default_burst() -> u32 { 10 } impl Default for RateLimitConfig { fn default() -> Self { Self { requests_per_minute: default_rpm(), burst: default_burst(), } } } /// 支付配置 /// /// 支付宝和微信支付商户配置。所有字段通过环境变量传入(不写入 TOML 文件)。 /// 字段缺失时自动降级为 mock 支付模式。 /// /// 注意:自定义 Debug 和 Serialize 实现会隐藏敏感字段。 #[derive(Clone, Serialize, Deserialize)] pub struct PaymentConfig { /// 支付宝 App ID(来自支付宝开放平台) #[serde(default)] pub alipay_app_id: Option, /// 支付宝商户私钥(RSA2)— 敏感,不序列化 #[serde(default, skip_serializing)] pub alipay_private_key: Option, /// 支付宝公钥证书路径(用于验签) #[serde(default)] pub alipay_cert_path: Option, /// 支付宝回调通知 URL #[serde(default)] pub alipay_notify_url: Option, /// 支付宝公钥(用于回调验签,PEM 格式)— 敏感,不序列化 #[serde(default, skip_serializing)] pub alipay_public_key: Option, /// 微信支付商户号 #[serde(default)] pub wechat_mch_id: Option, /// 微信支付商户证书序列号 #[serde(default)] pub wechat_serial_no: Option, /// 微信支付商户私钥路径 #[serde(default)] pub wechat_private_key_path: Option, /// 微信支付 API v3 密钥 — 敏感,不序列化 #[serde(default, skip_serializing)] pub wechat_api_v3_key: Option, /// 微信支付回调通知 URL #[serde(default)] pub wechat_notify_url: Option, /// 微信支付 App ID(公众号/小程序) #[serde(default)] pub wechat_app_id: Option, } impl std::fmt::Debug for PaymentConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PaymentConfig") .field("alipay_app_id", &self.alipay_app_id) .field("alipay_private_key", &self.alipay_private_key.as_ref().map(|_| "***REDACTED***")) .field("alipay_cert_path", &self.alipay_cert_path) .field("alipay_notify_url", &self.alipay_notify_url) .field("alipay_public_key", &self.alipay_public_key.as_ref().map(|_| "***REDACTED***")) .field("wechat_mch_id", &self.wechat_mch_id) .field("wechat_serial_no", &self.wechat_serial_no) .field("wechat_private_key_path", &self.wechat_private_key_path) .field("wechat_api_v3_key", &self.wechat_api_v3_key.as_ref().map(|_| "***REDACTED***")) .field("wechat_notify_url", &self.wechat_notify_url) .field("wechat_app_id", &self.wechat_app_id) .finish() } } impl Default for PaymentConfig { fn default() -> Self { // 优先从环境变量读取,未配置则降级 mock Self { alipay_app_id: std::env::var("ALIPAY_APP_ID").ok(), alipay_private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok(), alipay_cert_path: std::env::var("ALIPAY_CERT_PATH").ok(), alipay_notify_url: std::env::var("ALIPAY_NOTIFY_URL").ok(), alipay_public_key: std::env::var("ALIPAY_PUBLIC_KEY").ok(), wechat_mch_id: std::env::var("WECHAT_PAY_MCH_ID").ok(), wechat_serial_no: std::env::var("WECHAT_PAY_SERIAL_NO").ok(), wechat_private_key_path: std::env::var("WECHAT_PAY_PRIVATE_KEY_PATH").ok(), wechat_api_v3_key: std::env::var("WECHAT_PAY_API_V3_KEY").ok(), wechat_notify_url: std::env::var("WECHAT_PAY_NOTIFY_URL").ok(), wechat_app_id: std::env::var("WECHAT_PAY_APP_ID").ok(), } } } impl PaymentConfig { /// 支付宝是否已完整配置 pub fn alipay_configured(&self) -> bool { self.alipay_app_id.is_some() && self.alipay_private_key.is_some() && self.alipay_notify_url.is_some() } /// 微信支付是否已完整配置 pub fn wechat_configured(&self) -> bool { self.wechat_mch_id.is_some() && self.wechat_serial_no.is_some() && self.wechat_private_key_path.is_some() && self.wechat_notify_url.is_some() } } impl Default for SaaSConfig { fn default() -> Self { Self { config_version: 1, server: ServerConfig::default(), database: DatabaseConfig::default(), auth: AuthConfig::default(), relay: RelayConfig::default(), rate_limit: RateLimitConfig::default(), scheduler: SchedulerConfig::default(), payment: PaymentConfig::default(), } } } impl Default for ServerConfig { fn default() -> Self { Self { host: default_host(), port: default_port(), cors_origins: Vec::new(), trusted_proxies: vec![], } } } impl Default for DatabaseConfig { fn default() -> Self { Self { url: default_db_url(), max_connections: default_max_connections(), min_connections: default_min_connections(), acquire_timeout_secs: default_acquire_timeout(), idle_timeout_secs: default_idle_timeout(), max_lifetime_secs: default_max_lifetime(), worker_concurrency: default_worker_concurrency(), rate_limit_batch_interval_secs: default_rate_limit_batch_interval(), rate_limit_batch_max_size: default_rate_limit_batch_max(), } } } impl Default for AuthConfig { fn default() -> Self { Self { jwt_expiration_hours: default_jwt_hours(), totp_issuer: default_totp_issuer(), refresh_token_hours: default_refresh_hours(), } } } impl Default for RelayConfig { fn default() -> Self { Self { max_queue_size: default_max_queue(), max_concurrent_per_provider: default_max_concurrent(), batch_window_ms: default_batch_window(), retry_delay_ms: default_retry_delay(), max_attempts: default_max_attempts(), } } } impl SaaSConfig { /// 加载配置文件,优先级: ZCLAW_SAAS_CONFIG > ZCLAW_ENV > ./saas-config.toml /// /// ZCLAW_ENV 环境选择: /// development → config/saas-development.toml /// production → config/saas-production.toml /// test → config/saas-test.toml /// /// ZCLAW_SAAS_CONFIG 指定精确路径(最高优先级) pub fn load() -> anyhow::Result { let config_path = if let Ok(path) = std::env::var("ZCLAW_SAAS_CONFIG") { PathBuf::from(path) } else if let Ok(env) = std::env::var("ZCLAW_ENV") { let filename = format!("config/saas-{}.toml", env); let path = PathBuf::from(&filename); if !path.exists() { anyhow::bail!( "ZCLAW_ENV={} 指定的配置文件 {} 不存在", env, filename ); } tracing::info!("Loading config for environment: {}", env); path } else { PathBuf::from("saas-config.toml") }; let mut config = if config_path.exists() { let content = std::fs::read_to_string(&config_path)?; let interpolated = interpolate_env_vars(&content); toml::from_str(&interpolated)? } else { tracing::warn!("Config file {:?} not found, using defaults", config_path); SaaSConfig::default() }; // 配置版本兼容性检查 if config.config_version < CURRENT_CONFIG_VERSION { tracing::warn!( "[Config] config_version ({}) is below current version ({}). \ Some features may not work correctly. \ Please update your saas-config.toml. \ See docs for migration guide.", config.config_version, CURRENT_CONFIG_VERSION ); } else if config.config_version > CURRENT_CONFIG_VERSION { tracing::error!( "[Config] config_version ({}) is ahead of supported version ({}). \ This server version may not support all configured features. \ Consider upgrading the server.", config.config_version, CURRENT_CONFIG_VERSION ); } // 环境变量覆盖数据库 URL (避免在配置文件中存储密码) if let Ok(db_url) = std::env::var("ZCLAW_DATABASE_URL") { config.database.url = db_url; } Ok(config) } /// 获取 JWT 密钥 (从环境变量或生成临时值) /// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET pub fn jwt_secret(&self) -> anyhow::Result { match std::env::var("ZCLAW_SAAS_JWT_SECRET") { Ok(secret) => Ok(SecretString::from(secret)), Err(_) => { // 开发 fallback 密钥仅在 debug 构建中可用,不会进入 release #[cfg(debug_assertions)] { tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using development default (INSECURE)"); Ok(SecretString::from("zclaw-dev-only-secret-do-not-use-in-prod".to_string())) } #[cfg(not(debug_assertions))] { anyhow::bail!( "ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\ 请设置一个强随机密钥 (至少 32 字符)。" ) } } } } /// 获取 API Key 加密密钥 (复用 TOTP 加密密钥) pub fn api_key_encryption_key(&self) -> anyhow::Result<[u8; 32]> { self.totp_encryption_key() } /// 获取 TOTP 加密密钥 (AES-256-GCM, 32 字节) /// 从 ZCLAW_TOTP_ENCRYPTION_KEY 环境变量加载 (hex 编码的 64 字符) /// 开发环境使用默认值 (不安全) pub fn totp_encryption_key(&self) -> anyhow::Result<[u8; 32]> { match std::env::var("ZCLAW_TOTP_ENCRYPTION_KEY") { Ok(hex_key) => { if hex_key.len() != 64 { anyhow::bail!("ZCLAW_TOTP_ENCRYPTION_KEY 必须是 64 个十六进制字符 (32 字节)"); } let mut key = [0u8; 32]; for i in 0..32 { key[i] = u8::from_str_radix(&hex_key[i*2..i*2+2], 16) .map_err(|_| anyhow::anyhow!("ZCLAW_TOTP_ENCRYPTION_KEY 包含无效的十六进制字符"))?; } Ok(key) } Err(_) => { // 开发环境: 仅在 debug 构建中使用固定密钥 #[cfg(debug_assertions)] { tracing::warn!("ZCLAW_TOTP_ENCRYPTION_KEY not set, using development default (INSECURE)"); let mut key = [0u8; 32]; key.copy_from_slice(b"zclaw-dev-totp-encrypt-key-32b!x"); Ok(key) } #[cfg(not(debug_assertions))] { anyhow::bail!( "生产环境必须设置 ZCLAW_TOTP_ENCRYPTION_KEY 环境变量 (64 个十六进制字符, 32 字节)" ); } } } } } /// 替换 TOML 配置文件中的 `${ENV_VAR}` 模式为环境变量值 /// 未设置的环境变量保留原文,后续数据库连接或 JWT 初始化时会报明确错误 /// /// 注意: 使用 chars() 迭代器而非 bytes() 来正确处理多字节 UTF-8 字符(如中文), /// 避免将多字节 UTF-8 序列的每个字节单独 `as char` 导致编码损坏。 fn interpolate_env_vars(content: &str) -> String { let mut result = String::with_capacity(content.len()); let chars: Vec = content.chars().collect(); let mut i = 0; while i < chars.len() { if i + 1 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' { let start = i + 2; let mut end = start; while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') { end += 1; } if end < chars.len() && chars[end] == '}' { let var_name: String = chars[start..end].iter().collect(); match std::env::var(&var_name) { Ok(val) => { tracing::debug!("Config: ${{{}}} → resolved ({} bytes)", var_name, val.len()); result.push_str(&val); } Err(_) => { tracing::warn!("Config: ${{{}}} not set, keeping placeholder", var_name); result.push_str(&format!("${{{}}}", var_name)); } } i = end + 1; } else { result.push(chars[i]); i += 1; } } else { result.push(chars[i]); i += 1; } } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_interpolate_env_vars_resolves() { std::env::set_var("TEST_ZCLAW_DB_PW", "mypassword"); let input = "url = \"postgres://user:${TEST_ZCLAW_DB_PW}@localhost/db\""; let result = interpolate_env_vars(input); assert_eq!(result, "url = \"postgres://user:mypassword@localhost/db\""); std::env::remove_var("TEST_ZCLAW_DB_PW"); } #[test] fn test_interpolate_env_vars_missing_keeps_placeholder() { let input = "url = \"postgres://user:${NONEXISTENT_VAR_12345}@localhost/db\""; let result = interpolate_env_vars(input); assert_eq!(result, "url = \"postgres://user:${NONEXISTENT_VAR_12345}@localhost/db\""); } #[test] fn test_interpolate_env_vars_no_placeholders() { let input = "host = \"0.0.0.0\"\nport = 8080"; let result = interpolate_env_vars(input); assert_eq!(result, input); } }