fix: reliable SubTotal synchronization across identical inbounds

- Removed artificial `SubTotalGB == 0` inheritance blocker. Admins can now explicitly reset a group's shared quota to 0 (unlimited) without the system automatically reverting it to the old value.
- Fixed a JSON overwrite bug in `UpdateInboundClient` and `AddInboundClient` where `tx.Save()` would revert SubTotal updates for other clients located on the exact same inbound. The backend now proactively syncs the new SubTotal to all sibling clients within the JSON payload before marshaling and saving.
This commit is contained in:
SadeghKalami 2026-05-04 23:29:51 +03:30
parent 32c7ceec55
commit a52c3fd768
2 changed files with 115 additions and 14 deletions

View file

@ -11,6 +11,7 @@ import (
"os"
"path"
"slices"
"encoding/json"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database/model"
@ -108,6 +109,49 @@ func runSeeders(isUsersEmpty bool) error {
}
return db.Create(hashSeeder).Error
}
if !slices.Contains(seedersHistory, "SubTotalMigration") {
// Explicitly add sub_total column if it was somehow skipped, though AutoMigrate should handle it
if !db.Migrator().HasColumn(&xray.ClientTraffic{}, "sub_total") {
err := db.Exec("ALTER TABLE client_traffics ADD COLUMN sub_total integer DEFAULT 0").Error
if err != nil {
log.Printf("Error adding sub_total column to client_traffics: %v", err)
}
}
// Backfill subTotalGB into inbounds.settings JSON
var inbounds []model.Inbound
db.Find(&inbounds)
for i := range inbounds {
var settings map[string]any
if err := json.Unmarshal([]byte(inbounds[i].Settings), &settings); err != nil {
continue
}
if clients, ok := settings["clients"].([]any); ok {
modified := false
for j := range clients {
if clientMap, ok := clients[j].(map[string]any); ok {
if _, exists := clientMap["subTotalGB"]; !exists {
clientMap["subTotalGB"] = 0
modified = true
}
}
}
if modified {
newSettings, _ := json.MarshalIndent(settings, "", " ")
db.Model(&inbounds[i]).Update("settings", string(newSettings))
}
}
}
subTotalSeeder := &model.HistoryOfSeeders{
SeederName: "SubTotalMigration",
}
db.Create(subTotalSeeder)
}
}
return nil

View file

@ -8,6 +8,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
@ -24,7 +25,8 @@ import (
// It handles CRUD operations for inbounds, client management, traffic monitoring,
// and integration with the Xray API for real-time updates.
type InboundService struct {
xrayApi xray.XrayAPI
xrayApi xray.XrayAPI
jsonMutex sync.Mutex
}
type CopyClientsResult struct {
@ -700,8 +702,22 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}
oldClients := oldSettings["clients"].([]any)
oldClients = append(oldClients, interfaceClients...)
// Sync subTotalGB to old clients on this inbound with the same SubID
for _, newClient := range clients {
if newClient.SubID != "" {
for i, cAny := range oldClients {
if cMap, ok := cAny.(map[string]any); ok {
if sid, ok := cMap["subId"].(string); ok && sid == newClient.SubID {
cMap["subTotalGB"] = newClient.SubTotalGB
oldClients[i] = cMap
}
}
}
}
}
oldClients = append(oldClients, interfaceClients...)
oldSettings["clients"] = oldClients
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
@ -1141,6 +1157,19 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
}
}
settingsClients[clientIndex] = interfaceClients[0]
// Sync subTotalGB to all clients on this inbound with the same SubID
if clients[0].SubID != "" {
for i, cAny := range settingsClients {
if cMap, ok := cAny.(map[string]any); ok {
if sid, ok := cMap["subId"].(string); ok && sid == clients[0].SubID {
cMap["subTotalGB"] = clients[0].SubTotalGB
settingsClients[i] = cMap
}
}
}
}
oldSettings["clients"] = settingsClients
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
@ -1329,14 +1358,40 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
for dbTraffic_index := range dbClientTraffics {
for traffic_index := range traffics {
if dbClientTraffics[dbTraffic_index].Email == traffics[traffic_index].Email {
dbClientTraffics[dbTraffic_index].Up += traffics[traffic_index].Up
dbClientTraffics[dbTraffic_index].Down += traffics[traffic_index].Down
dbClientTraffics[dbTraffic_index].AllTime += (traffics[traffic_index].Up + traffics[traffic_index].Down)
upToAdd := traffics[traffic_index].Up
downToAdd := traffics[traffic_index].Down
// Add user in onlineUsers array on traffic
if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 {
onlineClients = append(onlineClients, traffics[traffic_index].Email)
dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli()
if upToAdd+downToAdd > 0 || dbClientTraffics[dbTraffic_index].ExpiryTime != traffics[traffic_index].ExpiryTime {
updates := map[string]any{
"up": gorm.Expr("up + ?", upToAdd),
"down": gorm.Expr("down + ?", downToAdd),
"all_time": gorm.Expr("all_time + ?", upToAdd+downToAdd),
}
// Update ExpiryTime if adjustTraffics modified it
if dbClientTraffics[dbTraffic_index].ExpiryTime > 0 {
updates["expiry_time"] = dbClientTraffics[dbTraffic_index].ExpiryTime
}
if upToAdd+downToAdd > 0 {
onlineClients = append(onlineClients, traffics[traffic_index].Email)
updates["last_online"] = time.Now().UnixMilli()
}
err = tx.Model(&xray.ClientTraffic{}).
Where("id = ?", dbClientTraffics[dbTraffic_index].Id).
Updates(updates).Error
if err != nil {
logger.Warning("AddClientTraffic update data ", err)
}
dbClientTraffics[dbTraffic_index].Up += upToAdd
dbClientTraffics[dbTraffic_index].Down += downToAdd
dbClientTraffics[dbTraffic_index].AllTime += (upToAdd + downToAdd)
if upToAdd+downToAdd > 0 {
dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli()
}
}
break
}
@ -1346,11 +1401,6 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
// Set onlineUsers
p.SetOnlineClients(onlineClients)
err = tx.Save(dbClientTraffics).Error
if err != nil {
logger.Warning("AddClientTraffic update data ", err)
}
return nil
}
@ -1368,6 +1418,10 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
if err != nil {
return nil, err
}
s.jsonMutex.Lock()
defer s.jsonMutex.Unlock()
for inbound_index := range inbounds {
settings := map[string]any{}
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
@ -1651,6 +1705,9 @@ func (s *InboundService) SyncSubTotal(tx *gorm.DB, subId string, subTotal int64)
return nil
}
s.jsonMutex.Lock()
defer s.jsonMutex.Unlock()
// 1. Update client_traffics table
err := tx.Model(xray.ClientTraffic{}).
Where("sub_id = ?", subId).