Files
subtrack/internal/store/store_test.go
T
chun_qiu cde1c84c9a feat(v1.11.5-L): lite 精简版——移除大屏/客户自查/多用户/小键盘,套餐仅保留按日
需求与确认(用户逐项确认):
- 老数据「按次 / 通行证 / 深渊」统一迁移成按日,代码里彻底删除三种模式
- 打手归属一并移除(只剩一个人,颜色与指派没有区分意义)
- 盲水印做成完全不可见:只存在于 DOM / 源码,肉眼与截图都看不到
- 拼写采用更正后的 Powered by Baka春秋
- 版本号与品牌全站统一带 -L
- screen_public / portal_public 保留解析但不起作用,保证旧 ini 能直接启动
- README 同步更新

品牌与版本(全站统一):
- const version = "1.11.5-L":<title>、启动横幅、日志前缀 [代打台-L]、
  设置页版本号、页脚版本号全部带 -L
- 左上角导航品牌 代打台 → 代打台-L(登录页、初始化页同步)
- 页脚新增可见署名 Powered by Baka春秋

盲水印:
- _layout.html 在 <body> 起始处注入 .wm-blind 容器(含注释与 data-wm 双标记),
  覆盖包括登录/初始化在内的所有页面
- app.css 用零尺寸 + 透明 + opacity:0 + clip-path:inset(50%) 四重隐藏,
  同时留同名注释;不占位、不上色、不进无障碍树、不影响打印

功能移除:
- 大屏:删除 handlers_screen.go、pages/screen.html、_screen_layout.html、
  static/screen.css、static/screen.js、store.Stats/ScreenStats;
  导航「大屏 ↗」、页脚「大屏」、设置页「大屏刷新/大屏」行、启动横幅大屏行一并删除
- 客户自查:删除 handlers_portal.go、pages/portal.html 与 .portal-*/.pcal-* 样式,
  设置页「客户自助」行删除
- 多管理员:删除 handleUserCreate/Toggle/Color/UserEditPage/UserEditSave、
  pages/user_form.html、store 侧 ActiveUsers/SetUserColor/SetUserActive/
  UpdateUserByAdmin/NormalizeColor/AssigneePalette;设置页「管理员」整块移除,
  只保留首次启动创建的那一个账户与「修改我的密码」
- 打手归属:删除 customers.assignee_id 相关读写与 UI(客户详情指派下拉、
  客户表单「归属打手」、日历格左侧打手色竖条、任务卡打手标签、套餐详情客户列)
- 登录页与初始化页的说明文案同步改为单账户口径(「只有一个管理员账户」
  「创建后不能再添加其他管理员」),字数与 main 对齐,卡片不换行、高度不变
- 触摸小键盘:删除 3×4 键盘、⌨/触控开关、localStorage 记忆、.keypad* 样式
- 套餐计费方式:只保留按日。删除按次/通行证/深渊的 Package 与 Subscription 字段、
  domain 里的 UnitCount/StateUsedUp/CycleIndex、package_form 计费方式单选与
  通行证/深渊/有效期字段、日历整段色块(has-block/cell-block/season-band)、
  追加次数分支、SeasonComplete/AddCount、CountActiveSubs 的次数条件

数据库迁移(幂等,Migrate 内自动执行):
- normalizeLegacyData:套餐一律 unit='day' 并清 season/abyss/cycle_days/
  default_validity_days/season_*;订阅清 season/abyss/cycle_days/season_done
- 通行证、深渊当年就是 unit='day' 且起止日完整,仅清标记,服务区间原样保留
- 按次按「1 次 = 1 天」折算:结束日 = 开始日 + 总次数 − 1;仍缺结束日退回开始日
- 只动数据,不删表不删列:老库的 season/abyss/cycle_days/total_count/used_count
  保留但不再使用,升级可逆、不会因为 DDL 失败卡住
- 新库 schema 干净:不再创建上述列,users.color / customers.assignee_id 也不创建
- 老库 ensureColumn 保留 calendar_separate / tag / archived 三项(真增量列)

兼容:
- config 仍解析 screen_public / portal_public(结构体字段注明已废弃),
  默认 ini 模板里改为「已废弃,写了不报错、不生效」

测试:
- go vet ./... 通过;go test ./... 全部通过
  (新增 TestNormalizeLegacyTypeData 验证三模式折算成按日;
   TestSingleUserAccount / TestSessionLifecycle 覆盖单用户与会话;
   删除按次、深渊、打手颜色、多用户等已不存在的能力的用例)
