فهرست منبع

fix(clients): persist group for node-inbound clients

The client create/edit form left `group` out of the request payload, so choosing a group in the form was silently dropped (bulkAdd from the Groups page still worked because it writes the column directly). Add `group` to the payload next to `comment`.

SyncInbound also overwrote group_name unconditionally; a group set via bulkAdd is never pushed to the node, so the next node snapshot — which lacks it — wiped the column. Keep group sticky (only overwrite when the incoming value is non-empty); group is only ever set/cleared via the Groups page. Preserve comment for node clients during snapshot sync the same way. Add tests.
MHSanaei 15 ساعت پیش
والد
کامیت
24d0e4ec7c
4فایلهای تغییر یافته به همراه148 افزوده شده و 1 حذف شده
  1. 1 0
      frontend/src/pages/clients/ClientFormModal.tsx
  2. 3 1
      web/service/client.go
  3. 118 0
      web/service/client_group_node_sync_test.go
  4. 26 0
      web/service/inbound.go

+ 1 - 0
frontend/src/pages/clients/ClientFormModal.tsx

@@ -344,6 +344,7 @@ export default function ClientFormModal({
       reset: Number(form.reset) || 0,
       limitIp: Number(form.limitIp) || 0,
       tgId: Number(form.tgId) || 0,
+      group: form.group,
       comment: form.comment,
       enable: !!form.enable,
     };

+ 3 - 1
web/service/client.go

@@ -237,7 +237,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 			row.ExpiryTime = incoming.ExpiryTime
 			row.Enable = incoming.Enable
 			row.TgID = incoming.TgID
-			row.Group = incoming.Group
+			if incoming.Group != "" {
+				row.Group = incoming.Group
+			}
 			row.Comment = incoming.Comment
 			row.Reset = incoming.Reset
 			if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {

+ 118 - 0
web/service/client_group_node_sync_test.go

@@ -0,0 +1,118 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+)
+
+func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(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 nodeID = 1
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003"
+	const wantGroup = "vip"
+	const wantComment = "renewed manually"
+
+	id := nodeID
+	central := &model.Inbound{
+		UserId:   1,
+		NodeID:   &id,
+		Tag:      "n1-vless",
+		Enable:   true,
+		Port:     20001,
+		Protocol: model.VLESS,
+		Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"group":"` + wantGroup + `","comment":"` + wantComment + `"}]}`,
+	}
+	if err := db.Create(central).Error; err != nil {
+		t.Fatalf("create node inbound: %v", err)
+	}
+
+	if err := db.Create(&model.ClientRecord{
+		Email:   email,
+		UUID:    uid,
+		Enable:  true,
+		Group:   wantGroup,
+		Comment: wantComment,
+	}).Error; err != nil {
+		t.Fatalf("create client record: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{
+			{
+				Tag:      "n1-vless",
+				Enable:   true,
+				Port:     20001,
+				Protocol: model.VLESS,
+				Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true}]}`,
+			},
+		},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var row model.ClientRecord
+	if err := db.Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("lookup client row after sync: %v", err)
+	}
+	if row.Group != wantGroup {
+		t.Errorf("group was wiped by node snapshot sync: got %q, want %q", row.Group, wantGroup)
+	}
+	if row.Comment != wantComment {
+		t.Errorf("comment was wiped by node snapshot sync: got %q, want %q", row.Comment, wantComment)
+	}
+}
+
+func TestSyncInbound_KeepsGroupWhenIncomingEmpty(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()
+
+	ib := &model.Inbound{Tag: "vless-grp", Enable: true, Port: 20002, Protocol: model.VLESS}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	svc := ClientService{}
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c004"
+	const wantGroup = "vip"
+
+	withGroup := model.Client{Email: email, ID: uid, Enable: true, Group: wantGroup}
+	if err := svc.SyncInbound(nil, ib.Id, []model.Client{withGroup}); err != nil {
+		t.Fatalf("SyncInbound (set group): %v", err)
+	}
+
+	noGroup := model.Client{Email: email, ID: uid, Enable: true, Group: ""}
+	if err := svc.SyncInbound(nil, ib.Id, []model.Client{noGroup}); err != nil {
+		t.Fatalf("SyncInbound (group-less rebuild): %v", err)
+	}
+
+	var row model.ClientRecord
+	if err := db.Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("lookup client row: %v", err)
+	}
+	if row.Group != wantGroup {
+		t.Errorf("group must survive a group-less settings rebuild (it is managed via the Groups page, not Xray settings): got %q, want %q", row.Group, wantGroup)
+	}
+}

+ 26 - 0
web/service/inbound.go

@@ -1589,6 +1589,32 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			}
 			filtered = append(filtered, clients[i])
 		}
+		localEmails := make([]string, 0, len(filtered))
+		for i := range filtered {
+			if filtered[i].Email != "" {
+				localEmails = append(localEmails, filtered[i].Email)
+			}
+		}
+		if len(localEmails) > 0 {
+			var localMeta []struct {
+				Email   string
+				Comment string `gorm:"column:comment"`
+			}
+			if err := tx.Table("clients").
+				Select("email, comment").
+				Where("email IN ?", localEmails).
+				Find(&localMeta).Error; err == nil {
+				commentByEmail := make(map[string]string, len(localMeta))
+				for _, m := range localMeta {
+					commentByEmail[m.Email] = m.Comment
+				}
+				for i := range filtered {
+					if cmt, ok := commentByEmail[filtered[i].Email]; ok {
+						filtered[i].Comment = cmt
+					}
+				}
+			}
+		}
 		if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
 			logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
 		}