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

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

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

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

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

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

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

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

528 lines
16 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"daili/internal/domain"
)
// Subscription 服务订阅(lite 版只有按日:一定有开始日与结束日)。
type Subscription struct {
ID int64
CustomerID int64
PackageID int64
Status string
StartDate string
EndDate string
Note string
CreatedBy int64
CreatedAt string
UpdatedAt string
ClosedAt string
// 联表
PackageName string
PackageColor string
CustomerTail string
CustomerName string
CustomerContact string
}
// Eval 计算展示状态。
func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
return domain.EvalSub(s.Status, s.StartDate, s.EndDate, today, warnDays)
}
// Pause 暂停分段。
type Pause struct {
ID int64
SubID int64
StartAt string
EndAt string
StartDate string
EndDate string
}
// Adjustment 追加记录。
type Adjustment struct {
ID int64
SubID int64
Kind string
BaseDate string
OldEndDate string
NewEndDate string
DaysAdded int
Note string
OperatorID int64
OperatorName string
CreatedAt string
// 联表
CustomerTail string
CustomerName string
PackageName string
}
// KindLabel 返回追加类型的中文名。
func (a *Adjustment) KindLabel() string {
switch a.Kind {
case "initial":
return "开通"
case "extend_days":
return "追加天数"
case "set_end_date":
return "调整结束日"
}
return "调整"
}
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
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
}
return &s, nil
}
// Sub 取单个订阅。
func (db *DB) Sub(id int64) (*Subscription, error) {
return scanSub(db.sql.QueryRow(subSelect+` WHERE s.id=?`, id))
}
// SubsByCustomer 取某客户的订阅。
func (db *DB) SubsByCustomer(customerID int64) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.customer_id=? ORDER BY s.status, s.id`, customerID)
}
// SubsByCustomers 批量取订阅(客户列表聚合用)。
func (db *DB) SubsByCustomers(ids []int64) ([]Subscription, error) {
if len(ids) == 0 {
return nil, nil
}
ph := placeholders(len(ids))
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return db.querySubs(subSelect+` WHERE s.customer_id IN (`+ph+`) ORDER BY s.id`, args...)
}
func (db *DB) querySubs(q string, args ...any) ([]Subscription, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Subscription{}
for rows.Next() {
s, err := scanSub(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// SubFilter 订阅列表筛选。
type SubFilter struct {
Q string
State string
PackageID int64
Limit int
Offset int
Today time.Time
}
const expiredExpr = `(IFNULL(s.end_date,'')<>'' AND s.end_date < ?)`
// ListSubs 分页查询订阅。
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
where := []string{"1=1"}
args := []any{}
today := domain.Date(f.Today)
if q := strings.TrimSpace(f.Q); q != "" {
like := "%" + q + "%"
where = append(where, "(c.tail LIKE ? OR c.name LIKE ? OR p.name LIKE ?)")
args = append(args, like, like, like)
}
if f.PackageID > 0 {
where = append(where, "s.package_id=?")
args = append(args, f.PackageID)
}
switch f.State {
case "active":
where = append(where, "s.status='active'", "NOT "+expiredExpr)
args = append(args, today)
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)
case "due7":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 6)))
case "due3":
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
args = append(args, today, domain.Date(domain.AddDays(f.Today, 2)))
case "due_today":
where = append(where, "s.status<>'closed'", "s.end_date = ?")
args = append(args, today)
}
w := strings.Join(where, " AND ")
var total int
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s
JOIN packages p ON p.id=s.package_id JOIN customers c ON c.id=s.customer_id WHERE `+w, args...).Scan(&total); err != nil {
return nil, 0, err
}
lim := f.Limit
if lim <= 0 {
lim = 30
}
q := subSelect + ` WHERE ` + w + ` ORDER BY s.status, IFNULL(s.end_date,'9999-12-31'), s.id DESC LIMIT ? OFFSET ?`
subs, err := db.querySubs(q, append(args, lim, f.Offset)...)
return subs, total, err
}
// CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。
func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0
AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ?
ORDER BY s.id`, to, from)
}
// DueOn 某天到期的订阅。
func (db *DB) DueOn(date string) ([]Subscription, error) {
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date=? ORDER BY s.id`, date)
}
// CreateSubInput 新建订阅入参。
type CreateSubInput struct {
CustomerID int64
PackageID int64
StartDate string
EndDate string
Days int
Note string
OperatorID int64
}
// CreateSub 新建订阅,并写入初始追加记录。
func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
pkg, err := db.Package(in.PackageID)
if err != nil {
return nil, errors.New("套餐不存在")
}
if pkg.Archived {
return nil, errors.New("该套餐已归档,不能新建订阅")
}
start, err := domain.ParseDate(in.StartDate)
if err != nil {
return nil, errors.New("开始日期格式不正确")
}
days := in.Days
if days <= 0 {
days = pkg.DefaultAmount
}
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(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))
}
now := nowStamp()
var id int64
err = db.withTx(func(tx *sql.Tx) error {
var existing int64
err := tx.QueryRow(`SELECT id FROM subscriptions WHERE customer_id=? AND package_id=?`,
in.CustomerID, in.PackageID).Scan(&existing)
if err == nil {
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,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("该客户已订阅此套餐,请改用「追加时长」")
}
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,note,operator_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, "新建订阅", nullIfZero(in.OperatorID), now)
return err
})
if err != nil {
return nil, err
}
return db.Sub(id)
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
// ExtendDays 按日追加:手动指定新的结束日,自动换算计算起点与追加天数。
func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64) (domain.ExtendPlan, error) {
var plan domain.ExtendPlan
today := domain.Today()
newEnd, err := domain.ParseDate(newEndDate)
if err != nil {
return plan, errors.New("请选择新的终止日期")
}
err = db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,请先「重新启用」")
}
oldEnd := domain.ParseDateOr(s.EndDate, today)
plan = domain.PlanExtend(oldEnd, newEnd, today)
if plan.Backwards {
return fmt.Errorf("所选日期早于计算起点 %s,不会增加时长", domain.Date(plan.Base))
}
status := s.Status
if status == domain.StatusPaused {
status = domain.StatusActive
}
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=?, status=?, updated_at=? WHERE id=?`,
domain.Date(newEnd), status, nowStamp(), subID); err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,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
}
// PauseSub 暂停订阅。
func (db *DB) PauseSub(subID, operatorID int64) error {
return db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status == domain.StatusPaused {
return errors.New("订阅已处于暂停状态")
}
if s.Status == domain.StatusClosed {
return errors.New("订阅已结束,无法暂停")
}
now := nowStamp()
if _, err := tx.Exec(`INSERT INTO sub_pauses(sub_id,start_at) VALUES(?,?)`, subID, now); err != nil {
return err
}
_, err = tx.Exec(`UPDATE subscriptions SET status='paused', updated_at=? WHERE id=?`, now, subID)
return err
})
}
// ResumeSub 恢复订阅,顺延结束日,返回顺延天数。
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
var shift int
today := domain.Today()
err := db.withTx(func(tx *sql.Tx) error {
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
if err != nil {
return err
}
if s.Status != domain.StatusPaused {
return errors.New("订阅未处于暂停状态")
}
var startAt string
err = tx.QueryRow(`SELECT start_at FROM sub_pauses WHERE sub_id=? AND end_at IS NULL ORDER BY id DESC LIMIT 1`, subID).Scan(&startAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
ps := domain.ParseDateOr(startAt[:10], today)
shift = domain.DayDiff(ps, today)
if shift < 0 {
shift = 0
}
}
now := nowStamp()
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
return err
}
if shift > 0 {
oldEnd := domain.ParseDateOr(s.EndDate, today)
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
return err
}
}
_, err = tx.Exec(`UPDATE subscriptions SET status='active', updated_at=? WHERE id=?`, now, subID)
return err
})
return shift, err
}
// CloseSub 结束订阅。
func (db *DB) CloseSub(subID int64) error {
now := nowStamp()
_, err := db.sql.Exec(`UPDATE subscriptions SET status='closed', closed_at=?, updated_at=? WHERE id=? AND status<>'closed'`,
now, now, subID)
return err
}
// ReopenSub 重新启用已结束的订阅。
func (db *DB) ReopenSub(subID int64) error {
_, err := db.sql.Exec(`UPDATE subscriptions SET status='active', closed_at=NULL, updated_at=? WHERE id=?`, nowStamp(), subID)
return err
}
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。
func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
WHERE a.sub_id=? ORDER BY a.id DESC`, subID)
}
// RecentAdjustments 最近追加记录。
func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) {
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
a.days_added,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
c.tail,c.name,p.name
FROM sub_adjustments a
LEFT JOIN users u ON u.id=a.operator_id
JOIN subscriptions s ON s.id=a.sub_id
JOIN customers c ON c.id=s.customer_id
JOIN packages p ON p.id=s.package_id
ORDER BY a.id DESC LIMIT ?`, limit)
}
func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Adjustment{}
for rows.Next() {
var a Adjustment
if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate,
&a.DaysAdded, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// PausesForSubs 批量取暂停分段。
func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
out := map[int64][]Pause{}
if len(ids) == 0 {
return out, nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := db.sql.Query(`SELECT id,sub_id,start_at,IFNULL(end_at,'') FROM sub_pauses
WHERE sub_id IN (`+placeholders(len(ids))+`) ORDER BY id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p Pause
if err := rows.Scan(&p.ID, &p.SubID, &p.StartAt, &p.EndAt); err != nil {
return nil, err
}
if len(p.StartAt) >= 10 {
p.StartDate = p.StartAt[:10]
}
if len(p.EndAt) >= 10 {
p.EndDate = p.EndAt[:10]
}
out[p.SubID] = append(out[p.SubID], p)
}
return out, rows.Err()
}
// CountActiveSubs 活跃订阅数。
func (db *DB) CountActiveSubs(today string) (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active'
AND IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0
AND s.end_date >= ?`, today).Scan(&n)
return n, err
}
// SubTitle 生成「1234 老张 · 包月日常」式标题。
func (s *Subscription) SubTitle() string {
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
}
// DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。
func (db *DB) DeleteSubscription(subID int64) error {
res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}