需求与确认(用户逐项确认): - 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式 - 打手归属一并移除(只剩一个人,颜色与指派没有区分意义) - 盲水印做成完全不可见:只存在于 DOM / 源码,肉眼与截图都看不到 - 拼写采用更正后的 Powered by Baka春秋 - 版本号与品牌全站统一带 -L - screen_public / portal_public 保留解析但不起作用,保证旧 ini 能直接启动 - README 同步更新 品牌与版本(全站统一): - const version = "1.11.5-L":<title>、启动横幅、日志前缀 [代打台-L]、 设置页版本号、页脚版本号全部带 -L - 左上角导航品牌 代打台 → 代打台-L(登录页、初始化页同步) - 页脚新增可见署名 Powered by Baka春秋 盲水印: - _layout.html 在 <body> 起始处注入 .wm-blind 容器(含注释与 data-wm 双标记), 覆盖包括登录/初始化在内的所有页面 - app.css 用零尺寸 + 透明 + opacity:0 + clip-path:inset(50%) 四重隐藏, 同时留同名注释;不占位、不上色、不进无障碍树、不影响打印 功能移除: - 大屏:删除 handlers_screen.go、pages/screen.html、_screen_layout.html、 static/screen.css、static/screen.js、store.Stats/ScreenStats; 导航「大屏 ↗」、页脚「大屏」、设置页「大屏刷新/大屏」行、启动横幅大屏行一并删除 - 客户自查:删除 handlers_portal.go、pages/portal.html 与 .portal-*/.pcal-* 样式, 设置页「客户自助」行删除 - 多管理员:删除 handleUserCreate/Toggle/Color/UserEditPage/UserEditSave、 pages/user_form.html、store 侧 ActiveUsers/SetUserColor/SetUserActive/ UpdateUserByAdmin/NormalizeColor/AssigneePalette;设置页「管理员」整块移除, 只保留首次启动创建的那一个账户与「修改我的密码」 - 打手归属:删除 customers.assignee_id 相关读写与 UI(客户详情指派下拉、 客户表单「归属打手」、日历格左侧打手色竖条、任务卡打手标签、套餐详情客户列) - 登录页与初始化页的说明文案同步改为单账户口径(「只有一个管理员账户」 「创建后不能再添加其他管理员」),字数与 main 对齐,卡片不换行、高度不变 - 触摸小键盘:删除 3×4 键盘、⌨/触控开关、localStorage 记忆、.keypad* 样式 - 套餐计费方式:只保留按日。删除按次/通行证/深渊的 Package 与 Subscription 字段、 domain 里的 UnitCount/StateUsedUp/CycleIndex、package_form 计费方式单选与 通行证/深渊/有效期字段、日历整段色块(has-block/cell-block/season-band)、 追加次数分支、SeasonComplete/AddCount、CountActiveSubs 的次数条件 数据库迁移(幂等,Migrate 内自动执行): - normalizeLegacyData:套餐一律 unit='day' 并清 season/abyss/cycle_days/ default_validity_days/season_*;订阅清 season/abyss/cycle_days/season_done - 通行证、深渊当年就是 unit='day' 且起止日完整,仅清标记,服务区间原样保留 - 按次按「1 次 = 1 天」折算:结束日 = 开始日 + 总次数 − 1;仍缺结束日退回开始日 - 只动数据,不删表不删列:老库的 season/abyss/cycle_days/total_count/used_count 保留但不再使用,升级可逆、不会因为 DDL 失败卡住 - 新库 schema 干净:不再创建上述列,users.color / customers.assignee_id 也不创建 - 老库 ensureColumn 保留 calendar_separate / tag / archived 三项(真增量列) 兼容: - config 仍解析 screen_public / portal_public(结构体字段注明已废弃), 默认 ini 模板里改为「已废弃,写了不报错、不生效」 测试: - go vet ./... 通过;go test ./... 全部通过 (新增 TestNormalizeLegacyTypeData 验证三模式折算成按日; TestSingleUserAccount / TestSessionLifecycle 覆盖单用户与会话; 删除按次、深渊、打手颜色、多用户等已不存在的能力的用例) - 新增 scripts/smoke-lite.ps1:起隔离实例,19 个页面级请求全部 200, 已移除的 /screen、/customer、/api/screen/day 均 404, 水印/品牌/v1.11.5-L/无残留词 12 项断言通过, 真实数据下日历出现待打任务、「剩 30 天」、今天到期徽标 4 项断言通过, 本次 0 失败 README:改为 lite 版说明,删除大屏/客户自查/通行证/深渊/按次/小键盘/多用户章节, 新增「盲水印」与「与 main 分支的差异」两节(含老数据折算规则与列保留说明)。
363 lines
12 KiB
Go
363 lines
12 KiB
Go
// 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")
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
// 增量列:老库升级时补上(SQLite 没有 ADD COLUMN IF NOT EXISTS)
|
||
if err := db.ensureColumn("packages", "calendar_separate",
|
||
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||
return err
|
||
}
|
||
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
|
||
if err := db.ensureColumn("packages", "tag",
|
||
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
|
||
return err
|
||
}
|
||
if err := db.ensureColumn("customers", "archived",
|
||
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||
return err
|
||
}
|
||
// lite:把老库里按次 / 通行证 / 深渊的数据统一折算成按日
|
||
return db.normalizeLegacyData()
|
||
}
|
||
|
||
// normalizeLegacyData 把 main 分支遗留的三种套餐类型折算成按日。
|
||
//
|
||
// - 通行证、深渊订阅当年就是 unit='day' 且起止日期完整,清掉标记即可;
|
||
// - 按次订阅按「1 次 = 1 天」折算成天:有 total_count 且没有结束日的,
|
||
// 结束日 = 开始日 + total_count - 1;仍缺结束日的退回开始日。
|
||
//
|
||
// 老库缺少这些列时自动跳过,因此可以反复执行。
|
||
func (db *DB) normalizeLegacyData() error {
|
||
// 套餐:一律按日
|
||
pkgSets := []string{"unit='day'"}
|
||
for _, col := range []string{"season", "abyss", "cycle_days", "default_validity_days"} {
|
||
if db.hasColumn("packages", col) {
|
||
pkgSets = append(pkgSets, col+"=0")
|
||
}
|
||
}
|
||
for _, col := range []string{"season_start", "season_end"} {
|
||
if db.hasColumn("packages", col) {
|
||
pkgSets = append(pkgSets, col+"=''")
|
||
}
|
||
}
|
||
if _, err := db.sql.Exec(`UPDATE packages SET ` + strings.Join(pkgSets, ",")); err != nil {
|
||
return fmt.Errorf("迁移套餐类型失败: %w", err)
|
||
}
|
||
|
||
// 订阅:按次先折算天数,再统一成按日
|
||
if db.hasColumn("subscriptions", "total_count") {
|
||
if _, err := db.sql.Exec(`UPDATE subscriptions
|
||
SET end_date = date(start_date, '+' || (total_count - 1) || ' days')
|
||
WHERE unit='count' AND IFNULL(total_count,0) > 0 AND IFNULL(end_date,'')=''`); err != nil {
|
||
return fmt.Errorf("迁移按次订阅失败: %w", err)
|
||
}
|
||
}
|
||
if _, err := db.sql.Exec(`UPDATE subscriptions
|
||
SET unit='day', end_date=IFNULL(NULLIF(end_date,''), start_date)
|
||
WHERE unit<>'day' OR IFNULL(end_date,'')=''`); err != nil {
|
||
return fmt.Errorf("迁移订阅计费方式失败: %w", err)
|
||
}
|
||
subSets := []string{}
|
||
for _, col := range []string{"season", "abyss", "cycle_days"} {
|
||
if db.hasColumn("subscriptions", col) {
|
||
subSets = append(subSets, col+"=0")
|
||
}
|
||
}
|
||
if db.hasColumn("subscriptions", "season_done") {
|
||
subSets = append(subSets, "season_done=''")
|
||
}
|
||
if len(subSets) > 0 {
|
||
if _, err := db.sql.Exec(`UPDATE subscriptions SET ` + strings.Join(subSets, ",")); err != nil {
|
||
return fmt.Errorf("迁移订阅标记失败: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// hasColumn 判断表里是否有某一列。
|
||
func (db *DB) hasColumn(table, column string) bool {
|
||
rows, err := db.sql.Query("PRAGMA table_info(" + table + ")")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var cid int
|
||
var name, ctype string
|
||
var notnull, pk int
|
||
var dflt any
|
||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||
return false
|
||
}
|
||
if strings.EqualFold(name, column) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
|
||
func (db *DB) ensureColumn(table, column, ddl string) error {
|
||
if db.hasColumn(table, column) {
|
||
return nil
|
||
}
|
||
if _, err := db.sql.Exec(ddl); err != nil {
|
||
return fmt.Errorf("迁移失败(%s.%s): %w", table, column, err)
|
||
}
|
||
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 DEFAULT 'day' CHECK (unit IN ('day')),
|
||
default_amount INTEGER NOT NULL DEFAULT 30,
|
||
price_cents INTEGER,
|
||
color TEXT NOT NULL DEFAULT '#3B6EA5',
|
||
note TEXT NOT NULL DEFAULT '',
|
||
archived INTEGER NOT NULL DEFAULT 0,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
calendar_separate INTEGER NOT NULL DEFAULT 0,
|
||
tag TEXT NOT NULL DEFAULT '',
|
||
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 '',
|
||
archived INTEGER NOT NULL DEFAULT 0,
|
||
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 DEFAULT 'day' CHECK (unit IN ('day')),
|
||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
|
||
start_date TEXT NOT NULL,
|
||
end_date TEXT,
|
||
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,
|
||
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 task_flags (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||
service_date TEXT NOT NULL,
|
||
urgent INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
UNIQUE (sub_id, service_date)
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_task_flags_date ON task_flags(service_date)`,
|
||
`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
|
||
}
|