Files
CC-Switch/src-tauri/src/database/backup.rs
T
Jason f8820aa22c feat(backup): add independent backup panel, configurable policy, and rename support
Extract backup & restore into a standalone AccordionItem in Advanced settings.
Add configurable auto-backup interval (disabled/6h/12h/24h/48h/7d) and retention
count (3-50) via settings. Add per-backup rename with inline editing UI.
2026-02-23 11:27:28 +08:00

535 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 数据库备份和恢复
//!
//! 提供 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<String, AppError> {
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<String, AppError> {
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<String, AppError> {
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<Connection, AppError> {
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<Option<PathBuf>, 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::<Vec<_>>(),
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<String, AppError> {
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::<Vec<_>>()
.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<Vec<String>, 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<String, AppError> {
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<Vec<BackupEntry>, AppError> {
let backup_dir = get_app_config_dir().join("backups");
if !backup_dir.exists() {
return Ok(vec![]);
}
let mut entries: Vec<BackupEntry> = 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<Utc> = 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<String, AppError> {
// 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<String, AppError> {
// 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)
}
}