From 1153d5db8c2eb6867d4d856d21d9c52d315bc845 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 2 Jul 2026 09:17:47 +0200 Subject: [PATCH] fix(groups): keep group traffic totals stable across client resets and deletes ListGroups displays live_sum(client_traffics) minus the group's stored reset baseline, but only ResetGroupTraffic ever moved the baseline. Any client-level operation that zeroed or deleted traffic rows (single/bulk reset, client delete, removing a client's last inbound) shrank the live sum and silently subtracted that client's history from the group total. Shift the baseline down by the removed counters inside the same transaction, so group totals only change through group reset. Derived groups without a stored row get one with a negative baseline, which the existing clamp handles. Closes #5675 --- internal/web/service/client_bulk.go | 3 + internal/web/service/client_crud.go | 37 ++++--- .../web/service/client_group_reset_test.go | 102 ++++++++++++++++++ internal/web/service/client_groups.go | 53 +++++++++ internal/web/service/client_portable.go | 3 + internal/web/service/client_traffic.go | 7 ++ internal/web/service/inbound_traffic.go | 33 ++++-- 7 files changed, 213 insertions(+), 25 deletions(-) diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 9ee341ed4..7db659123 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -834,6 +834,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string, // Serialize the row cleanup against the traffic poll to avoid the // cross-transaction lock-order deadlock on client_traffics/inbounds. if err := runSerializedTx(func(tx *gorm.DB) error { + if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil { + return e + } for _, batch := range chunkInts(successIds, sqlInChunk) { if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil { return e diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 08565ddac..1171cfa4a 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -466,24 +466,31 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b } db := database.GetDB() - if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil { - return needRestart, err - } - if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil { - return needRestart, err - } - if !keepTraffic && existing.Email != "" { - if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil { - return needRestart, err + if err := db.Transaction(func(tx *gorm.DB) error { + if existing.Email != "" { + if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil { + return err + } } - if err := clearGlobalTraffic(db, existing.Email); err != nil { - return needRestart, err + if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil { + return err } - if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { - return needRestart, err + if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil { + return err } - } - if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil { + if !keepTraffic && existing.Email != "" { + if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil { + return err + } + if err := clearGlobalTraffic(tx, existing.Email); err != nil { + return err + } + if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { + return err + } + } + return tx.Delete(&model.ClientRecord{}, id).Error + }); err != nil { return needRestart, err } return needRestart, nil diff --git a/internal/web/service/client_group_reset_test.go b/internal/web/service/client_group_reset_test.go index c91c9e493..4b7bb08d2 100644 --- a/internal/web/service/client_group_reset_test.go +++ b/internal/web/service/client_group_reset_test.go @@ -125,3 +125,105 @@ func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) { t.Fatal("ResetGroupTraffic(blank) = nil, want error") } } + +func TestGroupTotalsSurviveSingleClientReset(t *testing.T) { + initTrafficTestDB(t) + csvc := &ClientService{} + isvc := &InboundService{} + + seedGroupedClient(t, "erin", "gold", 100, 200) + seedGroupedClient(t, "frank", "gold", 40, 60) + + if err := isvc.ResetClientTrafficByEmail("erin"); err != nil { + t.Fatalf("ResetClientTrafficByEmail: %v", err) + } + + g := groupByName(t, csvc, "gold") + if g.Up != 140 || g.Down != 260 || g.TrafficUsed != 400 { + t.Fatalf("group totals changed by client reset: got %+v, want up=140 down=260 used=400", g) + } + + var erin xray.ClientTraffic + if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil { + t.Fatalf("load erin traffic: %v", err) + } + if erin.Up != 0 || erin.Down != 0 { + t.Fatalf("client traffic not reset: up=%d down=%d", erin.Up, erin.Down) + } +} + +func TestGroupTotalsSurviveBulkClientReset(t *testing.T) { + initTrafficTestDB(t) + csvc := &ClientService{} + isvc := &InboundService{} + + seedGroupedClient(t, "gina", "silver", 10, 20) + seedGroupedClient(t, "hank", "silver", 30, 40) + + affected, err := csvc.BulkResetTraffic(isvc, []string{"gina", "hank"}) + if err != nil { + t.Fatalf("BulkResetTraffic: %v", err) + } + if affected != 2 { + t.Fatalf("BulkResetTraffic affected = %d, want 2", affected) + } + + g := groupByName(t, csvc, "silver") + if g.Up != 40 || g.Down != 60 || g.TrafficUsed != 100 { + t.Fatalf("group totals changed by bulk reset: got %+v, want up=40 down=60 used=100", g) + } +} + +func TestGroupTotalsSurviveClientDelete(t *testing.T) { + initTrafficTestDB(t) + csvc := &ClientService{} + isvc := &InboundService{} + + seedGroupedClient(t, "iris", "bronze", 70, 30) + seedGroupedClient(t, "jack", "bronze", 5, 5) + + var rec model.ClientRecord + if err := database.GetDB().Where("email = ?", "iris").First(&rec).Error; err != nil { + t.Fatalf("load iris record: %v", err) + } + if _, err := csvc.Delete(isvc, rec.Id, false); err != nil { + t.Fatalf("Delete: %v", err) + } + + g := groupByName(t, csvc, "bronze") + if g.Up != 75 || g.Down != 35 || g.TrafficUsed != 110 { + t.Fatalf("group totals changed by client delete: got %+v, want up=75 down=35 used=110", g) + } + if g.ClientCount != 1 { + t.Fatalf("client count = %d, want 1", g.ClientCount) + } + + var trafficRows int64 + if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "iris").Count(&trafficRows).Error; err != nil { + t.Fatalf("count iris traffic rows: %v", err) + } + if trafficRows != 0 { + t.Fatalf("iris traffic row survived delete") + } +} + +func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) { + initTrafficTestDB(t) + csvc := &ClientService{} + isvc := &InboundService{} + + seedGroupedClient(t, "kate", "iron", 100, 100) + if err := isvc.ResetClientTrafficByEmail("kate"); err != nil { + t.Fatalf("ResetClientTrafficByEmail: %v", err) + } + if g := groupByName(t, csvc, "iron"); g.TrafficUsed != 200 { + t.Fatalf("pre group-reset: got %+v, want used=200", g) + } + + if err := csvc.ResetGroupTraffic("iron"); err != nil { + t.Fatalf("ResetGroupTraffic: %v", err) + } + if g := groupByName(t, csvc, "iron"); g.Up != 0 || g.Down != 0 || g.TrafficUsed != 0 { + t.Fatalf("group reset did not zero adjusted baselines: got %+v", g) + } +} diff --git a/internal/web/service/client_groups.go b/internal/web/service/client_groups.go index 63b396945..72c78a845 100644 --- a/internal/web/service/client_groups.go +++ b/internal/web/service/client_groups.go @@ -8,6 +8,8 @@ import ( "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 { @@ -69,6 +71,57 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) { 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 { + if len(emails) == 0 { + return nil + } + type groupDelta struct { + Name string + Up int64 + Down int64 + } + totals := make(map[string]*groupDelta) + for _, batch := range chunkStrings(emails, sqlInChunk) { + var part []groupDelta + if err := tx.Table("clients AS c"). + Select("c.group_name AS name, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down"). + Joins("JOIN client_traffics ct ON ct.email = c.email"). + Where("c.group_name <> '' AND c.email IN ?", batch). + Group("c.group_name"). + Scan(&part).Error; err != nil { + return err + } + for i := range part { + if agg, ok := totals[part[i].Name]; ok { + agg.Up += part[i].Up + agg.Down += part[i].Down + } else { + totals[part[i].Name] = &part[i] + } + } + } + for name, d := range totals { + if d.Up == 0 && d.Down == 0 { + continue + } + res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{ + "reset_up": gorm.Expr("reset_up - ?", d.Up), + "reset_down": gorm.Expr("reset_down - ?", d.Down), + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: -d.Up, ResetDown: -d.Down}).Error; err != nil { + return err + } + } + } + return nil +} + func (s *ClientService) EmailsByGroup(name string) ([]string, error) { name = strings.TrimSpace(name) if name == "" { diff --git a/internal/web/service/client_portable.go b/internal/web/service/client_portable.go index 192c204fe..4c35e86e3 100644 --- a/internal/web/service/client_portable.go +++ b/internal/web/service/client_portable.go @@ -187,6 +187,9 @@ func (s *ClientService) DeleteOrphans() (int, error) { tombstoneClientEmails(emails) if err := runSerializedTx(func(tx *gorm.DB) error { + if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil { + return e + } for _, batch := range chunkInts(ids, sqlInChunk) { if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil { return e diff --git a/internal/web/service/client_traffic.go b/internal/web/service/client_traffic.go index 486a275fc..34304b928 100644 --- a/internal/web/service/client_traffic.go +++ b/internal/web/service/client_traffic.go @@ -92,6 +92,9 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st err := submitTrafficWrite(func() error { db := database.GetDB() return db.Transaction(func(tx *gorm.DB) error { + if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil { + return err + } for _, batch := range chunkStrings(cleanEmails, sqlInChunk) { res := tx.Model(xray.ClientTraffic{}). Where("email IN ?", batch). @@ -150,6 +153,10 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error { return nil } + if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil { + return err + } + result := tx.Model(xray.ClientTraffic{}). Where("email IN ?", resetEmails). Updates(map[string]any{"enable": true, "up": 0, "down": 0}) diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index faf73fd80..34d349e6a 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -474,6 +474,9 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod } func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error { + if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{email}); err != nil { + return err + } if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil { return err } @@ -484,6 +487,9 @@ func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error { } func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error { + if err := adjustGroupBaselinesForRemovedTraffic(tx, emails); err != nil { + return err + } const chunk = 400 for start := 0; start < len(emails); start += chunk { end := min(start+chunk, len(emails)) @@ -503,16 +509,20 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { return submitTrafficWrite(func() error { - db := database.GetDB() - if err := clearGlobalTraffic(db, clientEmail); err != nil { - return err - } - if err := db.Model(xray.ClientTraffic{}). - Where("email = ?", clientEmail). - Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil { - return err - } - return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error + return database.GetDB().Transaction(func(tx *gorm.DB) error { + if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil { + return err + } + if err := clearGlobalTraffic(tx, clientEmail); err != nil { + return err + } + if err := tx.Model(xray.ClientTraffic{}). + Where("email = ?", clientEmail). + Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil { + return err + } + return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error + }) }) } @@ -596,6 +606,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b return false, err } if err := db.Transaction(func(tx *gorm.DB) error { + if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil { + return err + } if err := tx.Save(traffic).Error; err != nil { return err }