package store import ( "database/sql" "errors" "fmt" "strings" "daili/internal/domain" ) // Customer 客户。 type Customer struct { ID int64 Tail string Name string GameName string GameServer string Contact string Tags string Source string Note string CreatedAt string UpdatedAt string // 归属打手(管理员) AssigneeID int64 AssigneeName string AssigneeColor string // Archived 停用(软删除):默认隐藏,订阅保留但不上日历 Archived bool // 列表聚合 SubCount int Summary string Level string // ok | warn | today | expired | none } // TagList 拆分标签。 func (c *Customer) TagList() []string { var out []string for _, t := range strings.Split(c.Tags, ",") { t = strings.TrimSpace(t) if t != "" { out = append(out, t) } } 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)` 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) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } c.Archived = archived == 1 return &c, nil } // 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(?,?,?,?,?,?,?,?,?,?,?)`, 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) if err != nil { if isUnique(err) { return 0, ErrDuplicate } return 0, err } return res.LastInsertId() } // 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=? 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) if err != nil && isUnique(err) { return ErrDuplicate } return err } // Customer 取单个客户。 func (db *DB) Customer(id int64) (*Customer, error) { return scanCustomer(db.sql.QueryRow(`SELECT `+customerCols+` FROM customers WHERE id=?`, id)) } // ListCustomers 分页查询客户,并聚合订阅摘要。 func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int, includeArchived bool) ([]Customer, int, error) { where := "1=1" args := []any{} if !includeArchived { where = "IFNULL(archived,0)=0" } if q = strings.TrimSpace(q); q != "" { like := "%" + q + "%" if includeArchived { where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)" } else { where = "IFNULL(archived,0)=0 AND (tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)" } args = append(args, like, like, like, like, like) } var total int if err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE `+where, args...).Scan(&total); err != nil { return nil, 0, err } rows, err := db.sql.Query(`SELECT `+customerCols+` FROM customers WHERE `+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, append(args, limit, offset)...) if err != nil { return nil, 0, err } defer rows.Close() out := []Customer{} ids := []int64{} for rows.Next() { c, err := scanCustomer(rows) if err != nil { return nil, 0, err } out = append(out, *c) ids = append(ids, c.ID) } if err := rows.Err(); err != nil { return nil, 0, err } if len(ids) > 0 { subs, err := db.SubsByCustomers(ids) if err != nil { return nil, 0, err } byCustomer := map[int64][]Subscription{} for _, s := range subs { byCustomer[s.CustomerID] = append(byCustomer[s.CustomerID], s) } for i := range out { list := byCustomer[out[i].ID] out[i].SubCount = len(list) out[i].Summary, out[i].Level = summarizeSubs(list, today, warnDays) } } return out, total, nil } // summarizeSubs 生成“包月日常 剩12天 · 深渊 剩3次”式的摘要与最紧急等级。 func summarizeSubs(subs []Subscription, today string, warnDays int) (string, string) { if len(subs) == 0 { return "暂无订阅", domain.LevelNone } t := domain.ParseDateOr(today, domain.Today()) rank := map[string]int{domain.LevelExpired: 4, domain.LevelToday: 3, domain.LevelWarn: 2, domain.LevelOK: 1, domain.LevelNone: 0} worst := domain.LevelNone parts := []string{} for _, s := range subs { e := s.Eval(t, warnDays) if e.State == domain.StateClosed { continue } var text string 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) } } parts = append(parts, text) if rank[e.Level] > rank[worst] { worst = e.Level } } if len(parts) == 0 { return "仅历史订阅", domain.LevelNone } if len(parts) > 2 { parts = append(parts[:2], fmt.Sprintf("等%d项", len(parts))) } 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 err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE IFNULL(archived,0)=0`).Scan(&n) return n, err } // CustomersByTail 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。 func (db *DB) CustomersByTail(tail string) ([]Customer, error) { tail = strings.TrimSpace(tail) if tail == "" { return nil, nil } 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 } if len(out) > 0 { return out, nil } return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail LIKE ? ORDER BY id LIMIT 20`, "%"+tail+"%") } func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) { rows, err := db.sql.Query(q, args...) if err != nil { return nil, err } defer rows.Close() out := []Customer{} for rows.Next() { c, err := scanCustomer(rows) if err != nil { return nil, err } out = append(out, *c) } return out, rows.Err() } // SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历与大屏。 func (db *DB) SetCustomerArchived(id int64, archived bool) error { v := 0 if archived { v = 1 } res, err := db.sql.Exec(`UPDATE customers SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // DeleteCustomer 彻底删除客户;订阅、打卡、暂停、追加记录随外键级联删除。 func (db *DB) DeleteCustomer(id int64) error { res, err := db.sql.Exec(`DELETE FROM customers WHERE id=?`, id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // CountArchivedCustomers 统计已停用客户数。 func (db *DB) CountArchivedCustomers() (int, error) { var n int err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE IFNULL(archived,0)=1`).Scan(&n) return n, err }