首次推送
build / build-and-test (push) Failing after 1m24s

This commit is contained in:
2026-10-18 19:44:38 +08:00
commit f047662129
64 changed files with 10454 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// SetBackupDir 设置备份目录(来自配置文件)。
func (db *DB) SetBackupDir(dir string) { db.BackupPath = dir }
// BackupDir 返回备份目录。
func (db *DB) BackupDir() string {
if db.BackupPath != "" {
return db.BackupPath
}
return filepath.Join(filepath.Dir(db.Path), "backup")
}
// BackupDaily 当天首次调用时生成一份备份;已有今日备份则跳过。
// 空库(还没有客户)不生成备份,避免第一天的备份是空壳。
func (db *DB) BackupDaily(keep int) (string, error) {
dir := db.BackupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
now := time.Now()
prefix := "daili-" + now.Format("20060102") + "-"
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) {
return "", nil
}
}
if n, err := db.CustomerCount(); err != nil || n == 0 {
return "", err
}
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", now.Format("20060102-150405")))
return db.BackupTo(target, keep)
}
// BackupNow 立即生成一份备份。
func (db *DB) BackupNow(keep int) (string, error) {
dir := db.BackupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
target := filepath.Join(dir, fmt.Sprintf("daili-%s.db", time.Now().Format("20060102-150405")))
return db.BackupTo(target, keep)
}
// BackupTo 用 VACUUM INTO 生成一致性备份,并按保留份数清理。
func (db *DB) BackupTo(target string, keep int) (string, error) {
abs, err := filepath.Abs(target)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %w", err)
}
if _, err := os.Stat(abs); err == nil {
return abs, fmt.Errorf("备份文件已存在: %s", filepath.Base(abs))
}
escaped := strings.ReplaceAll(strings.ReplaceAll(abs, "\\", "/"), "'", "''")
if _, err := db.sql.Exec("VACUUM INTO '" + escaped + "'"); err != nil {
return "", err
}
if keep > 0 {
_ = db.pruneBackups(keep)
}
return abs, nil
}
// ListBackups 列出备份(新→旧)。
func (db *DB) ListBackups() ([]string, error) {
dir := db.BackupDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".db") {
out = append(out, e.Name())
}
}
sort.Sort(sort.Reverse(sort.StringSlice(out)))
return out, nil
}
func (db *DB) pruneBackups(keep int) error {
list, err := db.ListBackups()
if err != nil {
return err
}
if len(list) <= keep {
return nil
}
dir := db.BackupDir()
for _, name := range list[keep:] {
_ = os.Remove(filepath.Join(dir, name))
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package store
import (
"database/sql"
"errors"
"daili/internal/domain"
)
// Checkin 每日服务打卡。
type Checkin struct {
ID int64
SubID int64
ServiceDate string
Status string
Note string
OperatorID int64
OperatorName string
CreatedAt string
UpdatedAt string
}
// SaveCheckin 打卡(同订阅同日覆盖),按次套餐完成时扣次。
func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error {
if status != "done" && status != "skipped" {
return errors.New("打卡状态不正确")
}
if _, err := domain.ParseDate(date); err != nil {
return errors.New("日期格式不正确")
}
return db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法打卡")
}
var oldStatus string
err = tx.QueryRow(`SELECT status FROM checkins WHERE sub_id=? AND service_date=?`, subID, date).Scan(&oldStatus)
hasOld := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
delta := 0
if s.Unit == domain.UnitCount {
oldDone := hasOld && oldStatus == "done"
newDone := status == "done"
switch {
case newDone && !oldDone:
if s.UsedCount+1 > s.TotalCount {
return errors.New("剩余次数不足,请先追加次数")
}
delta = 1
case !newDone && oldDone:
delta = -1
}
}
now := nowStamp()
if hasOld {
if _, err := tx.Exec(`UPDATE checkins SET status=?, note=?, operator_id=?, updated_at=? WHERE sub_id=? AND service_date=?`,
status, note, nullIfZero(operatorID), now, subID, date); err != nil {
return err
}
} else {
if _, err := tx.Exec(`INSERT INTO checkins(sub_id,service_date,status,note,operator_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, subID, date, status, note, nullIfZero(operatorID), now, now); err != nil {
return err
}
}
if delta != 0 {
newUsed := s.UsedCount + delta
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`, newUsed, now, subID); err != nil {
return err
}
}
return nil
})
}
// DeleteCheckin 撤销打卡(按次套餐返还次数),返回所属订阅 ID。
func (db *DB) DeleteCheckin(id, operatorID int64) (int64, error) {
var subID int64
var status string
var date string
err := db.withTx(func(tx *sql.Tx) error {
if err := tx.QueryRow(`SELECT sub_id,status,service_date FROM checkins WHERE id=?`, id).
Scan(&subID, &status, &date); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id); err != nil {
return err
}
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit == domain.UnitCount && status == "done" {
newUsed := s.UsedCount - 1
if newUsed < 0 {
newUsed = 0
}
if _, err := tx.Exec(`UPDATE subscriptions SET used_count=?, updated_at=? WHERE id=?`,
newUsed, nowStamp(), subID); err != nil {
return err
}
}
return nil
})
return subID, err
}
// CheckinsBetween 取区间内打卡,索引为 [subID][date]。
func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.service_date BETWEEN ? AND ?`, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var c Checkin
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
if out[c.SubID] == nil {
out[c.SubID] = map[string]Checkin{}
}
out[c.SubID][c.ServiceDate] = c
}
return out, rows.Err()
}
// CheckinsForSub 某订阅的打卡历史。
func (db *DB) CheckinsForSub(subID int64, limit int) ([]Checkin, error) {
rows, err := db.sql.Query(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.sub_id=? ORDER BY c.service_date DESC LIMIT ?`, subID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Checkin{}
for rows.Next() {
var c Checkin
if err := rows.Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID,
&c.OperatorName, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CheckinFor 取某订阅某天的打卡。
func (db *DB) CheckinFor(subID int64, date string) (*Checkin, error) {
var c Checkin
err := db.sql.QueryRow(`SELECT c.id,c.sub_id,c.service_date,c.status,c.note,IFNULL(c.operator_id,0),
IFNULL(u.display_name,''),c.created_at,c.updated_at
FROM checkins c LEFT JOIN users u ON u.id=c.operator_id
WHERE c.sub_id=? AND c.service_date=?`, subID, date).
Scan(&c.ID, &c.SubID, &c.ServiceDate, &c.Status, &c.Note, &c.OperatorID, &c.OperatorName, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
+201
View File
@@ -0,0 +1,201 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"daili/internal/domain"
)
// Customer 客户。
type Customer struct {
ID int64
Tail string
Name string
GameName string
GameServer string
Contact string
Tags string
Source string
Note string
CreatedAt string
UpdatedAt string
// 列表聚合
SubCount int
Summary string
Level string // ok | warn | today | expired | none
}
// TagList 拆分标签。
func (c *Customer) TagList() []string {
var out []string
for _, t := range strings.Split(c.Tags, ",") {
t = strings.TrimSpace(t)
if t != "" {
out = append(out, t)
}
}
return out
}
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer
err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact,
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
// CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, now, now)
if err != nil {
if isUnique(err) {
return 0, ErrDuplicate
}
return 0, err
}
return res.LastInsertId()
}
// UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error {
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nowStamp(), c.ID)
if err != nil && isUnique(err) {
return ErrDuplicate
}
return err
}
// Customer 取单个客户。
func (db *DB) Customer(id int64) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE id=?`, id))
}
// ListCustomers 分页查询客户,并聚合订阅摘要。
func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int) ([]Customer, int, error) {
where := "1=1"
args := []any{}
if q = strings.TrimSpace(q); q != "" {
like := "%" + q + "%"
where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)"
args = append(args, like, like, like, like, like)
}
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := db.sql.Query(`SELECT `+customerCols+` FROM customers WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`,
append(args, limit, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []Customer{}
ids := []int64{}
for rows.Next() {
c, err := scanCustomer(rows)
if err != nil {
return nil, 0, err
}
out = append(out, *c)
ids = append(ids, c.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if len(ids) > 0 {
subs, err := db.SubsByCustomers(ids)
if err != nil {
return nil, 0, err
}
byCustomer := map[int64][]Subscription{}
for _, s := range subs {
byCustomer[s.CustomerID] = append(byCustomer[s.CustomerID], s)
}
for i := range out {
list := byCustomer[out[i].ID]
out[i].SubCount = len(list)
out[i].Summary, out[i].Level = summarizeSubs(list, today, warnDays)
}
}
return out, total, nil
}
// summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。
func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) {
if len(subs) == 0 {
return "暂无订阅", domain.LevelNone
}
t := domain.ParseDateOr(today, domain.Today())
rank := map[string]int{domain.LevelExpired: 4, domain.LevelToday: 3, domain.LevelWarn: 2, domain.LevelOK: 1, domain.LevelNone: 0}
worst := domain.LevelNone
parts := []string{}
for _, s := range subs {
e := s.Eval(t, warnDays)
if e.State == domain.StateClosed {
continue
}
var text string
if s.Unit == domain.UnitDay {
switch {
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
case e.DaysLeft == 0:
text = s.PackageName + " 今天到期"
default:
text = fmt.Sprintf("%s 剩%d天", s.PackageName, e.RemainingDays)
}
} else {
switch {
case e.RemainingCount <= 0:
text = s.PackageName + " 已用尽"
case e.State == domain.StateExpired:
text = s.PackageName + " 已过期"
default:
text = fmt.Sprintf("%s 剩%d次", s.PackageName, e.RemainingCount)
}
}
parts = append(parts, text)
if rank[e.Level] > rank[worst] {
worst = e.Level
}
}
if len(parts) == 0 {
return "仅历史订阅", domain.LevelNone
}
if len(parts) > 2 {
parts = append(parts[:2], fmt.Sprintf("等%d项", len(parts)))
}
return strings.Join(parts, " · "), worst
}
// FindCustomerByTailName 精确查找(用于查重提示)。
func (db *DB) FindCustomerByTailName(tail, name string) (*Customer, error) {
return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE tail=? AND name=?`,
strings.TrimSpace(tail), strings.TrimSpace(name)))
}
// CustomerCount 客户总数。
func (db *DB) CustomerCount() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n)
return n, err
}
+259
View File
@@ -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
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"fmt"
"time"
"daili/internal/domain"
)
// ExportRowSet 是导出的表头与数据行。
type ExportRowSet struct {
Header []string
Rows [][]string
}
// ExportCustomers 导出客户。
func (db *DB) ExportCustomers() (*ExportRowSet, error) {
rows, err := db.sql.Query(`SELECT id,tail,name,game_name,game_server,contact,tags,source,note,created_at FROM customers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := &ExportRowSet{Header: []string{"ID", "手机尾号", "客户名", "游戏名", "区服/UID", "联系方式", "标签", "来源", "备注", "创建时间"}}
for rows.Next() {
var id int64
var tail, name, game, server, contact, tags, source, note, created string
if err := rows.Scan(&id, &tail, &name, &game, &server, &contact, &tags, &source, &note, &created); err != nil {
return nil, err
}
out.Rows = append(out.Rows, []string{fmt.Sprint(id), tail, name, game, server, contact, tags, source, note, created})
}
return out, rows.Err()
}
// ExportSubscriptions 导出订阅(含派生剩余量)。
func (db *DB) ExportSubscriptions(today time.Time, warnDays int) (*ExportRowSet, error) {
subs, _, err := db.ListSubs(SubFilter{Today: today, Limit: 100000})
if err != nil {
return nil, err
}
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日/有效期",
"总次数", "已用", "剩余", "原文状态", "备注", "更新时间"}}
stateLabel := map[string]string{
domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中",
domain.StateExpired: "已到期", domain.StateUsedUp: "次数用尽", domain.StateClosed: "已结束",
}
for _, s := range subs {
e := s.Eval(today, warnDays)
remaining := ""
if s.Unit == domain.UnitDay {
remaining = fmt.Sprintf("%d 天", e.RemainingDays)
} else {
remaining = fmt.Sprintf("%d 次", e.RemainingCount)
}
out.Rows = append(out.Rows, []string{
fmt.Sprint(s.ID), s.CustomerTail, s.CustomerName, s.PackageName,
map[string]string{domain.UnitDay: "按日", domain.UnitCount: "按次"}[s.Unit],
stateLabel[e.State], s.StartDate, s.EndDate,
fmt.Sprint(s.TotalCount), fmt.Sprint(s.UsedCount), remaining, s.Status, s.Note, s.UpdatedAt,
})
}
return out, nil
}
// ExportLogs 导出操作日志。
func (db *DB) ExportLogs() (*ExportRowSet, error) {
rows, err := db.sql.Query(`SELECT created_at,operator_name,action,entity,summary,ip FROM op_logs ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := &ExportRowSet{Header: []string{"时间", "操作人", "动作", "对象", "摘要", "IP"}}
for rows.Next() {
var created, operator, action, entity, summary, ip string
if err := rows.Scan(&created, &operator, &action, &entity, &summary, &ip); err != nil {
return nil, err
}
out.Rows = append(out.Rows, []string{created, operator, action, entity, summary, ip})
}
return out, rows.Err()
}
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"strings"
)
// OpLog 操作日志。
type OpLog struct {
ID int64
OperatorID int64
OperatorName string
Action string
Entity string
EntityID int64
Summary string
Detail string
IP string
CreatedAt string
}
// Log 写一条操作日志。
func (db *DB) Log(l OpLog) error {
_, err := db.sql.Exec(`INSERT INTO op_logs(operator_id,operator_name,action,entity,entity_id,summary,detail,ip,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
nullIfZero(l.OperatorID), l.OperatorName, l.Action, l.Entity, nullIfZero(l.EntityID),
l.Summary, l.Detail, l.IP, nowStamp())
return err
}
func nullIfZero(v int64) any {
if v == 0 {
return nil
}
return v
}
// ListLogs 分页查询日志。
func (db *DB) ListLogs(q string, limit, offset int) ([]OpLog, int, error) {
where := "1=1"
args := []any{}
if q = strings.TrimSpace(q); q != "" {
like := "%" + q + "%"
where = "(summary LIKE ? OR action LIKE ? OR operator_name LIKE ? OR entity LIKE ?)"
args = append(args, like, like, like, like)
}
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := db.sql.Query(`SELECT id,IFNULL(operator_id,0),operator_name,action,entity,IFNULL(entity_id,0),summary,detail,ip,created_at
FROM op_logs WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, append(args, limit, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []OpLog{}
for rows.Next() {
var l OpLog
if err := rows.Scan(&l.ID, &l.OperatorID, &l.OperatorName, &l.Action, &l.Entity, &l.EntityID,
&l.Summary, &l.Detail, &l.IP, &l.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, l)
}
return out, total, rows.Err()
}
// LogCount 日志总数。
func (db *DB) LogCount() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM op_logs`).Scan(&n)
return n, err
}
+146
View File
@@ -0,0 +1,146 @@
package store
import (
"database/sql"
"errors"
"strings"
"daili/internal/domain"
)
// Package 服务套餐。
type Package struct {
ID int64
Name string
Unit string
DefaultAmount int
PriceCents int
HasPrice bool
Color string
Note string
Archived bool
SortOrder int
CreatedAt string
UpdatedAt string
SubCount int
ActiveCount int
}
// UnitLabel 返回类型中文名。
func (p *Package) UnitLabel() string {
if p.Unit == domain.UnitCount {
return "按次"
}
return "按日"
}
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
var p Package
var price sql.NullInt64
var archived int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Archived = archived == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
}
return &p, nil
}
// ListPackages 列出套餐(含订阅计数)。
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.created_at,p.updated_at,
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
FROM packages p`
if !includeArchived {
q += ` WHERE p.archived=0`
}
q += ` ORDER BY p.archived, p.sort_order, p.id`
rows, err := db.sql.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Package{}
for rows.Next() {
var p Package
var price sql.NullInt64
var archived int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err
}
p.Archived = archived == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
}
out = append(out, p)
}
return out, rows.Err()
}
// Package 取单个套餐。
func (db *DB) Package(id int64) (*Package, error) {
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at
FROM packages WHERE id=?`, id))
}
// CreatePackage 新建套餐。
func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp()
p.Unit = normalizeUnit(p.Unit)
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, now, now)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdatePackage 更新套餐。
func (db *DB) UpdatePackage(p *Package) error {
p.Unit = normalizeUnit(p.Unit)
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#2F5D50"
}
var price any
if p.HasPrice {
price = p.PriceCents
}
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, nowStamp(), p.ID)
return err
}
// SetPackageArchived 归档/取消归档。
func (db *DB) SetPackageArchived(id int64, archived bool) error {
v := 0
if archived {
v = 1
}
_, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
return err
}
+114
View File
@@ -0,0 +1,114 @@
package store
import (
"time"
"daili/internal/domain"
)
// Stats 大屏统计数据。
type Stats struct {
Customers int
ActiveSubs int
PausedSubs int
DueToday int
Due7 int
DueTodaySubs []Subscription
Heat map[string]int // 结束日 -> 该日到期数量
MonthDue int
Top []Subscription
TodayTasks int
TodayDone int
}
// Badges 顶栏常驻徽标。
func (db *DB) Badges(today time.Time) (dueToday, due3 int, err error) {
t := domain.Date(today)
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`, t).Scan(&dueToday); err != nil {
return
}
err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
t, domain.Date(domain.AddDays(today, 2))).Scan(&due3)
return
}
// ScreenStats 汇总大屏所需数据。
func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) {
st := &Stats{Heat: map[string]int{}}
var err error
if st.Customers, err = db.CustomerCount(); err != nil {
return nil, err
}
if st.ActiveSubs, err = db.CountActiveSubs(domain.Date(today)); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status='paused'`).Scan(&st.PausedSubs); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`,
domain.Date(today)).Scan(&st.DueToday); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`,
domain.Date(today), domain.Date(domain.AddDays(today, 6))).Scan(&st.Due7); err != nil {
return nil, err
}
if st.DueTodaySubs, err = db.DueOn(domain.Date(today)); err != nil {
return nil, err
}
// 本月到期热力
ms := domain.MonthStart(today.Year(), today.Month())
me := domain.MonthEnd(today.Year(), today.Month())
rows, err := db.sql.Query(`SELECT end_date, COUNT(*) FROM subscriptions
WHERE status<>'closed' AND end_date BETWEEN ? AND ? GROUP BY end_date`,
domain.Date(ms), domain.Date(me))
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
st.Heat[d] = n
st.MonthDue += n
}
rows.Close()
// TOP:按日按剩余天数、按次按剩余次数,各自排序后交替取前 10
t := domain.Date(today)
daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='day' AND s.end_date>=?
ORDER BY s.end_date ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='count' AND s.used_count < s.total_count
AND (IFNULL(s.end_date,'')='' OR s.end_date>=?)
ORDER BY (s.total_count - s.used_count) ASC LIMIT 12`, t)
if err != nil {
return nil, err
}
for i := 0; i < 10 && len(st.Top) < 10; i++ {
if i < len(daySubs) {
st.Top = append(st.Top, daySubs[i])
}
if len(st.Top) < 10 && i < len(cntSubs) {
st.Top = append(st.Top, cntSubs[i])
}
if i >= len(daySubs) && i >= len(cntSubs) {
break
}
}
_ = warnDays
return st, nil
}
// RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。
func (db *DB) RecentDue(from string, days int) ([]Subscription, error) {
to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1))
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date BETWEEN ? AND ?
ORDER BY s.end_date, s.id`, from, to)
}
+649
View File
@@ -0,0 +1,649 @@
package store
import (
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"daili/internal/domain"
)
func newTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("打开数据库失败: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
func mustPackage(t *testing.T, db *DB, name, unit string, amount int) *Package {
t.Helper()
id, err := db.CreatePackage(&Package{Name: name, Unit: unit, DefaultAmount: amount, Color: "#2F5D50"})
if err != nil {
t.Fatalf("创建套餐失败: %v", err)
}
p, err := db.Package(id)
if err != nil {
t.Fatalf("读取套餐失败: %v", err)
}
return p
}
func mustCustomer(t *testing.T, db *DB, tail, name string) int64 {
t.Helper()
id, err := db.CreateCustomer(&Customer{Tail: tail, Name: name})
if err != nil {
t.Fatalf("创建客户失败: %v", err)
}
return id
}
func TestCreateSubAndDuplicate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if err != nil {
t.Fatalf("创建订阅失败: %v", err)
}
if sub.EndDate != domain.Date(domain.AddDays(today, 29)) {
t.Fatalf("结束日=%s", sub.EndDate)
}
if sub.Unit != domain.UnitDay || sub.Status != domain.StatusActive {
t.Fatalf("unit=%s status=%s", sub.Unit, sub.Status)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
}); err == nil {
t.Fatal("同一客户同一套餐应禁止重复订阅")
}
// 初始记录
adj, err := db.AdjustmentsBySub(sub.ID)
if err != nil {
t.Fatalf("读取追加记录失败: %v", err)
}
if len(adj) != 1 || adj[0].Kind != "initial" || adj[0].DaysAdded != 30 {
t.Fatalf("初始追加记录不正确: %+v", adj)
}
}
func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -40)), Days: 30,
})
if err != nil {
t.Fatal(err)
}
if sub.EndDate != domain.Date(domain.AddDays(today, -11)) {
t.Fatalf("end=%s", sub.EndDate)
}
newEnd := domain.AddDays(today, 29)
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "续费", 0)
if err != nil {
t.Fatalf("追加失败: %v", err)
}
if domain.Date(plan.Base) != domain.Date(today) {
t.Fatalf("已到期订阅应从今天起算,实际 %s", domain.Date(plan.Base))
}
if plan.Days != 30 {
t.Fatalf("追加天数=%d want 30", plan.Days)
}
got, _ := db.Sub(sub.ID)
if got.EndDate != domain.Date(newEnd) {
t.Fatalf("新结束日=%s", got.EndDate)
}
if got.Status != domain.StatusActive {
t.Fatalf("状态=%s", got.Status)
}
adj, _ := db.AdjustmentsBySub(sub.ID)
if len(adj) != 2 || adj[0].Kind != "extend_days" || adj[0].DaysAdded != 30 {
t.Fatalf("追加记录不正确: %+v", adj[0])
}
if adj[0].BaseDate != domain.Date(today) {
t.Fatalf("记录起点=%s", adj[0].BaseDate)
}
}
func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
newEnd := domain.AddDays(today, 39)
plan, err := db.ExtendDays(sub.ID, domain.Date(newEnd), "", 0)
if err != nil {
t.Fatal(err)
}
if domain.Date(plan.Base) != domain.Date(domain.AddDays(today, 30)) {
t.Fatalf("未到期应从原结束日次日起算,实际 %s", domain.Date(plan.Base))
}
if plan.Days != 10 {
t.Fatalf("追加天数=%d want 10", plan.Days)
}
}
func TestExtendDaysRejectsBackwards(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if _, err := db.ExtendDays(sub.ID, domain.Date(domain.AddDays(today, 10)), "", 0); err == nil {
t.Fatal("早于计算起点的日期应被拒绝")
}
}
func TestAddCountAndCheckinConsumption(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
cid := mustCustomer(t, db, "5678", "大鹏")
today := domain.Today()
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), TotalCount: 10,
})
if err != nil {
t.Fatal(err)
}
if sub.TotalCount != 10 || sub.UsedCount != 0 {
t.Fatalf("total=%d used=%d", sub.TotalCount, sub.UsedCount)
}
// 打卡完成 → 扣 1 次
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 同一天改成「跳过」→ 返还
if err := db.SaveCheckin(sub.ID, domain.Date(today), "skipped", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 再改回完成 → 再扣
if err := db.SaveCheckin(sub.ID, domain.Date(today), "done", "", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 1 {
t.Fatalf("used=%d want 1", got.UsedCount)
}
// 撤销打卡 → 返还
ci, err := db.CheckinFor(sub.ID, domain.Date(today))
if err != nil {
t.Fatal(err)
}
if _, err := db.DeleteCheckin(ci.ID, 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.UsedCount != 0 {
t.Fatalf("used=%d want 0", got.UsedCount)
}
// 次数用尽后不能再打卡
_, _ = db.sql.Exec(`UPDATE subscriptions SET used_count=total_count WHERE id=?`, sub.ID)
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err == nil {
t.Fatal("次数用尽时打卡应被拒绝")
}
// 追加次数
if err := db.AddCount(sub.ID, 5, "", "加购", 0); err != nil {
t.Fatal(err)
}
got, _ = db.Sub(sub.ID)
if got.TotalCount != 15 {
t.Fatalf("total=%d want 15", got.TotalCount)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.AddDays(today, -1)), "done", "", 0); err != nil {
t.Fatalf("追加后应可打卡: %v", err)
}
}
func TestPauseResumeShiftsEndDate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
sub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30,
})
if err := db.PauseSub(sub.ID, 0); err != nil {
t.Fatal(err)
}
got, _ := db.Sub(sub.ID)
if got.Status != domain.StatusPaused {
t.Fatalf("status=%s", got.Status)
}
// 把暂停开始时间改成 5 天前,模拟暂停了 5 天
oldStart := domain.Date(domain.AddDays(today, -5)) + " 09:00:00"
if _, err := db.sql.Exec(`UPDATE sub_pauses SET start_at=? WHERE sub_id=? AND end_at IS NULL`, oldStart, sub.ID); err != nil {
t.Fatal(err)
}
shift, err := db.ResumeSub(sub.ID, 0)
if err != nil {
t.Fatal(err)
}
if shift != 5 {
t.Fatalf("顺延天数=%d want 5", shift)
}
got, _ = db.Sub(sub.ID)
if got.EndDate != domain.Date(domain.AddDays(today, 34)) {
t.Fatalf("end=%s want %s", got.EndDate, domain.Date(domain.AddDays(today, 34)))
}
if got.Status != domain.StatusActive {
t.Fatalf("status=%s", got.Status)
}
}
func TestCalendarSubsExcludesOpenEndedCount(t *testing.T) {
db := newTestDB(t)
dayPkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cntPkg := mustPackage(t, db, "深渊十次", domain.UnitCount, 10)
today := domain.Today()
cid := mustCustomer(t, db, "1234", "老张")
daySub, _ := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: dayPkg.ID, StartDate: domain.Date(today), Days: 30,
})
cid2 := mustCustomer(t, db, "5678", "大鹏")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: cntPkg.ID, StartDate: domain.Date(today), TotalCount: 10,
}); err != nil {
t.Fatal(err)
}
subs, err := db.CalendarSubs(domain.Date(today), domain.Date(today))
if err != nil {
t.Fatal(err)
}
if len(subs) != 1 || subs[0].ID != daySub.ID {
t.Fatalf("无有效期的按次订阅不应出现在日历: %+v", subs)
}
}
func TestBackup(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
path, err := db.BackupNow(14)
if err != nil {
t.Fatalf("备份失败: %v", err)
}
list, err := db.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("备份数量=%d want 1 (%s)", len(list), path)
}
// 同日再备份:文件名按秒生成,允许重复
if _, err := db.BackupNow(1); err != nil {
// 同一秒内会重名,算作已存在,不视为失败
t.Logf("第二次备份返回: %v", err)
}
list, _ = db.ListBackups()
if len(list) > 1 {
t.Fatalf("保留份数未生效: %d", len(list))
}
}
func TestSettingsAndBadges(t *testing.T) {
db := newTestDB(t)
_ = db.SetSetting("warn_days", "5")
if got := db.Setting("warn_days", "3"); got != "5" {
t.Fatalf("setting=%s", got)
}
if got := db.Setting("missing", "def"); got != "def" {
t.Fatalf("默认值=%s", got)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
_, _ = db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -30)), Days: 31,
})
due, due3, err := db.Badges(today)
if err != nil {
t.Fatal(err)
}
if due != 1 || due3 != 1 {
t.Fatalf("due=%d due3=%d want 1/1", due, due3)
}
}
func TestSearchByTail(t *testing.T) {
db := newTestDB(t)
mustCustomer(t, db, "8899", "阿飞")
mustCustomer(t, db, "8890", "阿飞二号")
today := domain.Today()
list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(list) != 1 || list[0].Name != "阿飞" {
t.Fatalf("搜索命中 %d 条", total)
}
list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3)
if total != 2 {
t.Fatalf("前缀搜索应命中 2 条,实际 %d", total)
}
}
func TestUniqueCustomer(t *testing.T) {
db := newTestDB(t)
mustCustomer(t, db, "1234", "老张")
_, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "老张"})
if err == nil {
t.Fatal("重复的尾号+姓名应被拒绝")
}
if _, err := db.CreateCustomer(&Customer{Tail: "1234", Name: "另一位"}); err != nil {
t.Fatalf("同尾号不同姓名应允许: %v", err)
}
}
var _ = time.Now
func TestScreenTopLimitedToTen(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
today := domain.Today()
for i := 0; i < 12; i++ {
cid, err := db.CreateCustomer(&Customer{Tail: "T" + strconv.Itoa(i), Name: "客户" + strconv.Itoa(i)})
if err != nil {
t.Fatalf("建客户失败: %v", err)
}
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(today), Days: 30 - i,
}); err != nil {
t.Fatalf("建订阅失败: %v", err)
}
}
st, err := db.ScreenStats(today, 3)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if len(st.Top) != 10 {
t.Fatalf("TOP 榜应为 10 条,实际 %d 条", len(st.Top))
}
}
func TestBackupKeepPrune(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", domain.UnitDay, 30)
dir := db.BackupDir()
names := []string{
"daili-20260101-000001.db",
"daili-20260102-000001.db",
"daili-20260103-000001.db",
}
for _, n := range names {
if _, err := db.BackupTo(filepath.Join(dir, n), 0); err != nil {
t.Fatalf("备份失败: %v", err)
}
}
if err := db.pruneBackups(2); err != nil {
t.Fatalf("清理失败: %v", err)
}
list, err := db.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("保留 2 份,实际 %d 份: %v", len(list), list)
}
if list[0] != names[2] || list[1] != names[1] {
t.Fatalf("保留的不是最新两份: %v", list)
}
}
func TestBackupDirOverride(t *testing.T) {
db := newTestDB(t)
custom := filepath.Join(t.TempDir(), "自定义备份")
db.SetBackupDir(custom)
if db.BackupDir() != custom {
t.Fatalf("备份目录未生效: %s", db.BackupDir())
}
if _, err := db.BackupNow(5); err != nil {
t.Fatalf("备份失败: %v", err)
}
if _, err := os.Stat(custom); err != nil {
t.Fatalf("备份目录未创建: %v", err)
}
}
// copyFile 用于模拟“用户只拷贝数据库文件”。
func copyFile(t *testing.T, src, dst string) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读取 %s 失败: %v", src, err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
t.Fatalf("写入 %s 失败: %v", dst, err)
}
}
func TestSingleFileDatabase(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
if mode, err := db.JournalMode(); err != nil || !strings.EqualFold(mode, "delete") {
t.Fatalf("journal_mode=%s err=%v", mode, err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
if _, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
}); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("运行中不应出现 %s", suffix)
}
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("关闭后不应残留 %s", suffix)
}
}
if fi, err := os.Stat(path); err != nil || fi.Size() < 8192 {
t.Fatalf("主库大小异常: %v", err)
}
}
func TestCopyDatabaseIsLossless(t *testing.T) {
srcDir := t.TempDir()
srcPath := filepath.Join(srcDir, "daili.db")
db, err := Open(srcPath)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "1234", "老张")
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
if err := db.SaveCheckin(sub.ID, domain.Date(domain.Today()), "done", "迁移前打卡", 0); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// 只拷贝 daili.db 一个文件
dstDir := t.TempDir()
dstPath := filepath.Join(dstDir, "daili.db")
copyFile(t, srcPath, dstPath)
cp, err := Open(dstPath)
if err != nil {
t.Fatalf("打开副本失败: %v", err)
}
defer cp.Close()
if n, err := cp.CustomerCount(); err != nil || n != 1 {
t.Fatalf("客户数=%d err=%v", n, err)
}
got, err := cp.Sub(sub.ID)
if err != nil || got.CustomerTail != "1234" || got.EndDate != sub.EndDate {
t.Fatalf("订阅丢失: %+v err=%v", got, err)
}
ci, err := cp.CheckinFor(sub.ID, domain.Date(domain.Today()))
if err != nil || ci.Note != "迁移前打卡" {
t.Fatalf("打卡记录丢失: %+v err=%v", ci, err)
}
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(dstPath + suffix); err == nil {
t.Fatalf("副本目录出现了 %s", suffix)
}
}
}
func TestDeleteDatabaseResets(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
mustCustomer(t, db, "1234", "老张")
if err := db.Close(); err != nil {
t.Fatal(err)
}
for _, p := range []string{path, path + "-wal", path + "-shm", path + "-journal"} {
_ = os.Remove(p)
}
fresh, err := Open(path)
if err != nil {
t.Fatalf("删除后重建失败: %v", err)
}
defer fresh.Close()
if err := fresh.Migrate(); err != nil {
t.Fatal(err)
}
if n, _ := fresh.CustomerCount(); n != 0 {
t.Fatalf("删库后应无客户,实际 %d", n)
}
if n, _ := fresh.CountUsers(); n != 0 {
t.Fatalf("删库后应无管理员,实际 %d", n)
}
}
func TestStaleSidecarsCleanup(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "daili.db")
// 模拟“删了主库但留下旧版本 WAL”的情况
if err := os.WriteFile(path+"-wal", []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path+"-shm", []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开失败: %v", err)
}
defer db.Close()
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(path + suffix); err == nil {
t.Fatalf("残留的 %s 应被清理", suffix)
}
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
}
func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.db")
// 模拟 v1.1 的 WAL 库
dsn := strings.ReplaceAll(path, "\\", "/") + "?_pragma=journal_mode(WAL)"
old, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := old.Exec(`CREATE TABLE legacy(x INTEGER)`); err != nil {
t.Fatal(err)
}
if _, err := old.Exec(`INSERT INTO legacy VALUES(42)`); err != nil {
t.Fatal(err)
}
if err := old.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开旧库失败: %v", err)
}
defer db.Close()
var x int
if err := db.sql.QueryRow(`SELECT x FROM legacy`).Scan(&x); err != nil || x != 42 {
t.Fatalf("旧库数据丢失: x=%d err=%v", x, err)
}
if mode, _ := db.JournalMode(); !strings.EqualFold(mode, "delete") {
t.Fatalf("旧库未切到单文件模式: %s", mode)
}
}
+597
View File
@@ -0,0 +1,597 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"daili/internal/domain"
)
// Subscription 服务订阅。
type Subscription struct {
ID int64
CustomerID int64
PackageID int64
Unit string
Status string
StartDate string
EndDate string
TotalCount int
UsedCount int
Note string
CreatedBy int64
CreatedAt string
UpdatedAt string
ClosedAt string
// 联表
PackageName string
PackageColor string
CustomerTail string
CustomerName string
CustomerContact string
}
// Eval 计算展示状态。
func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
return domain.EvalSub(s.Unit, s.Status, s.StartDate, s.EndDate, s.TotalCount, s.UsedCount, today, warnDays)
}
// IsCount 是否按次套餐。
func (s *Subscription) IsCount() bool { return s.Unit == domain.UnitCount }
// Pause 暂停分段。
type Pause struct {
ID int64
SubID int64
StartAt string
EndAt string
StartDate string
EndDate string
}
// Adjustment 追加记录。
type Adjustment struct {
ID int64
SubID int64
Kind string
BaseDate string
OldEndDate string
NewEndDate string
DaysAdded int
CountAdded int
OldTotal int
NewTotal int
Note string
OperatorID int64
OperatorName string
CreatedAt string
// 联表
CustomerTail string
CustomerName string
PackageName string
}
// KindLabel 返回追加类型的中文名。
func (a *Adjustment) KindLabel() string {
switch a.Kind {
case "initial":
return "开通"
case "extend_days":
return "追加天数"
case "add_count":
return "追加次数"
case "set_end_date":
return "调整结束日"
}
return "调整"
}
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''),
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
FROM subscriptions s
JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_id`
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
var s Subscription
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &s, nil
}
// Sub 取单个订阅。
func (db *DB) Sub(id int64) (*Subscription, error) {
return scanSub(db.sql.QueryRow(subSelect+` WHERE s.id=?`, id))
}
// SubsByCustomer 取某客户的订阅。
func (db *DB) SubsByCustomer(customerID int64) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.customer_id=? ORDER BY s.status, s.id`, customerID)
}
// SubsByCustomers 批量取订阅(客户列表聚合用)。
func (db *DB) SubsByCustomers(ids []int64) ([]Subscription, error) {
if len(ids) == 0 {
return nil, nil
}
ph := placeholders(len(ids))
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return db.querySubs(subSelect+` WHERE s.customer_id IN (`+ph+`) ORDER BY s.id`, args...)
}
func (db *DB) querySubs(q string, args ...any) ([]Subscription, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Subscription{}
for rows.Next() {
s, err := scanSub(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// SubFilter 订阅列表筛选。
type SubFilter struct {
Q string
State string
PackageID int64
Limit int
Offset int
Today time.Time
}
const expiredExpr = `((s.unit='day' AND s.end_date < ?) OR (s.unit='count' AND (s.used_count>=s.total_count OR (IFNULL(s.end_date,'')<>'' AND s.end_date < ?))))`
// ListSubs 分页查询订阅。
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
where := []string{"1=1"}
args := []any{}
today := domain.Date(f.Today)
if q := strings.TrimSpace(f.Q); q != "" {
like := "%" + q + "%"
where = append(where, "(c.tail LIKE ? OR c.name LIKE ? OR p.name LIKE ?)")
args = append(args, like, like, like)
}
if f.PackageID > 0 {
where = append(where, "s.package_id=?")
args = append(args, f.PackageID)
}
switch f.State {
case "active":
where = append(where, "s.status='active'", "NOT "+expiredExpr)
args = append(args, today, today)
case "paused":
where = append(where, "s.status='paused'")
case "closed":
where = append(where, "s.status='closed'")
case "expired":
where = append(where, "s.status<>'closed'", expiredExpr)
args = append(args, today, today)
case "due7":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 6)))
case "due3":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 2)))
case "due_today":
where = append(where, "s.status<>'closed'", "s.end_date = ?")
args = append(args, today)
}
w := strings.Join(where, " AND ")
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s
JOIN packages p ON p.id=s.package_id JOIN customers c ON c.id=s.customer_id WHERE `+w, args...).Scan(&total); err != nil {
return nil, 0, err
}
lim := f.Limit
if lim <= 0 {
lim = 30
}
q := subSelect + ` WHERE ` + w + ` ORDER BY s.status, IFNULL(s.end_date,'9999-12-31'), s.id DESC LIMIT ? OFFSET ?`
subs, err := db.querySubs(q, append(args, lim, f.Offset)...)
return subs, total, err
}
// CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。
func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed'
AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ?
ORDER BY s.id`, to, from)
}
// DueOn 某天到期的订阅。
func (db *DB) DueOn(date string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date=? ORDER BY s.id`, date)
}
// CreateSubInput 新建订阅入参。
type CreateSubInput struct {
CustomerID int64
PackageID int64
StartDate string
EndDate string
Days int
TotalCount int
Note string
OperatorID int64
}
// CreateSub 新建订阅,并写入初始追加记录。
func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
pkg, err := db.Package(in.PackageID)
if err != nil {
return nil, errors.New("套餐不存在")
}
if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅")
}
start, err := domain.ParseDate(in.StartDate)
if err != nil {
return nil, errors.New("开始日期格式不正确")
}
unit := pkg.Unit
endDate := ""
days := 0
total := 0
if unit == domain.UnitDay {
days = in.Days
if days <= 0 {
days = pkg.DefaultAmount
}
if days <= 0 {
days = 30
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("结束日期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("结束日期不能早于开始日期")
}
endDate = domain.Date(end)
days = domain.DayDiff(start, end) + 1
} else {
endDate = domain.Date(domain.AddDays(start, days-1))
}
} else {
total = in.TotalCount
if total <= 0 {
total = pkg.DefaultAmount
}
if total <= 0 {
return nil, errors.New("总次数必须大于 0")
}
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("有效期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
}
now := nowStamp()
var id int64
err = db.withTx(func(tx *sql.Tx) error {
var existing int64
err := tx.QueryRow(`SELECT id FROM subscriptions WHERE customer_id=? AND package_id=?`,
in.CustomerID, in.PackageID).Scan(&existing)
if err == nil {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?)`,
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now)
if err != nil {
if isUnique(err) {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
}
return err
}
id, _ = res.LastInsertId()
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, 0, 0, total, "新建订阅", nullIfZero(in.OperatorID), now)
return err
})
if err != nil {
return nil, err
}
return db.Sub(id)
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// ExtendDays 按日追加:手动指定新的结束日,自动换算计算起点与追加天数。
func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64) (domain.ExtendPlan, error) {
var plan domain.ExtendPlan
today := domain.Today()
newEnd, err := domain.ParseDate(newEndDate)
if err != nil {
return plan, errors.New("请选择新的终止日期")
}
err = db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitDay {
return errors.New("该订阅按次计费,请使用「追加次数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
oldEnd := domain.ParseDateOr(s.EndDate, today)
plan = domain.PlanExtend(oldEnd, newEnd, today)
if plan.Backwards {
return fmt.Errorf("所选日期早于计算起点 %s,不会增加时长", domain.Date(plan.Base))
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=?, status=?, updated_at=? WHERE id=?`,
domain.Date(newEnd), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`,
subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp())
return err
})
return plan, err
}
// AddCount 按次追加次数(可同时调整有效期)。
func (db *DB) AddCount(subID int64, add int, newEndDate, note string, operatorID int64) error {
if add <= 0 {
return errors.New("追加次数必须大于 0")
}
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Unit != domain.UnitCount {
return errors.New("该订阅按日计费,请使用「追加天数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
endDate := s.EndDate
if newEndDate != "" {
end, err := domain.ParseDate(newEndDate)
if err != nil {
return errors.New("有效期格式不正确")
}
if end.Before(domain.ParseDateOr(s.StartDate, domain.Today())) {
return errors.New("有效期不能早于开始日期")
}
endDate = domain.Date(end)
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
newTotal := s.TotalCount + add
if _, err := tx.Exec(`UPDATE subscriptions SET total_count=?, end_date=?, status=?, updated_at=? WHERE id=?`,
newTotal, nullIfEmpty(endDate), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
VALUES(?,?,?,?,0,?,?,?,?,?,?)`,
subID, "add_count", s.EndDate, endDate, add, s.TotalCount, newTotal, note, nullIfZero(operatorID), nowStamp())
return err
})
return err
}
// PauseSub 暂停订阅。
func (db *DB) PauseSub(subID, operatorID int64) error {
return db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusPaused {
return errors.New("订阅已处于暂停状态")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法暂停")
}
now := nowStamp()
if _, err := tx.Exec(`INSERT INTO sub_pauses(sub_id,start_at) VALUES(?,?)`, subID, now); err != nil {
return err
}
_, err = tx.Exec(`UPDATE subscriptions SET status='paused', updated_at=? WHERE id=?`, now, subID)
return err
})
}
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
var shift int
today := domain.Today()
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status != domain.StatusPaused {
return errors.New("订阅未处于暂停状态")
}
var startAt string
err = tx.QueryRow(`SELECT start_at FROM sub_pauses WHERE sub_id=? AND end_at IS NULL ORDER BY id DESC LIMIT 1`, subID).Scan(&startAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
ps := domain.ParseDateOr(startAt[:10], today)
shift = domain.DayDiff(ps, today)
if shift < 0 {
shift = 0
}
}
now := nowStamp()
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
return err
}
if s.Unit == domain.UnitDay && shift > 0 {
oldEnd := domain.ParseDateOr(s.EndDate, today)
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
return err
}
}
_, err = tx.Exec(`UPDATE subscriptions SET status='active', updated_at=? WHERE id=?`, now, subID)
return err
})
return shift, err
}
// CloseSub 结束订阅。
func (db *DB) CloseSub(subID int64) error {
now := nowStamp()
_, err := db.sql.Exec(`UPDATE subscriptions SET status='closed', closed_at=?, updated_at=? WHERE id=? AND status<>'closed'`,
now, now, subID)
return err
}
// ReopenSub 重新启用已结束的订阅。
func (db *DB) ReopenSub(subID int64) error {
_, err := db.sql.Exec(`UPDATE subscriptions SET status='active', closed_at=NULL, updated_at=? WHERE id=?`, nowStamp(), subID)
return err
}
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。
func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
WHERE a.sub_id=? ORDER BY a.id DESC`, subID)
}
// RecentAdjustments 最近追加记录。
func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
ORDER BY a.id DESC LIMIT ?`, limit)
}
func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Adjustment{}
for rows.Next() {
var a Adjustment
if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate,
&a.DaysAdded, &a.CountAdded, &a.OldTotal, &a.NewTotal, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// PausesForSubs 批量取暂停分段。
func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
out := map[int64][]Pause{}
if len(ids) == 0 {
return out, nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := db.sql.Query(`SELECT id,sub_id,start_at,IFNULL(end_at,'') FROM sub_pauses
WHERE sub_id IN (`+placeholders(len(ids))+`) ORDER BY id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p Pause
if err := rows.Scan(&p.ID, &p.SubID, &p.StartAt, &p.EndAt); err != nil {
return nil, err
}
if len(p.StartAt) >= 10 {
p.StartDate = p.StartAt[:10]
}
if len(p.EndAt) >= 10 {
p.EndDate = p.EndAt[:10]
}
out[p.SubID] = append(out[p.SubID], p)
}
return out, rows.Err()
}
// CountActiveSubs 活跃订阅数。
func (db *DB) CountActiveSubs(today string) (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' AND (
(s.unit='day' AND s.end_date >= ?) OR
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
)`, today, today).Scan(&n)
return n, err
}
// SubTitle 生成「1234 老张 · 包月日常」式标题。
func (s *Subscription) SubTitle() string {
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
}
+179
View File
@@ -0,0 +1,179 @@
package store
import (
"database/sql"
"errors"
"time"
"daili/internal/pw"
)
// User 管理员账户。
type User struct {
ID int64
Username string
DisplayName string
IsActive bool
CreatedAt string
LastLoginAt string
}
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
var active int
var last sql.NullString
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.IsActive = active == 1
u.LastLoginAt = last.String
return &u, nil
}
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
// CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// CreateUser 新建账户。
func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
hash, err := pw.Hash(password)
if err != nil {
return nil, err
}
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
VALUES(?,?,?,1,?)`, username, displayName, hash, now)
if err != nil {
if isUnique(err) {
return nil, ErrDuplicate
}
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
}
// Authenticate 校验用户名口令。
func (db *DB) Authenticate(username, password string) (*User, error) {
var hash string
var active int
var id int64
err := db.sql.QueryRow(`SELECT id, password_hash, is_active FROM users WHERE username=?`, username).
Scan(&id, &hash, &active)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if active != 1 || !pw.Verify(password, hash) {
return nil, ErrNotFound
}
_, _ = db.sql.Exec(`UPDATE users SET last_login_at=? WHERE id=?`, nowStamp(), id)
return db.UserByID(id)
}
// UserByID 按 ID 取账户。
func (db *DB) UserByID(id int64) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
}
// UserByUsername 按用户名取账户。
func (db *DB) UserByUsername(name string) (*User, error) {
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE username=?`, name))
}
// ListUsers 列出账户。
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// UpdatePassword 修改口令。
func (db *DB) UpdatePassword(userID int64, password string) error {
hash, err := pw.Hash(password)
if err != nil {
return err
}
_, err = db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID)
return err
}
// SetUserActive 启用/停用账户。
func (db *DB) SetUserActive(userID int64, active bool) error {
v := 0
if active {
v = 1
}
_, err := db.sql.Exec(`UPDATE users SET is_active=? WHERE id=?`, v, userID)
return err
}
// ---------- 会话 ----------
// CreateSession 新建登录会话。
func (db *DB) CreateSession(token string, userID int64, ttl time.Duration, ip string) error {
now := time.Now()
_, err := db.sql.Exec(`INSERT INTO sessions(token,user_id,created_at,expires_at,ip) VALUES(?,?,?,?,?)`,
token, userID, now.Format("2006-01-02 15:04:05"), now.Add(ttl).Format("2006-01-02 15:04:05"), ip)
return err
}
// SessionUser 通过令牌取账户,过期则返回 ErrNotFound。
func (db *DB) SessionUser(token string) (*User, error) {
var userID int64
var expires string
err := db.sql.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token=?`, token).Scan(&userID, &expires)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
exp, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.Local)
if err != nil || exp.Before(time.Now()) {
_ = db.DeleteSession(token)
return nil, ErrNotFound
}
u, err := db.UserByID(userID)
if err != nil {
return nil, err
}
if !u.IsActive {
return nil, ErrNotFound
}
return u, nil
}
// DeleteSession 删除会话。
func (db *DB) DeleteSession(token string) error {
_, err := db.sql.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
// PurgeSessions 清理过期会话。
func (db *DB) PurgeSessions() {
_, _ = db.sql.Exec(`DELETE FROM sessions WHERE expires_at < ?`, nowStamp())
}