@@ -0,0 +1,259 @@
|
||||
// Package store 负责 SQLite 连接、迁移与全部数据读写。
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"daili/internal/domain"
|
||||
)
|
||||
|
||||
// 常见错误
|
||||
var (
|
||||
ErrDuplicate = errors.New("记录已存在")
|
||||
ErrNotFound = errors.New("记录不存在")
|
||||
)
|
||||
|
||||
// DB 是数据库句柄。
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
Path string
|
||||
|
||||
// BackupPath 由配置文件指定;为空时回落到数据库同级的 backup/ 目录。
|
||||
BackupPath string
|
||||
}
|
||||
|
||||
// Open 打开或创建数据库文件。
|
||||
//
|
||||
// 采用 DELETE 回滚日志模式而不是 WAL:这样全部数据始终在 daili.db 这一个文件里,
|
||||
// 运行中不会产生 -wal/-shm 旁路文件,用户复制一个 daili.db 就能无损迁移;
|
||||
// 删掉 daili.db 也就等于清空全部数据。
|
||||
func Open(path string) (*DB, error) {
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
// 数据库文件不存在(用户删除 = 重置)时,清掉可能是旧版本残留的旁路文件,
|
||||
// 避免一个孤立的 -wal 干扰新建的库。
|
||||
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
|
||||
_ = os.Remove(path + "-wal")
|
||||
_ = os.Remove(path + "-shm")
|
||||
}
|
||||
dsn := strings.ReplaceAll(path, "\\", "/") +
|
||||
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=synchronous(FULL)"
|
||||
sqlDB, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
// 兜底确认:旧版本的 WAL 库在打开时会被自动 checkpoint 并切回 DELETE 模式
|
||||
var mode string
|
||||
if err := sqlDB.QueryRow("PRAGMA journal_mode").Scan(&mode); err == nil && !strings.EqualFold(mode, "delete") {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("数据库日志模式切换失败(当前 %s),请确认 daili.db 没有被其他程序占用", mode)
|
||||
}
|
||||
return &DB{sql: sqlDB, Path: path}, nil
|
||||
}
|
||||
|
||||
// JournalMode 返回当前日志模式(正常应为 delete)。
|
||||
func (db *DB) JournalMode() (string, error) {
|
||||
var mode string
|
||||
err := db.sql.QueryRow("PRAGMA journal_mode").Scan(&mode)
|
||||
return mode, err
|
||||
}
|
||||
|
||||
// Close 关闭数据库。
|
||||
func (db *DB) Close() error { return db.sql.Close() }
|
||||
|
||||
// SQL 暴露底层连接(供备份等使用)。
|
||||
func (db *DB) SQL() *sql.DB { return db.sql }
|
||||
|
||||
func nowStamp() string { return time.Now().Format(domain.StampLayout) }
|
||||
|
||||
func (db *DB) withTx(fn func(*sql.Tx) error) error {
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func isUnique(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
func normalizeUnit(u string) string {
|
||||
if u == domain.UnitCount {
|
||||
return domain.UnitCount
|
||||
}
|
||||
return domain.UnitDay
|
||||
}
|
||||
|
||||
// Migrate 幂等建表。
|
||||
func (db *DB) Migrate() error {
|
||||
for _, stmt := range schemaStatements {
|
||||
if _, err := db.sql.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("迁移失败: %w\nSQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var schemaStatements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
ip TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS packages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
|
||||
default_amount INTEGER NOT NULL DEFAULT 30,
|
||||
price_cents INTEGER,
|
||||
color TEXT NOT NULL DEFAULT '#2F5D50',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tail TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL DEFAULT '',
|
||||
game_server TEXT NOT NULL DEFAULT '',
|
||||
contact TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (tail, name)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
package_id INTEGER NOT NULL REFERENCES packages(id),
|
||||
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
closed_at TEXT,
|
||||
UNIQUE (customer_id, package_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_pauses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
start_at TEXT NOT NULL,
|
||||
end_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_adjustments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
base_date TEXT,
|
||||
old_end_date TEXT,
|
||||
new_end_date TEXT,
|
||||
days_added INTEGER NOT NULL DEFAULT 0,
|
||||
count_added INTEGER NOT NULL DEFAULT 0,
|
||||
old_total INTEGER NOT NULL DEFAULT 0,
|
||||
new_total INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
operator_id INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS checkins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
service_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('done','skipped')),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
operator_id INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (sub_id, service_date)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS op_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
operator_id INTEGER REFERENCES users(id),
|
||||
operator_name TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
entity TEXT NOT NULL DEFAULT '',
|
||||
entity_id INTEGER,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sub_status_end ON subscriptions(status, end_date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sub_customer ON subscriptions(customer_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sub_package ON subscriptions(package_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cust_tail ON customers(tail)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_adj_sub ON sub_adjustments(sub_id, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_ci_date ON checkins(service_date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_ci_sub ON checkins(sub_id, service_date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pause_sub ON sub_pauses(sub_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_log_created ON op_logs(created_at)`,
|
||||
}
|
||||
|
||||
// ---------- 设置 ----------
|
||||
|
||||
// Setting 读取设置,缺失时返回默认值。
|
||||
func (db *DB) Setting(key, def string) string {
|
||||
var v string
|
||||
err := db.sql.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
|
||||
if err != nil || v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// SetSetting 写入设置。
|
||||
func (db *DB) SetSetting(key, value string) error {
|
||||
_, err := db.sql.Exec(`INSERT INTO settings(key,value) VALUES(?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user