mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 13:46:08 +00:00
58905d81a4
A client attached to several panels has one aggregated row on each master, but a node only ever saw its local share: the node UI under-reported usage, and the node kept serving a client whose cross-panel total had already exceeded its quota — the master's disable push doesn't kill established connections unless the node restarts xray itself. Masters now push their aggregated per-client counters to each node from NodeTrafficSyncJob (throttled, scoped to the clients that node hosts). The node stores them in the new client_global_traffics side table keyed by (masterGuid, email), overwritten on every push so a master-side reset propagates, and: - overlays max(local, pushed) onto UI read paths (slim inbound list, inbound detail, clients list, WS stats, per-email lookups). The full /panel/api/inbounds/list stays un-overlaid on purpose: it doubles as the traffic snapshot masters poll, and overlaying it would corrupt every master's delta accounting; - trips disableInvalidClients when any master's pushed total exceeds the client's quota, so the existing RestartXrayOnClientDisable flow disconnects the client locally; - clears the side rows on traffic reset, auto-renew, and client delete, keeping a renewed quota window clean. Supersedes #5204, which folded pushed globals into client_traffics and compensated with read-back baselines — that double-counted first-sight emails and could not work with several masters sharing one node.
190 lines
5.1 KiB
Go
190 lines
5.1 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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/xray"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
row := &model.ClientRecord{}
|
|
err := tx.Where("email = ?", email).First(row).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// EffectiveFlow returns the client's flow from the first flow-capable inbound
|
|
// it is attached to (lowest inbound_id with a non-empty flow_override). The
|
|
// canonical clients.Flow column is unreliable for multi-inbound clients: a
|
|
// non-flow inbound (Hysteria, WS, gRPC, …) carries an empty flow and, when its
|
|
// SyncInbound runs last, overwrites the column to "" even though a VLESS Reality
|
|
// inbound stored a real flow. The per-inbound flow_override is always correct,
|
|
// so derive the display flow from it (order-independent). See issue #4792.
|
|
func (s *ClientService) EffectiveFlow(tx *gorm.DB, recordId int) (string, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
var flows []string
|
|
err := tx.Model(&model.ClientInbound{}).
|
|
Where("client_id = ? AND flow_override <> ?", recordId, "").
|
|
Order("inbound_id ASC").
|
|
Limit(1).
|
|
Pluck("flow_override", &flows).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(flows) == 0 {
|
|
return "", nil
|
|
}
|
|
return flows[0], nil
|
|
}
|
|
|
|
func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
|
|
if tx == nil {
|
|
tx = database.GetDB()
|
|
}
|
|
var ids []int
|
|
err := tx.Table("client_inbounds").
|
|
Select("client_inbounds.inbound_id").
|
|
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
|
Where("clients.email = ?", email).
|
|
Scan(&ids).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
|
row := &model.ClientRecord{}
|
|
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
|
|
var ids []int
|
|
err := database.GetDB().Table("client_inbounds").
|
|
Where("client_id = ?", id).
|
|
Order("inbound_id ASC").
|
|
Pluck("inbound_id", &ids).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *ClientService) List() ([]ClientWithAttachments, error) {
|
|
db := database.GetDB()
|
|
var rows []model.ClientRecord
|
|
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return []ClientWithAttachments{}, nil
|
|
}
|
|
|
|
clientIds := make([]int, 0, len(rows))
|
|
emails := make([]string, 0, len(rows))
|
|
for i := range rows {
|
|
clientIds = append(clientIds, rows[i].Id)
|
|
if rows[i].Email != "" {
|
|
emails = append(emails, rows[i].Email)
|
|
}
|
|
}
|
|
|
|
attachments := make(map[int][]int, len(rows))
|
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
|
var links []model.ClientInbound
|
|
if err := db.Where("client_id IN ?", batch).Find(&links).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, l := range links {
|
|
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
|
}
|
|
}
|
|
|
|
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
|
if len(emails) > 0 {
|
|
var stats []xray.ClientTraffic
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var batchStats []xray.ClientTraffic
|
|
if err := db.Where("email IN ?", batch).Find(&batchStats).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
stats = append(stats, batchStats...)
|
|
}
|
|
overlayGlobalTrafficValues(db, stats)
|
|
for i := range stats {
|
|
trafficByEmail[stats[i].Email] = &stats[i]
|
|
}
|
|
}
|
|
|
|
out := make([]ClientWithAttachments, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, ClientWithAttachments{
|
|
ClientRecord: rows[i],
|
|
InboundIds: attachments[rows[i].Id],
|
|
Traffic: trafficByEmail[rows[i].Email],
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) bool {
|
|
if strings.TrimSpace(email) == "" {
|
|
return false
|
|
}
|
|
ids, err := s.GetInboundIdsForEmail(nil, email)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return inboundSvc.AnyNodePending(ids)
|
|
}
|
|
|
|
// findInboundIdsByClientEmail returns every inbound whose settings.clients[]
|
|
// JSON contains an entry with the given email. Driver-portable (no JSON
|
|
// operators) by parsing in Go — fine for the rare fallback path.
|
|
func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) {
|
|
var inbounds []model.Inbound
|
|
if err := database.GetDB().
|
|
Select("id, settings").
|
|
Where("settings LIKE ?", "%"+email+"%").
|
|
Find(&inbounds).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]int, 0, len(inbounds))
|
|
for _, ib := range inbounds {
|
|
var settings map[string]any
|
|
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
|
continue
|
|
}
|
|
clients, ok := settings["clients"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, c := range clients {
|
|
cm, ok := c.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cEmail, _ := cm["email"].(string); cEmail == email {
|
|
out = append(out, ib.Id)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|