Files
3x-ui/internal/web/service/client_groups.go
T
MHSanaei dcdc56c64b fix(traffic): stop adding capped counters in SQL
Per-client counters are stored up to TrafficMax, which a node can reach
by reporting huge usage, and the group totals, group baselines and the
per-client reset lookup added two such counters inside SQL, overflowing
it: every group listing and reset failed, and no client on that reset
cycle was reset. The counters are now read separately and added in Go,
saturating at TrafficMax.
2026-10-31 14:41:12 +01:00

480 lines
12 KiB
Go

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
}