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
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:
@@ -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 单元测试需要真实数据库连接,此处保留接口兼容
|
||||
|
||||
Reference in New Issue
Block a user