mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-09-01 14:58:25 +08:00
f991726ff0
Parse cache_write_tokens from OpenAI usage details and preserve cache creation data across Chat, Responses, and Anthropic conversion paths. Add explicit input-token semantics to request logs and rollups so legacy rows subtract cache reads only while new total-inclusive rows subtract both cache reads and writes. Migrate v12 databases, normalize rollups to fresh input, and cover historical backfill behavior with regression tests.
290 lines
9.8 KiB
Rust
290 lines
9.8 KiB
Rust
//! 数据库模块 - SQLite 数据持久化
|
|
//!
|
|
//! 此模块提供应用的核心数据存储功能,包括:
|
|
//! - 供应商配置管理
|
|
//! - MCP 服务器配置
|
|
//! - 提示词管理
|
|
//! - Skills 管理
|
|
//! - 通用设置存储
|
|
//!
|
|
//! ## 架构设计
|
|
//!
|
|
//! ```text
|
|
//! database/
|
|
//! ├── mod.rs - Database 结构体 + 初始化
|
|
//! ├── schema.rs - 表结构定义 + Schema 迁移
|
|
//! ├── backup.rs - SQL 导入导出 + 快照备份
|
|
//! ├── migration.rs - JSON → SQLite 数据迁移
|
|
//! └── dao/ - 数据访问对象
|
|
//! ├── providers.rs
|
|
//! ├── mcp.rs
|
|
//! ├── prompts.rs
|
|
//! ├── skills.rs
|
|
//! └── settings.rs
|
|
//! ```
|
|
|
|
pub(crate) mod backup;
|
|
mod dao;
|
|
mod migration;
|
|
mod schema;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
// DAO 类型导出供外部使用
|
|
pub(crate) use dao::providers_seed::{is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID};
|
|
pub(crate) use dao::proxy::{
|
|
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
|
|
PRICING_SOURCE_RESPONSE,
|
|
};
|
|
pub use dao::FailoverQueueItem;
|
|
pub use dao::Profile;
|
|
|
|
use crate::config::get_app_config_dir;
|
|
use crate::error::AppError;
|
|
use rusqlite::{hooks::Action, Connection};
|
|
use serde::Serialize;
|
|
use std::sync::Mutex;
|
|
|
|
// DAO 方法通过 impl Database 提供,无需额外导出
|
|
|
|
/// 当前 Schema 版本号
|
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
|
pub(crate) const SCHEMA_VERSION: i32 = 13;
|
|
|
|
/// 安全地序列化 JSON,避免 unwrap panic
|
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
|
serde_json::to_string(value)
|
|
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))
|
|
}
|
|
|
|
/// 安全地获取 Mutex 锁,避免 unwrap panic
|
|
macro_rules! lock_conn {
|
|
($mutex:expr) => {
|
|
$mutex
|
|
.lock()
|
|
.map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))?
|
|
};
|
|
}
|
|
|
|
// 导出宏供子模块使用
|
|
pub(crate) use lock_conn;
|
|
|
|
/// 数据库连接封装
|
|
///
|
|
/// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享。
|
|
/// rusqlite::Connection 本身不是 Sync 的,因此需要这层包装。
|
|
pub struct Database {
|
|
pub(crate) conn: Mutex<Connection>,
|
|
}
|
|
|
|
fn register_db_change_hook(conn: &Connection) {
|
|
conn.update_hook(Some(
|
|
|action: Action, _database: &str, table: &str, _row_id: i64| match action {
|
|
Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {
|
|
crate::services::webdav_auto_sync::notify_db_changed(table);
|
|
crate::services::s3_auto_sync::notify_db_changed(table);
|
|
}
|
|
_ => {}
|
|
},
|
|
));
|
|
}
|
|
|
|
impl Database {
|
|
/// 初始化数据库连接并创建表
|
|
///
|
|
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
|
|
pub fn init() -> Result<Self, AppError> {
|
|
let db_path = get_app_config_dir().join("cc-switch.db");
|
|
let db_exists = db_path.exists();
|
|
|
|
// 确保父目录存在
|
|
if let Some(parent) = db_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
}
|
|
|
|
let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 启用外键约束
|
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
if !db_exists {
|
|
// For a brand-new database, configure incremental auto-vacuum
|
|
// before creating any tables so no rebuild is needed later.
|
|
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
}
|
|
register_db_change_hook(&conn);
|
|
|
|
let db = Self {
|
|
conn: Mutex::new(conn),
|
|
};
|
|
db.create_tables()?;
|
|
|
|
// Pre-migration backup: only when upgrading from an existing database
|
|
{
|
|
let conn = lock_conn!(db.conn);
|
|
let version = Self::get_user_version(&conn)?;
|
|
drop(conn);
|
|
if version > 0 && version < SCHEMA_VERSION {
|
|
log::info!(
|
|
"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
|
|
);
|
|
if let Err(e) = db.backup_database_file() {
|
|
log::warn!("Pre-migration backup failed, continuing migration: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
db.apply_schema_migrations()?;
|
|
if let Err(e) = db.ensure_incremental_auto_vacuum() {
|
|
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
|
}
|
|
db.ensure_model_pricing_seeded()?;
|
|
|
|
// Startup cleanup: prune old logs and reclaim space
|
|
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
|
|
log::warn!("Startup stream_check_logs cleanup failed: {e}");
|
|
}
|
|
if let Err(e) = db.rollup_and_prune(30) {
|
|
log::warn!("Startup rollup_and_prune failed: {e}");
|
|
}
|
|
// Reclaim disk space after cleanup
|
|
{
|
|
let conn = lock_conn!(db.conn);
|
|
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
|
log::warn!("Startup incremental vacuum failed: {e}");
|
|
}
|
|
}
|
|
|
|
Ok(db)
|
|
}
|
|
|
|
/// 读取磁盘上数据库的 `user_version`;仅当它比应用支持的 [`SCHEMA_VERSION`]
|
|
/// 更新时返回 `Some(version)`。
|
|
///
|
|
/// 用于初始化失败后判断是否为「数据库版本过新(应用过旧,需升级应用)」的可恢复
|
|
/// 场景——此时不应反复弹出无效的重试对话框,而应引导用户在应用内升级。
|
|
pub fn stored_user_version_exceeds_supported(
|
|
db_path: &std::path::Path,
|
|
) -> Result<Option<i32>, AppError> {
|
|
if !db_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let conn = Connection::open(db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
|
let version = Self::get_user_version(&conn)?;
|
|
Ok((version > SCHEMA_VERSION).then_some(version))
|
|
}
|
|
|
|
/// 创建内存数据库(用于测试)
|
|
pub fn memory() -> Result<Self, AppError> {
|
|
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
|
|
|
// 启用外键约束
|
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
register_db_change_hook(&conn);
|
|
|
|
let db = Self {
|
|
conn: Mutex::new(conn),
|
|
};
|
|
db.create_tables()?;
|
|
db.ensure_model_pricing_seeded()?;
|
|
|
|
Ok(db)
|
|
}
|
|
|
|
pub(crate) fn get_auto_vacuum_mode(conn: &Connection) -> Result<i32, AppError> {
|
|
conn.query_row("PRAGMA auto_vacuum;", [], |row| row.get(0))
|
|
.map_err(|e| AppError::Database(format!("读取 auto_vacuum 失败: {e}")))
|
|
}
|
|
|
|
fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
|
Ok(count > 0)
|
|
}
|
|
|
|
pub(crate) fn ensure_incremental_auto_vacuum_on_conn(
|
|
conn: &Connection,
|
|
) -> Result<bool, AppError> {
|
|
let mode = Self::get_auto_vacuum_mode(conn)?;
|
|
if mode == 2 {
|
|
return Ok(false);
|
|
}
|
|
|
|
let has_tables = Self::has_user_tables(conn)?;
|
|
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
|
.map_err(|e| AppError::Database(format!("设置 auto_vacuum 失败: {e}")))?;
|
|
|
|
if !has_tables {
|
|
return Ok(false);
|
|
}
|
|
|
|
conn.execute("VACUUM;", [])
|
|
.map_err(|e| AppError::Database(format!("执行 VACUUM 失败: {e}")))?;
|
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
|
.map_err(|e| AppError::Database(format!("恢复 foreign_keys 失败: {e}")))?;
|
|
Ok(true)
|
|
}
|
|
|
|
pub(crate) fn ensure_incremental_auto_vacuum(&self) -> Result<bool, AppError> {
|
|
let mode = {
|
|
let conn = lock_conn!(self.conn);
|
|
Self::get_auto_vacuum_mode(&conn)?
|
|
};
|
|
if mode == 2 {
|
|
return Ok(false);
|
|
}
|
|
|
|
let has_tables = {
|
|
let conn = lock_conn!(self.conn);
|
|
Self::has_user_tables(&conn)?
|
|
};
|
|
if has_tables {
|
|
log::info!(
|
|
"Detected auto_vacuum={mode}, rebuilding database to enable incremental vacuum"
|
|
);
|
|
self.backup_database_file()?;
|
|
}
|
|
|
|
let rebuilt = {
|
|
let conn = lock_conn!(self.conn);
|
|
Self::ensure_incremental_auto_vacuum_on_conn(&conn)?
|
|
};
|
|
|
|
if rebuilt {
|
|
log::info!("Incremental auto-vacuum enabled after database rebuild");
|
|
} else {
|
|
log::info!("Incremental auto-vacuum configured for new database");
|
|
}
|
|
|
|
Ok(rebuilt)
|
|
}
|
|
|
|
/// 检查 MCP 服务器表是否为空
|
|
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
|
|
let conn = lock_conn!(self.conn);
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
Ok(count == 0)
|
|
}
|
|
|
|
/// 检查提示词表是否为空
|
|
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
|
|
let conn = lock_conn!(self.conn);
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
Ok(count == 0)
|
|
}
|
|
}
|