@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e, &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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user