mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
1af20b5f8f
Core changes: - Extract transaction logic into reusable migrate_from_json_tx() helper - Add migrate_from_json_dry_run() for in-memory validation without disk writes - Implement three-state migration mode enum (Disabled/DryRun/Enabled) - Support CC_SWITCH_ENABLE_JSON_DB_MIGRATION=dryrun for safe testing Code quality improvements: - Remove redundant migration_needed variable - Unify all log messages to English - Use info level for user-initiated operations instead of warn - Add explicit drop(tx) in dry-run to clarify intent Testing: - Add dry_run_does_not_write_to_disk test - Add dry_run_validates_schema_compatibility test with real data - All 6 database tests passing, zero clippy warnings This enables users to safely validate migration compatibility before committing to the database, catching schema mismatches early.
1773 lines
65 KiB
Rust
1773 lines
65 KiB
Rust
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||
use crate::config::get_app_config_dir;
|
||
use crate::error::AppError;
|
||
use crate::prompt::Prompt;
|
||
use crate::provider::{Provider, ProviderMeta};
|
||
use crate::services::skill::{SkillRepo, SkillState};
|
||
use chrono::Utc;
|
||
use indexmap::IndexMap;
|
||
use rusqlite::backup::Backup;
|
||
use rusqlite::types::ValueRef;
|
||
use rusqlite::{params, Connection};
|
||
use serde::Serialize;
|
||
use std::collections::HashMap;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Mutex;
|
||
use tempfile::NamedTempFile;
|
||
|
||
/// 安全地序列化 JSON,避免 unwrap panic
|
||
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)))?
|
||
};
|
||
}
|
||
|
||
const DB_BACKUP_RETAIN: usize = 10;
|
||
const SCHEMA_VERSION: i32 = 1;
|
||
|
||
pub struct Database {
|
||
// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享
|
||
// rusqlite::Connection 本身不是 Sync 的
|
||
conn: Mutex<Connection>,
|
||
}
|
||
|
||
impl Database {
|
||
/// 初始化数据库连接并创建表
|
||
pub fn init() -> Result<Self, AppError> {
|
||
let db_path = get_app_config_dir().join("cc-switch.db");
|
||
|
||
// 确保父目录存在
|
||
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()))?;
|
||
|
||
let db = Self {
|
||
conn: Mutex::new(conn),
|
||
};
|
||
db.create_tables()?;
|
||
db.apply_schema_migrations()?;
|
||
|
||
Ok(db)
|
||
}
|
||
|
||
fn create_tables(&self) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
Self::create_tables_on_conn(&conn)
|
||
}
|
||
|
||
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,
|
||
PRIMARY KEY (id, app_type)
|
||
)",
|
||
[],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 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,
|
||
skills_path TEXT,
|
||
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()))?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
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}")))
|
||
}
|
||
|
||
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 apply_schema_migrations(&self) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
Self::apply_schema_migrations_on_conn(&conn)
|
||
}
|
||
|
||
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(())
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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::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",
|
||
)?;
|
||
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"provider_endpoints",
|
||
"added_at",
|
||
"INTEGER",
|
||
)?;
|
||
|
||
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",
|
||
)?;
|
||
|
||
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")?;
|
||
|
||
Self::add_column_if_missing(
|
||
conn,
|
||
"skills",
|
||
"installed_at",
|
||
"INTEGER NOT NULL DEFAULT 0",
|
||
)?;
|
||
|
||
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",
|
||
)?;
|
||
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
|
||
|
||
Self::set_user_version(conn, SCHEMA_VERSION)?;
|
||
}
|
||
_ => {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 创建内存快照以避免长时间持有数据库锁
|
||
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)
|
||
}
|
||
|
||
/// 导出为 SQLite 兼容的 SQL 文本
|
||
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||
let snapshot = self.snapshot_to_memory()?;
|
||
let dump = Self::dump_sql(&snapshot)?;
|
||
|
||
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 = Self::sanitize_import_sql(&sql_raw);
|
||
|
||
// 导入前备份现有数据库
|
||
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)
|
||
}
|
||
|
||
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
|
||
fn sanitize_import_sql(sql: &str) -> String {
|
||
let mut cleaned = String::new();
|
||
let lower_keyword = "sqlite_sequence";
|
||
|
||
for stmt in sql.split(';') {
|
||
let trimmed = stmt.trim();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
|
||
continue;
|
||
}
|
||
|
||
cleaned.push_str(trimmed);
|
||
cleaned.push_str(";\n");
|
||
}
|
||
|
||
cleaned
|
||
}
|
||
|
||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||
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 backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||
let backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||
|
||
{
|
||
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))
|
||
}
|
||
|
||
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
||
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() <= DB_BACKUP_RETAIN {
|
||
return Ok(());
|
||
}
|
||
|
||
let remove_count = entries.len().saturating_sub(DB_BACKUP_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(())
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 从 MultiAppConfig 迁移数据
|
||
pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {
|
||
let mut conn = lock_conn!(self.conn);
|
||
let tx = conn
|
||
.transaction()
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
Self::migrate_from_json_tx(&tx, config)?;
|
||
|
||
tx.commit()
|
||
.map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Run migration dry-run in memory for pre-deployment validation (no disk writes)
|
||
pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> {
|
||
let mut conn =
|
||
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||
Self::create_tables_on_conn(&conn)?;
|
||
Self::apply_schema_migrations_on_conn(&conn)?;
|
||
|
||
let tx = conn
|
||
.transaction()
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Self::migrate_from_json_tx(&tx, config)?;
|
||
|
||
// Explicitly drop transaction without committing (in-memory DB discarded anyway)
|
||
drop(tx);
|
||
Ok(())
|
||
}
|
||
|
||
fn migrate_from_json_tx(
|
||
tx: &rusqlite::Transaction<'_>,
|
||
config: &MultiAppConfig,
|
||
) -> Result<(), AppError> {
|
||
// 1. 迁移 Providers
|
||
for (app_key, manager) in &config.apps {
|
||
let app_type = app_key; // "claude", "codex", "gemini"
|
||
let current_id = &manager.current;
|
||
|
||
for (id, provider) in &manager.providers {
|
||
let is_current = if id == current_id { 1 } else { 0 };
|
||
|
||
// 处理 meta 和 endpoints
|
||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO providers (
|
||
id, app_type, name, settings_config, website_url, category,
|
||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||
params![
|
||
id,
|
||
app_type,
|
||
provider.name,
|
||
to_json_string(&provider.settings_config)?,
|
||
provider.website_url,
|
||
provider.category,
|
||
provider.created_at,
|
||
provider.sort_index,
|
||
provider.notes,
|
||
provider.icon,
|
||
provider.icon_color,
|
||
to_json_string(&meta_clone)?, // 不含 endpoints 的 meta
|
||
is_current,
|
||
],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?;
|
||
|
||
// 迁移 Endpoints
|
||
for (url, endpoint) in endpoints {
|
||
tx.execute(
|
||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
||
VALUES (?1, ?2, ?3, ?4)",
|
||
params![id, app_type, url, endpoint.added_at],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 迁移 MCP Servers
|
||
if let Some(servers) = &config.mcp.servers {
|
||
for (id, server) in servers {
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO mcp_servers (
|
||
id, name, server_config, description, homepage, docs, tags,
|
||
enabled_claude, enabled_codex, enabled_gemini
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||
params![
|
||
id,
|
||
server.name,
|
||
to_json_string(&server.server)?,
|
||
server.description,
|
||
server.homepage,
|
||
server.docs,
|
||
to_json_string(&server.tags)?,
|
||
server.apps.claude,
|
||
server.apps.codex,
|
||
server.apps.gemini,
|
||
],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?;
|
||
}
|
||
}
|
||
|
||
// 3. 迁移 Prompts
|
||
let migrate_prompts =
|
||
|prompts_map: &std::collections::HashMap<String, crate::prompt::Prompt>,
|
||
app_type: &str|
|
||
-> Result<(), AppError> {
|
||
for (id, prompt) in prompts_map {
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO prompts (
|
||
id, app_type, name, content, description, enabled, created_at, updated_at
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||
params![
|
||
id,
|
||
app_type,
|
||
prompt.name,
|
||
prompt.content,
|
||
prompt.description,
|
||
prompt.enabled,
|
||
prompt.created_at,
|
||
prompt.updated_at,
|
||
],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
|
||
}
|
||
Ok(())
|
||
};
|
||
|
||
migrate_prompts(&config.prompts.claude.prompts, "claude")?;
|
||
migrate_prompts(&config.prompts.codex.prompts, "codex")?;
|
||
migrate_prompts(&config.prompts.gemini.prompts, "gemini")?;
|
||
|
||
// 4. 迁移 Skills
|
||
for (key, state) in &config.skills.skills {
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||
params![key, state.installed, state.installed_at.timestamp()],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
|
||
}
|
||
|
||
for repo in &config.skills.repos {
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
|
||
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
|
||
}
|
||
|
||
// 5. 迁移 Common Config
|
||
if let Some(snippet) = &config.common_config_snippets.claude {
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||
params!["common_config_claude", snippet],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||
}
|
||
if let Some(snippet) = &config.common_config_snippets.codex {
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||
params!["common_config_codex", snippet],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||
}
|
||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||
params!["common_config_gemini", snippet],
|
||
)
|
||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 检查数据库是否为空(需要首次导入)
|
||
/// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断
|
||
pub fn is_empty_for_first_import(&self) -> Result<bool, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
|
||
// 检查是否有 MCP 服务器
|
||
let mcp_count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 检查是否有提示词
|
||
let prompt_count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 检查是否有 Skills 仓库
|
||
let skill_repo_count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 检查是否有供应商
|
||
let provider_count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// 如果四者都为 0,说明是空数据库
|
||
Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0)
|
||
}
|
||
|
||
// --- Providers DAO ---
|
||
|
||
pub fn get_all_providers(
|
||
&self,
|
||
app_type: &str,
|
||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut stmt = conn.prepare(
|
||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||
FROM providers WHERE app_type = ?1
|
||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let provider_iter = stmt
|
||
.query_map(params![app_type], |row| {
|
||
let id: String = row.get(0)?;
|
||
let name: String = row.get(1)?;
|
||
let settings_config_str: String = row.get(2)?;
|
||
let website_url: Option<String> = row.get(3)?;
|
||
let category: Option<String> = row.get(4)?;
|
||
let created_at: Option<i64> = row.get(5)?;
|
||
let sort_index: Option<usize> = row.get(6)?;
|
||
let notes: Option<String> = row.get(7)?;
|
||
let icon: Option<String> = row.get(8)?;
|
||
let icon_color: Option<String> = row.get(9)?;
|
||
let meta_str: String = row.get(10)?;
|
||
|
||
let settings_config =
|
||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||
|
||
Ok((
|
||
id,
|
||
Provider {
|
||
id: "".to_string(), // Placeholder, set below
|
||
name,
|
||
settings_config,
|
||
website_url,
|
||
category,
|
||
created_at,
|
||
sort_index,
|
||
notes,
|
||
meta: Some(meta),
|
||
icon,
|
||
icon_color,
|
||
},
|
||
))
|
||
})
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut providers = IndexMap::new();
|
||
for provider_res in provider_iter {
|
||
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||
provider.id = id.clone();
|
||
|
||
// Load endpoints
|
||
let mut stmt_endpoints = conn.prepare(
|
||
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
|
||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let endpoints_iter = stmt_endpoints
|
||
.query_map(params![id, app_type], |row| {
|
||
let url: String = row.get(0)?;
|
||
let added_at: Option<i64> = row.get(1)?;
|
||
Ok((
|
||
url,
|
||
crate::settings::CustomEndpoint {
|
||
url: "".to_string(),
|
||
added_at: added_at.unwrap_or(0),
|
||
last_used: None,
|
||
},
|
||
))
|
||
})
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut custom_endpoints = HashMap::new();
|
||
for ep_res in endpoints_iter {
|
||
let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||
ep.url = url.clone();
|
||
custom_endpoints.insert(url, ep);
|
||
}
|
||
|
||
if let Some(meta) = &mut provider.meta {
|
||
meta.custom_endpoints = custom_endpoints;
|
||
}
|
||
|
||
providers.insert(id, provider);
|
||
}
|
||
|
||
Ok(providers)
|
||
}
|
||
|
||
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut stmt = conn
|
||
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1")
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut rows = stmt
|
||
.query(params![app_type])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||
Ok(Some(
|
||
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
||
))
|
||
} else {
|
||
Ok(None)
|
||
}
|
||
}
|
||
|
||
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
||
let mut conn = lock_conn!(self.conn);
|
||
let tx = conn
|
||
.transaction()
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// Handle meta and endpoints
|
||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||
|
||
// Check if it exists to preserve is_current
|
||
let is_current: bool = tx
|
||
.query_row(
|
||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
||
params![provider.id, app_type],
|
||
|row| row.get(0),
|
||
)
|
||
.unwrap_or(false);
|
||
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO providers (
|
||
id, app_type, name, settings_config, website_url, category,
|
||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||
params![
|
||
provider.id,
|
||
app_type,
|
||
provider.name,
|
||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||
provider.website_url,
|
||
provider.category,
|
||
provider.created_at,
|
||
provider.sort_index,
|
||
provider.notes,
|
||
provider.icon,
|
||
provider.icon_color,
|
||
serde_json::to_string(&meta_clone).unwrap(),
|
||
is_current,
|
||
],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// Sync endpoints: Delete all and re-insert
|
||
tx.execute(
|
||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
|
||
params![provider.id, app_type],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
for (url, endpoint) in endpoints {
|
||
tx.execute(
|
||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
||
VALUES (?1, ?2, ?3, ?4)",
|
||
params![provider.id, app_type, url, endpoint.added_at],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
}
|
||
|
||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
||
params![id, app_type],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||
let mut conn = lock_conn!(self.conn);
|
||
let tx = conn
|
||
.transaction()
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// Reset all to 0
|
||
tx.execute(
|
||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
||
params![app_type],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
// Set new current
|
||
tx.execute(
|
||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
|
||
params![id, app_type],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn add_custom_endpoint(
|
||
&self,
|
||
app_type: &str,
|
||
provider_id: &str,
|
||
url: &str,
|
||
) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let added_at = chrono::Utc::now().timestamp_millis();
|
||
conn.execute(
|
||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
|
||
params![provider_id, app_type, url, added_at],
|
||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn remove_custom_endpoint(
|
||
&self,
|
||
app_type: &str,
|
||
provider_id: &str,
|
||
url: &str,
|
||
) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
||
params![provider_id, app_type, url],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
// --- MCP Servers DAO ---
|
||
|
||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut stmt = conn.prepare(
|
||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
||
FROM mcp_servers
|
||
ORDER BY name ASC, id ASC"
|
||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let server_iter = stmt
|
||
.query_map([], |row| {
|
||
let id: String = row.get(0)?;
|
||
let name: String = row.get(1)?;
|
||
let server_config_str: String = row.get(2)?;
|
||
let description: Option<String> = row.get(3)?;
|
||
let homepage: Option<String> = row.get(4)?;
|
||
let docs: Option<String> = row.get(5)?;
|
||
let tags_str: String = row.get(6)?;
|
||
let enabled_claude: bool = row.get(7)?;
|
||
let enabled_codex: bool = row.get(8)?;
|
||
let enabled_gemini: bool = row.get(9)?;
|
||
|
||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||
|
||
Ok((
|
||
id.clone(),
|
||
McpServer {
|
||
id,
|
||
name,
|
||
server,
|
||
apps: McpApps {
|
||
claude: enabled_claude,
|
||
codex: enabled_codex,
|
||
gemini: enabled_gemini,
|
||
},
|
||
description,
|
||
homepage,
|
||
docs,
|
||
tags,
|
||
},
|
||
))
|
||
})
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut servers = IndexMap::new();
|
||
for server_res in server_iter {
|
||
let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||
servers.insert(id, server);
|
||
}
|
||
Ok(servers)
|
||
}
|
||
|
||
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO mcp_servers (
|
||
id, name, server_config, description, homepage, docs, tags,
|
||
enabled_claude, enabled_codex, enabled_gemini
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||
params![
|
||
server.id,
|
||
server.name,
|
||
serde_json::to_string(&server.server).unwrap(),
|
||
server.description,
|
||
server.homepage,
|
||
server.docs,
|
||
serde_json::to_string(&server.tags).unwrap(),
|
||
server.apps.claude,
|
||
server.apps.codex,
|
||
server.apps.gemini,
|
||
],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
// --- Prompts DAO ---
|
||
|
||
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT id, name, content, description, enabled, created_at, updated_at
|
||
FROM prompts WHERE app_type = ?1
|
||
ORDER BY created_at ASC, id ASC",
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let prompt_iter = stmt
|
||
.query_map(params![app_type], |row| {
|
||
let id: String = row.get(0)?;
|
||
let name: String = row.get(1)?;
|
||
let content: String = row.get(2)?;
|
||
let description: Option<String> = row.get(3)?;
|
||
let enabled: bool = row.get(4)?;
|
||
let created_at: Option<i64> = row.get(5)?;
|
||
let updated_at: Option<i64> = row.get(6)?;
|
||
|
||
Ok((
|
||
id.clone(),
|
||
Prompt {
|
||
id,
|
||
name,
|
||
content,
|
||
description,
|
||
enabled,
|
||
created_at,
|
||
updated_at,
|
||
},
|
||
))
|
||
})
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut prompts = IndexMap::new();
|
||
for prompt_res in prompt_iter {
|
||
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||
prompts.insert(id, prompt);
|
||
}
|
||
Ok(prompts)
|
||
}
|
||
|
||
pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO prompts (
|
||
id, app_type, name, content, description, enabled, created_at, updated_at
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||
params![
|
||
prompt.id,
|
||
app_type,
|
||
prompt.name,
|
||
prompt.content,
|
||
prompt.description,
|
||
prompt.enabled,
|
||
prompt.created_at,
|
||
prompt.updated_at,
|
||
],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
|
||
params![id, app_type],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
// --- Skills DAO ---
|
||
|
||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut stmt = conn
|
||
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let skill_iter = stmt
|
||
.query_map([], |row| {
|
||
let key: String = row.get(0)?;
|
||
let installed: bool = row.get(1)?;
|
||
let installed_at_ts: i64 = row.get(2)?;
|
||
|
||
let installed_at =
|
||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||
|
||
Ok((
|
||
key,
|
||
SkillState {
|
||
installed,
|
||
installed_at,
|
||
},
|
||
))
|
||
})
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut skills = IndexMap::new();
|
||
for skill_res in skill_iter {
|
||
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||
skills.insert(key, skill);
|
||
}
|
||
Ok(skills)
|
||
}
|
||
|
||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||
params![key, state.installed, state.installed_at.timestamp()],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut stmt = conn
|
||
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let repo_iter = stmt
|
||
.query_map([], |row| {
|
||
Ok(SkillRepo {
|
||
owner: row.get(0)?,
|
||
name: row.get(1)?,
|
||
branch: row.get(2)?,
|
||
enabled: row.get(3)?,
|
||
skills_path: row.get(4)?,
|
||
})
|
||
})
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut repos = Vec::new();
|
||
for repo_res in repo_iter {
|
||
repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?);
|
||
}
|
||
Ok(repos)
|
||
}
|
||
|
||
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
|
||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
|
||
params![owner, name],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 初始化默认的 Skill 仓库(首次启动时调用)
|
||
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
||
// 检查是否已有仓库
|
||
let existing = self.get_skill_repos()?;
|
||
if !existing.is_empty() {
|
||
return Ok(0);
|
||
}
|
||
|
||
// 获取默认仓库列表
|
||
let default_store = crate::services::skill::SkillStore::default();
|
||
let mut count = 0;
|
||
|
||
for repo in &default_store.repos {
|
||
self.save_skill_repo(repo)?;
|
||
count += 1;
|
||
}
|
||
|
||
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
|
||
Ok(count)
|
||
}
|
||
|
||
// --- Settings DAO ---
|
||
|
||
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
let mut stmt = conn
|
||
.prepare("SELECT value FROM settings WHERE key = ?1")
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
let mut rows = stmt
|
||
.query(params![key])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
|
||
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||
Ok(Some(
|
||
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
||
))
|
||
} else {
|
||
Ok(None)
|
||
}
|
||
}
|
||
|
||
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||
params![key, value],
|
||
)
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
|
||
// --- Config Snippets Helper Methods ---
|
||
|
||
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||
self.get_setting(&format!("common_config_{app_type}"))
|
||
}
|
||
|
||
pub fn set_config_snippet(
|
||
&self,
|
||
app_type: &str,
|
||
snippet: Option<String>,
|
||
) -> Result<(), AppError> {
|
||
let key = format!("common_config_{app_type}");
|
||
if let Some(value) = snippet {
|
||
self.set_setting(&key, &value)
|
||
} else {
|
||
// Delete if None
|
||
let conn = lock_conn!(self.conn);
|
||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||
Ok(())
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const LEGACY_SCHEMA_SQL: &str = r#"
|
||
CREATE TABLE providers (
|
||
id TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
settings_config TEXT NOT NULL,
|
||
PRIMARY KEY (id, app_type)
|
||
);
|
||
CREATE TABLE provider_endpoints (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
provider_id TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
url TEXT NOT NULL
|
||
);
|
||
CREATE TABLE mcp_servers (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
server_config TEXT NOT NULL
|
||
);
|
||
CREATE TABLE prompts (
|
||
id TEXT NOT NULL,
|
||
app_type TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
PRIMARY KEY (id, app_type)
|
||
);
|
||
CREATE TABLE skills (
|
||
key TEXT PRIMARY KEY,
|
||
installed BOOLEAN NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE skill_repos (
|
||
owner TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
PRIMARY KEY (owner, name)
|
||
);
|
||
CREATE TABLE settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT
|
||
);
|
||
"#;
|
||
|
||
#[derive(Debug)]
|
||
struct ColumnInfo {
|
||
name: String,
|
||
r#type: String,
|
||
notnull: i64,
|
||
default: Option<String>,
|
||
}
|
||
|
||
fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
|
||
let mut stmt = conn
|
||
.prepare(&format!("PRAGMA table_info(\"{table}\");"))
|
||
.expect("prepare pragma");
|
||
let mut rows = stmt.query([]).expect("query pragma");
|
||
while let Some(row) = rows.next().expect("read row") {
|
||
let name: String = row.get(1).expect("name");
|
||
if name.eq_ignore_ascii_case(column) {
|
||
return ColumnInfo {
|
||
name,
|
||
r#type: row.get::<_, String>(2).expect("type"),
|
||
notnull: row.get::<_, i64>(3).expect("notnull"),
|
||
default: row.get::<_, Option<String>>(4).ok().flatten(),
|
||
};
|
||
}
|
||
}
|
||
panic!("column {table}.{column} not found");
|
||
}
|
||
|
||
fn normalize_default(default: &Option<String>) -> Option<String> {
|
||
default
|
||
.as_ref()
|
||
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
|
||
}
|
||
|
||
#[test]
|
||
fn migration_sets_user_version_when_missing() {
|
||
let conn = Connection::open_in_memory().expect("open memory db");
|
||
|
||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||
assert_eq!(
|
||
Database::get_user_version(&conn).expect("read version before"),
|
||
0
|
||
);
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
|
||
|
||
assert_eq!(
|
||
Database::get_user_version(&conn).expect("read version after"),
|
||
SCHEMA_VERSION
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_rejects_future_version() {
|
||
let conn = Connection::open_in_memory().expect("open memory db");
|
||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||
|
||
let err = Database::apply_schema_migrations_on_conn(&conn)
|
||
.expect_err("should reject higher version");
|
||
assert!(
|
||
err.to_string().contains("数据库版本过新"),
|
||
"unexpected error: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_adds_missing_columns_for_providers() {
|
||
let conn = Connection::open_in_memory().expect("open memory db");
|
||
|
||
// 创建旧版 providers 表,缺少新增列
|
||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||
.expect("seed old schema");
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||
|
||
// 验证关键新增列已补齐
|
||
for (table, column) in [
|
||
("providers", "meta"),
|
||
("providers", "is_current"),
|
||
("provider_endpoints", "added_at"),
|
||
("mcp_servers", "enabled_gemini"),
|
||
("prompts", "updated_at"),
|
||
("skills", "installed_at"),
|
||
("skill_repos", "enabled"),
|
||
] {
|
||
assert!(
|
||
Database::has_column(&conn, table, column).expect("check column"),
|
||
"{table}.{column} should exist after migration"
|
||
);
|
||
}
|
||
|
||
// 验证 meta 列约束保持一致
|
||
let meta = get_column_info(&conn, "providers", "meta");
|
||
assert_eq!(meta.notnull, 1, "meta should be NOT NULL");
|
||
assert_eq!(
|
||
normalize_default(&meta.default).as_deref(),
|
||
Some("{}"),
|
||
"meta default should be '{{}}'"
|
||
);
|
||
|
||
assert_eq!(
|
||
Database::get_user_version(&conn).expect("version after migration"),
|
||
SCHEMA_VERSION
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_aligns_column_defaults_and_types() {
|
||
let conn = Connection::open_in_memory().expect("open memory db");
|
||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||
.expect("seed old schema");
|
||
|
||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||
|
||
let is_current = get_column_info(&conn, "providers", "is_current");
|
||
assert_eq!(is_current.r#type, "BOOLEAN");
|
||
assert_eq!(is_current.notnull, 1);
|
||
assert_eq!(
|
||
normalize_default(&is_current.default).as_deref(),
|
||
Some("0")
|
||
);
|
||
|
||
let tags = get_column_info(&conn, "mcp_servers", "tags");
|
||
assert_eq!(tags.r#type, "TEXT");
|
||
assert_eq!(tags.notnull, 1);
|
||
assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]"));
|
||
|
||
let enabled = get_column_info(&conn, "prompts", "enabled");
|
||
assert_eq!(enabled.r#type, "BOOLEAN");
|
||
assert_eq!(enabled.notnull, 1);
|
||
assert_eq!(
|
||
normalize_default(&enabled.default).as_deref(),
|
||
Some("1")
|
||
);
|
||
|
||
let installed_at = get_column_info(&conn, "skills", "installed_at");
|
||
assert_eq!(installed_at.r#type, "INTEGER");
|
||
assert_eq!(installed_at.notnull, 1);
|
||
assert_eq!(
|
||
normalize_default(&installed_at.default).as_deref(),
|
||
Some("0")
|
||
);
|
||
|
||
let branch = get_column_info(&conn, "skill_repos", "branch");
|
||
assert_eq!(branch.r#type, "TEXT");
|
||
assert_eq!(normalize_default(&branch.default).as_deref(), Some("main"));
|
||
|
||
let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled");
|
||
assert_eq!(skill_repo_enabled.r#type, "BOOLEAN");
|
||
assert_eq!(skill_repo_enabled.notnull, 1);
|
||
assert_eq!(
|
||
normalize_default(&skill_repo_enabled.default).as_deref(),
|
||
Some("1")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dry_run_does_not_write_to_disk() {
|
||
use crate::app_config::MultiAppConfig;
|
||
use crate::provider::ProviderManager;
|
||
use std::collections::HashMap;
|
||
|
||
// Create minimal valid config for migration
|
||
let mut apps = HashMap::new();
|
||
apps.insert("claude".to_string(), ProviderManager::default());
|
||
|
||
let config = MultiAppConfig {
|
||
version: 2,
|
||
apps,
|
||
mcp: Default::default(),
|
||
prompts: Default::default(),
|
||
skills: Default::default(),
|
||
common_config_snippets: Default::default(),
|
||
claude_common_config_snippet: None,
|
||
};
|
||
|
||
// Dry-run should succeed without any file I/O errors
|
||
let result = Database::migrate_from_json_dry_run(&config);
|
||
assert!(
|
||
result.is_ok(),
|
||
"Dry-run should succeed with valid config: {result:?}"
|
||
);
|
||
|
||
// Verify dry-run can detect schema errors early
|
||
// (This would fail if migrate_from_json_tx had incompatible SQL)
|
||
}
|
||
|
||
#[test]
|
||
fn dry_run_validates_schema_compatibility() {
|
||
use crate::app_config::MultiAppConfig;
|
||
use crate::provider::{Provider, ProviderManager};
|
||
use indexmap::IndexMap;
|
||
use serde_json::json;
|
||
|
||
// Create config with actual provider data
|
||
let mut providers = IndexMap::new();
|
||
providers.insert(
|
||
"test-provider".to_string(),
|
||
Provider {
|
||
id: "test-provider".to_string(),
|
||
name: "Test Provider".to_string(),
|
||
settings_config: json!({
|
||
"anthropicApiKey": "sk-test-123",
|
||
}),
|
||
website_url: None,
|
||
category: None,
|
||
created_at: Some(1234567890),
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
},
|
||
);
|
||
|
||
let mut manager = ProviderManager::default();
|
||
manager.providers = providers;
|
||
manager.current = "test-provider".to_string();
|
||
|
||
let mut apps = HashMap::new();
|
||
apps.insert("claude".to_string(), manager);
|
||
|
||
let config = MultiAppConfig {
|
||
version: 2,
|
||
apps,
|
||
mcp: Default::default(),
|
||
prompts: Default::default(),
|
||
skills: Default::default(),
|
||
common_config_snippets: Default::default(),
|
||
claude_common_config_snippet: None,
|
||
};
|
||
|
||
// Dry-run should validate the full migration path
|
||
let result = Database::migrate_from_json_dry_run(&config);
|
||
assert!(
|
||
result.is_ok(),
|
||
"Dry-run should succeed with provider data: {result:?}"
|
||
);
|
||
}
|
||
}
|