精简: 去掉分组管理功能: GroupController 路由与 client_groups 管理函数(ListGroups/Create/Rename/Delete/AddToGroup/ResetGroupTraffic 等); 保留 group 字段/表与删除流量基线 helper
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user