邻接查询 tie-break 与 pinned 排序键与列表序对齐,补表驱动测试(round2 P1-1); GC DELETE 带条件复查,防 SELECT 与 DELETE 间恢复/引用竞态(P2-6); 备份经 umask 收紧创建即 0600,消除 chmod 前暴露窗口(P2-7); page_size 改 strconv.Atoi 全文解析,拒部分解析脏值(P2-13); OpenData 不迁移、不做版本守卫(D6/P1-5)。
174 lines
4.9 KiB
Go
174 lines
4.9 KiB
Go
// Package store SQLite 打开、版本化迁移与全部数据访问。
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"path/filepath"
|
||
"time"
|
||
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
// bgCtx DAO 内部使用的后台 context(个人规模查询均为快速查询)。
|
||
var bgCtx = context.Background()
|
||
|
||
// ErrSchemaNewer 数据库 schema 版本超出代码支持范围(§10.4 启动守卫)。
|
||
var ErrSchemaNewer = errors.New("数据库 schema 版本高于本程序支持的最高版本,拒绝启动(可用 --allow-newer 显式放行)")
|
||
|
||
// migrations 仅追加式:禁止删列/重命名/改类型(§6.2)。
|
||
var migrations = []string{
|
||
// v1: 初始 schema(§6.1 DDL)
|
||
`
|
||
CREATE TABLE IF NOT EXISTS notes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
title TEXT NOT NULL,
|
||
summary TEXT NOT NULL DEFAULT '',
|
||
content TEXT NOT NULL DEFAULT '',
|
||
status TEXT NOT NULL DEFAULT 'private'
|
||
CHECK (status IN ('public','private')),
|
||
tags TEXT NOT NULL DEFAULT '[]',
|
||
pinned INTEGER NOT NULL DEFAULT 0,
|
||
deleted_at INTEGER,
|
||
created_at INTEGER NOT NULL,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_notes_public
|
||
ON notes (status, pinned, updated_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_notes_deleted ON notes (deleted_at);
|
||
|
||
CREATE TABLE IF NOT EXISTS images (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
sha256 TEXT NOT NULL UNIQUE,
|
||
mime TEXT NOT NULL,
|
||
size INTEGER NOT NULL,
|
||
data BLOB NOT NULL,
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS image_refs (
|
||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (image_id, note_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_image_refs_note ON image_refs (note_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
token_hash TEXT PRIMARY KEY,
|
||
csrf_token TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at);
|
||
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
`,
|
||
}
|
||
|
||
// MaxSchemaVersion 代码支持的最高 schema 版本。
|
||
var MaxSchemaVersion = len(migrations)
|
||
|
||
// Store 封装 *sql.DB;单连接串行写(§6.2 并发策略)。
|
||
type Store struct {
|
||
db *sql.DB
|
||
}
|
||
|
||
// Open 打开数据库并执行迁移(start/init 用)。dbPath 为文件绝对/相对路径。
|
||
// allowNewer 跳过 user_version 上界守卫(§10.4)。
|
||
func Open(dbPath string, allowNewer bool) (*Store, error) {
|
||
db, err := openDB(dbPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := migrateDB(db, allowNewer); err != nil {
|
||
db.Close()
|
||
return nil, err
|
||
}
|
||
return &Store{db: db}, nil
|
||
}
|
||
|
||
// OpenData 打开数据库仅做数据操作:**不迁移、不做版本守卫**
|
||
// (passwd/backup/gc 维护子命令,D6:在更新 schema 的库上备份/清理
|
||
// 不应被守卫阻断,也不应产生迁移副作用)。
|
||
func OpenData(dbPath string) (*Store, error) {
|
||
db, err := openDB(dbPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &Store{db: db}, nil
|
||
}
|
||
|
||
func openDB(dbPath string) (*sql.DB, error) {
|
||
// SQLite URI 不接受相对路径
|
||
abs, err := filepath.Abs(dbPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dsn := url.URL{
|
||
Scheme: "file",
|
||
Path: abs,
|
||
RawQuery: "_pragma=journal_mode(WAL)" +
|
||
"&_pragma=busy_timeout(5000)" +
|
||
"&_pragma=foreign_keys(1)" +
|
||
"&_pragma=synchronous(NORMAL)",
|
||
}
|
||
db, err := sql.Open("sqlite", dsn.String())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 单写者串行化(§6.2)
|
||
db.SetMaxOpenConns(1)
|
||
return db, nil
|
||
}
|
||
|
||
func migrateDB(db *sql.DB, allowNewer bool) error {
|
||
var v int
|
||
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
||
return err
|
||
}
|
||
if v > MaxSchemaVersion && !allowNewer {
|
||
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
|
||
}
|
||
for i := v; i < MaxSchemaVersion; i++ {
|
||
tx, err := db.Begin()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(migrations[i]); err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("执行迁移 v%d 失败: %w", i+1, err)
|
||
}
|
||
// PRAGMA 不能参数化,i 为内部 int 常量,无注入面。
|
||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SchemaVersion 返回当前库版本。
|
||
func (s *Store) SchemaVersion() (int, error) {
|
||
var v int
|
||
err := s.db.QueryRow("PRAGMA user_version").Scan(&v)
|
||
return v, err
|
||
}
|
||
|
||
// Close 关闭连接。
|
||
func (s *Store) Close() error { return s.db.Close() }
|
||
|
||
// DB 暴露底层连接(仅备份等维护路径使用)。
|
||
func (s *Store) DB() *sql.DB { return s.db }
|
||
|
||
func nowUnix() int64 { return time.Now().Unix() }
|