mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 03:47:15 +00:00
fix(traffic): clear cross-panel rows only for clients actually renewed (#6263)
autoRenewClients collects every expired client that carries a reset interval, but three of them never reach a new window: one may be missing from its inbound's settings, one may resolve to no whole interval, and one may still land in the past once the reset cap truncates the catch-up. All three keep their counters and their expiry on purpose. clearGlobalTraffic was still called with the full candidate list, so those three lost their cross-panel rows while their local counters stayed. The next push recreates the rows, and the expiry branch of the depletion check cuts these clients regardless, so nothing is served past its limit — but between the delete and the next push the cross-panel view under-reports them, and since the expiry never advances that repeats on every poll. Pass only the clients whose counters this pass reset. clearGlobalTraffic already early-returns on an empty list, so a poll that renews nobody stays a no-op rather than deleting every row. The renewed count returned to the caller now counts the same set, instead of reporting candidates as renewals. Tests cover both directions: a capped catch-up keeps its rows, and an actually renewed client still loses them, since stale pushed totals would otherwise re-deplete the fresh window at once.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user