package database import ( "database/sql" "fmt" "strings" _ "modernc.org/sqlite" ) // DB 数据库连接 type DB struct { conn *sql.DB } // migration 单个版本迁移 type migration struct { version int // 版本号,单调递增,永不修改 desc string // 描述,便于日志追踪 stmts []string } // migrations 所有版本迁移,按 version 升序排列 // 规则: // - 只能追加新版本,绝对不能修改已有版本 // - version 从 1 开始,每次发布新版本时递增 // - 每个 version 对应一批幂等的 DDL 语句 var migrations = []migration{ { version: 1, desc: "初始化核心表结构", stmts: []string{ `CREATE TABLE IF NOT EXISTS launch_codes ( profile_id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`, `CREATE TABLE IF NOT EXISTS browser_profiles ( profile_id TEXT PRIMARY KEY, profile_name TEXT NOT NULL, user_data_dir TEXT NOT NULL DEFAULT '', core_id TEXT NOT NULL DEFAULT '', fingerprint_args TEXT NOT NULL DEFAULT '[]', proxy_id TEXT NOT NULL DEFAULT '', proxy_config TEXT NOT NULL DEFAULT '', launch_args TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]', keywords TEXT NOT NULL DEFAULT '[]', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_browser_profiles_created_at ON browser_profiles(created_at)`, `CREATE TABLE IF NOT EXISTS browser_proxies ( proxy_id TEXT PRIMARY KEY, proxy_name TEXT NOT NULL, proxy_config TEXT NOT NULL, dns_servers TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS browser_cores ( core_id TEXT PRIMARY KEY, core_name TEXT NOT NULL, core_path TEXT NOT NULL, is_default INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS browser_bookmarks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL UNIQUE, sort_order INTEGER NOT NULL DEFAULT 0 )`, }, }, { version: 2, desc: "添加实例分组支持", stmts: []string{ `CREATE TABLE IF NOT EXISTS browser_groups ( group_id TEXT PRIMARY KEY, group_name TEXT NOT NULL, parent_id TEXT DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `CREATE INDEX IF NOT EXISTS idx_browser_groups_parent_id ON browser_groups(parent_id)`, `ALTER TABLE browser_profiles ADD COLUMN group_id TEXT DEFAULT ''`, }, }, { version: 3, desc: "代理表添加分组和测速字段", stmts: []string{ `ALTER TABLE browser_proxies ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE browser_proxies ADD COLUMN last_latency_ms INTEGER NOT NULL DEFAULT -1`, `ALTER TABLE browser_proxies ADD COLUMN last_test_ok INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE browser_proxies ADD COLUMN last_tested_at TEXT NOT NULL DEFAULT ''`, }, }, { version: 4, desc: "代理表添加 IP 健康结果字段", stmts: []string{ `ALTER TABLE browser_proxies ADD COLUMN last_ip_health_json TEXT NOT NULL DEFAULT ''`, }, }, { version: 5, desc: "代理表添加 URL 来源与自动刷新字段", stmts: []string{ `ALTER TABLE browser_proxies ADD COLUMN source_id TEXT NOT NULL DEFAULT ''`, `ALTER TABLE browser_proxies ADD COLUMN source_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE browser_proxies ADD COLUMN source_name_prefix TEXT NOT NULL DEFAULT ''`, `ALTER TABLE browser_proxies ADD COLUMN source_auto_refresh INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE browser_proxies ADD COLUMN source_refresh_interval_m INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE browser_proxies ADD COLUMN source_last_refresh_at TEXT NOT NULL DEFAULT ''`, }, }, // ── 新版本在此追加,格式: // { // version: 4, // desc: "描述本次变更", // stmts: []string{ // `ALTER TABLE xxx ADD COLUMN yyy TEXT NOT NULL DEFAULT ''`, // }, // }, } // NewDB 创建新的数据库连接 func NewDB(dbPath string) (*DB, error) { conn, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("打开数据库失败: %w", err) } conn.SetMaxOpenConns(1) conn.SetMaxIdleConns(1) if err := conn.Ping(); err != nil { return nil, fmt.Errorf("连接数据库失败: %w", err) } // WAL 模式:写不阻塞读 if _, err := conn.Exec(`PRAGMA journal_mode=WAL`); err != nil { return nil, fmt.Errorf("设置 WAL 模式失败: %w", err) } // 开启外键约束 if _, err := conn.Exec(`PRAGMA foreign_keys=ON`); err != nil { return nil, fmt.Errorf("开启外键约束失败: %w", err) } return &DB{conn: conn}, nil } // GetConn 获取数据库连接 func (db *DB) GetConn() *sql.DB { return db.conn } // Close 关闭数据库连接 func (db *DB) Close() error { if db.conn != nil { return db.conn.Close() } return nil } // Migrate 执行版本化迁移 // 原理:维护 schema_migrations 表记录已执行版本,每次启动只执行未执行的版本 func (db *DB) Migrate() error { // 确保版本记录表存在 if _, err := db.conn.Exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, desc TEXT NOT NULL DEFAULT '', applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`); err != nil { return fmt.Errorf("创建 schema_migrations 表失败: %w", err) } // 查询已执行的最大版本号 var currentVersion int row := db.conn.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`) if err := row.Scan(¤tVersion); err != nil { return fmt.Errorf("查询当前 schema 版本失败: %w", err) } // 按版本顺序执行未执行的迁移 for _, m := range migrations { if m.version <= currentVersion { continue // 已执行,跳过 } // 每个版本在事务内执行,保证原子性 if err := db.applyMigration(m); err != nil { return fmt.Errorf("迁移版本 %d (%s) 失败: %w", m.version, m.desc, err) } } return nil } // applyMigration 在事务内执行单个版本的所有语句,并记录版本号 func (db *DB) applyMigration(m migration) error { tx, err := db.conn.Begin() if err != nil { return fmt.Errorf("开启事务失败: %w", err) } defer tx.Rollback() for _, stmt := range m.stmts { if _, err := tx.Exec(stmt); err != nil { // ALTER TABLE 添加已存在列时忽略(兼容从旧版本直接升级的情况) if isColumnExistsError(err) { continue } return fmt.Errorf("执行语句失败 [%s]: %w", truncate(stmt, 60), err) } } // 记录版本号 if _, err := tx.Exec( `INSERT INTO schema_migrations (version, desc) VALUES (?, ?)`, m.version, m.desc, ); err != nil { return fmt.Errorf("记录迁移版本失败: %w", err) } return tx.Commit() } // isColumnExistsError 检查是否是列已存在的错误(SQLite 错误信息) func isColumnExistsError(err error) bool { if err == nil { return false } s := err.Error() return strings.Contains(s, "duplicate column") || strings.Contains(s, "already exists") } // truncate 截断字符串用于日志展示 func truncate(s string, n int) string { s = strings.TrimSpace(s) if len(s) <= n { return s } return s[:n] + "..." }