Files
zclaw_openfang/crates/zclaw-saas/src/config.rs
iven 28299807b6 fix(desktop): DeerFlow UI — ChatArea refactor + ai-elements + dead CSS cleanup
ChatArea retry button uses setInput instead of direct sendToGateway,
fix bootstrap spinner stuck for non-logged-in users,
remove dead CSS (aurora-title/sidebar-open/quick-action-chips),
add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress),
add ClassroomPlayer + ResizableChatLayout + artifact panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 19:24:44 +08:00

533 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<JobConfig>,
}
/// 单个定时任务配置
#[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_json::Value>,
/// 是否在启动时立即执行
#[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<String>,
/// 可信反向代理 IP 列表。仅对来自这些 IP 的请求解析 X-Forwarded-For 头。
/// 生产环境应为 Nginx/Caddy 的实际 IP如 ["127.0.0.1", "10.0.0.1"]
#[serde(default)]
pub trusted_proxies: Vec<String>,
}
/// 数据库配置
#[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<String>,
/// 支付宝商户私钥RSA2— 敏感,不序列化
#[serde(default, skip_serializing)]
pub alipay_private_key: Option<String>,
/// 支付宝公钥证书路径(用于验签)
#[serde(default)]
pub alipay_cert_path: Option<String>,
/// 支付宝回调通知 URL
#[serde(default)]
pub alipay_notify_url: Option<String>,
/// 支付宝公钥用于回调验签PEM 格式)— 敏感,不序列化
#[serde(default, skip_serializing)]
pub alipay_public_key: Option<String>,
/// 微信支付商户号
#[serde(default)]
pub wechat_mch_id: Option<String>,
/// 微信支付商户证书序列号
#[serde(default)]
pub wechat_serial_no: Option<String>,
/// 微信支付商户私钥路径
#[serde(default)]
pub wechat_private_key_path: Option<String>,
/// 微信支付 API v3 密钥 — 敏感,不序列化
#[serde(default, skip_serializing)]
pub wechat_api_v3_key: Option<String>,
/// 微信支付回调通知 URL
#[serde(default)]
pub wechat_notify_url: Option<String>,
/// 微信支付 App ID公众号/小程序)
#[serde(default)]
pub wechat_app_id: Option<String>,
}
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<Self> {
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<SecretString> {
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 初始化时会报明确错误
fn interpolate_env_vars(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
let start = i + 2;
let mut end = start;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
{
end += 1;
}
if end < bytes.len() && bytes[end] == b'}' {
let var_name = std::str::from_utf8(&bytes[start..end]).unwrap_or("");
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(bytes[i] as char);
i += 1;
}
} else {
result.push(bytes[i] as char);
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);
}
}