Files
chun_qiu cde1c84c9a feat(v1.11.5-L): lite 精简版——移除大屏/客户自查/多用户/小键盘,套餐仅保留按日
需求与确认(用户逐项确认):
- 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式
- 打手归属一并移除(只剩一个人,颜色与指派没有区分意义)
- 盲水印做成完全不可见:只存在于 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 分支的差异」两节(含老数据折算规则与列保留说明)。
2026-10-21 03:33:27 +08:00

363 lines
12 KiB
Go
Raw Permalink 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 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, &notnull, &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
}