精简: 去掉分组管理功能: 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")
|
clients := api.Group("/clients")
|
||||||
NewClientController(clients)
|
NewClientController(clients)
|
||||||
NewGroupController(clients)
|
|
||||||
|
|
||||||
// Server API
|
// Server API
|
||||||
server := api.Group("/server")
|
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("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("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("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
|
||||||
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
|
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
|
||||||
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
|
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
|
package service
|
||||||
|
|
||||||
import (
|
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/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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'
|
// 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).
|
// current counters so ListGroups totals survive a traffic reset or client delete (#5675).
|
||||||
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
|
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
|
||||||
if len(emails) == 0 {
|
if len(emails) == 0 {
|
||||||
@@ -115,328 +57,3 @@ func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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) {
|
func TestDelAllClientsPostgresScale(t *testing.T) {
|
||||||
setupScaleDB(t)
|
setupScaleDB(t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user