mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-04 20:04:20 +00:00
1153d5db8c
ListGroups displays live_sum(client_traffics) minus the group's stored reset baseline, but only ResetGroupTraffic ever moved the baseline. Any client-level operation that zeroed or deleted traffic rows (single/bulk reset, client delete, removing a client's last inbound) shrank the live sum and silently subtracted that client's history from the group total. Shift the baseline down by the removed counters inside the same transaction, so group totals only change through group reset. Derived groups without a stored row get one with a negative baseline, which the existing clamp handles. Closes #5675
443 lines
11 KiB
Go
443 lines
11 KiB
Go
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 := agg.up - baseUp[name]
|
|
if up < 0 {
|
|
up = 0
|
|
}
|
|
down := agg.down - baseDown[name]
|
|
if down < 0 {
|
|
down = 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 ListGroups totals survive a traffic reset or client delete (#5675).
|
|
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
|
|
if len(emails) == 0 {
|
|
return nil
|
|
}
|
|
type groupDelta struct {
|
|
Name string
|
|
Up int64
|
|
Down int64
|
|
}
|
|
totals := make(map[string]*groupDelta)
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var part []groupDelta
|
|
if err := tx.Table("clients AS c").
|
|
Select("c.group_name AS name, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
|
|
Joins("JOIN client_traffics ct ON ct.email = c.email").
|
|
Where("c.group_name <> '' AND c.email IN ?", batch).
|
|
Group("c.group_name").
|
|
Scan(&part).Error; err != nil {
|
|
return err
|
|
}
|
|
for i := range part {
|
|
if agg, ok := totals[part[i].Name]; ok {
|
|
agg.Up += part[i].Up
|
|
agg.Down += part[i].Down
|
|
} else {
|
|
totals[part[i].Name] = &part[i]
|
|
}
|
|
}
|
|
}
|
|
for name, d := range totals {
|
|
if d.Up == 0 && d.Down == 0 {
|
|
continue
|
|
}
|
|
res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{
|
|
"reset_up": gorm.Expr("reset_up - ?", d.Up),
|
|
"reset_down": gorm.Expr("reset_down - ?", d.Down),
|
|
})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: -d.Up, ResetDown: -d.Down}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
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).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()
|
|
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
|
|
if err := tx.Model(&model.ClientRecord{}).
|
|
Where("email IN ?", batch).
|
|
UpdateColumn("group_name", group).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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 len(records), 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
|
|
}
|