package store import ( "database/sql" "errors" "strconv" "strings" ) // 套餐计费方式:lite 版只有「按日」一种, // 但 packages.unit 列保留(写入固定值 "day"),以便老库平滑升级。 const unitDay = "day" // Package 服务套餐。 type Package struct { ID int64 Name string DefaultAmount int // 默认天数 PriceCents int HasPrice bool Color string Note string Archived bool SortOrder int // CalendarSeparate 勾选后在工作台日历中单独成页 CalendarSeparate bool // Tag 套餐标签:相同标签的套餐在日历里合并显示 Tag string CreatedAt string UpdatedAt string SubCount int ActiveCount int } // UnitLabel 返回类型中文名(lite 版只有按日)。 func (p *Package) UnitLabel() string { return "按日" } // AmountText 返回「默认量」列的中文描述。 func (p *Package) AmountText() string { return strconv.Itoa(p.DefaultAmount) + " 天" } 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 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 } return nil, err } p.Archived = archived == 1 p.CalendarSeparate = separate == 1 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.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 unit string var price sql.NullInt64 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 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 `+packageCols+` FROM packages WHERE id=?`, id)) } 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,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 } return res.LastInsertId() } // UpdatePackage 更新套餐。 func (db *DB) UpdatePackage(p *Package) error { 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=?,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 } // 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() }