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>
533 lines
19 KiB
Rust
533 lines
19 KiB
Rust
//! 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);
|
||
}
|
||
}
|