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 }