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:
+14
-135
@@ -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
@@ -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
@@ -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, ¬null, &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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user