//! 数据库备份和恢复 //! //! 提供 SQL 导出/导入和二进制快照备份功能。 use super::{lock_conn, Database}; use crate::config::get_app_config_dir; use crate::error::AppError; use chrono::Utc; use rusqlite::backup::Backup; use rusqlite::types::ValueRef; use rusqlite::Connection; use std::fs; use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; /// A database backup entry for the UI #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupEntry { pub filename: String, pub size_bytes: u64, pub created_at: String, // ISO 8601 } impl Database { /// 导出为 SQLite 兼容的 SQL 文本(内存字符串) pub fn export_sql_string(&self) -> Result { let snapshot = self.snapshot_to_memory()?; Self::dump_sql(&snapshot) } /// 导出为 SQLite 兼容的 SQL 文本 pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { let dump = self.export_sql_string()?; if let Some(parent) = target_path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } crate::config::atomic_write(target_path, dump.as_bytes()) } /// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串) pub fn import_sql(&self, source_path: &Path) -> Result { if !source_path.exists() { return Err(AppError::InvalidInput(format!( "SQL 文件不存在: {}", source_path.display() ))); } let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; let sql_content = sql_raw.trim_start_matches('\u{feff}'); self.import_sql_string(sql_content) } /// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串) pub fn import_sql_string(&self, sql_raw: &str) -> Result { let sql_content = sql_raw.trim_start_matches('\u{feff}'); Self::validate_cc_switch_sql_export(sql_content)?; // 导入前备份现有数据库 let backup_path = self.backup_database_file()?; // 在临时数据库执行导入,确保失败不会污染主库 let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext { context: "创建临时数据库文件失败".to_string(), source: e, })?; let temp_path = temp_file.path().to_path_buf(); let temp_conn = Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; temp_conn .execute_batch(sql_content) .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; // 补齐缺失表/索引并进行基础校验 Self::create_tables_on_conn(&temp_conn)?; Self::apply_schema_migrations_on_conn(&temp_conn)?; Self::validate_basic_state(&temp_conn)?; // 使用 Backup 将临时库原子写回主库 { let mut main_conn = lock_conn!(self.conn); let backup = Backup::new(&temp_conn, &mut main_conn) .map_err(|e| AppError::Database(e.to_string()))?; backup .step(-1) .map_err(|e| AppError::Database(e.to_string()))?; } let backup_id = backup_path .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) .unwrap_or_default(); Ok(backup_id) } /// 创建内存快照以避免长时间持有数据库锁 pub(crate) fn snapshot_to_memory(&self) -> Result { let conn = lock_conn!(self.conn); let mut snapshot = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?; { let backup = Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?; backup .step(-1) .map_err(|e| AppError::Database(e.to_string()))?; } Ok(snapshot) } fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> { let trimmed = sql.trim_start(); if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) { return Ok(()); } Err(AppError::localized( "backup.sql.invalid_format", "仅支持导入由 CC Switch 导出的 SQL 备份文件。", "Only SQL backups exported by CC Switch are supported.", )) } /// Periodic backup: create a new backup if the latest one is older than the configured interval pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> { let interval_hours = crate::settings::effective_backup_interval_hours(); if interval_hours == 0 { return Ok(()); // Auto-backup disabled } let backup_dir = get_app_config_dir().join("backups"); if !backup_dir.exists() { self.backup_database_file()?; return Ok(()); } let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| { entries .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false)) .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok())) .max() }); let interval_secs = u64::from(interval_hours) * 3600; let needs_backup = match latest { None => true, Some(last_modified) => { last_modified.elapsed().unwrap_or_default() > std::time::Duration::from_secs(interval_secs) } }; if needs_backup { log::info!( "Periodic backup: latest backup is older than {interval_hours} hours, creating new backup" ); self.backup_database_file()?; } Ok(()) } /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) pub(crate) fn backup_database_file(&self) -> Result, AppError> { let db_path = get_app_config_dir().join("cc-switch.db"); if !db_path.exists() { return Ok(None); } let backup_dir = db_path .parent() .ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))? .join("backups"); fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let mut backup_id = base_id.clone(); let mut backup_path = backup_dir.join(format!("{backup_id}.db")); let mut counter = 1; while backup_path.exists() { backup_id = format!("{base_id}_{counter}"); backup_path = backup_dir.join(format!("{backup_id}.db")); counter += 1; } { let conn = lock_conn!(self.conn); let mut dest_conn = Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; let backup = Backup::new(&conn, &mut dest_conn) .map_err(|e| AppError::Database(e.to_string()))?; backup .step(-1) .map_err(|e| AppError::Database(e.to_string()))?; } Self::cleanup_db_backups(&backup_dir)?; Ok(Some(backup_path)) } /// 清理旧的数据库备份,保留最新的 N 个 fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> { let retain = crate::settings::effective_backup_retain_count(); let entries = match fs::read_dir(dir) { Ok(iter) => iter .filter_map(|entry| entry.ok()) .filter(|entry| { entry .path() .extension() .map(|ext| ext == "db") .unwrap_or(false) }) .collect::>(), Err(_) => return Ok(()), }; if entries.len() <= retain { return Ok(()); } let remove_count = entries.len().saturating_sub(retain); let mut sorted = entries; sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); for entry in sorted.into_iter().take(remove_count) { if let Err(err) = fs::remove_file(entry.path()) { log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err); } } Ok(()) } /// 基础状态校验 fn validate_basic_state(conn: &Connection) -> Result<(), AppError> { let provider_count: i64 = conn .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0)) .map_err(|e| AppError::Database(e.to_string()))?; let mcp_count: i64 = conn .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0)) .map_err(|e| AppError::Database(e.to_string()))?; if provider_count == 0 && mcp_count == 0 { return Err(AppError::Config( "导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(), )); } Ok(()) } /// 导出数据库为 SQL 文本 fn dump_sql(conn: &Connection) -> Result { let mut output = String::new(); let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); let user_version: i64 = conn .query_row("PRAGMA user_version;", [], |row| row.get(0)) .unwrap_or(0); output.push_str(&format!( "-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n" )); output.push_str("PRAGMA foreign_keys=OFF;\n"); output.push_str(&format!("PRAGMA user_version={user_version};\n")); output.push_str("BEGIN TRANSACTION;\n"); // 导出 schema let mut stmt = conn .prepare( "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql NOT NULL AND type IN ('table','index','trigger','view') ORDER BY type='table' DESC, name", ) .map_err(|e| AppError::Database(e.to_string()))?; let mut tables = Vec::new(); let mut rows = stmt .query([]) .map_err(|e| AppError::Database(e.to_string()))?; while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?; let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?; let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?; // 跳过 SQLite 内部对象(如 sqlite_sequence) if name.starts_with("sqlite_") { continue; } output.push_str(&sql); output.push_str(";\n"); if obj_type == "table" && !name.starts_with("sqlite_") { tables.push(name); } } // 导出数据 for table in tables { let columns = Self::get_table_columns(conn, &table)?; if columns.is_empty() { continue; } let mut stmt = conn .prepare(&format!("SELECT * FROM \"{table}\"")) .map_err(|e| AppError::Database(e.to_string()))?; let mut rows = stmt .query([]) .map_err(|e| AppError::Database(e.to_string()))?; while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? { let mut values = Vec::with_capacity(columns.len()); for idx in 0..columns.len() { let value = row .get_ref(idx) .map_err(|e| AppError::Database(e.to_string()))?; values.push(Self::format_sql_value(value)?); } let cols = columns .iter() .map(|c| format!("\"{c}\"")) .collect::>() .join(", "); output.push_str(&format!( "INSERT INTO \"{table}\" ({cols}) VALUES ({});\n", values.join(", ") )); } } output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n"); Ok(output) } /// 获取表的列名列表 fn get_table_columns(conn: &Connection, table: &str) -> Result, AppError> { let mut stmt = conn .prepare(&format!("PRAGMA table_info(\"{table}\")")) .map_err(|e| AppError::Database(e.to_string()))?; let iter = stmt .query_map([], |row| row.get::<_, String>(1)) .map_err(|e| AppError::Database(e.to_string()))?; let mut columns = Vec::new(); for col in iter { columns.push(col.map_err(|e| AppError::Database(e.to_string()))?); } Ok(columns) } /// 格式化 SQL 值 fn format_sql_value(value: ValueRef<'_>) -> Result { match value { ValueRef::Null => Ok("NULL".to_string()), ValueRef::Integer(i) => Ok(i.to_string()), ValueRef::Real(f) => Ok(f.to_string()), ValueRef::Text(t) => { let text = std::str::from_utf8(t) .map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?; let escaped = text.replace('\'', "''"); Ok(format!("'{escaped}'")) } ValueRef::Blob(bytes) => { let mut s = String::from("X'"); for b in bytes { use std::fmt::Write; let _ = write!(&mut s, "{b:02X}"); } s.push('\''); Ok(s) } } } /// List all database backup files, sorted by creation time (newest first) pub fn list_backups() -> Result, AppError> { let backup_dir = get_app_config_dir().join("backups"); if !backup_dir.exists() { return Ok(vec![]); } let mut entries: Vec = fs::read_dir(&backup_dir) .map_err(|e| AppError::io(&backup_dir, e))? .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false)) .filter_map(|e| { let metadata = e.metadata().ok()?; let filename = e.file_name().to_string_lossy().to_string(); let size_bytes = metadata.len(); let created_at = metadata .modified() .ok() .map(|t| { let dt: chrono::DateTime = t.into(); dt.to_rfc3339() }) .unwrap_or_default(); Some(BackupEntry { filename, size_bytes, created_at, }) }) .collect(); // Sort by created_at descending (newest first) entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(entries) } /// Restore database from a backup file. Returns the safety backup ID. pub fn restore_from_backup(&self, filename: &str) -> Result { // Security: validate filename to prevent path traversal if filename.contains("..") || filename.contains('/') || filename.contains('\\') || !filename.ends_with(".db") { return Err(AppError::InvalidInput( "Invalid backup filename".to_string(), )); } let backup_dir = get_app_config_dir().join("backups"); let backup_path = backup_dir.join(filename); if !backup_path.exists() { return Err(AppError::InvalidInput(format!( "Backup file not found: {filename}" ))); } // Step 1: Create safety backup of current database let safety_backup = self.backup_database_file()?; let safety_id = safety_backup .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) .unwrap_or_default(); // Step 2: Open the backup file and restore it to the main database let source_conn = Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; { let mut main_conn = lock_conn!(self.conn); let backup = Backup::new(&source_conn, &mut main_conn) .map_err(|e| AppError::Database(e.to_string()))?; backup .step(-1) .map_err(|e| AppError::Database(e.to_string()))?; } // Step 3: Run schema migrations (backup may be from an older version) self.create_tables()?; self.apply_schema_migrations()?; self.ensure_model_pricing_seeded()?; log::info!("Database restored from backup: {filename}, safety backup: {safety_id}"); Ok(safety_id) } /// Rename a backup file. Returns the new filename. pub fn rename_backup(old_filename: &str, new_name: &str) -> Result { // Validate old filename (path traversal + .db suffix) if old_filename.contains("..") || old_filename.contains('/') || old_filename.contains('\\') || !old_filename.ends_with(".db") { return Err(AppError::InvalidInput( "Invalid backup filename".to_string(), )); } // Clean new name let trimmed = new_name.trim(); if trimmed.is_empty() { return Err(AppError::InvalidInput( "New name cannot be empty".to_string(), )); } // Length limit (without .db suffix) let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed); if name_part.len() > 100 { return Err(AppError::InvalidInput( "Name too long (max 100 characters)".to_string(), )); } // Prevent path traversal in new name if name_part.contains("..") || name_part.contains('/') || name_part.contains('\\') || name_part.contains('\0') { return Err(AppError::InvalidInput( "Invalid characters in new name".to_string(), )); } let new_filename = format!("{name_part}.db"); let backup_dir = get_app_config_dir().join("backups"); let old_path = backup_dir.join(old_filename); let new_path = backup_dir.join(&new_filename); if !old_path.exists() { return Err(AppError::InvalidInput(format!( "Backup file not found: {old_filename}" ))); } if new_path.exists() { return Err(AppError::InvalidInput(format!( "A backup named '{new_filename}' already exists" ))); } fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?; log::info!("Renamed backup: {old_filename} -> {new_filename}"); Ok(new_filename) } }