200 lines
7.1 KiB
Go
200 lines
7.1 KiB
Go
// 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
|
||
}
|