diff --git a/internal/web/service/inbound_autorenew_globaltraffic_test.go b/internal/web/service/inbound_autorenew_globaltraffic_test.go new file mode 100644 index 000000000..7f06a3ca0 --- /dev/null +++ b/internal/web/service/inbound_autorenew_globaltraffic_test.go @@ -0,0 +1,107 @@ +package service + +import ( + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +func seedGlobalTraffic(t *testing.T, email string, up, down int64) { + t.Helper() + if err := database.GetDB().Create(&model.ClientGlobalTraffic{ + MasterGuid: "peer-" + email, Email: email, Up: up, Down: down, + }).Error; err != nil { + t.Fatalf("seed client_global_traffics: %v", err) + } +} + +func countGlobalTraffic(t *testing.T, email string) int64 { + t.Helper() + var n int64 + if err := database.GetDB().Model(&model.ClientGlobalTraffic{}). + Where("email = ?", email).Count(&n).Error; err != nil { + t.Fatalf("count client_global_traffics: %v", err) + } + return n +} + +// A capped catch-up keeps its counters and stays expired, so its cross-panel +// rows still describe a window this client has not spent. +func TestAutoRenewClients_TruncatedCatchUpKeepsCrossPanelTraffic(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + past := time.Now().Add(-150 * 24 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "capped@x", ID: "66666666-6666-6666-6666-666666666666", Enable: false, Reset: 30, ResetMax: 3, ExpiryTime: past}, + } + ib := mkInbound(t, 30126, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "capped@x", Enable: false, Reset: 30, ResetMax: 3, ResetCount: 2, + Up: 111, Down: 222, ExpiryTime: past, + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + seedGlobalTraffic(t, "capped@x", 111, 222) + + if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "capped@x").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.Up != 111 || row.Down != 222 { + t.Fatalf("local counters were reset, so this case no longer exercises the cap: up=%d down=%d", row.Up, row.Down) + } + if got := countGlobalTraffic(t, "capped@x"); got != 1 { + t.Fatalf("cross-panel rows=%d, want 1: the window was dropped for a client that never got it", got) + } +} + +// The counterpart: a real renewal must drop the rows, or the stale pushed +// totals re-deplete the fresh window at once. +func TestAutoRenewClients_RenewedClientLosesCrossPanelTraffic(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + past := time.Now().Add(-40 * 24 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "rolled@x", ID: "77777777-7777-7777-7777-777777777777", Enable: false, Reset: 30, ExpiryTime: past}, + } + ib := mkInbound(t, 30127, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "rolled@x", Enable: false, Reset: 30, + Up: 333, Down: 444, ExpiryTime: past, + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + seedGlobalTraffic(t, "rolled@x", 333, 444) + + if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "rolled@x").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.Up != 0 || row.Down != 0 { + t.Fatalf("counters survived a renewal: up=%d down=%d", row.Up, row.Down) + } + if got := countGlobalTraffic(t, "rolled@x"); got != 0 { + t.Fatalf("cross-panel rows=%d, want 0: stale pushed totals would re-deplete the fresh window", got) + } +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 0c0d12a3e..aaa175c9b 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -325,6 +325,8 @@ func apiUserFromClient(client map[string]any, cipher string) map[string]any { return user } +// Candidates and renewals are not the same set: a skipped candidate keeps its +// counters, so only the clients actually reset may lose their cross-panel rows. func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, error) { // check for time expired var traffics []*xray.ClientTraffic @@ -408,6 +410,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut for i := range traffics { trafficByEmail[traffics[i].Email] = traffics[i] } + renewedEmails := make([]string, 0, len(traffics)) for inbound_index := range inbounds { settings := map[string]any{} _ = json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings) @@ -464,6 +467,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut } traffic.Down = 0 traffic.Up = 0 + renewedEmails = append(renewedEmails, email) if !traffic.Enable { traffic.Enable = true c["enable"] = true @@ -508,7 +512,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut } // A renewed client starts a fresh quota window: drop the cross-panel rows // too, or the stale pushed totals would re-deplete it immediately. - if err = clearGlobalTraffic(tx, renewEmails...); err != nil { + if err = clearGlobalTraffic(tx, renewedEmails...); err != nil { return false, 0, err } for _, clientToAdd := range clientsToAdd { @@ -520,7 +524,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut action: trafficAddUser, inbound: clientToAdd.inbound, client: clientToAdd.client, }) } - return needRestart, int64(len(traffics)), nil + return needRestart, int64(len(renewedEmails)), nil } // AddClientStat inserts a per-client accounting row, or refreshes the