mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
Fix Telegram bot client lookup for multi-node inbounds
This commit is contained in:
parent
a07b68894c
commit
c64cdbb0ed
2 changed files with 251 additions and 32 deletions
|
|
@ -2620,10 +2620,144 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||||
|
|
||||||
func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
|
func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
|
var emails []string
|
||||||
|
|
||||||
|
// Prefer the normalized clients table. It is populated for both local
|
||||||
|
// inbounds and remote-node snapshots, and avoids brittle JSON LIKE matching
|
||||||
|
// against settings formatting.
|
||||||
|
err := db.Table("clients").
|
||||||
|
Select("DISTINCT clients.email").
|
||||||
|
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||||
|
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
|
||||||
|
Where("clients.tg_id = ? AND clients.email <> ''", tgId).
|
||||||
|
Pluck("clients.email", &emails).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Error retrieving client emails for tgId %d: %v", tgId, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(emails) == 0 {
|
||||||
|
emails, err = s.getClientTrafficTgBotFromSettings(tgId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqEmails := uniqueNonEmptyStrings(emails)
|
||||||
|
if len(uniqEmails) == 0 {
|
||||||
|
logger.Warningf("No clients found for tgId: %d", tgId)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
trafficsByEmail := make(map[string]*xray.ClientTraffic, len(uniqEmails))
|
||||||
|
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
|
||||||
|
var page []*xray.ClientTraffic
|
||||||
|
if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, traffic := range page {
|
||||||
|
trafficsByEmail[traffic.Email] = traffic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
missingEmails := make([]string, 0)
|
||||||
|
for _, email := range uniqEmails {
|
||||||
|
if _, ok := trafficsByEmail[email]; !ok {
|
||||||
|
missingEmails = append(missingEmails, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missingEmails) > 0 {
|
||||||
|
type clientTrafficSeed struct {
|
||||||
|
Email string
|
||||||
|
InboundId int
|
||||||
|
Enable bool
|
||||||
|
TotalGB int64
|
||||||
|
ExpiryTime int64
|
||||||
|
Reset int
|
||||||
|
UUID string
|
||||||
|
SubID string
|
||||||
|
}
|
||||||
|
var rows []clientTrafficSeed
|
||||||
|
for _, batch := range chunkStrings(missingEmails, sqliteMaxVars) {
|
||||||
|
var page []clientTrafficSeed
|
||||||
|
if err = db.Table("clients").
|
||||||
|
Select(`clients.email,
|
||||||
|
client_inbounds.inbound_id,
|
||||||
|
clients.enable,
|
||||||
|
clients.total_gb,
|
||||||
|
clients.expiry_time,
|
||||||
|
clients.reset,
|
||||||
|
clients.uuid,
|
||||||
|
clients.sub_id`).
|
||||||
|
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||||
|
Where("clients.email IN ?", batch).
|
||||||
|
Find(&page).Error; err != nil {
|
||||||
|
logger.Errorf("Error retrieving client records for emails %v: %v", batch, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows = append(rows, page...)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if _, ok := trafficsByEmail[row.Email]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trafficsByEmail[row.Email] = &xray.ClientTraffic{
|
||||||
|
InboundId: row.InboundId,
|
||||||
|
Email: row.Email,
|
||||||
|
Enable: row.Enable,
|
||||||
|
Total: row.TotalGB,
|
||||||
|
ExpiryTime: row.ExpiryTime,
|
||||||
|
Reset: row.Reset,
|
||||||
|
UUID: row.UUID,
|
||||||
|
SubId: row.SubID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
|
||||||
|
for _, email := range uniqEmails {
|
||||||
|
traffic, ok := trafficsByEmail[email]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ct, client, e := s.GetClientByEmail(email); e == nil && ct != nil && client != nil {
|
||||||
|
traffic.Enable = client.Enable
|
||||||
|
traffic.UUID = client.ID
|
||||||
|
traffic.SubId = client.SubID
|
||||||
|
} else if traffic.UUID == "" || traffic.SubId == "" {
|
||||||
|
clients, clientErr := s.clientService.ListForInbound(nil, traffic.InboundId)
|
||||||
|
if clientErr != nil {
|
||||||
|
logger.Errorf("Error retrieving clients for inbound %d: %v", traffic.InboundId, clientErr)
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.Email == email {
|
||||||
|
traffic.Enable = client.Enable
|
||||||
|
traffic.UUID = client.ID
|
||||||
|
traffic.SubId = client.SubID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traffics = append(traffics, traffic)
|
||||||
|
}
|
||||||
|
if len(traffics) == 0 {
|
||||||
|
logger.Warning("No ClientTraffic records found for emails:", emails)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return traffics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) getClientTrafficTgBotFromSettings(tgId int64) ([]string, error) {
|
||||||
|
db := database.GetDB()
|
||||||
var inbounds []*model.Inbound
|
var inbounds []*model.Inbound
|
||||||
|
|
||||||
// Retrieve inbounds where settings contain the given tgId
|
err := db.Model(model.Inbound{}).Where("settings LIKE ?", fmt.Sprintf(`%%"tgId"%%%d%%`, tgId)).Find(&inbounds).Error
|
||||||
err := db.Model(model.Inbound{}).Where("settings LIKE ?", fmt.Sprintf(`%%"tgId": %d%%`, tgId)).Find(&inbounds).Error
|
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -2643,36 +2777,7 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunked to stay under SQLite's bind-variable limit when a single Telegram
|
return emails, nil
|
||||||
// account owns thousands of clients across inbounds.
|
|
||||||
uniqEmails := uniqueNonEmptyStrings(emails)
|
|
||||||
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
|
|
||||||
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
|
|
||||||
var page []*xray.ClientTraffic
|
|
||||||
if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
traffics = append(traffics, page...)
|
|
||||||
}
|
|
||||||
if len(traffics) == 0 {
|
|
||||||
logger.Warning("No ClientTraffic records found for emails:", emails)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate UUID and other client data for each traffic record
|
|
||||||
for i := range traffics {
|
|
||||||
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
|
|
||||||
traffics[i].Enable = client.Enable
|
|
||||||
traffics[i].UUID = client.ID
|
|
||||||
traffics[i].SubId = client.SubID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return traffics, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sqliteMaxVars is a safe ceiling for the number of bind parameters in a
|
// sqliteMaxVars is a safe ceiling for the number of bind parameters in a
|
||||||
|
|
|
||||||
114
web/service/tgbot_client_traffic_test.go
Normal file
114
web/service/tgbot_client_traffic_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTgBotTrafficTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClientTrafficTgBotUsesNormalizedRemoteNodeClients(t *testing.T) {
|
||||||
|
setupTgBotTrafficTestDB(t)
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
nodeID := 7
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
NodeID: &nodeID,
|
||||||
|
Tag: "node-vless",
|
||||||
|
Enable: true,
|
||||||
|
Port: 10001,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: `{"clients":[]}`,
|
||||||
|
}
|
||||||
|
if err := db.Create(inbound).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tgID int64 = 505739390
|
||||||
|
const email = "remote-user@example.com"
|
||||||
|
const uuid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010"
|
||||||
|
const subID = "remote-sub-id"
|
||||||
|
|
||||||
|
clientSvc := ClientService{}
|
||||||
|
if err := clientSvc.SyncInbound(nil, inbound.Id, []model.Client{{
|
||||||
|
Email: email,
|
||||||
|
ID: uuid,
|
||||||
|
SubID: subID,
|
||||||
|
Enable: true,
|
||||||
|
TgID: tgID,
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatalf("SyncInbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{
|
||||||
|
InboundId: inbound.Id,
|
||||||
|
Email: email,
|
||||||
|
Enable: true,
|
||||||
|
Total: 1024,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create traffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
traffics, err := (&InboundService{}).GetClientTrafficTgBot(tgID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetClientTrafficTgBot: %v", err)
|
||||||
|
}
|
||||||
|
if len(traffics) != 1 {
|
||||||
|
t.Fatalf("expected one traffic row, got %d", len(traffics))
|
||||||
|
}
|
||||||
|
if traffics[0].Email != email || traffics[0].UUID != uuid || traffics[0].SubId != subID {
|
||||||
|
t.Fatalf("unexpected traffic: %#v", traffics[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClientTrafficTgBotFallsBackToCompactSettingsJSON(t *testing.T) {
|
||||||
|
setupTgBotTrafficTestDB(t)
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
const tgID int64 = 505739390
|
||||||
|
const email = "legacy-user@example.com"
|
||||||
|
const uuid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c011"
|
||||||
|
const subID = "legacy-sub-id"
|
||||||
|
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
Tag: "legacy-vless",
|
||||||
|
Enable: true,
|
||||||
|
Port: 10002,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: fmt.Sprintf(`{"clients":[{"email":%q,"id":%q,"subId":%q,"enable":true,"tgId":%d}]}`, email, uuid, subID, tgID),
|
||||||
|
}
|
||||||
|
if err := db.Create(inbound).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{
|
||||||
|
InboundId: inbound.Id,
|
||||||
|
Email: email,
|
||||||
|
Enable: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create traffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
traffics, err := (&InboundService{}).GetClientTrafficTgBot(tgID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetClientTrafficTgBot: %v", err)
|
||||||
|
}
|
||||||
|
if len(traffics) != 1 {
|
||||||
|
t.Fatalf("expected one traffic row, got %d", len(traffics))
|
||||||
|
}
|
||||||
|
if traffics[0].Email != email || traffics[0].UUID != uuid || traffics[0].SubId != subID {
|
||||||
|
t.Fatalf("unexpected traffic: %#v", traffics[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue