diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 8efc0ddba..f4325470c 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -6,7 +6,7 @@ use super::*; use crate::app_config::MultiAppConfig; use crate::provider::{Provider, ProviderManager}; use indexmap::IndexMap; -use rusqlite::Connection; +use rusqlite::{params, Connection}; use serde_json::json; use std::collections::HashMap; @@ -51,6 +51,74 @@ const LEGACY_SCHEMA_SQL: &str = r#" ); "#; +// v3.8.x(schema v1)的真实表结构快照:用于验证从 v3.8.* 升级到当前版本的迁移链路 +// 参考:tag v3.8.3 的 src-tauri/src/database/schema.rs +const V3_8_SCHEMA_V1_SQL: &str = r#" + CREATE TABLE 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) + ); + CREATE TABLE 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 + ); + CREATE TABLE 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 + ); + CREATE TABLE 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) + ); + CREATE TABLE skills ( + key TEXT PRIMARY KEY, + installed BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE skill_repos ( + owner TEXT NOT NULL, + name TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + enabled BOOLEAN NOT NULL DEFAULT 1, + PRIMARY KEY (owner, name) + ); + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT + ); +"#; + #[derive(Debug)] struct ColumnInfo { r#type: String, @@ -246,6 +314,124 @@ fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() { .expect("query by app_type"); } +#[test] +fn migration_from_v3_8_schema_v1_to_current_schema_v3() { + let conn = Connection::open_in_memory().expect("open memory db"); + conn.execute("PRAGMA foreign_keys = ON;", []) + .expect("enable foreign keys"); + + // 模拟 v3.8.* 用户的数据库(schema v1) + conn.execute_batch(V3_8_SCHEMA_V1_SQL) + .expect("seed v3.8 schema v1"); + Database::set_user_version(&conn, 1).expect("set user_version=1"); + + // 插入一条旧版 Provider + Skill(用于验证迁移不会破坏既有数据) + conn.execute( + "INSERT 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![ + "p1", + "claude", + "Test Provider", + serde_json::to_string(&json!({ "anthropicApiKey": "sk-test" })).unwrap(), + Option::::None, + Option::::None, + Option::::None, + Option::::None, + Option::::None, + Option::::None, + Option::::None, + "{}", + 1, + ], + ) + .expect("seed provider"); + + conn.execute( + "INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", + params!["claude:demo-skill", 1, 1700000000i64], + ) + .expect("seed legacy skill"); + + // 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移) + Database::create_tables_on_conn(&conn).expect("create tables"); + Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); + + assert_eq!( + Database::get_user_version(&conn).expect("user_version after migration"), + SCHEMA_VERSION + ); + + // v1 -> v2:providers 新增字段必须补齐 + for column in [ + "cost_multiplier", + "limit_daily_usd", + "limit_monthly_usd", + "provider_type", + "in_failover_queue", + ] { + assert!( + Database::has_column(&conn, "providers", column).expect("check column"), + "providers.{column} should exist after migration" + ); + } + + // 旧 provider 不应丢失,且新增字段应有默认值 + let provider_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'", + [], + |r| r.get(0), + ) + .expect("count providers"); + assert_eq!(provider_count, 1); + + let cost_multiplier: String = conn + .query_row( + "SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'", + [], + |r| r.get(0), + ) + .expect("read cost_multiplier"); + assert_eq!(cost_multiplier, "1.0"); + + // v2 -> v3:skills 表重建为统一结构,并设置 pending 标记(后续由启动时扫描文件系统重建数据) + assert!( + Database::has_column(&conn, "skills", "enabled_claude").expect("check skills v3 column"), + "skills table should be migrated to v3 structure" + ); + let skills_count: i64 = conn + .query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0)) + .expect("count skills"); + assert_eq!(skills_count, 0, "skills table should be rebuilt empty"); + + let pending: Option = conn + .query_row( + "SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'", + [], + |r| r.get(0), + ) + .ok(); + assert!( + matches!(pending.as_deref(), Some("true") | Some("1")), + "skills_ssot_migration_pending should be set after v2->v3 migration" + ); + + // v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值) + let proxy_rows: i64 = conn + .query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0)) + .expect("count proxy_config rows"); + assert_eq!(proxy_rows, 3); + + // model_pricing 应具备默认数据(迁移时会 seed) + let pricing_rows: i64 = conn + .query_row("SELECT COUNT(*) FROM model_pricing", [], |r| r.get(0)) + .expect("count model_pricing rows"); + assert!(pricing_rows > 0, "model_pricing should be seeded"); +} + #[test] fn dry_run_does_not_write_to_disk() { // Create minimal valid config for migration diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 307d2eafd..d6e1e9b78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,12 +273,25 @@ pub fn run() { None }; - // 现在创建数据库 - let db = match crate::database::Database::init() { - Ok(db) => Arc::new(db), - Err(e) => { - log::error!("Failed to init database: {e}"); - return Err(Box::new(e)); + // 现在创建数据库(包含 Schema 迁移) + // + // 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移, + // 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示, + // 否则表现可能只是“应用打不开/闪退”。 + let db = loop { + match crate::database::Database::init() { + Ok(db) => break Arc::new(db), + Err(e) => { + log::error!("Failed to init database: {e}"); + + if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string()) + { + log::info!("用户选择退出程序"); + std::process::exit(1); + } + + log::info!("用户选择重试初始化数据库"); + } } }; @@ -1033,3 +1046,68 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool { )) .blocking_show() } + +/// 显示数据库初始化/Schema 迁移失败对话框 +/// 返回 true 表示用户选择重试,false 表示用户选择退出 +fn show_database_init_error_dialog( + app: &tauri::AppHandle, + db_path: &std::path::Path, + error: &str, +) -> bool { + let title = if is_chinese_locale() { + "数据库初始化失败" + } else { + "Database Initialization Failed" + }; + + let message = if is_chinese_locale() { + format!( + "初始化数据库或迁移数据库结构时发生错误:\n\n{error}\n\n\ + 数据库文件路径:\n{db}\n\n\ + 您的数据尚未丢失,应用不会自动删除数据库文件。\n\ + 常见原因包括:数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\n\n\ + 建议:\n\ + 1) 先备份整个配置目录(包含 cc-switch.db)\n\ + 2) 如果提示“数据库版本过新”,请升级到更新版本\n\ + 3) 如果刚升级出现异常,可回退旧版本导出/备份后再升级\n\n\ + 点击「重试」重新尝试初始化\n\ + 点击「退出」关闭程序", + db = db_path.display() + ) + } else { + format!( + "An error occurred while initializing or migrating the database:\n\n{error}\n\n\ + Database file path:\n{db}\n\n\ + Your data is NOT lost - the app will not delete the database automatically.\n\ + Common causes include: newer database version, corrupted file, permission issues, or low disk space.\n\n\ + Suggestions:\n\ + 1) Back up the entire config directory (including cc-switch.db)\n\ + 2) If you see “database version is newer”, please upgrade CC Switch\n\ + 3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\n\n\ + Click 'Retry' to attempt initialization again\n\ + Click 'Exit' to close the program", + db = db_path.display() + ) + }; + + let retry_text = if is_chinese_locale() { + "重试" + } else { + "Retry" + }; + let exit_text = if is_chinese_locale() { + "退出" + } else { + "Exit" + }; + + app.dialog() + .message(&message) + .title(title) + .kind(MessageDialogKind::Error) + .buttons(MessageDialogButtons::OkCancelCustom( + retry_text.to_string(), + exit_text.to_string(), + )) + .blocking_show() +}