package store import ( "database/sql" "errors" "fmt" "strings" "time" "daili/internal/domain" ) // Subscription 服务订阅。 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 // 联表 PackageName string PackageColor string 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 } // 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 CountAdded int OldTotal int NewTotal 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 "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,''), 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),'') 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 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, &s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact, &s.AssigneeID, &s.AssigneeName, &s.AssigneeColor) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } s.Season = seasonInt == 1 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 = `((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 < ?))))` // 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, 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) 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 TotalCount 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("该套餐已归档,不能新建订阅") } 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)) } } if pkg.Season { if pkg.SeasonStart == "" || pkg.SeasonEnd == "" { return nil, errors.New("该赛季套餐未设置起止日期") } end, err := domain.ParseDate(pkg.SeasonEnd) if err != nil { return nil, errors.New("赛季结束日期不正确") } if domain.DayDiff(domain.Today(), end) < 0 { return nil, errors.New("该赛季已结束,不能开通") } if domain.DayDiff(start, end) < 0 { return nil, errors.New("赛季结束日期早于开始日期") } endDate = domain.Date(end) unit = domain.UnitDay days = domain.DayDiff(start, end) + 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,total_count,used_count,note,created_by,created_at,updated_at,season) VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?)`, in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now, b2i(pkg.Season)) 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,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) 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.Unit != domain.UnitDay { return errors.New("该订阅按次计费,请使用「追加次数」") } 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,count_added,old_total,new_total,note,operator_id,created_at) VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`, 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 { 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 s.Unit == domain.UnitDay && 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.count_added,a.old_total,a.new_total,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.count_added,a.old_total,a.new_total,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.CountAdded, &a.OldTotal, &a.NewTotal, &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.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) return n, err } // SubTitle 生成「1234 老张 · 包月日常」式标题。 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) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil }