// Package database 封装了本项目所有 SQLite 数据库的生命周期管理。 // // # 架构设计说明 // // 本项目采用"双 DB 模式",而非传统的"单一数据库": // // - settings.db — 全局设置库(AI 配置键值对 + 知识库注册表) // 存储在 os.UserConfigDir()/AI-Expert-Sidebar/ 下,随应用永久保留。 // // - *.db — 知识库文件(每个业务话题一个独立 .db 文件) // 用户可随时创建/切换,彼此完全物理隔离,互不干扰。 // // 这样设计的原因:传统单库方案依赖 user_id 做逻辑隔离, // 一旦查询漏加 WHERE 条件就会数据泄露;物理隔离则从根本上消除此风险, // 同时让用户可以直接拷贝/备份/分享单个 .db 文件,操作极为直观。 // // # 并发安全 // // 所有全局变量通过 sync.RWMutex 保护。读操作使用 RLock, // 仅写(切换库)时用 Lock,保证多 goroutine 并发安全。 package database import ( "fmt" "log" "os" "path/filepath" "sync" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "AI-Expert-Sidebar/internal/models" ) var ( // mu 保护下面所有包级变量的并发读写。 mu sync.RWMutex // settingsDB 是全局设置数据库,存储 AppSetting 和 Library 两张表。 // 生命周期与应用相同,Init() 调用后即可使用。 settingsDB *gorm.DB // activeLib 指向当前正在使用的知识库 SQLite 连接。 // 通过 OpenLibrary() 切换,Get() 读取。 activeLib *gorm.DB // activeLibNm 缓存当前活跃知识库的显示名称,供前端展示。 // 避免每次都查数据库。 activeLibNm string // DataDir 是应用数据目录的绝对路径。 // 暴露给 service 层用于拼接新知识库 .db 文件路径。 DataDir string ) // Init 初始化全局设置数据库。 // // 具体操作: // 1. 调用 appDataDir() 确定存储目录(macOS: ~/Library/Application Support/AI-Expert-Sidebar/); // 2. 若目录不存在则创建(权限 0750,防止其他用户读取 API Key); // 3. 打开/创建 settings.db,并执行 AutoMigrate 建表; // 4. 若 settings.db 损坏或被删除,GORM 会重新创建,程序不会崩溃。 // // 设计原则:Init 只负责"基础设施",不做业务初始化(那是 service.InitLibraries 的职责)。 func Init() error { dir, err := appDataDir() if err != nil { return err } DataDir = dir // 0o750: 当前用户 rwx,组 r-x,其他无权限 —— 保护含密钥的目录 if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("create data dir: %w", err) } db, err := openSQLite(filepath.Join(dir, "settings.db")) if err != nil { return fmt.Errorf("open settings.db: %w", err) } // AutoMigrate 是幂等的:若表已存在则只做增量列变更,不会删数据 if err := db.AutoMigrate(&models.AppSetting{}, &models.Library{}); err != nil { return fmt.Errorf("migrate settings schema: %w", err) } mu.Lock() settingsDB = db mu.Unlock() log.Printf("[DB] Settings DB ready at %s/settings.db", dir) return nil } // OpenLibrary 切换当前活跃的知识库到指定的 Library 记录所对应的 .db 文件。 // // 调用时机:用户在 LibraryBar 下拉框中选择了某个知识库, // 或程序启动时 InitLibraries 恢复上次的选择。 // // 注意:OpenLibrary 不关闭旧连接,GORM 底层连接池会自动管理; // 这使切换库操作无需等待旧连接关闭,响应更快。 func OpenLibrary(lib models.Library) error { db, err := openSQLite(lib.FilePath) if err != nil { return fmt.Errorf("open library %q: %w", lib.Name, err) } // 每次打开都 AutoMigrate,保证旧版本数据库在新版 Entry schema 下能正常工作 if err := db.AutoMigrate(&models.Entry{}); err != nil { return fmt.Errorf("migrate library %q: %w", lib.Name, err) } mu.Lock() activeLib = db activeLibNm = lib.Name mu.Unlock() // 持久化"上次活跃库"偏好,使用 UPSERT 避免 duplicate key 错误 settingsDB.Exec( "INSERT INTO app_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", "active_library", lib.Name, ) log.Printf("[DB] Active library: %s", lib.Name) return nil } // NewLibraryDB 在指定路径创建一个全新的知识库 SQLite 文件并建表。 // // 物理创建而非逻辑创建——每个知识库是真实独立的文件, // 天然隔离,用户可直接在 Finder 中看到、备份或删除。 func NewLibraryDB(path string) error { db, err := openSQLite(path) if err != nil { return err } return db.AutoMigrate(&models.Entry{}) } // NewLibraryDBReadOnly 以只读模式打开一个已有的知识库文件。 // // 专用于"条目计数"等查询场景,避免只读操作意外创建 WAL 日志文件 // 或触发写锁,适合在列出知识库列表时并发调用。 func NewLibraryDBReadOnly(path string) (*gorm.DB, error) { // ?mode=ro 是 SQLite URI 参数,让驱动以 SQLITE_OPEN_READONLY 打开 return openSQLite(path + "?mode=ro") } // GetSettings 返回全局设置数据库的 *gorm.DB 实例。 // 调用方应先检查返回值是否为 nil(Init 尚未调用时可能为 nil)。 func GetSettings() *gorm.DB { mu.RLock() defer mu.RUnlock() return settingsDB } // Get 返回当前活跃知识库的 *gorm.DB 实例。 // 在任何 CRUD 操作前都应先调用此函数,若返回 nil 则知识库尚未选定。 func Get() *gorm.DB { mu.RLock() defer mu.RUnlock() return activeLib } // GetActiveLibName 返回当前活跃知识库的显示名称(空字符串表示尚未打开)。 func GetActiveLibName() string { mu.RLock() defer mu.RUnlock() return activeLibNm } // IsReady 报告系统是否"就绪":设置库已初始化 且 有活跃知识库。 // 前端通过 GetDBStatus() 轮询此函数,决定是否显示"本地 SQLite"绿点。 func IsReady() bool { mu.RLock() defer mu.RUnlock() return settingsDB != nil && activeLib != nil } // ── 内部辅助函数 ─────────────────────────────────────────────────────────────── // openSQLite 使用 glebarez/sqlite 纯 Go 驱动打开 SQLite 文件。 // // 选用 glebarez/sqlite(而非 mattn/go-sqlite3)的原因: // 纯 Go 实现,无需 CGO,可以 GOOS=darwin/linux/windows 直接交叉编译, // 大幅简化 CI/CD 和分发流程,且兼容 GORM 接口。 func openSQLite(path string) (*gorm.DB, error) { return gorm.Open(sqlite.Open(path), &gorm.Config{ // Warn 级别:只记录慢查询和错误,避免调试日志污染生产输出 Logger: logger.Default.LogMode(logger.Warn), }) } // appDataDir 返回应用推荐的数据目录。 // macOS: ~/Library/Application Support/AI-Expert-Sidebar/ // Linux: ~/.config/AI-Expert-Sidebar/ // Windows: %AppData%\AI-Expert-Sidebar\ // 使用 os.UserConfigDir() 而非硬编码路径,保证跨平台兼容。 func appDataDir() (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("user config dir: %w", err) } return filepath.Join(dir, "AI-Expert-Sidebar"), nil }