package service import ( "encoding/json" "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" ) type GroupSummary struct { Name string `json:"name"` ClientCount int `json:"clientCount"` TrafficUsed int64 `json:"trafficUsed"` } 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. var derived []GroupSummary if err := db.Table("clients AS c"). Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used"). Joins("LEFT JOIN client_traffics ct ON ct.email = c.email"). Where("c.group_name <> ''"). Group("c.group_name"). Scan(&derived).Error; 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 traffic int64 } merged := make(map[string]groupAgg, len(derived)+len(stored)) for _, g := range stored { merged[g.Name] = groupAgg{} } for _, g := range derived { merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed} } out := make([]GroupSummary, 0, len(merged)) for name, agg := range merged { out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic}) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out, 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) 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).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() for _, batch := range chunkStrings(affectedEmails, sqlInChunk) { if err := tx.Model(&model.ClientRecord{}). Where("email IN ?", batch). UpdateColumn("group_name", group).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) } } } 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 len(records), 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 }