Переглянути джерело

fix(client): apply per-field client edits to every inbound of the email (#5039)

applyClientFieldByEmail patched only the first inbound that the
client_traffics row pointed at. For a multi-inbound client the sibling
inbounds kept the old expiryTime/totalGB/limitIp in their settings JSON,
and the next SyncInbound over a stale sibling reverted the edit in the
normalized records — the Telegram bot's expiry change appeared to apply
and then sprang back. Patch the field on every inbound linked to the
email, falling back to the legacy single-inbound lookup for clients that
were never normalized.
MHSanaei 13 годин тому
батько
коміт
1a525b4cb4

+ 84 - 0
internal/web/service/client_apply_field_test.go

@@ -0,0 +1,84 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestResetClientExpiryTimeByEmail_MultiInbound reproduces #5039: a client
+// attached to several inbounds had its expiry patched only on the first
+// inbound's JSON, so the stale siblings reverted the change on the next sync.
+func TestResetClientExpiryTimeByEmail_MultiInbound(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() })
+
+	db := database.GetDB()
+
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c111"
+	const oldExpiry = int64(1700000000000)
+	const newExpiry = int64(1800000000000)
+
+	clientJSON := func(expiry int64) string {
+		b, _ := json.Marshal(map[string]any{"clients": []map[string]any{{
+			"email": email, "id": uid, "enable": true, "expiryTime": expiry, "subId": "sub-multi-1",
+		}}})
+		return string(b)
+	}
+
+	first := &model.Inbound{Tag: "vless-a", Enable: true, Port: 50001, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON(oldExpiry)}
+	second := &model.Inbound{Tag: "vless-b", Enable: true, Port: 50002, Protocol: model.VLESS,
+		StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON(oldExpiry)}
+	for _, ib := range []*model.Inbound{first, second} {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	clientSvc := ClientService{}
+	inboundSvc := InboundService{}
+	for _, ib := range []*model.Inbound{first, second} {
+		clients, err := inboundSvc.GetClients(ib)
+		if err != nil {
+			t.Fatalf("GetClients(%s): %v", ib.Tag, err)
+		}
+		if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
+			t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
+		}
+	}
+
+	if _, err := clientSvc.ResetClientExpiryTimeByEmail(&inboundSvc, email, newExpiry); err != nil {
+		t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
+	}
+
+	for _, ib := range []*model.Inbound{first, second} {
+		fresh, err := inboundSvc.GetInbound(ib.Id)
+		if err != nil {
+			t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
+		}
+		clients, err := inboundSvc.GetClients(fresh)
+		if err != nil {
+			t.Fatalf("GetClients(%s): %v", ib.Tag, err)
+		}
+		if len(clients) != 1 || clients[0].ExpiryTime != newExpiry {
+			t.Errorf("inbound %s settings expiry = %d, want %d (#5039)", ib.Tag, clients[0].ExpiryTime, newExpiry)
+		}
+	}
+
+	rec, err := clientSvc.GetRecordByEmail(nil, email)
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.ExpiryTime != newExpiry {
+		t.Errorf("client record expiry = %d, want %d", rec.ExpiryTime, newExpiry)
+	}
+}

+ 60 - 36
internal/web/service/client_inbound_apply.go

@@ -949,54 +949,78 @@ func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clien
 // the matched client — that is the input contract UpdateInboundClient expects
 // (clients[0] is the new data; clientEmail locates the row to replace). It
 // backs the single-field by-email setters below.
+// applyClientFieldByEmail mutates a client field on every inbound the email is
+// attached to. A multi-inbound client is one logical identity: patching only
+// the first inbound's JSON would leave the siblings stale, and the next
+// SyncInbound over a stale sibling would revert the edit in the normalized
+// records (#5039).
 func (s *ClientService) applyClientFieldByEmail(inboundSvc *InboundService, clientEmail string, mutate func(c map[string]any)) (bool, error) {
-	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	inboundIds, err := s.GetInboundIdsForEmail(database.GetDB(), clientEmail)
 	if err != nil {
 		return false, err
 	}
-	if inbound == nil {
-		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
-	}
-
-	oldClients, err := inboundSvc.GetClients(inbound)
-	if err != nil {
-		return false, err
+	if len(inboundIds) == 0 {
+		// Legacy fallback for clients that only live in the inbound JSON and
+		// were never normalized into client_inbounds.
+		_, inbound, gErr := inboundSvc.GetClientInboundByEmail(clientEmail)
+		if gErr != nil {
+			return false, gErr
+		}
+		if inbound == nil {
+			return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+		}
+		inboundIds = []int{inbound.Id}
 	}
 
+	needRestart := false
 	found := false
-	for _, oldClient := range oldClients {
-		if oldClient.Email == clientEmail {
-			found = true
-			break
+	for _, ibId := range inboundIds {
+		inbound, gErr := inboundSvc.GetInbound(ibId)
+		if gErr != nil {
+			return needRestart, gErr
 		}
-	}
-
-	if !found {
-		return false, common.NewError("Client Not Found For Email:", clientEmail)
-	}
 
-	var settings map[string]any
-	err = json.Unmarshal([]byte(inbound.Settings), &settings)
-	if err != nil {
-		return false, err
-	}
-	clients := settings["clients"].([]any)
-	var newClients []any
-	for client_index := range clients {
-		c := clients[client_index].(map[string]any)
-		if c["email"] == clientEmail {
-			mutate(c)
-			c["updated_at"] = time.Now().Unix() * 1000
-			newClients = append(newClients, any(c))
+		var settings map[string]any
+		if uErr := json.Unmarshal([]byte(inbound.Settings), &settings); uErr != nil {
+			return needRestart, uErr
+		}
+		clients, _ := settings["clients"].([]any)
+		// UpdateInboundClient expects a single-client payload, so keep only the
+		// matching entry in the scratch copy; it splices the result back into
+		// the inbound's full client list itself.
+		var newClients []any
+		for client_index := range clients {
+			c, ok := clients[client_index].(map[string]any)
+			if !ok {
+				continue
+			}
+			if c["email"] == clientEmail {
+				mutate(c)
+				c["updated_at"] = time.Now().Unix() * 1000
+				newClients = append(newClients, any(c))
+			}
 		}
+		if len(newClients) == 0 {
+			continue
+		}
+		found = true
+		settings["clients"] = newClients
+		modifiedSettings, mErr := json.MarshalIndent(settings, "", "  ")
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		inbound.Settings = string(modifiedSettings)
+		nr, uErr := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
+		if uErr != nil {
+			return needRestart, uErr
+		}
+		needRestart = needRestart || nr
 	}
-	settings["clients"] = newClients
-	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
-	if err != nil {
-		return false, err
+
+	if !found {
+		return needRestart, common.NewError("Client Not Found For Email:", clientEmail)
 	}
-	inbound.Settings = string(modifiedSettings)
-	return s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
+	return needRestart, nil
 }
 
 func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {