diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index cb9c866..82c082c 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -178,7 +178,6 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { clients := api.Group("/clients") NewClientController(clients) - NewGroupController(clients) // Server API server := api.Group("/server") diff --git a/internal/web/controller/group.go b/internal/web/controller/group.go deleted file mode 100644 index ede2522..0000000 --- a/internal/web/controller/group.go +++ /dev/null @@ -1,172 +0,0 @@ -package controller - -import ( - "strings" - - "github.com/mhsanaei/3x-ui/v3/internal/util/common" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" - - "github.com/gin-gonic/gin" -) - -type GroupController struct { - clientService service.ClientService - xrayService service.XrayService -} - -func NewGroupController(g *gin.RouterGroup) *GroupController { - a := &GroupController{} - a.initRouter(g) - return a -} - -func (a *GroupController) initRouter(g *gin.RouterGroup) { - g.GET("/groups", a.list) - g.GET("/groups/:name/emails", a.emails) - g.POST("/groups/create", a.create) - g.POST("/groups/rename", a.rename) - g.POST("/groups/delete", a.delete) - g.POST("/groups/resetTraffic", a.resetTraffic) - g.POST("/groups/bulkAdd", a.bulkAdd) - g.POST("/groups/bulkRemove", a.bulkRemove) -} - -func (a *GroupController) list(c *gin.Context) { - rows, err := a.clientService.ListGroups() - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonObj(c, rows, nil) -} - -func (a *GroupController) emails(c *gin.Context) { - name := c.Param("name") - emails, err := a.clientService.EmailsByGroup(name) - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonObj(c, emails, nil) -} - -type groupCreateBody struct { - Name string `json:"name"` -} - -func (a *GroupController) create(c *gin.Context) { - var body groupCreateBody - if err := c.ShouldBindJSON(&body); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - if err := a.clientService.CreateGroup(body.Name); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonObj(c, gin.H{"name": body.Name}, nil) - notifyClientsChanged() -} - -type groupRenameBody struct { - OldName string `json:"oldName"` - NewName string `json:"newName"` -} - -func (a *GroupController) rename(c *gin.Context) { - var body groupRenameBody - if err := c.ShouldBindJSON(&body); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - affected, err := a.clientService.RenameGroup(body.OldName, body.NewName) - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - a.xrayService.SetToNeedRestart() - jsonObj(c, gin.H{"affected": affected}, nil) - notifyClientsChanged() -} - -type groupDeleteBody struct { - Name string `json:"name"` -} - -func (a *GroupController) delete(c *gin.Context) { - var body groupDeleteBody - if err := c.ShouldBindJSON(&body); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - affected, err := a.clientService.DeleteGroup(body.Name) - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - a.xrayService.SetToNeedRestart() - jsonObj(c, gin.H{"affected": affected}, nil) - notifyClientsChanged() -} - -type groupResetTrafficBody struct { - Name string `json:"name"` -} - -func (a *GroupController) resetTraffic(c *gin.Context) { - var body groupResetTrafficBody - if err := c.ShouldBindJSON(&body); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - if err := a.clientService.ResetGroupTraffic(body.Name); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonObj(c, gin.H{"name": body.Name}, nil) - notifyClientsChanged() -} - -type bulkAddToGroupRequest struct { - Emails []string `json:"emails"` - Group string `json:"group"` -} - -func (a *GroupController) bulkAdd(c *gin.Context) { - var req bulkAddToGroupRequest - if err := c.ShouldBindJSON(&req); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - if strings.TrimSpace(req.Group) == "" { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required")) - return - } - affected, err := a.clientService.AddToGroup(req.Emails, req.Group) - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonObj(c, gin.H{"affected": affected}, nil) - notifyClientsChanged() -} - -type bulkRemoveFromGroupRequest struct { - Emails []string `json:"emails"` -} - -func (a *GroupController) bulkRemove(c *gin.Context) { - var req bulkRemoveFromGroupRequest - if err := c.ShouldBindJSON(&req); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - affected, err := a.clientService.RemoveFromGroup(req.Emails) - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonObj(c, gin.H{"affected": affected}, nil) - a.xrayService.SetToNeedRestart() - notifyClientsChanged() -} diff --git a/internal/web/service/api_scale_postgres_test.go b/internal/web/service/api_scale_postgres_test.go index 3448ba4..d98c03d 100644 --- a/internal/web/service/api_scale_postgres_test.go +++ b/internal/web/service/api_scale_postgres_test.go @@ -99,12 +99,6 @@ func TestAllAPIsPostgresScale(t *testing.T) { run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err }) run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err }) - run("ListGroups", func() error { _, err := svc.ListGroups(); return err }) - run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err }) - run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err }) - run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err }) - run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err }) - run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) }) run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() }) run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err }) diff --git a/internal/web/service/client_group_bulk_test.go b/internal/web/service/client_group_bulk_test.go deleted file mode 100644 index 6b8899a..0000000 --- a/internal/web/service/client_group_bulk_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package service - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -func TestAddToGroupReportsOnlyChangedRecordsIncludingNull(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - if err := db.Create(&model.ClientGroup{Name: "paid"}).Error; err != nil { - t.Fatalf("create group: %v", err) - } - rows := []model.ClientRecord{ - {Email: "same@example", UUID: "same", Group: "paid"}, - {Email: "other@example", UUID: "other", Group: "free"}, - {Email: "null@example", UUID: "null"}, - } - if err := db.Create(&rows).Error; err != nil { - t.Fatalf("create clients: %v", err) - } - if err := db.Model(&model.ClientRecord{}).Where("email = ?", "null@example").UpdateColumn("group_name", nil).Error; err != nil { - t.Fatalf("set NULL group: %v", err) - } - - got, err := (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example", "missing@example"}, "paid") - if err != nil { - t.Fatalf("AddToGroup: %v", err) - } - if got != 2 { - t.Fatalf("affected = %d, want 2 changed records", got) - } - got, err = (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example"}, "paid") - if err != nil { - t.Fatalf("second AddToGroup: %v", err) - } - if got != 0 { - t.Fatalf("second affected = %d, want 0", got) - } -} diff --git a/internal/web/service/client_group_reset_test.go b/internal/web/service/client_group_reset_test.go deleted file mode 100644 index 4b7bb08..0000000 --- a/internal/web/service/client_group_reset_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package service - -import ( - "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" -) - -func groupByName(t *testing.T, svc *ClientService, name string) GroupSummary { - t.Helper() - rows, err := svc.ListGroups() - if err != nil { - t.Fatalf("ListGroups: %v", err) - } - for _, g := range rows { - if g.Name == name { - return g - } - } - t.Fatalf("group %q not found in %v", name, rows) - return GroupSummary{} -} - -func seedGroupedClient(t *testing.T, email, group string, up, down int64) { - t.Helper() - if err := database.GetDB().Create(&model.ClientRecord{Email: email, Enable: true, Group: group}).Error; err != nil { - t.Fatalf("seed client record %q: %v", email, err) - } - seedClientRow(t, email, 1, up, down, 0) -} - -func TestResetGroupTraffic_ZeroesGroupButKeepsClients(t *testing.T) { - initTrafficTestDB(t) - svc := &ClientService{} - - seedGroupedClient(t, "alice", "vip", 100, 200) - seedGroupedClient(t, "bob", "vip", 50, 50) - - before := groupByName(t, svc, "vip") - if before.Up != 150 || before.Down != 250 || before.TrafficUsed != 400 || before.ClientCount != 2 { - t.Fatalf("before reset: got %+v, want up=150 down=250 used=400 count=2", before) - } - - if err := svc.ResetGroupTraffic("vip"); err != nil { - t.Fatalf("ResetGroupTraffic: %v", err) - } - - after := groupByName(t, svc, "vip") - if after.Up != 0 || after.Down != 0 || after.TrafficUsed != 0 { - t.Fatalf("after reset: got %+v, want up=0 down=0 used=0", after) - } - if after.ClientCount != 2 { - t.Fatalf("after reset: client count changed to %d, want 2", after.ClientCount) - } - - var alice xray.ClientTraffic - if err := database.GetDB().Where("email = ?", "alice").First(&alice).Error; err != nil { - t.Fatalf("load alice traffic: %v", err) - } - if alice.Up != 100 || alice.Down != 200 { - t.Fatalf("client counter modified by group reset: alice up=%d down=%d, want 100/200", alice.Up, alice.Down) - } -} - -func TestResetGroupTraffic_NewTrafficAccumulatesAboveBaseline(t *testing.T) { - initTrafficTestDB(t) - svc := &ClientService{} - - seedGroupedClient(t, "carol", "team", 100, 100) - if err := svc.ResetGroupTraffic("team"); err != nil { - t.Fatalf("ResetGroupTraffic: %v", err) - } - if g := groupByName(t, svc, "team"); g.Up != 0 || g.Down != 0 { - t.Fatalf("after reset: got %+v, want up=0 down=0", g) - } - - if err := database.GetDB().Table("client_traffics"). - Where("email = ?", "carol"). - Updates(map[string]any{"up": 130, "down": 100}).Error; err != nil { - t.Fatalf("bump carol traffic: %v", err) - } - - g := groupByName(t, svc, "team") - if g.Up != 30 || g.Down != 0 || g.TrafficUsed != 30 { - t.Fatalf("post-bump: got %+v, want up=30 down=0 used=30", g) - } -} - -func TestResetGroupTraffic_CreatesRowForDerivedGroup(t *testing.T) { - initTrafficTestDB(t) - svc := &ClientService{} - - seedGroupedClient(t, "dave", "adhoc", 70, 30) - - var rows int64 - if err := database.GetDB().Model(&model.ClientGroup{}).Where("name = ?", "adhoc").Count(&rows).Error; err != nil { - t.Fatalf("count client_groups: %v", err) - } - if rows != 0 { - t.Fatalf("precondition: derived group should have no client_groups row, got %d", rows) - } - - if err := svc.ResetGroupTraffic("adhoc"); err != nil { - t.Fatalf("ResetGroupTraffic: %v", err) - } - - var stored model.ClientGroup - if err := database.GetDB().Where("name = ?", "adhoc").First(&stored).Error; err != nil { - t.Fatalf("client_groups row not created: %v", err) - } - if stored.ResetUp != 70 || stored.ResetDown != 30 { - t.Fatalf("baseline not snapshotted: got up=%d down=%d, want 70/30", stored.ResetUp, stored.ResetDown) - } - if g := groupByName(t, svc, "adhoc"); g.Up != 0 || g.Down != 0 { - t.Fatalf("after reset: got %+v, want up=0 down=0", g) - } -} - -func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) { - initTrafficTestDB(t) - svc := &ClientService{} - if err := svc.ResetGroupTraffic(" "); err == nil { - t.Fatal("ResetGroupTraffic(blank) = nil, want error") - } -} - -func TestGroupTotalsSurviveSingleClientReset(t *testing.T) { - initTrafficTestDB(t) - csvc := &ClientService{} - isvc := &InboundService{} - - seedGroupedClient(t, "erin", "gold", 100, 200) - seedGroupedClient(t, "frank", "gold", 40, 60) - - if err := isvc.ResetClientTrafficByEmail("erin"); err != nil { - t.Fatalf("ResetClientTrafficByEmail: %v", err) - } - - g := groupByName(t, csvc, "gold") - if g.Up != 140 || g.Down != 260 || g.TrafficUsed != 400 { - t.Fatalf("group totals changed by client reset: got %+v, want up=140 down=260 used=400", g) - } - - var erin xray.ClientTraffic - if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil { - t.Fatalf("load erin traffic: %v", err) - } - if erin.Up != 0 || erin.Down != 0 { - t.Fatalf("client traffic not reset: up=%d down=%d", erin.Up, erin.Down) - } -} - -func TestGroupTotalsSurviveBulkClientReset(t *testing.T) { - initTrafficTestDB(t) - csvc := &ClientService{} - isvc := &InboundService{} - - seedGroupedClient(t, "gina", "silver", 10, 20) - seedGroupedClient(t, "hank", "silver", 30, 40) - - affected, err := csvc.BulkResetTraffic(isvc, []string{"gina", "hank"}) - if err != nil { - t.Fatalf("BulkResetTraffic: %v", err) - } - if affected != 2 { - t.Fatalf("BulkResetTraffic affected = %d, want 2", affected) - } - - g := groupByName(t, csvc, "silver") - if g.Up != 40 || g.Down != 60 || g.TrafficUsed != 100 { - t.Fatalf("group totals changed by bulk reset: got %+v, want up=40 down=60 used=100", g) - } -} - -func TestGroupTotalsSurviveClientDelete(t *testing.T) { - initTrafficTestDB(t) - csvc := &ClientService{} - isvc := &InboundService{} - - seedGroupedClient(t, "iris", "bronze", 70, 30) - seedGroupedClient(t, "jack", "bronze", 5, 5) - - var rec model.ClientRecord - if err := database.GetDB().Where("email = ?", "iris").First(&rec).Error; err != nil { - t.Fatalf("load iris record: %v", err) - } - if _, err := csvc.Delete(isvc, rec.Id, false); err != nil { - t.Fatalf("Delete: %v", err) - } - - g := groupByName(t, csvc, "bronze") - if g.Up != 75 || g.Down != 35 || g.TrafficUsed != 110 { - t.Fatalf("group totals changed by client delete: got %+v, want up=75 down=35 used=110", g) - } - if g.ClientCount != 1 { - t.Fatalf("client count = %d, want 1", g.ClientCount) - } - - var trafficRows int64 - if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "iris").Count(&trafficRows).Error; err != nil { - t.Fatalf("count iris traffic rows: %v", err) - } - if trafficRows != 0 { - t.Fatalf("iris traffic row survived delete") - } -} - -func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) { - initTrafficTestDB(t) - csvc := &ClientService{} - isvc := &InboundService{} - - seedGroupedClient(t, "kate", "iron", 100, 100) - if err := isvc.ResetClientTrafficByEmail("kate"); err != nil { - t.Fatalf("ResetClientTrafficByEmail: %v", err) - } - if g := groupByName(t, csvc, "iron"); g.TrafficUsed != 200 { - t.Fatalf("pre group-reset: got %+v, want used=200", g) - } - - if err := csvc.ResetGroupTraffic("iron"); err != nil { - t.Fatalf("ResetGroupTraffic: %v", err) - } - if g := groupByName(t, csvc, "iron"); g.Up != 0 || g.Down != 0 || g.TrafficUsed != 0 { - t.Fatalf("group reset did not zero adjusted baselines: got %+v", g) - } -} diff --git a/internal/web/service/client_groups.go b/internal/web/service/client_groups.go index 2f3b70e..ce8fcc9 100644 --- a/internal/web/service/client_groups.go +++ b/internal/web/service/client_groups.go @@ -1,71 +1,13 @@ package service import ( - "encoding/json" - "sort" - "strings" - - "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/util/common" "gorm.io/gorm" ) -type GroupSummary struct { - Name string `json:"name"` - ClientCount int `json:"clientCount"` - TrafficUsed int64 `json:"trafficUsed"` - Up int64 `json:"up"` - Down int64 `json:"down"` -} - -func (s *ClientService) ListGroups() ([]GroupSummary, error) { - db := database.GetDB() - // email is unique in both clients and client_traffics, so the LEFT JOIN - // never double-counts a client's traffic. - var derived []GroupSummary - if err := db.Table("clients AS c"). - Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down"). - Joins("LEFT JOIN client_traffics ct ON ct.email = c.email"). - Where("c.group_name <> ''"). - Group("c.group_name"). - Scan(&derived).Error; err != nil { - return nil, err - } - var stored []model.ClientGroup - if err := db.Find(&stored).Error; err != nil { - return nil, err - } - type groupAgg struct { - count int - up int64 - down int64 - } - baseUp := make(map[string]int64, len(stored)) - baseDown := make(map[string]int64, len(stored)) - merged := make(map[string]groupAgg, len(derived)+len(stored)) - for _, g := range stored { - merged[g.Name] = groupAgg{} - baseUp[g.Name] = g.ResetUp - baseDown[g.Name] = g.ResetDown - } - for _, g := range derived { - merged[g.Name] = groupAgg{count: g.ClientCount, up: g.Up, down: g.Down} - } - out := make([]GroupSummary, 0, len(merged)) - for name, agg := range merged { - up := max(agg.up-baseUp[name], 0) - down := max(agg.down-baseDown[name], 0) - out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: up + down, Up: up, Down: down}) - } - sort.Slice(out, func(i, j int) bool { - return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) - }) - return out, nil -} - // adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients' +// current counters so per-group traffic totals survive a traffic reset or client delete (#5675). // current counters so ListGroups totals survive a traffic reset or client delete (#5675). func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error { if len(emails) == 0 { @@ -115,328 +57,3 @@ func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error { } return nil } - -func (s *ClientService) EmailsByGroup(name string) ([]string, error) { - name = strings.TrimSpace(name) - if name == "" { - return []string{}, nil - } - db := database.GetDB() - var emails []string - if err := db.Model(&model.ClientRecord{}). - Where("group_name = ?", name). - Order("email ASC"). - Pluck("email", &emails).Error; err != nil { - return nil, err - } - if emails == nil { - emails = []string{} - } - return emails, nil -} - -func (s *ClientService) ResetGroupTraffic(name string) error { - name = strings.TrimSpace(name) - if name == "" { - return common.NewError("group name is required") - } - db := database.GetDB() - var agg struct { - Up int64 - Down int64 - } - if err := db.Table("clients AS c"). - Select("COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down"). - Joins("LEFT JOIN client_traffics ct ON ct.email = c.email"). - Where("c.group_name = ?", name). - Scan(&agg).Error; err != nil { - return err - } - var count int64 - if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil { - return err - } - if count == 0 { - return db.Create(&model.ClientGroup{Name: name, ResetUp: agg.Up, ResetDown: agg.Down}).Error - } - return db.Model(&model.ClientGroup{}).Where("name = ?", name). - Updates(map[string]any{"reset_up": agg.Up, "reset_down": agg.Down}).Error -} - -func (s *ClientService) CreateGroup(name string) error { - name = strings.TrimSpace(name) - if name == "" { - return common.NewError("group name is required") - } - db := database.GetDB() - var count int64 - if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil { - return err - } - if count > 0 { - return common.NewError("group already exists") - } - return db.Create(&model.ClientGroup{Name: name}).Error -} - -func (s *ClientService) RenameGroup(oldName, newName string) (int, error) { - oldName = strings.TrimSpace(oldName) - newName = strings.TrimSpace(newName) - if oldName == "" { - return 0, common.NewError("old group name is required") - } - if newName == "" { - return 0, common.NewError("new group name is required") - } - if oldName == newName { - return 0, nil - } - return s.replaceGroupValue(oldName, newName) -} - -func (s *ClientService) DeleteGroup(name string) (int, error) { - name = strings.TrimSpace(name) - if name == "" { - return 0, common.NewError("group name is required") - } - return s.replaceGroupValue(name, "") -} - -func (s *ClientService) RemoveFromGroup(emails []string) (int, error) { - return s.AddToGroup(emails, "") -} - -func (s *ClientService) AddToGroup(emails []string, group string) (int, error) { - group = strings.TrimSpace(group) - if len(emails) == 0 { - return 0, nil - } - db := database.GetDB() - - if group != "" { - var exists int64 - if err := db.Model(&model.ClientGroup{}).Where("name = ?", group).Count(&exists).Error; err != nil { - return 0, err - } - if exists == 0 { - var derived int64 - if err := db.Model(&model.ClientRecord{}).Where("group_name = ?", group).Count(&derived).Error; err != nil { - return 0, err - } - if derived == 0 { - if err := db.Create(&model.ClientGroup{Name: group}).Error; err != nil { - return 0, err - } - } - } - } - - var records []model.ClientRecord - for _, batch := range chunkStrings(emails, sqlInChunk) { - var rows []model.ClientRecord - if err := db.Where("email IN ?", batch). - Where("group_name IS NULL OR group_name <> ?", group). - Find(&rows).Error; err != nil { - return 0, err - } - records = append(records, rows...) - } - if len(records) == 0 { - return 0, nil - } - affectedEmails := make([]string, 0, len(records)) - for _, r := range records { - affectedEmails = append(affectedEmails, r.Email) - } - - tx := db.Begin() - var affected int64 - for _, batch := range chunkStrings(affectedEmails, sqlInChunk) { - result := tx.Model(&model.ClientRecord{}). - Where("email IN ?", batch). - Where("group_name IS NULL OR group_name <> ?", group). - UpdateColumn("group_name", group) - if result.Error != nil { - tx.Rollback() - return 0, result.Error - } - affected += result.RowsAffected - } - - var inboundIDs []int - inboundIDSeen := make(map[int]struct{}) - for _, batch := range chunkStrings(affectedEmails, sqlInChunk) { - var ids []int - if err := tx.Table("client_inbounds"). - Joins("JOIN clients ON clients.id = client_inbounds.client_id"). - Where("clients.email IN ?", batch). - Distinct("client_inbounds.inbound_id"). - Pluck("inbound_id", &ids).Error; err != nil { - tx.Rollback() - return 0, err - } - for _, id := range ids { - if _, ok := inboundIDSeen[id]; !ok { - inboundIDSeen[id] = struct{}{} - inboundIDs = append(inboundIDs, id) - } - } - } - - emailSet := make(map[string]struct{}, len(affectedEmails)) - for _, e := range affectedEmails { - emailSet[e] = struct{}{} - } - - for _, ibID := range inboundIDs { - var ib model.Inbound - if err := tx.First(&ib, ibID).Error; err != nil { - tx.Rollback() - return 0, err - } - var settings map[string]any - if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil { - continue - } - clients, ok := settings["clients"].([]any) - if !ok { - continue - } - modified := false - for i := range clients { - cm, ok := clients[i].(map[string]any) - if !ok { - continue - } - email, _ := cm["email"].(string) - if _, hit := emailSet[email]; !hit { - continue - } - if group == "" { - delete(cm, "group") - } else { - cm["group"] = group - } - clients[i] = cm - modified = true - } - if modified { - settings["clients"] = clients - newSettings, err := json.Marshal(settings) - if err != nil { - continue - } - ib.Settings = string(newSettings) - if err := tx.Save(&ib).Error; err != nil { - tx.Rollback() - return 0, err - } - } - } - - if err := tx.Commit().Error; err != nil { - return 0, err - } - return int(affected), nil -} - -func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) { - db := database.GetDB() - if newName == "" { - if err := db.Where("name = ?", oldName).Delete(&model.ClientGroup{}).Error; err != nil { - return 0, err - } - } else { - if err := db.Model(&model.ClientGroup{}).Where("name = ?", oldName).Update("name", newName).Error; err != nil { - return 0, err - } - } - var records []model.ClientRecord - if err := db.Where("group_name = ?", oldName).Find(&records).Error; err != nil { - return 0, err - } - if len(records) == 0 { - return 0, nil - } - affectedEmails := make([]string, 0, len(records)) - for _, r := range records { - affectedEmails = append(affectedEmails, r.Email) - } - - tx := db.Begin() - if err := tx.Model(&model.ClientRecord{}). - Where("group_name = ?", oldName). - UpdateColumn("group_name", newName).Error; err != nil { - tx.Rollback() - return 0, err - } - - var inboundIDs []int - inboundIDSeen := make(map[int]struct{}) - for _, batch := range chunkStrings(affectedEmails, sqlInChunk) { - var ids []int - if err := tx.Table("client_inbounds"). - Joins("JOIN clients ON clients.id = client_inbounds.client_id"). - Where("clients.email IN ?", batch). - Distinct("client_inbounds.inbound_id"). - Pluck("inbound_id", &ids).Error; err != nil { - tx.Rollback() - return 0, err - } - for _, id := range ids { - if _, ok := inboundIDSeen[id]; !ok { - inboundIDSeen[id] = struct{}{} - inboundIDs = append(inboundIDs, id) - } - } - } - - for _, ibID := range inboundIDs { - var ib model.Inbound - if err := tx.First(&ib, ibID).Error; err != nil { - tx.Rollback() - return 0, err - } - var settings map[string]any - if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil { - continue - } - clients, ok := settings["clients"].([]any) - if !ok { - continue - } - modified := false - for i := range clients { - cm, ok := clients[i].(map[string]any) - if !ok { - continue - } - if g, ok := cm["group"].(string); ok && g == oldName { - if newName == "" { - delete(cm, "group") - } else { - cm["group"] = newName - } - clients[i] = cm - modified = true - } - } - if modified { - settings["clients"] = clients - newSettings, err := json.Marshal(settings) - if err != nil { - continue - } - ib.Settings = string(newSettings) - if err := tx.Save(&ib).Error; err != nil { - tx.Rollback() - return 0, err - } - } - } - - if err := tx.Commit().Error; err != nil { - return 0, err - } - return len(records), nil -} diff --git a/internal/web/service/sync_scale_postgres_test.go b/internal/web/service/sync_scale_postgres_test.go index bf69e23..7dfbebf 100644 --- a/internal/web/service/sync_scale_postgres_test.go +++ b/internal/web/service/sync_scale_postgres_test.go @@ -216,58 +216,6 @@ func TestAddDelClientPostgresScale(t *testing.T) { } } -func TestGroupAndListPostgresScale(t *testing.T) { - setupScaleDB(t) - - svc := &ClientService{} - sizes := []int{5000, 100000} - - for _, n := range sizes { - t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) { - db := database.GetDB() - resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics") - clients := makeScaleClients(n) - ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)} - if err := db.Create(ib).Error; err != nil { - t.Fatalf("create inbound: %v", err) - } - if err := svc.SyncInbound(nil, ib.Id, clients); err != nil { - t.Fatalf("seed SyncInbound: %v", err) - } - db.Exec("ANALYZE") - emails := make([]string, n) - for i := range n { - emails[i] = clients[i].Email - } - - start := time.Now() - if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil { - t.Fatalf("AddToGroup: %v", err) - } - addDur := time.Since(start) - - start = time.Now() - if _, err := svc.RemoveFromGroup(emails); err != nil { - t.Fatalf("RemoveFromGroup: %v", err) - } - rmDur := time.Since(start) - - start = time.Now() - list, err := svc.List() - if err != nil { - t.Fatalf("List: %v", err) - } - listDur := time.Since(start) - if len(list) != n { - t.Fatalf("List returned %d, want %d", len(list), n) - } - - t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n, - addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond)) - }) - } -} - func TestDelAllClientsPostgresScale(t *testing.T) { setupScaleDB(t)