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 分支的差异」两节(含老数据折算规则与列保留说明)。
This commit is contained in:
2026-10-21 03:33:27 +08:00
parent 0392f44cf1
commit cde1c84c9a
45 changed files with 786 additions and 4391 deletions
+14 -135
View File
@@ -20,7 +20,7 @@ type Checkin struct {
UpdatedAt string
}
// SaveCheckin 打卡(同订阅同日覆盖),按次套餐完成时扣次。
// SaveCheckin 打卡(同订阅同日覆盖)。
func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int64) error {
if status != "done" && status != "skipped" {
return errors.New("打卡状态不正确")
@@ -36,133 +36,33 @@ func (db *DB) SaveCheckin(subID int64, date, status, note string, operatorID int
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) {
now := nowStamp()
res, 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)
if err != nil {
return err
}
delta := 0
if s.Abyss {
if s.CycleDays <= 0 {
return errors.New("该深渊套餐未设置周期天数")
}
d, _ := domain.ParseDate(date)
start := domain.ParseDateOr(s.StartDate, domain.Today())
idx := domain.CycleIndex(start, d, s.CycleDays)
if idx < 0 || idx >= s.TotalCount {
return errors.New("日期不在深渊周期内")
}
cycStart := domain.Date(domain.CycleStart(start, s.CycleDays, idx))
cycEnd := domain.Date(domain.CycleEnd(start, s.CycleDays, idx))
var otherDone int
if err := tx.QueryRow(`SELECT COUNT(*) FROM checkins WHERE sub_id=? AND status='done'
AND service_date BETWEEN ? AND ? AND service_date<>?`, subID, cycStart, cycEnd, date).Scan(&otherDone); err != nil {
return err
}
oldDone := hasOld && oldStatus == "done"
newDone := status == "done"
switch {
case newDone && !oldDone && otherDone == 0:
if s.UsedCount >= s.TotalCount {
return errors.New("所有周期已完成")
}
delta = 1
case !newDone && oldDone && otherDone == 0:
delta = -1
}
} else 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
}
if n, _ := res.RowsAffected(); n > 0 {
return nil
}
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
_, 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)
return err
})
}
// DeleteCheckin 撤销打卡(按次套餐返还次数),返回所属订阅 ID。
// 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 err := tx.QueryRow(`SELECT sub_id FROM checkins WHERE id=?`, id).Scan(&subID); 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
}
delta := 0
if s.Unit == domain.UnitCount && status == "done" {
delta = -1
}
if s.Abyss && status == "done" && s.CycleDays > 0 {
d, _ := domain.ParseDate(date)
start := domain.ParseDateOr(s.StartDate, domain.Today())
idx := domain.CycleIndex(start, d, s.CycleDays)
if idx >= 0 {
cycStart := domain.Date(domain.CycleStart(start, s.CycleDays, idx))
cycEnd := domain.Date(domain.CycleEnd(start, s.CycleDays, idx))
var remain int
if err := tx.QueryRow(`SELECT COUNT(*) FROM checkins WHERE sub_id=? AND status='done'
AND service_date BETWEEN ? AND ?`, subID, cycStart, cycEnd).Scan(&remain); err != nil {
return err
}
if remain == 0 {
delta = -1
}
}
}
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, nowStamp(), subID); err != nil {
return err
}
}
return nil
_, err := tx.Exec(`DELETE FROM checkins WHERE id=?`, id)
return err
})
return subID, err
}
@@ -179,27 +79,6 @@ func (db *DB) CheckinsBetween(from, to string) (map[int64]map[string]Checkin, er
return scanCheckinMap(rows)
}
// CheckinsForSubs 只取指定订阅在区间内的打卡,避免客户自助页把全部客户的打卡读进内存。
func (db *DB) CheckinsForSubs(ids []int64, from, to string) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
if len(ids) == 0 {
return out, nil
}
args := make([]any, 0, len(ids)+2)
for _, id := range ids {
args = append(args, id)
}
args = append(args, from, to)
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 IN (`+placeholders(len(ids))+`) AND c.service_date BETWEEN ? AND ?`, args...)
if err != nil {
return nil, err
}
return scanCheckinMap(rows)
}
func scanCheckinMap(rows *sql.Rows) (map[int64]map[string]Checkin, error) {
out := map[int64]map[string]Checkin{}
defer rows.Close()
+17 -67
View File
@@ -23,11 +23,6 @@ type Customer struct {
CreatedAt string
UpdatedAt string
// 归属打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
// Archived 停用(软删除):默认隐藏,订阅保留但不上日历
Archived bool
@@ -49,14 +44,13 @@ func (c *Customer) TagList() []string {
return out
}
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at,IFNULL(assignee_id,0),IFNULL((SELECT u.display_name FROM users u WHERE u.id=customers.assignee_id),''),IFNULL((SELECT u.color FROM users u WHERE u.id=customers.assignee_id),''),IFNULL(archived,0)`
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at,IFNULL(archived,0)`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer
var archived int
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,
&c.AssigneeID, &c.AssigneeName, &c.AssigneeColor, &archived)
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt, &archived)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -70,10 +64,10 @@ func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
// 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,assignee_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
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, nullIfZero(c.AssigneeID), now, now)
c.Tags, c.Source, c.Note, now, now)
if err != nil {
if isUnique(err) {
return 0, ErrDuplicate
@@ -85,10 +79,10 @@ func (db *DB) CreateCustomer(c *Customer) (int64, error) {
// 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=?,assignee_id=?,updated_at=?
_, 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, nullIfZero(c.AssigneeID), nowStamp(), c.ID)
c.Tags, c.Source, c.Note, nowStamp(), c.ID)
if err != nil && isUnique(err) {
return ErrDuplicate
}
@@ -157,7 +151,7 @@ func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays
return out, total, nil
}
// summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。
// summarizeSubs 生成「包月日常 剩12天 · 深渊日常 今天到期」式的摘要与最紧急等级。
func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) {
if len(subs) == 0 {
return "暂无订阅", domain.LevelNone
@@ -172,42 +166,13 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
continue
}
var text string
if s.Season {
switch {
case s.SeasonDone != "":
text = s.PackageName + " 通行证已完成"
case e.State == domain.StateExpired:
text = s.PackageName + " 通行证已结束"
default:
text = fmt.Sprintf("%s 通行证 %d/%d", s.PackageName, s.BlockDone(), s.BlockTotal())
}
} else if s.Abyss {
switch {
case s.TotalCount > 0 && s.UsedCount >= s.TotalCount:
text = fmt.Sprintf("%s %d/%d 周期", s.PackageName, s.UsedCount, s.TotalCount)
case e.State == domain.StateExpired:
text = fmt.Sprintf("%s %d/%d 周期 已过期", s.PackageName, s.UsedCount, s.TotalCount)
default:
text = fmt.Sprintf("%s %d/%d 周期", s.PackageName, s.UsedCount, s.TotalCount)
}
} else 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)
}
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)
}
parts = append(parts, text)
if rank[e.Level] > rank[worst] {
@@ -223,12 +188,6 @@ func summarizeSubs(subs []Subscription, today string, warnDays int) (string, str
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
@@ -236,22 +195,13 @@ func (db *DB) CustomerCount() (int, error) {
return n, err
}
// CustomersByTailExact 按手机尾号精确匹配客户(客户自助页用,不退回模糊匹配)。
func (db *DB) CustomersByTailExact(tail string) ([]Customer, error) {
tail = strings.TrimSpace(tail)
if tail == "" {
return nil, nil
}
return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail = ? ORDER BY id`, tail)
}
// CustomersByTail 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。
func (db *DB) CustomersByTail(tail string) ([]Customer, error) {
tail = strings.TrimSpace(tail)
if tail == "" {
return nil, nil
}
out, err := db.CustomersByTailExact(tail)
out, err := db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail = ? ORDER BY id`, tail)
if err != nil {
return nil, err
}
@@ -278,7 +228,7 @@ func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) {
return out, rows.Err()
}
// SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历与大屏。
// SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历。
func (db *DB) SetCustomerArchived(id int64, archived bool) error {
v := 0
if archived {
+64 -81
View File
@@ -102,13 +102,6 @@ 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 {
@@ -121,91 +114,98 @@ func (db *DB) Migrate() error {
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// v1.3:管理员颜色 + 客户归属打手
if err := db.ensureColumn("users", "color",
"ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("customers", "assignee_id",
"ALTER TABLE customers ADD COLUMN assignee_id INTEGER REFERENCES users(id)"); err != nil {
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
if err := db.ensureColumn("packages", "tag",
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("customers", "archived",
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
if err := db.ensureColumn("packages", "tag",
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
return err
// lite:把老库里按次 / 通行证 / 深渊的数据统一折算成按日
return db.normalizeLegacyData()
}
// normalizeLegacyData 把 main 分支遗留的三种套餐类型折算成按日。
//
// - 通行证、深渊订阅当年就是 unit='day' 且起止日期完整,清掉标记即可;
// - 按次订阅按「1 次 = 1 天」折算成天:有 total_count 且没有结束日的,
// 结束日 = 开始日 + total_count - 1;仍缺结束日的退回开始日。
//
// 老库缺少这些列时自动跳过,因此可以反复执行。
func (db *DB) normalizeLegacyData() error {
// 套餐:一律按日
pkgSets := []string{"unit='day'"}
for _, col := range []string{"season", "abyss", "cycle_days", "default_validity_days"} {
if db.hasColumn("packages", col) {
pkgSets = append(pkgSets, col+"=0")
}
}
// v1.8:包赛季套餐 + 按次默认有效期
if err := db.ensureColumn("packages", "season",
"ALTER TABLE packages ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
for _, col := range []string{"season_start", "season_end"} {
if db.hasColumn("packages", col) {
pkgSets = append(pkgSets, col+"=''")
}
}
if err := db.ensureColumn("packages", "default_validity_days",
"ALTER TABLE packages ADD COLUMN default_validity_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
if _, err := db.sql.Exec(`UPDATE packages SET ` + strings.Join(pkgSets, ",")); err != nil {
return fmt.Errorf("迁移套餐类型失败: %w", err)
}
if err := db.ensureColumn("packages", "season_start",
"ALTER TABLE packages ADD COLUMN season_start TEXT NOT NULL DEFAULT ''"); err != nil {
return err
// 订阅:按次先折算天数,再统一成按日
if db.hasColumn("subscriptions", "total_count") {
if _, err := db.sql.Exec(`UPDATE subscriptions
SET end_date = date(start_date, '+' || (total_count - 1) || ' days')
WHERE unit='count' AND IFNULL(total_count,0) > 0 AND IFNULL(end_date,'')=''`); err != nil {
return fmt.Errorf("迁移按次订阅失败: %w", err)
}
}
if err := db.ensureColumn("packages", "season_end",
"ALTER TABLE packages ADD COLUMN season_end TEXT NOT NULL DEFAULT ''"); err != nil {
return err
if _, err := db.sql.Exec(`UPDATE subscriptions
SET unit='day', end_date=IFNULL(NULLIF(end_date,''), start_date)
WHERE unit<>'day' OR IFNULL(end_date,'')=''`); err != nil {
return fmt.Errorf("迁移订阅计费方式失败: %w", err)
}
if err := db.ensureColumn("subscriptions", "season",
"ALTER TABLE subscriptions ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
subSets := []string{}
for _, col := range []string{"season", "abyss", "cycle_days"} {
if db.hasColumn("subscriptions", col) {
subSets = append(subSets, col+"=0")
}
}
if err := db.ensureColumn("subscriptions", "season_done",
"ALTER TABLE subscriptions ADD COLUMN season_done TEXT NOT NULL DEFAULT ''"); err != nil {
return err
if db.hasColumn("subscriptions", "season_done") {
subSets = append(subSets, "season_done=''")
}
// v1.11:深渊模式(N 天一个周期)与通行证改名的数据字段
if err := db.ensureColumn("packages", "abyss",
"ALTER TABLE packages ADD COLUMN abyss INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("packages", "cycle_days",
"ALTER TABLE packages ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("subscriptions", "abyss",
"ALTER TABLE subscriptions ADD COLUMN abyss INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := db.ensureColumn("subscriptions", "cycle_days",
"ALTER TABLE subscriptions ADD COLUMN cycle_days INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
if len(subSets) > 0 {
if _, err := db.sql.Exec(`UPDATE subscriptions SET ` + strings.Join(subSets, ",")); err != nil {
return fmt.Errorf("迁移订阅标记失败: %w", err)
}
}
return nil
}
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
func (db *DB) ensureColumn(table, column, ddl string) error {
// hasColumn 判断表里是否有某一列。
func (db *DB) hasColumn(table, column string) bool {
rows, err := db.sql.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return fmt.Errorf("读取 %s 表结构失败: %w", table, err)
return false
}
found := false
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
return false
}
if strings.EqualFold(name, column) {
found = true
return true
}
}
rows.Close()
if found {
return false
}
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
func (db *DB) ensureColumn(table, column, ddl string) error {
if db.hasColumn(table, column) {
return nil
}
if _, err := db.sql.Exec(ddl); err != nil {
@@ -221,7 +221,6 @@ var schemaStatements = []string{
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_login_at TEXT
)`,
@@ -235,7 +234,7 @@ var schemaStatements = []string{
`CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('day','count')),
unit TEXT NOT NULL DEFAULT 'day' CHECK (unit IN ('day')),
default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#3B6EA5',
@@ -244,12 +243,6 @@ var schemaStatements = []string{
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
)`,
@@ -263,7 +256,6 @@ var schemaStatements = []string{
tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
assignee_id INTEGER REFERENCES users(id),
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -273,16 +265,10 @@ var schemaStatements = []string{
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')),
unit TEXT NOT NULL DEFAULT 'day' CHECK (unit IN ('day')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','closed')),
start_date TEXT NOT NULL,
end_date TEXT,
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 REFERENCES users(id),
created_at TEXT NOT NULL,
@@ -304,9 +290,6 @@ var schemaStatements = []string{
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
+5 -11
View File
@@ -38,25 +38,19 @@ func (db *DB) ExportSubscriptions(today time.Time, warnDays int) (*ExportRowSet,
if err != nil {
return nil, err
}
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日/有效期",
"总次数", "已用", "剩余", "原文状态", "备注", "更新时间"}}
out := &ExportRowSet{Header: []string{"订阅ID", "手机尾号", "客户名", "套餐", "类型", "状态", "开始日", "结束日",
"剩余", "原文状态", "备注", "更新时间"}}
stateLabel := map[string]string{
domain.StateActive: "进行中", domain.StatePending: "未开始", domain.StatePaused: "暂停中",
domain.StateExpired: "已到期", domain.StateUsedUp: "次数用尽", domain.StateClosed: "已结束",
domain.StateExpired: "已到期", 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,
fmt.Sprintf("%d 天", e.RemainingDays), s.Status, s.Note, s.UpdatedAt,
})
}
return out, nil
+36 -91
View File
@@ -5,85 +5,50 @@ import (
"errors"
"strconv"
"strings"
"daili/internal/domain"
)
// 套餐计费方式:lite 版只有「按日」一种,
// 但 packages.unit 列保留(写入固定值 "day"),以便老库平滑升级。
const unitDay = "day"
// Package 服务套餐。
type Package struct {
ID int64
Name string
Unit string
DefaultAmount int
DefaultAmount int // 默认天数
PriceCents int
HasPrice bool
Color string
Note string
Archived bool
SortOrder int
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
// CalendarSeparate 勾选后在工作台日历中单独成页
CalendarSeparate bool
// Tag 套餐标签:相同标签的套餐在日历里合并显示
Tag string
// Season 通行证模式:开始~结束之间任意一天可整段完成
Season bool
// Abyss 深渊模式:CycleDays 天一个周期,共 DefaultAmount 个周期
Abyss bool
CycleDays int
// DefaultValidityDays 按次套餐默认有效期(天),0 不限
DefaultValidityDays int
// 通行证的固定区间(套餐级)
SeasonStart string
SeasonEnd string
CreatedAt string
UpdatedAt string
SubCount int
Tag string
CreatedAt string
UpdatedAt string
SubCount int
ActiveCount int
}
// UnitLabel 返回类型中文名。
func (p *Package) UnitLabel() string {
if p.Abyss {
return "深渊"
}
if p.Season {
return "通行证"
}
if p.Unit == domain.UnitCount {
return "按次"
}
return "按日"
}
// UnitLabel 返回类型中文名(lite 版只有按日)。
func (p *Package) UnitLabel() string { return "按日" }
// AmountText 返回「默认量」列的中文描述。
func (p *Package) AmountText() string {
if p.Abyss {
return strconv.Itoa(p.DefaultAmount) + " 周期"
}
if p.Season {
return "整段"
}
if p.Unit == domain.UnitCount {
return strconv.Itoa(p.DefaultAmount) + " 次"
}
return strconv.Itoa(p.DefaultAmount) + " 天"
}
// CycleLabel 返回深渊模式的人话描述。
func (p *Package) CycleLabel() string {
if !p.Abyss || p.CycleDays <= 0 {
return ""
}
return strconv.Itoa(p.CycleDays) + " 天/周期"
}
const packageCols = `id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at`
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
var p Package
var unit string
var price sql.NullInt64
var archived, separate, season, validity, abyss, cycleDays int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd,
&abyss, &cycleDays, &p.CreatedAt, &p.UpdatedAt)
var archived, separate int
err := row.Scan(&p.ID, &p.Name, &unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -92,10 +57,6 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
p.Season = season == 1
p.Abyss = abyss == 1
p.CycleDays = cycleDays
p.DefaultValidityDays = validity
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
@@ -105,7 +66,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
// 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.calendar_separate,p.tag,p.season,p.default_validity_days,p.season_start,p.season_end,p.abyss,p.cycle_days,p.created_at,p.updated_at,
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,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`
@@ -121,19 +82,15 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
out := []Package{}
for rows.Next() {
var p Package
var unit string
var price sql.NullInt64
var archived, separate, season, validity, abyss, cycleDays int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd,
&abyss, &cycleDays, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
var archived, separate int
if err := rows.Scan(&p.ID, &p.Name, &unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
p.Season = season == 1
p.Abyss = abyss == 1
p.CycleDays = cycleDays
p.DefaultValidityDays = validity
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
@@ -145,27 +102,29 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
// 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,calendar_separate,tag,season,default_validity_days,season_start,season_end,abyss,cycle_days,created_at,updated_at
FROM packages WHERE id=?`, id))
return scanPackage(db.sql.QueryRow(`SELECT `+packageCols+` FROM packages WHERE id=?`, id))
}
// CreatePackage 新建套餐。
func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp()
p.Unit = normalizeUnit(p.Unit)
func (p *Package) normalize() {
if p.DefaultAmount <= 0 {
p.DefaultAmount = 30
}
if strings.TrimSpace(p.Color) == "" {
p.Color = "#3B6EA5"
}
}
// CreatePackage 新建套餐。
func (db *DB) CreatePackage(p *Package) (int64, error) {
now := nowStamp()
p.normalize()
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,calendar_separate,tag,season,default_validity_days,season_start,season_end,abyss,cycle_days,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, b2i(p.Abyss), p.CycleDays, now, now)
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
p.Name, unitDay, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), now, now)
if err != nil {
return 0, err
}
@@ -174,19 +133,13 @@ func (db *DB) CreatePackage(p *Package) (int64, error) {
// 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 = "#3B6EA5"
}
p.normalize()
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=?,calendar_separate=?,tag=?,season=?,default_validity_days=?,season_start=?,season_end=?,abyss=?,cycle_days=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, b2i(p.Abyss), p.CycleDays, nowStamp(), p.ID)
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,updated_at=?
WHERE id=?`, p.Name, unitDay, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), nowStamp(), p.ID)
return err
}
@@ -243,11 +196,3 @@ func (db *DB) PackageTags() ([]string, error) {
}
return out, rows.Err()
}
// SeasonExpired 判断包赛季套餐在 today 是否已结束。
func (p Package) SeasonExpired(today string) bool {
if !p.Season || p.SeasonEnd == "" {
return false
}
return today > p.SeasonEnd
}
-93
View File
@@ -6,21 +6,6 @@ import (
"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, due7 int, err error) {
t := domain.Date(today)
@@ -37,84 +22,6 @@ func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) {
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'
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`).Scan(&st.PausedSubs); err != nil {
return nil, err
}
if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`,
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 ?
AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`,
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 s
JOIN customers c ON c.id=s.customer_id
WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date BETWEEN ? AND ? GROUP BY s.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 IFNULL(c.archived,0)=0 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 IFNULL(c.archived,0)=0 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))
+196 -287
View File
@@ -2,10 +2,8 @@ package store
import (
"database/sql"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -26,9 +24,9 @@ func newTestDB(t *testing.T) *DB {
return db
}
func mustPackage(t *testing.T, db *DB, name, unit string, amount int) *Package {
func mustPackage(t *testing.T, db *DB, name string, amount int) *Package {
t.Helper()
id, err := db.CreatePackage(&Package{Name: name, Unit: unit, DefaultAmount: amount, Color: "#2F5D50"})
id, err := db.CreatePackage(&Package{Name: name, DefaultAmount: amount, Color: "#2F5D50"})
if err != nil {
t.Fatalf("创建套餐失败: %v", err)
}
@@ -50,7 +48,7 @@ func mustCustomer(t *testing.T, db *DB, tail, name string) int64 {
func TestCreateSubAndDuplicate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
@@ -63,8 +61,8 @@ func TestCreateSubAndDuplicate(t *testing.T) {
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 sub.Status != domain.StatusActive {
t.Fatalf("status=%s", sub.Status)
}
if _, err := db.CreateSub(CreateSubInput{
@@ -85,7 +83,7 @@ func TestCreateSubAndDuplicate(t *testing.T) {
func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
@@ -129,7 +127,7 @@ func TestExtendDaysFromTodayWhenExpired(t *testing.T) {
func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
@@ -151,7 +149,7 @@ func TestExtendDaysFromOldEndWhenActive(t *testing.T) {
func TestExtendDaysRejectsBackwards(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
@@ -163,84 +161,9 @@ func TestExtendDaysRejectsBackwards(t *testing.T) {
}
}
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)
pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
@@ -276,35 +199,9 @@ func TestPauseResumeShiftsEndDate(t *testing.T) {
}
}
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)
mustPackage(t, db, "包月日常", 30)
path, err := db.BackupNow(14)
if err != nil {
t.Fatalf("备份失败: %v", err)
@@ -337,7 +234,7 @@ func TestSettingsAndBadges(t *testing.T) {
t.Fatalf("默认值=%s", got)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
pkg := mustPackage(t, db, "包月日常", 30)
cid := mustCustomer(t, db, "1234", "老张")
today := domain.Today()
_, _ = db.CreateSub(CreateSubInput{
@@ -384,33 +281,9 @@ func TestUniqueCustomer(t *testing.T) {
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)
mustPackage(t, db, "包月日常", 30)
dir := db.BackupDir()
names := []string{
"daili-20260101-000001.db",
@@ -478,7 +351,7 @@ func TestSingleFileDatabase(t *testing.T) {
t.Fatalf("journal_mode=%s err=%v", mode, err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
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,
@@ -513,7 +386,7 @@ func TestCopyDatabaseIsLossless(t *testing.T) {
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
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,
@@ -651,7 +524,7 @@ func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
func TestPackageCalendarSeparate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "每日任务", domain.UnitDay, 30)
pkg := mustPackage(t, db, "每日任务", 30)
if pkg.CalendarSeparate {
t.Fatal("新建套餐默认不应勾选独立日历")
}
@@ -678,7 +551,7 @@ func TestPackageCalendarSeparate(t *testing.T) {
func TestTaskUrgentFlags(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
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,
@@ -686,10 +559,9 @@ func TestTaskUrgentFlags(t *testing.T) {
if err != nil {
t.Fatal(err)
}
pkg2 := mustPackage(t, db, "按次", domain.UnitCount, 10)
pkg2 := mustPackage(t, db, "包周", 7)
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)),
CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()), Days: 7,
})
if err != nil {
t.Fatal(err)
@@ -825,26 +697,17 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
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 {
// 老客户仍可读
if _, err := db.Customer(1); err != nil {
t.Fatalf("老客户读取失败: %v", err)
}
if c.AssigneeID != 0 {
t.Fatalf("老客户不应有打手: %+v", c)
// 老套餐已被规范化成按日
if pkg.UnitLabel() != "按日" {
t.Fatalf("老套餐未规范化为按日: %s", pkg.UnitLabel())
}
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)
// 迁移后仍可新建套餐(老库 packages.unit 是 NOT NULL 且没有默认值)
if _, err := db.CreatePackage(&Package{Name: "新套餐", DefaultAmount: 7, Color: "#123456"}); err != nil {
t.Fatalf("迁移后无法新建套餐: %v", err)
}
// 新表 task_flags 已建立
@@ -858,171 +721,89 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
}
}
func TestUserColorAndAssignee(t *testing.T) {
// TestSingleUserAccount 覆盖 lite 版只保留一个账户后的账户与会话行为。
func TestSingleUserAccount(t *testing.T) {
db := newTestDB(t)
// 新建管理员自动分配调色板颜色
u1, err := db.CreateUser("dashou1", "阿强", "daili123")
u, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
u2, err := db.CreateUser("dashou2", "小美", "daili123")
if err != nil {
t.Fatal(err)
if u.ID != 1 {
t.Fatalf("首个账户 id 应为 1,实际 %d", u.ID)
}
if u1.Color == "" || u2.Color == "" || u1.Color == u2.Color {
t.Fatalf("自动颜色不合理: %q %q", u1.Color, u2.Color)
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.SetUserColor(u2.ID, "#c0392b"); err != nil {
t.Fatal(err)
// 登录
if _, err := db.Authenticate("admin", "daili123"); err != nil {
t.Fatalf("正确密码应可登录: %v", 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("不存在的账户应报错")
if _, err := db.Authenticate("admin", "wrong"); err == nil {
t.Fatal("错误密码不应通过")
}
// 客户指派
cid := mustCustomer(t, db, "3001", "客户甲")
if err := db.SetCustomerAssignee(cid, u1.ID); err != nil {
// 改密码后旧密码失效
if err := db.UpdatePassword(u.ID, "newpass123"); err != nil {
t.Fatal(err)
}
c, err := db.Customer(cid)
if err != nil {
t.Fatal(err)
if _, err := db.Authenticate("admin", "daili123"); err == nil {
t.Fatal("旧密码不应还能登录")
}
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("不存在的打手应报错")
if _, err := db.Authenticate("admin", "newpass123"); err != nil {
t.Fatalf("新密码应可登录: %v", err)
}
// 客户创建时直接带指派
cid2, err := db.CreateCustomer(&Customer{Tail: "3002", Name: "客户乙", AssigneeID: u2.ID})
if err != nil {
// 改显示名称
if err := db.UpdateDisplayName(u.ID, " 强哥 "); 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("停用账户不应出现在打手候选里")
}
got, err := db.UserByID(u.ID)
if err != nil || got.DisplayName != "强哥" {
t.Fatalf("显示名称未更新: %+v err=%v", got, err)
}
}
func TestUpdateUserByAdmin(t *testing.T) {
// TestSessionLifecycle 覆盖会话创建、过期清理与登出。
func TestSessionLifecycle(t *testing.T) {
db := newTestDB(t)
admin, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
u, err := db.CreateUser("dashou", "阿强", "dashou123")
u, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
// 建一个会话,稍后用密码重置把它踢掉
token := "tok-" + domain.Date(domain.Today())
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.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil {
// 过期会话不可用,且会被删除
if err := db.CreateSession("tok-old", u.ID, -time.Hour, "127.0.0.1"); err != nil {
t.Fatal(err)
}
got, _ := db.UserByID(u.ID)
if got.DisplayName != "强哥" || got.Username != "dashou" {
t.Fatalf("昵称未更新: %+v", got)
if _, err := db.SessionUser("tok-old"); err == nil {
t.Fatal("过期会话不应有效")
}
if _, err := db.SessionUser(token); err != nil {
t.Fatalf("未改密码不应踢下线: %v", err)
}
// 改用户名 + 重置密码:会话失效,新密码可登录
if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil {
// 登出
if err := db.DeleteSession(token); 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)
t.Fatal("登出后会话应失效")
}
db.PurgeSessions()
}
func TestCustomerArchiveFilters(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
pkg := mustPackage(t, db, "包月日常", 30)
// 一个正常客户 + 一个按次客户
cid := mustCustomer(t, db, "7001", "小张")
@@ -1107,10 +888,138 @@ func TestCustomerArchiveFilters(t *testing.T) {
}
}
// 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, "包月活动托", domain.UnitDay, 30)
p1 := mustPackage(t, db, "包月活动托", 30)
if p1.Tag != "" {
t.Fatalf("默认标签应为空: %q", p1.Tag)
}
@@ -1123,7 +1032,7 @@ func TestPackageTag(t *testing.T) {
t.Fatalf("标签未保存: %+v err=%v", got, err)
}
p2 := &Package{Name: "深渊委托", Unit: domain.UnitCount, DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "}
p2 := &Package{Name: "深渊委托", DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "}
if _, err := db.CreatePackage(p2); err != nil {
t.Fatal(err)
}
+40 -223
View File
@@ -10,28 +10,19 @@ import (
"daili/internal/domain"
)
// Subscription 服务订阅。
// Subscription 服务订阅(lite 版只有按日:一定有开始日与结束日)。
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
// Season 通行证;SeasonDone 标记整段完成的日期(空=未完成)
Season bool
SeasonDone string
// Abyss 深渊模式:CycleDays 天一个周期,TotalCount 是周期总数
Abyss bool
CycleDays int
// 联表
PackageName string
@@ -39,47 +30,11 @@ type Subscription struct {
CustomerTail string
CustomerName string
CustomerContact string
// 客户归属的打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor 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 }
// IsBlock 是否整段型(通行证 / 深渊):日历上按连续色块展示,不按天计任务。
func (s *Subscription) IsBlock() bool { return s.Season || s.Abyss }
// BlockTotal 返回整段型订阅的总单数:通行证固定 1,深渊为周期数。
func (s *Subscription) BlockTotal() int {
if s.Season {
return 1
}
if s.Abyss {
return s.TotalCount
}
return 0
}
// BlockDone 返回整段型订阅的已完成单数:通行证看完成标记,深渊看已完成周期数。
func (s *Subscription) BlockDone() int {
if s.Season {
if s.SeasonDone != "" {
return 1
}
return 0
}
if s.Abyss {
return s.UsedCount
}
return 0
return domain.EvalSub(s.Status, s.StartDate, s.EndDate, today, warnDays)
}
// Pause 暂停分段。
@@ -101,9 +56,6 @@ type Adjustment struct {
OldEndDate string
NewEndDate string
DaysAdded int
CountAdded int
OldTotal int
NewTotal int
Note string
OperatorID int64
OperatorName string
@@ -122,42 +74,30 @@ func (a *Adjustment) KindLabel() string {
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,''),
IFNULL(s.season,0),IFNULL(s.season_done,''),IFNULL(s.abyss,0),IFNULL(s.cycle_days,0),
p.name,p.color,c.tail,c.name,IFNULL(c.contact,''),
IFNULL(c.assignee_id,0),
IFNULL((SELECT u.display_name FROM users u WHERE u.id=c.assignee_id),''),
IFNULL((SELECT u.color FROM users u WHERE u.id=c.assignee_id),'')
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.status,s.start_date,IFNULL(s.end_date,''),
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
var seasonInt, abyssInt, cycleDays int
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,
&seasonInt, &s.SeasonDone, &abyssInt, &cycleDays,
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact,
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Status, &s.StartDate, &s.EndDate,
&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
}
s.Season = seasonInt == 1
s.Abyss = abyssInt == 1
s.CycleDays = cycleDays
return &s, nil
}
@@ -218,7 +158,7 @@ type SubFilter struct {
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 < ?))))`
const expiredExpr = `(IFNULL(s.end_date,'')<>'' AND s.end_date < ?)`
// ListSubs 分页查询订阅。
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
@@ -237,14 +177,14 @@ func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
switch f.State {
case "active":
where = append(where, "s.status='active'", "NOT "+expiredExpr)
args = append(args, today, today)
args = append(args, 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)
args = append(args, 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)))
@@ -289,7 +229,6 @@ type CreateSubInput struct {
StartDate string
EndDate string
Days int
TotalCount int
Note string
OperatorID int64
}
@@ -303,90 +242,31 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅")
}
if pkg.Season && strings.TrimSpace(in.StartDate) == "" {
in.StartDate = pkg.SeasonStart
}
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)
} else if pkg.DefaultValidityDays > 0 {
endDate = domain.Date(domain.AddDays(start, pkg.DefaultValidityDays-1))
}
days := in.Days
if days <= 0 {
days = pkg.DefaultAmount
}
if pkg.Abyss {
if pkg.CycleDays <= 0 {
return nil, errors.New("该深渊套餐未设置周期天数")
}
total = in.TotalCount
if total <= 0 {
total = pkg.DefaultAmount
}
if total <= 0 {
total = 1
}
endDate = domain.Date(domain.AddDays(start, pkg.CycleDays*total-1))
unit = domain.UnitDay
days = pkg.CycleDays * total
} else if pkg.Season {
if pkg.SeasonStart == "" || pkg.SeasonEnd == "" {
return nil, errors.New("该通行证套餐未设置起止日期")
}
end, err := domain.ParseDate(pkg.SeasonEnd)
if days <= 0 {
days = 30
}
endDate := ""
if in.EndDate != "" {
end, err := domain.ParseDate(in.EndDate)
if err != nil {
return nil, errors.New("通行证结束日期不正确")
}
if domain.DayDiff(domain.Today(), end) < 0 {
return nil, errors.New("该通行证已结束,不能开通")
return nil, errors.New("结束日期格式不正确")
}
if domain.DayDiff(start, end) < 0 {
return nil, errors.New("通行证结束日期早于开始日期")
return nil, errors.New("结束日期不能早于开始日期")
}
endDate = domain.Date(end)
unit = domain.UnitDay
days = domain.DayDiff(start, end) + 1
} else {
endDate = domain.Date(domain.AddDays(start, days-1))
}
now := nowStamp()
@@ -401,9 +281,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
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,season,abyss,cycle_days)
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?,?,?)`,
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now, b2i(pkg.Season), b2i(pkg.Abyss), pkg.CycleDays)
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,note,created_by,created_at,updated_at)
VALUES(?,?,?,'active',?,?,?,?,?,?)`,
in.CustomerID, in.PackageID, unitDay, domain.Date(start), nullIfEmpty(endDate), in.Note, nullIfZero(in.OperatorID), now, now)
if err != nil {
if isUnique(err) {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
@@ -411,9 +291,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
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)
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, "新建订阅", nullIfZero(in.OperatorID), now)
return err
})
if err != nil {
@@ -442,9 +322,6 @@ func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64)
if err != nil {
return err
}
if s.Unit != domain.UnitDay {
return errors.New("该订阅按次计费,请使用「追加次数」")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
@@ -461,58 +338,14 @@ func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64)
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,?,?,?)`,
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
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 {
@@ -535,7 +368,7 @@ func (db *DB) PauseSub(subID, operatorID int64) error {
})
}
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。
// ResumeSub 恢复订阅,顺延结束日,返回顺延天数。
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
var shift int
today := domain.Today()
@@ -563,7 +396,7 @@ func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
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 {
if 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 {
@@ -593,7 +426,7 @@ func (db *DB) ReopenSub(subID int64) error {
// 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,
a.days_added,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
@@ -606,7 +439,7 @@ func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
// 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,
a.days_added,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
@@ -626,7 +459,7 @@ func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
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.DaysAdded, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
return nil, err
}
@@ -671,10 +504,8 @@ func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
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 IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0 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)
AND IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0
AND s.end_date >= ?`, today).Scan(&n)
return n, err
}
@@ -683,20 +514,6 @@ func (s *Subscription) SubTitle() string {
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
}
// SeasonComplete 把通行证订阅整段标记为已完成。
func (db *DB) SeasonComplete(subID int64, date string) error {
sub, err := db.Sub(subID)
if err != nil {
return err
}
if !sub.Season {
return errors.New("不是通行证订阅")
}
_, err = db.sql.Exec(`UPDATE subscriptions SET season_done=?, updated_at=? WHERE id=?`,
date, nowStamp(), subID)
return err
}
// DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。
func (db *DB) DeleteSubscription(subID int64) error {
res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID)
+12 -160
View File
@@ -10,46 +10,25 @@ import (
)
// User 管理员账户。
//
// lite 版只保留首次启动时创建的那一个账户:不再有新增/停用/改他人资料,
// 因此这里只保留登录、改自己密码与会话相关的最小集合。
type User struct {
ID int64
Username string
DisplayName string
IsActive bool
Color string
CreatedAt string
LastLoginAt string
}
// AssigneePalette 是新建管理员时自动分配的颜色(区分度较高的深色系)。
var AssigneePalette = []string{
"#2F5D50", "#8C4A3C", "#3E5C8A", "#7A5AA6",
"#A66A1E", "#2C7A7B", "#8A3B5C", "#5B6B2F",
}
// NormalizeColor 校验并规范化 #RRGGBB;空值返回空。
func NormalizeColor(c string) (string, error) {
c = strings.TrimSpace(c)
if c == "" {
return "", nil
}
if len(c) != 7 || c[0] != '#' {
return "", errors.New("颜色格式应为 #RRGGBB")
}
for _, r := range c[1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return "", errors.New("颜色格式应为 #RRGGBB")
}
}
return strings.ToUpper(c), nil
}
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, &u.Color); err != nil {
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
}
@@ -60,7 +39,7 @@ func scanUser(row interface {
return &u, nil
}
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,''), IFNULL(color,'')`
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
// CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) {
@@ -69,16 +48,15 @@ func (db *DB) CountUsers() (int, error) {
return n, err
}
// CreateUser 新建账户。
// CreateUser 新建账户。只用于首次初始化,之后不再允许新增。
func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
hash, err := pw.Hash(password)
if err != nil {
return nil, err
}
now := nowStamp()
color := db.nextAssigneeColor()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, color, created_at)
VALUES(?,?,?,1,?,?)`, username, displayName, hash, color, now)
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
@@ -86,106 +64,7 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, Color: color, CreatedAt: now}, nil
}
// nextAssigneeColor 依据现有账户数循环取调色板,尽量不重复。
func (db *DB) nextAssigneeColor() string {
n, _ := db.CountUsers()
return AssigneePalette[n%len(AssigneePalette)]
}
// SetUserColor 设置管理员颜色(空字符串表示清除)。
func (db *DB) SetUserColor(userID int64, color string) error {
c, err := NormalizeColor(color)
if err != nil {
return err
}
res, err := db.sql.Exec(`UPDATE users SET color=? WHERE id=?`, c, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetCustomerAssignee 把客户指派给某个打手;userID=0 表示取消指派。
func (db *DB) SetCustomerAssignee(customerID, userID int64) error {
var v any
if userID > 0 {
if _, err := db.UserByID(userID); err != nil {
return errors.New("打手不存在")
}
v = userID
}
res, err := db.sql.Exec(`UPDATE customers SET assignee_id=?, updated_at=? WHERE id=?`, v, nowStamp(), customerID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ActiveUsers 返回在用的管理员(打手候选)。
func (db *DB) ActiveUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users WHERE is_active=1 ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// DeleteUserSessions 清除某个账户的全部登录会话。
func (db *DB) DeleteUserSessions(userID int64) error {
_, err := db.sql.Exec(`DELETE FROM sessions WHERE user_id=?`, userID)
return err
}
// UpdateUserByAdmin 由超管修改用户名/昵称,password 非空时同时重置密码。
// 重置密码会强制该账户下线。
func (db *DB) UpdateUserByAdmin(userID int64, username, displayName, password string) error {
username = strings.TrimSpace(username)
displayName = strings.TrimSpace(displayName)
if username == "" {
return errors.New("用户名不能为空")
}
hash := ""
if password != "" {
var err error
hash, err = pw.Hash(password)
if err != nil {
return err
}
}
if _, err := db.sql.Exec(`UPDATE users SET username=?, display_name=? WHERE id=?`,
username, displayName, userID); err != nil {
if isUnique(err) {
return ErrDuplicate
}
return err
}
if hash != "" {
if _, err := db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID); err != nil {
return err
}
if err := db.DeleteUserSessions(userID); err != nil {
return err
}
}
return nil
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
}
// Authenticate 校验用户名口令。
@@ -213,29 +92,6 @@ 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)
@@ -246,13 +102,9 @@ func (db *DB) UpdatePassword(userID int64, password string) error {
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)
// UpdateDisplayName 修改显示名称(仅限自己)。
func (db *DB) UpdateDisplayName(userID int64, displayName string) error {
_, err := db.sql.Exec(`UPDATE users SET display_name=? WHERE id=?`, strings.TrimSpace(displayName), userID)
return err
}