mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
dcdc56c64b
Per-client counters are stored up to TrafficMax, which a node can reach by reporting huge usage, and the group totals, group baselines and the per-client reset lookup added two such counters inside SQL, overflowing it: every group listing and reset failed, and no client on that reset cycle was reset. The counters are now read separately and added in Go, saturating at TrafficMax.
480 lines
12 KiB
Go
480 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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.
|
|
derived, err := sumGroupTraffic(db, "c.group_name <> ''")
|
|
if 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 name, g := range derived {
|
|
merged[name] = groupAgg{count: g.clients, up: g.up, down: g.down}
|
|
}
|
|
out := make([]GroupSummary, 0, len(merged))
|
|
for name, agg := range merged {
|
|
up := max(clampedTrafficAdd(agg.up, -baseUp[name]), 0)
|
|
down := max(clampedTrafficAdd(agg.down, -baseDown[name]), 0)
|
|
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: clampedTrafficAdd(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 {
|
|
return shiftGroupBaselines(tx, emails, -1)
|
|
}
|
|
|
|
// adjustGroupBaselinesForRestoredTraffic shifts group baselines up by counters an
|
|
// import restored, so usage from before the import never enters a group total.
|
|
func adjustGroupBaselinesForRestoredTraffic(tx *gorm.DB, emails []string) error {
|
|
return shiftGroupBaselines(tx, emails, 1)
|
|
}
|
|
|
|
func shiftGroupBaselines(tx *gorm.DB, emails []string, sign int64) error {
|
|
if len(emails) == 0 {
|
|
return nil
|
|
}
|
|
totals := make(map[string]*groupCounters)
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
part, err := sumGroupTraffic(tx, "c.group_name <> '' AND c.email IN ?", batch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for name, p := range part {
|
|
if agg, ok := totals[name]; ok {
|
|
agg.up = clampedTrafficAdd(agg.up, p.up)
|
|
agg.down = clampedTrafficAdd(agg.down, p.down)
|
|
} else {
|
|
totals[name] = p
|
|
}
|
|
}
|
|
}
|
|
for name, d := range totals {
|
|
if d.up == 0 && d.down == 0 {
|
|
continue
|
|
}
|
|
var group model.ClientGroup
|
|
err := tx.Where("name = ?", name).First(&group).Error
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
group.Name = name
|
|
group.ResetUp = clampedTrafficAdd(group.ResetUp, sign*d.up)
|
|
group.ResetDown = clampedTrafficAdd(group.ResetDown, sign*d.down)
|
|
if err := tx.Save(&group).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()
|
|
totals, err := sumGroupTraffic(db, "c.group_name = ?", name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
agg := groupCounters{}
|
|
if t := totals[name]; t != nil {
|
|
agg = *t
|
|
}
|
|
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
|
|
}
|
|
|
|
// groupCounters is a group's summed traffic, held within ±TrafficMax.
|
|
type groupCounters struct {
|
|
clients int
|
|
up, down int64
|
|
}
|
|
|
|
// clampedTrafficAdd adds two counters, saturating at ±TrafficMax: stored counters reach
|
|
// the cap, and SQL SUM over two of them overflowed and failed the whole query.
|
|
func clampedTrafficAdd(a, b int64) int64 {
|
|
switch {
|
|
case b > 0 && a > database.TrafficMax-b:
|
|
return database.TrafficMax
|
|
case b < 0 && a < -database.TrafficMax-b:
|
|
return -database.TrafficMax
|
|
}
|
|
return a + b
|
|
}
|
|
|
|
// sumGroupTraffic reads one row per grouped client matching where and adds them up in Go.
|
|
func sumGroupTraffic(tx *gorm.DB, where string, args ...any) (map[string]*groupCounters, error) {
|
|
var rows []struct {
|
|
Name string
|
|
Up, Down int64
|
|
}
|
|
if err := tx.Table("clients AS c").
|
|
Select("c.group_name AS name, COALESCE(ct.up, 0) AS up, COALESCE(ct.down, 0) AS down").
|
|
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
|
|
Where(where, args...).
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
totals := make(map[string]*groupCounters)
|
|
for _, r := range rows {
|
|
agg := totals[r.Name]
|
|
if agg == nil {
|
|
agg = &groupCounters{}
|
|
totals[r.Name] = agg
|
|
}
|
|
agg.clients++
|
|
agg.up = clampedTrafficAdd(agg.up, r.Up)
|
|
agg.down = clampedTrafficAdd(agg.down, r.Down)
|
|
}
|
|
return totals, nil
|
|
}
|