mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 08:35:00 +03:00
7da8fd23be
Adjust group baselines before clearing client counters so group usage survives a global reset.
492 lines
13 KiB
Go
492 lines
13 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)
|
|
}
|
|
|
|
func adjustAllGroupBaselinesForRemovedTraffic(tx *gorm.DB) error {
|
|
totals, err := sumGroupTraffic(tx, "c.group_name <> ''")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return applyGroupBaselineShift(tx, totals, -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
|
|
}
|
|
}
|
|
}
|
|
return applyGroupBaselineShift(tx, totals, sign)
|
|
}
|
|
|
|
func applyGroupBaselineShift(tx *gorm.DB, totals map[string]*groupCounters, sign int64) error {
|
|
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
|
|
}
|