Files
subtrack a2542e1135
build / build-and-test (push) Successful in 3m36s
feat(v1.7): 计数改已完成/总数、客户月历、套餐标签合并、批量开通、触控模式、按次到期
1) 日历计数口径
- 工作台与大屏的格子统一改为「已完成 / 总数」(如 2/5 = 共 5 单、做完 2 单)

2) 客户详情·当月排单
- 新增客户专属月历:每天显示 已完成/总数,可翻月,点某天跳转到日历对应日期
- 没有排单的日期淡化显示

3) 套餐标签与页签合并
- packages 新增 tag 列(可空),套餐编辑页可填
- 日历/大屏页签按标签合并:同标签套餐合成一个页签;勾了「独立显示」的套餐额外再开一个自己的页签
- 大屏同样支持标签筛选,链接参数为 tag=xxx

4) 批量开通
- 新增套餐详情页 /packages/{id}:显示套餐信息 + 客户列表
- 表头「全选」置顶,已订阅该套餐的客户置灰不可选;开始日期默认今天、天数/次数默认取套餐设置,均可改
- POST /packages/{id}/subscribe 批量开通,重复的自动跳过并汇总提示
- 修复 r.PostForm 未 ParseForm 导致拿不到勾选项的问题(批量打卡同源问题一并修正)

5) 触控 / PC 模式
- 尾号输入框右侧新增切换按钮,触控模式展开 3×4 键盘、输入框只读 + inputmode=none(不唤起系统输入法),按键直接写值
- 偏好记在 localStorage,默认 PC 模式(键盘收起)

6) 按次到期
- 按次订阅的到期日保持选填;填了到期后状态显示「已到期」且不再进日历待排(复用既有 end_date,无新列)

数据库兼容性:
- 只新增 packages.tag 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变

测试:
- 无头 Chrome 实拍 5 张页面(日历/触控模式/客户月历/套餐批量/大屏)
- 功能验证 18 项全过(计数口径、标签页签、标签筛选、批量开通与重复跳过、月历翻月、触控开关、按次过期不上日历)
- 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.7.0
2026-10-19 16:23:01 +08:00

