package store import ( "database/sql" "errors" "strconv" "strings" "daili/internal/domain" ) // Package 服务套餐。 type Package struct { ID int64 Name string Unit string DefaultAmount int PriceCents int HasPrice bool Color string Note string Archived bool SortOrder int // 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 ActiveCount int } // UnitLabel 返回类型中文名。 func (p *Package) UnitLabel() string { if p.Abyss { return "深渊" } if p.Season { return "通行证" } if p.Unit == domain.UnitCount { return "按次" } 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) + " 天/周期" } func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { var p Package 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) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } 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 } return &p, nil } // 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, (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` if !includeArchived { q += ` WHERE p.archived=0` } q += ` ORDER BY p.archived, p.sort_order, p.id` rows, err := db.sql.Query(q) if err != nil { return nil, err } defer rows.Close() out := []Package{} for rows.Next() { var p Package 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 { 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 } out = append(out, p) } return out, rows.Err() } // 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)) } // CreatePackage 新建套餐。 func (db *DB) CreatePackage(p *Package) (int64, error) { now := nowStamp() p.Unit = normalizeUnit(p.Unit) if p.DefaultAmount <= 0 { p.DefaultAmount = 30 } if strings.TrimSpace(p.Color) == "" { p.Color = "#3B6EA5" } 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) if err != nil { return 0, err } return res.LastInsertId() } // 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" } 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) return err } // b2i 把 bool 转成 SQLite 的 0/1。 func b2i(v bool) int { if v { return 1 } return 0 } // SetPackageArchived 归档/取消归档。 func (db *DB) SetPackageArchived(id int64, archived bool) error { v := 0 if archived { v = 1 } _, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id) return err } // SubscribedCustomerIDs 返回已订阅某套餐的客户 ID 集合(含已关闭,用于批量订阅时置灰)。 func (db *DB) SubscribedCustomerIDs(packageID int64) (map[int64]bool, error) { rows, err := db.sql.Query(`SELECT customer_id FROM subscriptions WHERE package_id=?`, packageID) if err != nil { return nil, err } defer rows.Close() out := map[int64]bool{} for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } out[id] = true } return out, rows.Err() } // PackageTags 返回所有非空标签(去重,按字典序)。 func (db *DB) PackageTags() ([]string, error) { rows, err := db.sql.Query(`SELECT DISTINCT tag FROM packages WHERE IFNULL(tag,'')<>'' ORDER BY tag`) if err != nil { return nil, err } defer rows.Close() out := []string{} for rows.Next() { var t string if err := rows.Scan(&t); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // SeasonExpired 判断包赛季套餐在 today 是否已结束。 func (p Package) SeasonExpired(today string) bool { if !p.Season || p.SeasonEnd == "" { return false } return today > p.SeasonEnd }