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
This commit is contained in:
MHSanaei
2026-07-02 09:17:47 +02:00
parent 539bcc897c
commit 1153d5db8c
7 changed files with 213 additions and 25 deletions
+3
View File
@@ -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
+22 -15
View File
@@ -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
@@ -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)
}
}
+53
View File
@@ -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 == "" {
+3
View File
@@ -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
+7
View File
@@ -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})
+23 -10
View File
@@ -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
}