Files
AI-Expert-Sidebar/internal/database/db.go
T
2026-04-01 15:29:35 +08:00

200 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}