package service import ( "encoding/json" "errors" "sort" "strings" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "gorm.io/gorm" ) type GroupSummary struct { Name string `json:"name"` ClientCount int `json:"clientCount"` TrafficUsed int64 `json:"trafficUsed"` Up int64 `json:"up"` Down int64 `json:"down"` } func (s *ClientService) ListGroups() ([]GroupSummary, error) { db := database.GetDB() // email is unique in both clients and client_traffics, so the LEFT JOIN // never double-counts a client's traffic. derived, err := sumGroupTraffic(db, "c.group_name <> ''") if err != nil { return nil, err } var stored []model.ClientGroup if err := db.Find(&stored).Error; err != nil { return nil, err } type groupAgg struct { count int up int64 down int64 } baseUp := make(map[string]int64, len(stored)) baseDown := make(map[string]int64, len(stored)) merged := make(map[string]groupAgg, len(derived)+len(stored)) for _, g := range stored { merged[g.Name] = groupAgg{} baseUp[g.Name] = g.ResetUp baseDown[g.Name] = g.ResetDown } for name, g := range derived { merged[name] = groupAgg{count: g.clients, up: g.up, down: g.down} } out := make([]GroupSummary, 0, len(merged)) for name, agg := range merged { up := max(clampedTrafficAdd(agg.up, -baseUp[name]), 0) down := max(clampedTrafficAdd(agg.down, -baseDown[name]), 0) out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: clampedTrafficAdd(up, down), Up: up, Down: down}) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out, nil } // adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients' // current counters so ListGroups totals survive a traffic reset or client delete (#5675). func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error { return shiftGroupBaselines(tx, emails, -1) } // adjustGroupBaselinesForRestoredTraffic shifts group baselines up by counters an // import restored, so usage from before the import never enters a group total. func adjustGroupBaselinesForRestoredTraffic(tx *gorm.DB, emails []string) error { return shiftGroupBaselines(tx, emails, 1) } func shiftGroupBaselines(tx *gorm.DB, emails []string, sign int64) error { if len(emails) == 0 { return nil } totals := make(map[string]*groupCounters) for _, batch := range chunkStrings(emails, sqlInChunk) { part, err := sumGroupTraffic(tx, "c.group_name <> '' AND c.email IN ?", batch) if err != nil { return err } for name, p := range part { if agg, ok := totals[name]; ok { agg.up = clampedTrafficAdd(agg.up, p.up) agg.down = clampedTrafficAdd(agg.down, p.down) } else { totals[name] = p } } } for name, d := range totals { if d.up == 0 && d.down == 0 { continue } var group model.ClientGroup err := tx.Where("name = ?", name).First(&group).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } group.Name = name group.ResetUp = clampedTrafficAdd(group.ResetUp, sign*d.up) group.ResetDown = clampedTrafficAdd(group.ResetDown, sign*d.down) if err := tx.Save(&group).Error; err != nil { return err } } return nil } func (s *ClientService) EmailsByGroup(name string) ([]string, error) { name = strings.TrimSpace(name) if name == "" { return []string{}, nil } db := database.GetDB() var emails []string if err := db.Model(&model.ClientRecord{}). Where("group_name = ?", name). Order("email ASC"). Pluck("email", &emails).Error; err != nil { return nil, err } if emails == nil { emails = []string{} } return emails, nil } func (s *ClientService) ResetGroupTraffic(name string) error { name = strings.TrimSpace(name) if name == "" { return common.NewError("group name is required") } db := database.GetDB() totals, err := sumGroupTraffic(db, "c.group_name = ?", name) if err != nil { return err } agg := groupCounters{} if t := totals[name]; t != nil { agg = *t } var count int64 if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil { return err } if count == 0 { return db.Create(&model.ClientGroup{Name: name, ResetUp: agg.up, ResetDown: agg.down}).Error } return db.Model(&model.ClientGroup{}).Where("name = ?", name). Updates(map[string]any{"reset_up": agg.up, "reset_down": agg.down}).Error } func (s *ClientService) CreateGroup(name string) error { name = strings.TrimSpace(name) if name == "" { return common.NewError("group name is required") } db := database.GetDB() var count int64 if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil { return err } if count > 0 { return common.NewError("group already exists") } return db.Create(&model.ClientGroup{Name: name}).Error } func (s *ClientService) RenameGroup(oldName, newName string) (int, error) { oldName = strings.TrimSpace(oldName) newName = strings.TrimSpace(newName) if oldName == "" { return 0, common.NewError("old group name is required") } if newName == "" { return 0, common.NewError("new group name is required") } if oldName == newName { return 0, nil } return s.replaceGroupValue(oldName, newName) } func (s *ClientService) DeleteGroup(name string) (int, error) { name = strings.TrimSpace(name) if name == "" { return 0, common.NewError("group name is required") } return s.replaceGroupValue(name, "") } func (s *ClientService) RemoveFromGroup(emails []string) (int, error) { return s.AddToGroup(emails, "") } func (s *ClientService) AddToGroup(emails []string, group string) (int, error) { group = strings.TrimSpace(group) if len(emails) == 0 { return 0, nil } db := database.GetDB() if group != "" { var exists int64 if err := db.Model(&model.ClientGroup{}).Where("name = ?", group).Count(&exists).Error; err != nil { return 0, err } if exists == 0 { var derived int64 if err := db.Model(&model.ClientRecord{}).Where("group_name = ?", group).Count(&derived).Error; err != nil { return 0, err } if derived == 0 { if err := db.Create(&model.ClientGroup{Name: group}).Error; err != nil { return 0, err } } } } var records []model.ClientRecord for _, batch := range chunkStrings(emails, sqlInChunk) { var rows []model.ClientRecord if err := db.Where("email IN ?", batch). Where("group_name IS NULL OR group_name <> ?", group). Find(&rows).Error; err != nil { return 0, err } records = append(records, rows...) } if len(records) == 0 { return 0, nil } affectedEmails := make([]string, 0, len(records)) for _, r := range records { affectedEmails = append(affectedEmails, r.Email) } tx := db.Begin() var affected int64 for _, batch := range chunkStrings(affectedEmails, sqlInChunk) { result := tx.Model(&model.ClientRecord{}). Where("email IN ?", batch). Where("group_name IS NULL OR group_name <> ?", group). UpdateColumn("group_name", group) if result.Error != nil { tx.Rollback() return 0, result.Error } affected += result.RowsAffected } var inboundIDs []int inboundIDSeen := make(map[int]struct{}) for _, batch := range chunkStrings(affectedEmails, sqlInChunk) { var ids []int if err := tx.Table("client_inbounds"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Where("clients.email IN ?", batch). Distinct("client_inbounds.inbound_id"). Pluck("inbound_id", &ids).Error; err != nil { tx.Rollback() return 0, err } for _, id := range ids { if _, ok := inboundIDSeen[id]; !ok { inboundIDSeen[id] = struct{}{} inboundIDs = append(inboundIDs, id) } } } emailSet := make(map[string]struct{}, len(affectedEmails)) for _, e := range affectedEmails { emailSet[e] = struct{}{} } for _, ibID := range inboundIDs { var ib model.Inbound if err := tx.First(&ib, ibID).Error; err != nil { tx.Rollback() return 0, err } var settings map[string]any if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil { continue } clients, ok := settings["clients"].([]any) if !ok { continue } modified := false for i := range clients { cm, ok := clients[i].(map[string]any) if !ok { continue } email, _ := cm["email"].(string) if _, hit := emailSet[email]; !hit { continue } if group == "" { delete(cm, "group") } else { cm["group"] = group } clients[i] = cm modified = true } if modified { settings["clients"] = clients newSettings, err := json.Marshal(settings) if err != nil { continue } ib.Settings = string(newSettings) if err := tx.Save(&ib).Error; err != nil { tx.Rollback() return 0, err } } } if err := tx.Commit().Error; err != nil { return 0, err } return int(affected), nil } func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) { db := database.GetDB() if newName == "" { if err := db.Where("name = ?", oldName).Delete(&model.ClientGroup{}).Error; err != nil { return 0, err } } else { if err := db.Model(&model.ClientGroup{}).Where("name = ?", oldName).Update("name", newName).Error; err != nil { return 0, err } } var records []model.ClientRecord if err := db.Where("group_name = ?", oldName).Find(&records).Error; err != nil { return 0, err } if len(records) == 0 { return 0, nil } affectedEmails := make([]string, 0, len(records)) for _, r := range records { affectedEmails = append(affectedEmails, r.Email) } tx := db.Begin() if err := tx.Model(&model.ClientRecord{}). Where("group_name = ?", oldName). UpdateColumn("group_name", newName).Error; err != nil { tx.Rollback() return 0, err } var inboundIDs []int inboundIDSeen := make(map[int]struct{}) for _, batch := range chunkStrings(affectedEmails, sqlInChunk) { var ids []int if err := tx.Table("client_inbounds"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Where("clients.email IN ?", batch). Distinct("client_inbounds.inbound_id"). Pluck("inbound_id", &ids).Error; err != nil { tx.Rollback() return 0, err } for _, id := range ids { if _, ok := inboundIDSeen[id]; !ok { inboundIDSeen[id] = struct{}{} inboundIDs = append(inboundIDs, id) } } } for _, ibID := range inboundIDs { var ib model.Inbound if err := tx.First(&ib, ibID).Error; err != nil { tx.Rollback() return 0, err } var settings map[string]any if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil { continue } clients, ok := settings["clients"].([]any) if !ok { continue } modified := false for i := range clients { cm, ok := clients[i].(map[string]any) if !ok { continue } if g, ok := cm["group"].(string); ok && g == oldName { if newName == "" { delete(cm, "group") } else { cm["group"] = newName } clients[i] = cm modified = true } } if modified { settings["clients"] = clients newSettings, err := json.Marshal(settings) if err != nil { continue } ib.Settings = string(newSettings) if err := tx.Save(&ib).Error; err != nil { tx.Rollback() return 0, err } } } if err := tx.Commit().Error; err != nil { return 0, err } return len(records), nil } // groupCounters is a group's summed traffic, held within ±TrafficMax. type groupCounters struct { clients int up, down int64 } // clampedTrafficAdd adds two counters, saturating at ±TrafficMax: stored counters reach // the cap, and SQL SUM over two of them overflowed and failed the whole query. func clampedTrafficAdd(a, b int64) int64 { switch { case b > 0 && a > database.TrafficMax-b: return database.TrafficMax case b < 0 && a < -database.TrafficMax-b: return -database.TrafficMax } return a + b } // sumGroupTraffic reads one row per grouped client matching where and adds them up in Go. func sumGroupTraffic(tx *gorm.DB, where string, args ...any) (map[string]*groupCounters, error) { var rows []struct { Name string Up, Down int64 } if err := tx.Table("clients AS c"). Select("c.group_name AS name, COALESCE(ct.up, 0) AS up, COALESCE(ct.down, 0) AS down"). Joins("LEFT JOIN client_traffics ct ON ct.email = c.email"). Where(where, args...). Scan(&rows).Error; err != nil { return nil, err } totals := make(map[string]*groupCounters) for _, r := range rows { agg := totals[r.Name] if agg == nil { agg = &groupCounters{} totals[r.Name] = agg } agg.clients++ agg.up = clampedTrafficAdd(agg.up, r.Up) agg.down = clampedTrafficAdd(agg.down, r.Down) } return totals, nil }