Files
3x-ui/internal/web/service/client_lookup.go
T
Sanaei 8b9cf260b6 perf(clients): batch the client record lookup in bulk operations
BulkResetTraffic resolved every address with its own GetRecordByEmail
call, one SELECT per email, purely to find the disabled clients it has to
re-enable. Resetting 30 clients issued 30 queries before the batched
transaction even started, while BulkAdjust, BulkDelete and BulkSetEnable
next to it already loaded their rows with a single chunked IN query.

Those three carried a verbatim copy each of both the trim/dedupe loop and
the chunked record load, so the reuse is the fix: trimmedUniqueEmails now
delegates to the existing uniqueNonEmptyStrings, and clientRecordsByEmail
holds the one chunked lookup all four call sites share.

A DB failure during the lookup now aborts the reset instead of being
swallowed per email; a missing row is still skipped, as before.

The new test drives BulkResetTraffic with 3 and with 30 emails and fails
unless both issue the same number of SELECTs against clients.
2026-09-10 21:07:49 +02:00

295 lines
8.8 KiB
Go

package service
import (
"encoding/json"
"errors"
"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
}
// EffectiveFlowsByEmails resolves the intended flow (non-empty flow_override,
// lowest inbound_id first — same rule as EffectiveFlow) for many clients in one
// query, keyed by email. Emails absent from the result carry no flow anywhere.
// Batched so flow restoration on an inbound with many clients is O(1) queries
// instead of O(clients). Used to restore a stripped flow onto an inbound that
// has just become flow-eligible.
func (s *ClientService) EffectiveFlowsByEmails(tx *gorm.DB, emails []string) (map[string]string, error) {
if tx == nil {
tx = database.GetDB()
}
out := make(map[string]string, len(emails))
if len(emails) == 0 {
return out, nil
}
type row struct {
Email string
Flow string `gorm:"column:flow_override"`
}
for _, batch := range chunkStrings(emails, sqlInChunk) {
var rows []row
err := tx.Table("client_inbounds").
Select("clients.email AS email, client_inbounds.flow_override AS flow_override").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email IN ? AND client_inbounds.flow_override <> ?", batch, "").
Order("client_inbounds.inbound_id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
if _, seen := out[r.Email]; !seen { // ordered by inbound_id ASC → first = lowest
out[r.Email] = r.Flow
}
}
}
return out, 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) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
if tgId <= 0 {
return nil, errors.New("tg_id must be a positive integer")
}
var rows []*model.ClientRecord
err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error
return rows, err
}
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
}
// TunnelAllowedIPsByInbound returns, for each given WireGuard/AmneziaWG
// inbound id, the real AllowedIPs this email currently has on that specific
// inbound's own settings JSON -- joined comma-separated, matching the form
// value shape a single AllowedIPs field already uses. Non-tunnel inbounds
// and ids the email isn't actually attached to are simply absent from the
// result (not an error): callers use this to seed a per-protocol display
// field, and ClientRecord's own single AllowedIPs column can't tell two
// different protocol addresses apart, which is exactly the gap this closes.
func (s *ClientService) TunnelAllowedIPsByInbound(inboundSvc *InboundService, email string, inboundIds []int) (map[int]string, error) {
result := make(map[int]string, len(inboundIds))
for _, ibId := range inboundIds {
inbound, err := inboundSvc.GetInbound(ibId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return nil, err
}
if inbound.Protocol != model.WireGuard && inbound.Protocol != model.AmneziaWG {
continue
}
clients, err := inboundSvc.GetClients(inbound)
if err != nil {
return nil, err
}
for i := range clients {
if strings.EqualFold(clients[i].Email, email) {
result[ibId] = strings.Join(clients[i].AllowedIPs, ",")
break
}
}
}
return result, 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
}
// clientRecordsByEmail batch-loads client rows for emails, keyed by email.
// Callers pass an already-deduplicated list; absent addresses are simply
// missing from the map.
func clientRecordsByEmail(tx *gorm.DB, emails []string) (map[string]*model.ClientRecord, error) {
if tx == nil {
tx = database.GetDB()
}
var records []model.ClientRecord
for _, batch := range chunkStrings(emails, sqlInChunk) {
var rows []model.ClientRecord
if err := tx.Where("email IN ?", batch).Find(&rows).Error; err != nil {
return nil, err
}
records = append(records, rows...)
}
byEmail := make(map[string]*model.ClientRecord, len(records))
for i := range records {
byEmail[records[i].Email] = &records[i]
}
return byEmail, nil
}