浏览代码

fix(sub): recover {{TRAFFIC_USED}} for clients with orphaned traffic rows

statsForClient resolved usage only through paths keyed by client_traffics.inbound_id (preloaded ClientStats + the statsByEmail index). That id is written once by AddClientStat and never updated, so an inbound delete+recreate orphans the row from every loaded inbound, both paths miss, and the zero-traffic placeholder makes {{TRAFFIC_USED}} read 0.00B for pre-existing clients while the sub-info header (AggregateTrafficByEmails, email-keyed) stays correct.

Add a last-resort lookup by the globally-unique email, cached into statsByEmail for the request. Closes #5567.
MHSanaei 1 天之前
父节点
当前提交
a4be5a0deb
共有 3 个文件被更改,包括 87 次插入0 次删除
  1. 7 0
      internal/sub/remark_vars.go
  2. 26 0
      internal/sub/service.go
  3. 54 0
      internal/sub/service_orphaned_stats_test.go

+ 7 - 0
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,

+ 26 - 0
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:

+ 54 - 0
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 = "[email protected]"
+	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)
+	}
+}