diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index 32da932a0..2a93d31e8 100644 --- a/internal/sub/remark_vars.go +++ b/internal/sub/remark_vars.go @@ -441,6 +441,13 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client) if stats, ok := s.statsByEmail[client.Email]; ok { return stats } + // Both in-memory paths key off client_traffics.inbound_id, which goes stale + // when an inbound is deleted and recreated, orphaning the row from every + // loaded inbound. Fall back to a direct lookup by the globally-unique email + // so usage still resolves for clients predating that recreation (#5567). + if stats, ok := s.statsByEmailFromDB(client.Email); ok { + return stats + } return xray.ClientTraffic{ Enable: client.Enable, ExpiryTime: client.ExpiryTime, diff --git a/internal/sub/service.go b/internal/sub/service.go index 4bded42af..f75f15a5a 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1665,6 +1665,32 @@ func (s *SubService) findClientStats(inbound *model.Inbound, email string) (xray return xray.ClientTraffic{}, false } +// statsByEmailFromDB resolves a client's traffic row straight from the DB by its +// globally-unique email, caching the hit into statsByEmail for the rest of the +// request. It's the last-resort lookup behind statsForClient: the preloaded +// ClientStats and the statsByEmail index are both keyed by +// client_traffics.inbound_id, which is written once by AddClientStat and never +// updated. When an inbound is deleted and recreated it gets a new id, so the old +// row is orphaned from every loaded inbound and both in-memory paths miss — +// leaving {{TRAFFIC_USED}} stuck at 0 for pre-existing clients even though their +// usage is intact (#5567). Matching by email recovers it, the same way the +// sub-info header's AggregateTrafficByEmails already does. +func (s *SubService) statsByEmailFromDB(email string) (xray.ClientTraffic, bool) { + db := database.GetDB() + if db == nil { + return xray.ClientTraffic{}, false + } + var row xray.ClientTraffic + if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil { + return xray.ClientTraffic{}, false + } + if s.statsByEmail == nil { + s.statsByEmail = map[string]xray.ClientTraffic{} + } + s.statsByEmail[email] = row + return row, true +} + func searchKey(data any, key string) (any, bool) { switch val := data.(type) { case map[string]any: diff --git a/internal/sub/service_orphaned_stats_test.go b/internal/sub/service_orphaned_stats_test.go new file mode 100644 index 000000000..6a7502e97 --- /dev/null +++ b/internal/sub/service_orphaned_stats_test.go @@ -0,0 +1,54 @@ +package sub + +import ( + "path/filepath" + "testing" + + "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" +) + +// statsForClient recovers a client's usage by email when the client_traffics row +// is orphaned — its inbound_id points at an inbound that was deleted and +// recreated, so the preloaded ClientStats and the statsByEmail index both miss. +// Before the email fallback, {{TRAFFIC_USED}} stayed at 0 for such pre-existing +// clients while the sub-info header was correct (#5567). +func TestStatsForClient_OrphanedInboundIdFallback(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + const email = "old-client@example.com" + const total = int64(100) * gb + + db := database.GetDB() + if err := db.Create(&xray.ClientTraffic{ + InboundId: 999, + Email: email, + Up: 15 * gb, + Down: 5 * gb, + Total: total, + Enable: true, + }).Error; err != nil { + t.Fatalf("seed orphaned traffic: %v", err) + } + + s := &SubService{statsByEmail: map[string]xray.ClientTraffic{}} + inbound := &model.Inbound{Id: 1, Remark: "DE"} + client := model.Client{Email: email, TotalGB: total, Enable: true} + + st := s.statsForClient(inbound, client) + if used := st.Up + st.Down; used != 20*gb { + t.Fatalf("statsForClient used = %d, want %d (email fallback)", used, 20*gb) + } + if _, ok := s.statsByEmail[email]; !ok { + t.Fatalf("email fallback must cache the row into statsByEmail") + } + if got := remarkVarValue("TRAFFIC_USED", remarkContext{stats: st}); got != "20.00GB" { + t.Fatalf("TRAFFIC_USED = %q, want 20.00GB", got) + } +}