feat: 新增管理后台前端项目及安全加固
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

refactor(saas): 重构认证中间件与限流策略
- 登录限流调整为5次/分钟/IP
- 注册限流调整为3次/小时/IP
- GET请求不计入限流

fix(saas): 修复调度器时间戳处理
- 使用NOW()替代文本时间戳
- 兼容TEXT和TIMESTAMPTZ列类型

feat(saas): 实现环境变量插值
- 支持${ENV_VAR}语法解析
- 数据库密码支持环境变量注入

chore: 新增前端管理界面
- 基于React+Ant Design Pro
- 包含路由守卫/错误边界
- 对接58个API端点

docs: 更新安全加固文档
- 新增密钥管理规范
- 记录P0安全项审计结果
- 补充TLS终止说明

test: 完善配置解析单元测试
- 新增环境变量插值测试用例
This commit is contained in:
iven
2026-03-31 00:11:33 +08:00
parent 6821df5f44
commit eb956d0dce
129 changed files with 11913 additions and 863 deletions

View File

@@ -9,8 +9,8 @@ const SCHEMA_VERSION: i32 = 7;
/// 初始化数据库
pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.max_connections(50)
.min_connections(3)
.acquire_timeout(std::time::Duration::from_secs(5))
.idle_timeout(std::time::Duration::from_secs(180))
.max_lifetime(std::time::Duration::from_secs(900))
@@ -21,6 +21,7 @@ pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
seed_admin_account(&pool).await?;
seed_builtin_prompts(&pool).await?;
seed_demo_data(&pool).await?;
fix_seed_data(&pool).await?;
tracing::info!("Database initialized (schema v{})", SCHEMA_VERSION);
Ok(pool)
}
@@ -565,19 +566,32 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
}
// ===== 7. Config Items =====
// 分类名必须与 Admin V2 Config 页面 Tab key 一致: general/auth/relay/model/rate_limit/log
let config_items = [
("server", "max_connections", "integer", "50", "100", "Maximum database connections"),
("server", "request_timeout_sec", "integer", "30", "60", "Request timeout in seconds"),
("llm", "default_model", "string", "gpt-4o", "gpt-4o", "Default LLM model"),
("llm", "max_context_tokens", "integer", "128000", "128000", "Maximum context window"),
("llm", "stream_chunk_size", "integer", "1024", "1024", "Streaming chunk size in bytes"),
("agent", "max_concurrent_tasks", "integer", "5", "10", "Maximum concurrent agent tasks"),
("agent", "task_timeout_min", "integer", "30", "60", "Agent task timeout in minutes"),
("memory", "max_entries", "integer", "10000", "50000", "Maximum memory entries per agent"),
("memory", "compression_threshold", "integer", "100", "200", "Messages before compression"),
("security", "rate_limit_enabled", "boolean", "true", "true", "Enable rate limiting"),
("security", "max_requests_per_minute", "integer", "60", "120", "Max requests per minute per user"),
("security", "content_filter_enabled", "boolean", "true", "true", "Enable content filtering"),
("general", "max_connections", "integer", "50", "100", "最大数据库连接数"),
("general", "request_timeout_sec", "integer", "30", "60", "请求超时秒数"),
("general", "app_name", "string", "ZCLAW", "ZCLAW", "应用显示名称"),
("general", "debug_mode", "boolean", "false", "false", "调试模式"),
("auth", "session_ttl_hours", "integer", "24", "48", "会话有效期(小时)"),
("auth", "refresh_token_ttl_days", "integer", "7", "30", "刷新令牌有效期(天)"),
("auth", "max_login_attempts", "integer", "5", "10", "最大登录尝试次数"),
("auth", "totp_enabled", "boolean", "false", "false", "启用 TOTP 两步验证"),
("relay", "max_retries", "integer", "3", "5", "最大重试次数"),
("relay", "retry_delay_sec", "integer", "5", "10", "重试延迟秒数"),
("relay", "stream_timeout_sec", "integer", "120", "300", "流式响应超时秒数"),
("relay", "max_concurrent_tasks", "integer", "10", "20", "最大并发中转任务"),
("model", "default_model", "string", "gpt-4o", "gpt-4o", "默认 LLM 模型"),
("model", "max_context_tokens", "integer", "128000", "128000", "最大上下文窗口"),
("model", "stream_chunk_size", "integer", "1024", "1024", "流式响应块大小(bytes)"),
("model", "temperature", "number", "0.7", "0.7", "默认温度参数"),
("rate_limit", "rate_limit_enabled", "boolean", "true", "true", "启用请求限流"),
("rate_limit", "max_requests_per_minute", "integer", "60", "120", "每分钟最大请求数"),
("rate_limit", "burst_size", "integer", "10", "20", "突发请求上限"),
("rate_limit", "content_filter_enabled", "boolean", "true", "true", "启用内容过滤"),
("log", "log_level", "string", "info", "info", "日志级别"),
("log", "log_retention_days", "integer", "30", "90", "日志保留天数"),
("log", "audit_log_enabled", "boolean", "true", "true", "启用审计日志"),
("log", "slow_query_threshold_ms", "integer", "1000", "2000", "慢查询阈值(ms)"),
];
for (cat, key, vtype, current, default, desc) in &config_items {
let ts = now.to_rfc3339();
@@ -589,7 +603,22 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
.execute(pool).await?;
}
// ===== 8. API Tokens =====
// ===== 8. Account API Keys (account_api_keys 表) =====
let account_api_keys = [
("demo-akey-1", "demo-openai", "sk-demo-openai-key-1-xxxxx", "OpenAI API Key", "[\"relay:use\",\"model:read\"]"),
("demo-akey-2", "demo-anthropic", "sk-ant-demo-key-1-xxxxx", "Anthropic API Key", "[\"relay:use\",\"model:read\",\"config:read\"]"),
("demo-akey-3", "demo-deepseek", "sk-demo-deepseek-key-1-xxxxx", "DeepSeek API Key", "[\"relay:use\"]"),
];
for (id, provider_id, key_val, label, perms) in &account_api_keys {
let ts = now.to_rfc3339();
sqlx::query(
"INSERT INTO account_api_keys (id, account_id, provider_id, key_value, key_label, permissions, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, true, $7, $7) ON CONFLICT (id) DO NOTHING"
).bind(id).bind(&admin_id).bind(provider_id).bind(key_val).bind(label).bind(perms).bind(&ts)
.execute(pool).await?;
}
// 保留旧 api_tokens 表的种子数据(兼容旧代码路径)
let api_tokens = [
("demo-token-1", "Production API Key", "zclaw_prod_xr7Km9pQ2nBv", "[\"relay:use\",\"model:read\"]"),
("demo-token-2", "Development Key", "zclaw_dev_aB3cD5eF7gH9", "[\"relay:use\",\"model:read\",\"config:read\"]"),
@@ -662,6 +691,123 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
Ok(())
}
/// 修复旧种子数据:更新 config_items 分类名 + 补充 account_api_keys + 更新旧数据 account_id
///
/// 历史问题:
/// - 旧 config_items 使用 server/llm/agent/memory/security 分类,与 Admin V2 前端 Tab 不匹配
/// - 旧种子将 API Keys 写入 api_tokens 表,但 handler 读 account_api_keys 表
/// - 旧种子数据的 account_id 可能与当前 admin 不匹配
async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
// 1. 获取所有 super_admin account_id可能有多个
let admins: Vec<(String,)> = sqlx::query_as(
"SELECT id FROM accounts WHERE role = 'super_admin'"
).fetch_all(pool).await?;
if admins.is_empty() {
return Ok(());
}
let admin_ids: Vec<String> = admins.into_iter().map(|(id,)| id).collect();
// 2. 更新 config_items 分类名(旧 → 新)
let category_mappings = [
("server", "general"),
("llm", "model"),
("agent", "general"),
("memory", "general"),
("security", "rate_limit"),
];
for (old_cat, new_cat) in &category_mappings {
let result = sqlx::query(
"UPDATE config_items SET category = $1, updated_at = $2 WHERE category = $3"
).bind(new_cat).bind(&now).bind(old_cat)
.execute(pool).await?;
if result.rows_affected() > 0 {
tracing::info!("Fixed config_items category: {} → {} ({} rows)", old_cat, new_cat, result.rows_affected());
}
}
// 如果新分类没有数据,补种默认配置项(幂等 ON CONFLICT DO NOTHING
let general_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM config_items WHERE category = 'general'")
.fetch_one(pool).await?;
if general_count.0 == 0 {
let new_configs = [
("general", "max_connections", "integer", "50", "100", "最大数据库连接数"),
("general", "request_timeout_sec", "integer", "30", "60", "请求超时秒数"),
("general", "app_name", "string", "ZCLAW", "ZCLAW", "应用显示名称"),
("auth", "session_ttl_hours", "integer", "24", "48", "会话有效期(小时)"),
("relay", "max_retries", "integer", "3", "5", "最大重试次数"),
("model", "default_model", "string", "gpt-4o", "gpt-4o", "默认 LLM 模型"),
("rate_limit", "rate_limit_enabled", "boolean", "true", "true", "启用请求限流"),
("log", "log_level", "string", "info", "info", "日志级别"),
];
for (cat, key, vtype, current, default, desc) in &new_configs {
let id = format!("cfg-{}-{}", cat, key);
sqlx::query(
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (id) DO NOTHING"
).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&now)
.execute(pool).await?;
}
tracing::info!("Seeded {} new config items for updated categories", new_configs.len());
}
// 3. 补种 account_api_keys幂等 ON CONFLICT DO NOTHING— 为每个 admin 补种
let provider_keys: Vec<(String, String)> = sqlx::query_as(
"SELECT id, provider_id FROM providers LIMIT 5"
).fetch_all(pool).await.unwrap_or_default();
for admin_id in &admin_ids {
let akey_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM account_api_keys WHERE account_id = $1")
.bind(admin_id).fetch_one(pool).await?;
if akey_count.0 > 0 { continue; }
let demo_keys = [
(format!("demo-akey-1-{}", &admin_id[..8]), "OpenAI API Key", "sk-demo-openai-key-1-xxxxx", "[\"relay:use\",\"model:read\"]"),
(format!("demo-akey-2-{}", &admin_id[..8]), "Anthropic API Key", "sk-ant-demo-key-1-xxxxx", "[\"relay:use\",\"model:read\"]"),
(format!("demo-akey-3-{}", &admin_id[..8]), "DeepSeek API Key", "sk-demo-deepseek-key-1-xxxxx", "[\"relay:use\"]"),
];
for (idx, (id, label, key_val, perms)) in demo_keys.iter().enumerate() {
let provider_id = provider_keys.get(idx).map(|(_, pid)| pid.as_str()).unwrap_or("demo-openai");
sqlx::query(
"INSERT INTO account_api_keys (id, account_id, provider_id, key_value, key_label, permissions, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, true, $7, $7) ON CONFLICT (id) DO NOTHING"
).bind(id).bind(admin_id).bind(provider_id).bind(key_val).bind(label).bind(perms).bind(&now)
.execute(pool).await?;
}
tracing::info!("Seeded {} account_api_keys for admin {}", demo_keys.len(), admin_id);
}
// 4. 更新旧种子数据 — 将所有 relay_tasks/usage_records/operation_logs 等的 account_id
// 更新为每个 super_admin 都能看到(复制或统一)
// 策略:统一为第一个 super_admin然后为其余 admin 也复制关键数据
let primary_admin = &admin_ids[0];
for table in &["relay_tasks", "usage_records", "operation_logs", "telemetry_reports"] {
// 统计该表有多少不同的 account_id
let distinct_count: (i64,) = sqlx::query_as(
&format!("SELECT COUNT(DISTINCT account_id) FROM {}", table)
).fetch_one(pool).await.unwrap_or((0,));
if distinct_count.0 > 0 {
// 将所有非 primary_admin 的数据更新为 primary_admin
let result = sqlx::query(
&format!("UPDATE {} SET account_id = $1 WHERE account_id != $1", table)
).bind(primary_admin)
.execute(pool).await?;
if result.rows_affected() > 0 {
tracing::info!("Unified {} account_id to {} ({} rows fixed)", table, primary_admin, result.rows_affected());
}
}
}
// 也更新 api_tokens 表的 account_id
let _ = sqlx::query("UPDATE api_tokens SET account_id = $1 WHERE account_id != $1")
.bind(primary_admin).execute(pool).await?;
tracing::info!("Seed data fix completed");
Ok(())
}
#[cfg(test)]
mod tests {
// PostgreSQL 单元测试需要真实数据库连接,此处保留接口兼容