精简: 去掉分组管理功能: GroupController 路由与 client_groups 管理函数(ListGroups/Create/Rename/Delete/AddToGroup/ResetGroupTraffic 等); 保留 group 字段/表与删除流量基线 helper

This commit is contained in:
Muse
2026-10-20 10:13:06 +00:00
parent e42b5d770d
commit 3cbf9164a4
7 changed files with 1 additions and 886 deletions
-1
View File
@@ -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")
-172
View File
@@ -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()
}
@@ -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 })
@@ -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)
}
}
@@ -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)
}
}
+1 -384
View File
@@ -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
}
@@ -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)