mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
2a4fcc46ff
Add database support for auto-failover feature: - Add circuit_breaker_config table for storing failover thresholds - Add get/update_circuit_breaker_config methods in proxy DAO - Add reset_provider_health method for manual recovery - Add set_proxy_target and get_proxy_targets methods in providers DAO for managing multi-provider failover configuration
827 lines
30 KiB
Rust
827 lines
30 KiB
Rust
//! Schema 定义和迁移
|
|
//!
|
|
//! 负责数据库表结构的创建和版本迁移。
|
|
|
|
use super::{lock_conn, Database, SCHEMA_VERSION};
|
|
use crate::error::AppError;
|
|
use rusqlite::Connection;
|
|
|
|
impl Database {
|
|
/// 创建所有数据库表
|
|
pub(crate) fn create_tables(&self) -> Result<(), AppError> {
|
|
let conn = lock_conn!(self.conn);
|
|
Self::create_tables_on_conn(&conn)
|
|
}
|
|
|
|
/// 在指定连接上创建表(供迁移和测试使用)
|
|
pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
|
|
// 1. Providers 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS providers (
|
|
id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
settings_config TEXT NOT NULL,
|
|
website_url TEXT,
|
|
category TEXT,
|
|
created_at INTEGER,
|
|
sort_index INTEGER,
|
|
notes TEXT,
|
|
icon TEXT,
|
|
icon_color TEXT,
|
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
is_current BOOLEAN NOT NULL DEFAULT 0,
|
|
is_proxy_target BOOLEAN NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (id, app_type)
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 尝试添加 is_proxy_target 列(如果表已存在但缺少该列)
|
|
let _ = conn.execute(
|
|
"ALTER TABLE providers ADD COLUMN is_proxy_target BOOLEAN NOT NULL DEFAULT 0",
|
|
[],
|
|
);
|
|
|
|
// 2. Provider Endpoints 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS provider_endpoints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
added_at INTEGER,
|
|
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 3. MCP Servers 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
server_config TEXT NOT NULL,
|
|
description TEXT,
|
|
homepage TEXT,
|
|
docs TEXT,
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
|
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 4. Prompts 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS prompts (
|
|
id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
description TEXT,
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
PRIMARY KEY (id, app_type)
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 5. Skills 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS skills (
|
|
key TEXT PRIMARY KEY,
|
|
installed BOOLEAN NOT NULL DEFAULT 0,
|
|
installed_at INTEGER NOT NULL DEFAULT 0
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 6. Skill Repos 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS skill_repos (
|
|
owner TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
branch TEXT NOT NULL DEFAULT 'main',
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
PRIMARY KEY (owner, name)
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 7. Settings 表 (通用配置)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 8. Proxy Config 表 (代理服务器配置)
|
|
// 代理配置表(单例)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS proxy_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
enabled INTEGER NOT NULL DEFAULT 0,
|
|
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
|
listen_port INTEGER NOT NULL DEFAULT 5000,
|
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
request_timeout INTEGER NOT NULL DEFAULT 300,
|
|
enable_logging INTEGER NOT NULL DEFAULT 1,
|
|
target_app TEXT NOT NULL DEFAULT 'claude',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 尝试添加 target_app 列(如果表已存在但缺少该列)
|
|
// 忽略 "duplicate column name" 错误
|
|
let _ = conn.execute(
|
|
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
|
|
[],
|
|
);
|
|
|
|
// 9. Provider Health 表 (Provider健康状态)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS provider_health (
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
is_healthy INTEGER NOT NULL DEFAULT 1,
|
|
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
last_success_at TEXT,
|
|
last_failure_at TEXT,
|
|
last_error TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (provider_id, app_type),
|
|
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 10. Proxy Usage 表 (代理使用统计,可选)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS proxy_usage (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
endpoint TEXT NOT NULL,
|
|
request_tokens INTEGER,
|
|
response_tokens INTEGER,
|
|
status_code INTEGER NOT NULL,
|
|
latency_ms INTEGER NOT NULL,
|
|
error TEXT,
|
|
timestamp TEXT NOT NULL
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 为 proxy_usage 创建索引
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_timestamp
|
|
ON proxy_usage(timestamp)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_provider
|
|
ON proxy_usage(provider_id, app_type)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 11. Proxy Request Logs 表 (详细请求日志)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
|
request_id TEXT PRIMARY KEY,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
latency_ms INTEGER NOT NULL,
|
|
first_token_ms INTEGER,
|
|
duration_ms INTEGER,
|
|
status_code INTEGER NOT NULL,
|
|
error_message TEXT,
|
|
session_id TEXT,
|
|
provider_type TEXT,
|
|
is_streaming INTEGER NOT NULL DEFAULT 0,
|
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
created_at INTEGER NOT NULL
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
|
|
ON proxy_request_logs(provider_id, app_type)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
|
|
ON proxy_request_logs(created_at)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
|
|
ON proxy_request_logs(model)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
|
|
ON proxy_request_logs(session_id)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
|
|
ON proxy_request_logs(status_code)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 12. Model Pricing 表 (模型定价)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS model_pricing (
|
|
model_id TEXT PRIMARY KEY,
|
|
display_name TEXT NOT NULL,
|
|
input_cost_per_million TEXT NOT NULL,
|
|
output_cost_per_million TEXT NOT NULL,
|
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 13. Usage Daily Stats 表 (每日聚合统计)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
|
|
date TEXT NOT NULL,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
request_count INTEGER NOT NULL DEFAULT 0,
|
|
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
success_count INTEGER NOT NULL DEFAULT 0,
|
|
error_count INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (date, provider_id, app_type, model)
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 14. Model Test Logs 表 (模型测试日志,独立于代理使用统计)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS model_test_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider_id TEXT NOT NULL,
|
|
provider_name TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
prompt TEXT NOT NULL,
|
|
success INTEGER NOT NULL,
|
|
message TEXT NOT NULL,
|
|
response_time_ms INTEGER,
|
|
http_status INTEGER,
|
|
tested_at INTEGER NOT NULL
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_provider
|
|
ON model_test_logs(provider_id, app_type)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_tested_at
|
|
ON model_test_logs(tested_at DESC)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 15. Circuit Breaker Config 表 (熔断器配置)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
failure_threshold INTEGER NOT NULL DEFAULT 5,
|
|
success_threshold INTEGER NOT NULL DEFAULT 2,
|
|
timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
|
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
|
min_requests INTEGER NOT NULL DEFAULT 10,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 插入默认熔断器配置
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
|
|
[],
|
|
)
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 应用 Schema 迁移
|
|
pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
|
|
let conn = lock_conn!(self.conn);
|
|
Self::apply_schema_migrations_on_conn(&conn)
|
|
}
|
|
|
|
/// 在指定连接上应用 Schema 迁移
|
|
pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
|
|
conn.execute("SAVEPOINT schema_migration;", [])
|
|
.map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
|
|
|
|
let mut version = Self::get_user_version(conn)?;
|
|
|
|
if version > SCHEMA_VERSION {
|
|
conn.execute("ROLLBACK TO schema_migration;", []).ok();
|
|
conn.execute("RELEASE schema_migration;", []).ok();
|
|
return Err(AppError::Database(format!(
|
|
"数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。"
|
|
)));
|
|
}
|
|
|
|
let result = (|| {
|
|
while version < SCHEMA_VERSION {
|
|
match version {
|
|
0 => {
|
|
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
|
|
Self::migrate_v0_to_v1(conn)?;
|
|
Self::set_user_version(conn, 1)?;
|
|
}
|
|
1 => {
|
|
log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
|
|
Self::migrate_v1_to_v2(conn)?;
|
|
Self::set_user_version(conn, 2)?;
|
|
}
|
|
_ => {
|
|
return Err(AppError::Database(format!(
|
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
|
)));
|
|
}
|
|
}
|
|
version = Self::get_user_version(conn)?;
|
|
}
|
|
Ok(())
|
|
})();
|
|
|
|
match result {
|
|
Ok(_) => {
|
|
conn.execute("RELEASE schema_migration;", [])
|
|
.map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?;
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
conn.execute("ROLLBACK TO schema_migration;", []).ok();
|
|
conn.execute("RELEASE schema_migration;", []).ok();
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// v0 -> v1 迁移:补齐所有缺失列
|
|
fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {
|
|
// providers 表
|
|
Self::add_column_if_missing(conn, "providers", "category", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?;
|
|
Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?;
|
|
Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?;
|
|
Self::add_column_if_missing(
|
|
conn,
|
|
"providers",
|
|
"is_current",
|
|
"BOOLEAN NOT NULL DEFAULT 0",
|
|
)?;
|
|
|
|
// provider_endpoints 表
|
|
Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?;
|
|
|
|
// mcp_servers 表
|
|
Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?;
|
|
Self::add_column_if_missing(
|
|
conn,
|
|
"mcp_servers",
|
|
"enabled_codex",
|
|
"BOOLEAN NOT NULL DEFAULT 0",
|
|
)?;
|
|
Self::add_column_if_missing(
|
|
conn,
|
|
"mcp_servers",
|
|
"enabled_gemini",
|
|
"BOOLEAN NOT NULL DEFAULT 0",
|
|
)?;
|
|
|
|
// prompts 表
|
|
Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
|
Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
|
|
Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
|
|
|
|
// skills 表
|
|
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
|
|
|
|
// skill_repos 表
|
|
Self::add_column_if_missing(
|
|
conn,
|
|
"skill_repos",
|
|
"branch",
|
|
"TEXT NOT NULL DEFAULT 'main'",
|
|
)?;
|
|
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
|
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// v1 -> v2 迁移:添加使用统计表和完整字段
|
|
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
|
// providers 表字段
|
|
Self::add_column_if_missing(
|
|
conn,
|
|
"providers",
|
|
"cost_multiplier",
|
|
"TEXT NOT NULL DEFAULT '1.0'",
|
|
)?;
|
|
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
|
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
|
|
|
// proxy_request_logs 表(包含所有字段)
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
|
request_id TEXT PRIMARY KEY,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
latency_ms INTEGER NOT NULL,
|
|
first_token_ms INTEGER,
|
|
duration_ms INTEGER,
|
|
status_code INTEGER NOT NULL,
|
|
error_message TEXT,
|
|
session_id TEXT,
|
|
provider_type TEXT,
|
|
is_streaming INTEGER NOT NULL DEFAULT 0,
|
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
created_at INTEGER NOT NULL
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// 为已存在的表添加新字段
|
|
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
|
Self::add_column_if_missing(
|
|
conn,
|
|
"proxy_request_logs",
|
|
"is_streaming",
|
|
"INTEGER NOT NULL DEFAULT 0",
|
|
)?;
|
|
Self::add_column_if_missing(
|
|
conn,
|
|
"proxy_request_logs",
|
|
"cost_multiplier",
|
|
"TEXT NOT NULL DEFAULT '1.0'",
|
|
)?;
|
|
Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
|
|
Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
|
|
|
|
// model_pricing 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS model_pricing (
|
|
model_id TEXT PRIMARY KEY,
|
|
display_name TEXT NOT NULL,
|
|
input_cost_per_million TEXT NOT NULL,
|
|
output_cost_per_million TEXT NOT NULL,
|
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// usage_daily_stats 表
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
|
|
date TEXT NOT NULL,
|
|
provider_id TEXT NOT NULL,
|
|
app_type TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
request_count INTEGER NOT NULL DEFAULT 0,
|
|
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
success_count INTEGER NOT NULL DEFAULT 0,
|
|
error_count INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (date, provider_id, app_type, model)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
// 清空并重新插入模型定价
|
|
conn.execute("DELETE FROM model_pricing", [])
|
|
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
|
Self::seed_model_pricing(conn)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 插入默认模型定价数据
|
|
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
|
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
|
let pricing_data = [
|
|
// Claude 4.5 系列
|
|
(
|
|
"claude-opus-4-5",
|
|
"Claude Opus 4.5",
|
|
"5",
|
|
"25",
|
|
"0.50",
|
|
"6.25",
|
|
),
|
|
(
|
|
"claude-sonnet-4-5",
|
|
"Claude Sonnet 4.5",
|
|
"3",
|
|
"15",
|
|
"0.30",
|
|
"3.75",
|
|
),
|
|
(
|
|
"claude-haiku-4-5",
|
|
"Claude Haiku 4.5",
|
|
"1",
|
|
"5",
|
|
"0.10",
|
|
"1.25",
|
|
),
|
|
// Claude 4.1 系列
|
|
(
|
|
"claude-opus-4-1",
|
|
"Claude Opus 4.1",
|
|
"15",
|
|
"75",
|
|
"1.50",
|
|
"18.75",
|
|
),
|
|
(
|
|
"claude-sonnet-4-1",
|
|
"Claude Sonnet 4.1",
|
|
"3",
|
|
"15",
|
|
"0.30",
|
|
"3.75",
|
|
),
|
|
// Claude 3.7 系列
|
|
(
|
|
"claude-sonnet-3-7",
|
|
"Claude Sonnet 3.7",
|
|
"3",
|
|
"15",
|
|
"0.30",
|
|
"3.75",
|
|
),
|
|
// Claude 3.5 系列
|
|
(
|
|
"claude-sonnet-3-5",
|
|
"Claude Sonnet 3.5",
|
|
"3",
|
|
"15",
|
|
"0.30",
|
|
"3.75",
|
|
),
|
|
(
|
|
"claude-haiku-3-5",
|
|
"Claude Haiku 3.5",
|
|
"0.80",
|
|
"4",
|
|
"0.08",
|
|
"1",
|
|
),
|
|
// GPT-5 系列(model_id 使用短横线格式)
|
|
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
|
|
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
|
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
|
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
|
// Gemini 3 系列
|
|
(
|
|
"gemini-3-pro-preview",
|
|
"Gemini 3 Pro Preview",
|
|
"2",
|
|
"12",
|
|
"0",
|
|
"0",
|
|
),
|
|
// Gemini 2.5 系列(model_id 使用短横线格式)
|
|
(
|
|
"gemini-2-5-pro",
|
|
"Gemini 2.5 Pro",
|
|
"1.25",
|
|
"10",
|
|
"0.125",
|
|
"0",
|
|
),
|
|
(
|
|
"gemini-2-5-flash",
|
|
"Gemini 2.5 Flash",
|
|
"0.3",
|
|
"2.5",
|
|
"0.03",
|
|
"0",
|
|
),
|
|
];
|
|
|
|
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO model_pricing (
|
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
model_id,
|
|
display_name,
|
|
input,
|
|
output,
|
|
cache_read,
|
|
cache_creation
|
|
],
|
|
)
|
|
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
|
}
|
|
|
|
log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
|
|
Ok(())
|
|
}
|
|
|
|
/// 确保模型定价表具备默认数据
|
|
pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
|
|
let conn = lock_conn!(self.conn);
|
|
Self::ensure_model_pricing_seeded_on_conn(&conn)
|
|
}
|
|
|
|
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
|
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
|
|
|
|
if count == 0 {
|
|
Self::seed_model_pricing(conn)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// --- 辅助方法 ---
|
|
|
|
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
|
|
conn.query_row("PRAGMA user_version;", [], |row| row.get(0))
|
|
.map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}")))
|
|
}
|
|
|
|
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
|
|
if version < 0 {
|
|
return Err(AppError::Database("user_version 不能为负数".to_string()));
|
|
}
|
|
let sql = format!("PRAGMA user_version = {version};");
|
|
conn.execute(&sql, [])
|
|
.map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
|
|
if s.is_empty() {
|
|
return Err(AppError::Database(format!("{kind} 不能为空")));
|
|
}
|
|
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
|
return Err(AppError::Database(format!(
|
|
"非法{kind}: {s},仅允许字母、数字和下划线"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
|
|
Self::validate_identifier(table, "表名")?;
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
.map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
|
|
let mut rows = stmt
|
|
.query([])
|
|
.map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
|
|
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
let name: String = row
|
|
.get(0)
|
|
.map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
|
|
if name.eq_ignore_ascii_case(table) {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
pub(crate) fn has_column(
|
|
conn: &Connection,
|
|
table: &str,
|
|
column: &str,
|
|
) -> Result<bool, AppError> {
|
|
Self::validate_identifier(table, "表名")?;
|
|
Self::validate_identifier(column, "列名")?;
|
|
|
|
let sql = format!("PRAGMA table_info(\"{table}\");");
|
|
let mut stmt = conn
|
|
.prepare(&sql)
|
|
.map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?;
|
|
let mut rows = stmt
|
|
.query([])
|
|
.map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?;
|
|
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
|
let name: String = row
|
|
.get(1)
|
|
.map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?;
|
|
if name.eq_ignore_ascii_case(column) {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn add_column_if_missing(
|
|
conn: &Connection,
|
|
table: &str,
|
|
column: &str,
|
|
definition: &str,
|
|
) -> Result<bool, AppError> {
|
|
Self::validate_identifier(table, "表名")?;
|
|
Self::validate_identifier(column, "列名")?;
|
|
|
|
if !Self::table_exists(conn, table)? {
|
|
return Err(AppError::Database(format!(
|
|
"表 {table} 不存在,无法添加列 {column}"
|
|
)));
|
|
}
|
|
if Self::has_column(conn, table, column)? {
|
|
return Ok(false);
|
|
}
|
|
|
|
let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
|
|
conn.execute(&sql, [])
|
|
.map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
|
|
log::info!("已为表 {table} 添加缺失列 {column}");
|
|
Ok(true)
|
|
}
|
|
}
|