- 新增 scripts/smoke-lite.ps1:起隔离实例,19 个页面级请求全部 200,
  已移除的 /screen、/customer、/api/screen/day 均 404,
  水印/品牌/v1.11.5-L/无残留词 12 项断言通过,
  真实数据下日历出现待打任务、「剩 30 天」、今天到期徽标 4 项断言通过,
  本次 0 失败

README:改为 lite 版说明,删除大屏/客户自查/通行证/深渊/按次/小键盘/多用户章节,
新增「盲水印」与「与 main 分支的差异」两节(含老数据折算规则与列保留说明)。
2026-10-21 03:33:27 +08:00

1054 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"os"
"path/filepath"
"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 string, amount int) *Package {
t.Helper()
id, err := db.CreatePackage(&Package{Name: name, 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, "包月日常", 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.Status != domain.StatusActive {
t.Fatalf("status=%s", 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, "包月日常", 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, "包月日常", 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, "包月日常", 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 TestPauseResumeShiftsEndDate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", 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 TestBackup(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", 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, "包月日常", 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, false)
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, false)
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 TestBackupKeepPrune(t *testing.T) {
db := newTestDB(t)
mustPackage(t, db, "包月日常", 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, "包月日常", 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, "包月日常", 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)
}
}
func TestPackageCalendarSeparate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "每日任务", 30)
if pkg.CalendarSeparate {
t.Fatal("新建套餐默认不应勾选独立日历")
}
pkg.CalendarSeparate = true
if err := db.UpdatePackage(pkg); err != nil {
t.Fatal(err)
}
got, err := db.Package(pkg.ID)
if err != nil || !got.CalendarSeparate {
t.Fatalf("独立日历标记未保存: %+v err=%v", got, err)
}
list, err := db.ListPackages(true)
if err != nil || len(list) != 1 || !list[0].CalendarSeparate {
t.Fatalf("列表未带独立日历标记: %+v err=%v", list, err)
}
got.CalendarSeparate = false
if err := db.UpdatePackage(got); err != nil {
t.Fatal(err)
}
if again, _ := db.Package(pkg.ID); again.CalendarSeparate {
t.Fatal("取消勾选失败")
}
}
func TestTaskUrgentFlags(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "4321", "老王")
sub1, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
pkg2 := mustPackage(t, db, "包周", 7)
sub2, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()), Days: 7,
})
if err != nil {
t.Fatal(err)
}
d1 := domain.Date(domain.Today())
d2 := domain.Date(domain.AddDays(domain.Today(), 1))
if err := db.SetUrgent(sub1.ID, d1, true); err != nil {
t.Fatal(err)
}
if err := db.SetUrgent(sub2.ID, d2, true); err != nil {
t.Fatal(err)
}
flags, err := db.UrgentFlags(d1, d2)
if err != nil {
t.Fatal(err)
}
if !flags[sub1.ID][d1] || !flags[sub2.ID][d2] {
t.Fatalf("加急标记缺失: %+v", flags)
}
if ok, _ := db.IsUrgent(sub1.ID, d1); !ok {
t.Fatal("IsUrgent 应为 true")
}
if n, _ := db.CountUrgent(d1, d2); n != 2 {
t.Fatalf("加急计数=%d 期望 2", n)
}
// 重复设置不报错(UPSERT)
if err := db.SetUrgent(sub1.ID, d1, true); err != nil {
t.Fatalf("重复标记加急失败: %v", err)
}
// 取消
if err := db.SetUrgent(sub1.ID, d1, false); err != nil {
t.Fatal(err)
}
if ok, _ := db.IsUrgent(sub1.ID, d1); ok {
t.Fatal("取消加急后仍为 true")
}
if n, _ := db.CountUrgent(d1, d2); n != 1 {
t.Fatalf("取消后计数=%d 期望 1", n)
}
// 非法日期
if err := db.SetUrgent(sub1.ID, "2026-13-40", true); err == nil {
t.Fatal("非法日期应报错")
}
// 不存在的订阅
if err := db.SetUrgent(99999, d1, true); err == nil {
t.Fatal("不存在的订阅应报错")
}
}
// TestMigrateOldDatabaseAddsColumn 模拟 v1.1 的旧库:packages 表没有 calendar_separate。
func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "old.db")
dsn := strings.ReplaceAll(path, "\\", "/")
old, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
unit TEXT NOT NULL,
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
)`,
`INSERT INTO packages(name,unit,default_amount,color,note,archived,sort_order,created_at,updated_at)
VALUES('老套餐','day',30,'#2F5D50','',0,1,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`CREATE TABLE 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
)`,
`INSERT INTO users(username,display_name,password_hash,is_active,created_at)
VALUES('admin','老板','x',1,'2026-01-01 00:00:00')`,
`CREATE TABLE 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)
)`,
`INSERT INTO customers(tail,name,created_at,updated_at)
VALUES('1001','老客户','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
}
for _, s := range stmts {
if _, err := old.Exec(s); 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()
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
pkg, err := db.Package(1)
if err != nil {
t.Fatalf("读取老套餐失败: %v", err)
}
if pkg.Name != "老套餐" || pkg.CalendarSeparate {
t.Fatalf("老数据/默认值不对: %+v", pkg)
}
// 新列可写
pkg.CalendarSeparate = true
if err := db.UpdatePackage(pkg); err != nil {
t.Fatal(err)
}
if got, _ := db.Package(1); !got.CalendarSeparate {
t.Fatal("迁移后新列不可写")
}
// 老客户仍可读
if _, err := db.Customer(1); err != nil {
t.Fatalf("老客户读取失败: %v", err)
}
// 老套餐已被规范化成按日
if pkg.UnitLabel() != "按日" {
t.Fatalf("老套餐未规范化为按日: %s", pkg.UnitLabel())
}
// 迁移后仍可新建套餐(老库 packages.unit 是 NOT NULL 且没有默认值)
if _, err := db.CreatePackage(&Package{Name: "新套餐", DefaultAmount: 7, Color: "#123456"}); err != nil {
t.Fatalf("迁移后无法新建套餐: %v", err)
}
// 新表 task_flags 已建立
var tbl string
if err := db.sql.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='task_flags'`).Scan(&tbl); err != nil {
t.Fatalf("task_flags 表未建立: %v", err)
}
// 迁移可重复执行
if err := db.Migrate(); err != nil {
t.Fatalf("重复迁移失败: %v", err)
}
}
// TestSingleUserAccount 覆盖 lite 版只保留一个账户后的账户与会话行为。
func TestSingleUserAccount(t *testing.T) {
db := newTestDB(t)
u, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
if u.ID != 1 {
t.Fatalf("首个账户 id 应为 1,实际 %d", u.ID)
}
if n, _ := db.CountUsers(); n != 1 {
t.Fatalf("账户数=%d want 1", n)
}
// 用户名唯一:不会再出现第二个账户
if _, err := db.CreateUser("admin", "另一个", "daili123"); err == nil {
t.Fatal("重复用户名应被拒绝")
}
// 登录
if _, err := db.Authenticate("admin", "daili123"); err != nil {
t.Fatalf("正确密码应可登录: %v", err)
}
if _, err := db.Authenticate("admin", "wrong"); err == nil {
t.Fatal("错误密码不应通过")
}
// 改密码后旧密码失效
if err := db.UpdatePassword(u.ID, "newpass123"); err != nil {
t.Fatal(err)
}
if _, err := db.Authenticate("admin", "daili123"); err == nil {
t.Fatal("旧密码不应还能登录")
}
if _, err := db.Authenticate("admin", "newpass123"); err != nil {
t.Fatalf("新密码应可登录: %v", err)
}
// 改显示名称
if err := db.UpdateDisplayName(u.ID, " 强哥 "); err != nil {
t.Fatal(err)
}
got, err := db.UserByID(u.ID)
if err != nil || got.DisplayName != "强哥" {
t.Fatalf("显示名称未更新: %+v err=%v", got, err)
}
}
// TestSessionLifecycle 覆盖会话创建、过期清理与登出。
func TestSessionLifecycle(t *testing.T) {
db := newTestDB(t)
u, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
token := "tok-test"
if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser(token); err != nil {
t.Fatalf("会话应有效: %v", err)
}
// 过期会话不可用,且会被删除
if err := db.CreateSession("tok-old", u.ID, -time.Hour, "127.0.0.1"); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser("tok-old"); err == nil {
t.Fatal("过期会话不应有效")
}
// 登出
if err := db.DeleteSession(token); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser(token); err == nil {
t.Fatal("登出后会话应失效")
}
db.PurgeSessions()
}
func TestCustomerArchiveFilters(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", 30)
// 一个正常客户 + 一个按次客户
cid := mustCustomer(t, db, "7001", "小张")
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 10,
})
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.SetUrgent(sub.ID, domain.Date(domain.Today()), true); err != nil {
t.Fatal(err)
}
today := domain.Date(domain.Today())
subs, err := db.CalendarSubs(today, today)
if err != nil || len(subs) != 1 {
t.Fatalf("停用前日历应有 1 条: %v err=%v", len(subs), err)
}
if n, _ := db.CustomerCount(); n != 1 {
t.Fatalf("停用前客户数=1, got %d", n)
}
// 停用
if err := db.SetCustomerArchived(cid, true); err != nil {
t.Fatal(err)
}
if subs, _ := db.CalendarSubs(today, today); len(subs) != 0 {
t.Fatalf("停用后不应上日历: %d", len(subs))
}
if due, _ := db.DueOn(domain.Date(domain.AddDays(domain.Today(), 9))); len(due) != 0 {
t.Fatalf("停用后不应提醒到期: %d", len(due))
}
if n, _ := db.CustomerCount(); n != 0 {
t.Fatalf("停用后客户数应为 0, got %d", n)
}
if list, total, _ := db.ListCustomers("", 50, 0, today, 3, false); total != 0 || len(list) != 0 {
t.Fatalf("默认列表不应包含停用客户: total=%d", total)
}
if list, total, _ := db.ListCustomers("", 50, 0, today, 3, true); total != 1 || len(list) != 1 || !list[0].Archived {
t.Fatalf("已停用视图应包含: total=%d list=%+v", total, list)
}
if c, _ := db.Customer(cid); !c.Archived {
t.Fatal("Customer() 应返回 Archived=true")
}
if due, _, _, _ := db.Badges(domain.Today()); due != 0 {
t.Fatalf("停用后徽标应为 0, got %d", due)
}
if all, _ := db.CustomersByTail("7001"); len(all) != 0 {
t.Fatalf("停用客户不应被尾号搜到: %d", len(all))
}
// 恢复
if err := db.SetCustomerArchived(cid, false); err != nil {
t.Fatal(err)
}
if subs, _ := db.CalendarSubs(today, today); len(subs) != 1 {
t.Fatalf("恢复后应回到日历: %d", len(subs))
}
// 彻底删除:订阅/打卡/加急一起消失
if err := db.DeleteCustomer(cid); err != nil {
t.Fatal(err)
}
if _, err := db.Customer(cid); err == nil {
t.Fatal("删除后不应还能查到客户")
}
if subs, _ := db.CalendarSubs(today, today); len(subs) != 0 {
t.Fatalf("删除后订阅应级联消失: %d", len(subs))
}
var flags, checkins int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags`).Scan(&flags)
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM checkins`).Scan(&checkins)
if flags != 0 || checkins != 0 {
t.Fatalf("删除后打卡/加急应级联删除: flags=%d checkins=%d", flags, checkins)
}
if err := db.DeleteCustomer(9999); err == nil {
t.Fatal("删除不存在的客户应报错")
}
}
// TestNormalizeLegacyTypeData 验证 main 分支遗留的「按次 / 通行证 / 深渊」
// 数据在 lite 版迁移后统一折算成按日。
func TestNormalizeLegacyTypeData(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.db")
old, err := sql.Open("sqlite", strings.ReplaceAll(path, "\\", "/"))
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE 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 '#3B6EA5', note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
calendar_separate INTEGER NOT NULL DEFAULT 0, tag TEXT NOT NULL DEFAULT '',
season INTEGER NOT NULL DEFAULT 0, default_validity_days INTEGER NOT NULL DEFAULT 0,
season_start TEXT NOT NULL DEFAULT '', season_end TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0, cycle_days INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT, tail TEXT NOT NULL, name TEXT NOT NULL,
game_name TEXT NOT NULL DEFAULT '', game_server TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (tail, name))`,
`CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, package_id INTEGER NOT NULL,
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,
season INTEGER NOT NULL DEFAULT 0, season_done TEXT NOT NULL DEFAULT '',
abyss INTEGER NOT NULL DEFAULT 0, cycle_days INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '', created_by INTEGER,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT,
UNIQUE (customer_id, package_id))`,
// 三个套餐:按次 / 通行证 / 深渊
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('十次副本','count',10,'#111111',0,'','',0,0,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('通行证','day',1,'#222222',1,'2026-01-01','2026-01-31',0,0,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO packages(name,unit,default_amount,color,season,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES('深渊','day',4,'#333333',0,'','',1,7,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1001','甲','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1002','乙','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`INSERT INTO customers(tail,name,created_at,updated_at) VALUES('1003','丙','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 按次:10 次、已用 4 次、没有结束日 → 应折算成 2026-01-01 起共 10 天
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,created_at,updated_at)
VALUES(1,1,'count','active','2026-01-01',NULL,10,4,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 通行证:区间完整,清标记即可
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,season,created_at,updated_at)
VALUES(2,2,'day','active','2026-01-01','2026-01-31',1,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
// 深渊:周期 7 天 × 4 周期
`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,abyss,cycle_days,total_count,created_at,updated_at)
VALUES(3,3,'day','active','2026-02-01','2026-02-28',1,7,4,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
}
for _, s := range stmts {
if _, err := old.Exec(s); err != nil {
t.Fatalf("准备老库失败: %v\nSQL: %s", err, s)
}
}
if err := old.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开老库失败: %v", err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
// 套餐:全部按日,season / abyss / cycle_days 归零
pkgs, err := db.ListPackages(true)
if err != nil {
t.Fatal(err)
}
if len(pkgs) != 3 {
t.Fatalf("套餐数量=%d want 3", len(pkgs))
}
for _, p := range pkgs {
if p.UnitLabel() != "按日" {
t.Fatalf("套餐 %q 未折算成按日: %s", p.Name, p.UnitLabel())
}
}
var legacy int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM packages WHERE unit<>'day' OR season<>0 OR abyss<>0 OR cycle_days<>0`).Scan(&legacy); err != nil {
t.Fatal(err)
}
if legacy != 0 {
t.Fatalf("仍有 %d 个套餐残留旧类型字段", legacy)
}
// 按次订阅:折算成 10 天,剩余 10 天
sub1, err := db.Sub(1)
if err != nil {
t.Fatal(err)
}
if sub1.StartDate != "2026-01-01" || sub1.EndDate != "2026-01-10" {
t.Fatalf("按次订阅折算区间不对: %s → %s", sub1.StartDate, sub1.EndDate)
}
// 通行证订阅:区间保留
sub2, err := db.Sub(2)
if err != nil {
t.Fatal(err)
}
if sub2.StartDate != "2026-01-01" || sub2.EndDate != "2026-01-31" {
t.Fatalf("通行证订阅区间被改动: %s → %s", sub2.StartDate, sub2.EndDate)
}
// 深渊订阅:区间保留
sub3, err := db.Sub(3)
if err != nil {
t.Fatal(err)
}
if sub3.StartDate != "2026-02-01" || sub3.EndDate != "2026-02-28" {
t.Fatalf("深渊订阅区间被改动: %s → %s", sub3.StartDate, sub3.EndDate)
}
// 迁移可重复执行
if err := db.Migrate(); err != nil {
t.Fatalf("重复迁移失败: %v", err)
}
}
func TestPackageTag(t *testing.T) {
db := newTestDB(t)
p1 := mustPackage(t, db, "包月活动托", 30)
if p1.Tag != "" {
t.Fatalf("默认标签应为空: %q", p1.Tag)
}
p1.Tag = "包月"
if err := db.UpdatePackage(p1); err != nil {
t.Fatal(err)
}
got, err := db.Package(p1.ID)
if err != nil || got.Tag != "包月" {
t.Fatalf("标签未保存: %+v err=%v", got, err)
}
p2 := &Package{Name: "深渊委托", DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "}
if _, err := db.CreatePackage(p2); err != nil {
t.Fatal(err)
}
tags, err := db.PackageTags()
if err != nil || len(tags) != 1 || tags[0] != "包月" {
t.Fatalf("标签去重/去空格失败: %+v err=%v", tags, err)
}
list, err := db.ListPackages(true)
if err != nil || len(list) != 2 {
t.Fatalf("列表应有 2 个套餐: %d err=%v", len(list), err)
}
for _, p := range list {
if p.Tag != "包月" {
t.Fatalf("列表标签不对: %+v", p)
}
}
}