1145 lines
33 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"errors"
"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, 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 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)
}
}
func TestPackageCalendarSeparate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "每日任务", domain.UnitDay, 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, "包月日常", domain.UnitDay, 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, "按次", domain.UnitCount, 10)
sub2, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()),
TotalCount: 10, EndDate: domain.Date(domain.AddDays(domain.Today(), 30)),
})
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("迁移后新列不可写")
}
// 老库的 users / customers 也补上了新列
var userColor string
if err := db.sql.QueryRow(`SELECT IFNULL(color,'') FROM users WHERE id=1`).Scan(&userColor); err != nil {
t.Fatalf("users.color 未补上: %v", err)
}
if err := db.SetUserColor(1, "#123456"); err != nil {
t.Fatalf("老库 users.color 不可写: %v", err)
}
c, err := db.Customer(1)
if err != nil {
t.Fatalf("老客户读取失败: %v", err)
}
if c.AssigneeID != 0 {
t.Fatalf("老客户不应有打手: %+v", c)
}
if err := db.SetCustomerAssignee(1, 1); err != nil {
t.Fatalf("老库 customers.assignee_id 不可写: %v", err)
}
if c2, _ := db.Customer(1); c2.AssigneeID != 1 {
t.Fatalf("指派未生效: %+v", c2)
}
// 新表 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)
}
}
func TestUserColorAndAssignee(t *testing.T) {
db := newTestDB(t)
// 新建管理员自动分配调色板颜色
u1, err := db.CreateUser("dashou1", "阿强", "daili123")
if err != nil {
t.Fatal(err)
}
u2, err := db.CreateUser("dashou2", "小美", "daili123")
if err != nil {
t.Fatal(err)
}
if u1.Color == "" || u2.Color == "" || u1.Color == u2.Color {
t.Fatalf("自动颜色不合理: %q %q", u1.Color, u2.Color)
}
// 改色(小写转大写)
if err := db.SetUserColor(u2.ID, "#c0392b"); err != nil {
t.Fatal(err)
}
got, err := db.UserByID(u2.ID)
if err != nil || got.Color != "#C0392B" {
t.Fatalf("颜色未保存或未规范化: %+v err=%v", got, err)
}
// 非法颜色
if err := db.SetUserColor(u2.ID, "red"); err == nil {
t.Fatal("非法颜色应报错")
}
if err := db.SetUserColor(u2.ID, "#12345"); err == nil {
t.Fatal("长度不对应报错")
}
// 不存在的账户
if err := db.SetUserColor(9999, "#123456"); err == nil {
t.Fatal("不存在的账户应报错")
}
// 客户指派
cid := mustCustomer(t, db, "3001", "客户甲")
if err := db.SetCustomerAssignee(cid, u1.ID); err != nil {
t.Fatal(err)
}
c, err := db.Customer(cid)
if err != nil {
t.Fatal(err)
}
if c.AssigneeID != u1.ID || c.AssigneeName != "阿强" || c.AssigneeColor != "#2F5D50" {
t.Fatalf("客户指派信息不对: %+v", c)
}
// 取消指派
if err := db.SetCustomerAssignee(cid, 0); err != nil {
t.Fatal(err)
}
if c2, _ := db.Customer(cid); c2.AssigneeID != 0 || c2.AssigneeName != "" {
t.Fatalf("取消指派失败: %+v", c2)
}
// 指派给不存在的打手
if err := db.SetCustomerAssignee(cid, 9999); err == nil {
t.Fatal("不存在的打手应报错")
}
// 客户创建时直接带指派
cid2, err := db.CreateCustomer(&Customer{Tail: "3002", Name: "客户乙", AssigneeID: u2.ID})
if err != nil {
t.Fatal(err)
}
c3, _ := db.Customer(cid2)
if c3.AssigneeName != "小美" || c3.AssigneeColor != "#C0392B" {
t.Fatalf("新建客户的指派未生效: %+v", c3)
}
// 订阅联表能带出打手
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
gotSub, err := db.Sub(sub.ID)
if err != nil {
t.Fatal(err)
}
if gotSub.AssigneeName != "小美" || gotSub.AssigneeColor != "#C0392B" {
t.Fatalf("订阅未带出打手: %+v", gotSub)
}
// 只有启用中的账户可作为打手候选
if err := db.SetUserActive(u1.ID, false); err != nil {
t.Fatal(err)
}
users, err := db.ActiveUsers()
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.ID == u1.ID {
t.Fatal("停用账户不应出现在打手候选里")
}
}
}
func TestUpdateUserByAdmin(t *testing.T) {
db := newTestDB(t)
admin, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
u, err := db.CreateUser("dashou", "阿强", "dashou123")
if err != nil {
t.Fatal(err)
}
// 建一个会话,稍后用密码重置把它踢掉
token := "tok-" + domain.Date(domain.Today())
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.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil {
t.Fatal(err)
}
got, _ := db.UserByID(u.ID)
if got.DisplayName != "强哥" || got.Username != "dashou" {
t.Fatalf("昵称未更新: %+v", got)
}
if _, err := db.SessionUser(token); err != nil {
t.Fatalf("未改密码不应踢下线: %v", err)
}
// 改用户名 + 重置密码:会话失效,新密码可登录
if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser(token); err == nil {
t.Fatal("改密后旧会话应失效")
}
if _, err := db.Authenticate("laoban", "newpass123"); err != nil {
t.Fatalf("新密码应可登录: %v", err)
}
if _, err := db.Authenticate("laoban", "dashou123"); err == nil {
t.Fatal("旧密码不应还能登录")
}
// 用户名冲突
if err := db.UpdateUserByAdmin(u.ID, "admin", "", ""); !errors.Is(err, ErrDuplicate) {
t.Fatalf("重名应返回 ErrDuplicate, got %v", err)
}
// 空用户名
if err := db.UpdateUserByAdmin(u.ID, " ", "", ""); err == nil {
t.Fatal("空用户名应报错")
}
// 超管自己也在表里,但接口层才限制“只能改别人”
if admin.ID != 1 {
t.Fatalf("首个账户 id 应为 1,实际 %d", admin.ID)
}
}
func TestCustomerArchiveFilters(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 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("删除不存在的客户应报错")
}
}
func TestPackageTag(t *testing.T) {
db := newTestDB(t)
p1 := mustPackage(t, db, "包月活动托", domain.UnitDay, 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: "深渊委托", Unit: domain.UnitCount, 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)
}
}
}