mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-11 15:16:07 +00:00
ed9686bf29
* fix(clients): include Telegram ID in client list search clientMatchesSearch only checked Email/SubID/Comment/UUID/Password/Auth, so searching the client list for a Telegram user ID never matched even though the field is stored on every client. This is a real regression, not a field that was simply never included: before the paged search endpoint (#4500), the frontend searched with ObjectUtil.deepSearch() over the full client object, which recursed into every field including tgId. Replacing that with a fixed backend field list silently dropped it (along with a few other fields, but tgId is the one that's actually needed here since it's the panel's own way of looking a client up when it only knows their Telegram ID). TgID is int64 (0 = unset), so it can't sit in the existing []string candidates array — matched separately via strconv, and skipped when 0 to avoid a needle of "0" spuriously matching every client without a Telegram ID. Fixes #5880 * fix(clients): drop explanatory comment, mention Telegram ID in search hint Addresses review feedback on #5888: - Removed the // comment block above the TgID check in clientMatchesSearch per repo convention (code should read on its own). - Updated searchPlaceholder in all 13 locale files to mention Telegram ID, since the search box now actually matches on it. * test(clients): remove TgID search test per maintainer request
585 lines
15 KiB
Go
585 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// ClientSlim is the row-shape used by the clients page. It drops fields the
|
|
// table never reads (UUID, password, auth, flow, security, reverse, tgId)
|
|
// so the list payload stays compact even when the panel manages thousands
|
|
// of clients. Modals that need the full record still call /get/:email.
|
|
type ClientSlim struct {
|
|
Email string `json:"email"`
|
|
SubID string `json:"subId"`
|
|
Enable bool `json:"enable"`
|
|
TotalGB int64 `json:"totalGB"`
|
|
ExpiryTime int64 `json:"expiryTime"`
|
|
LimitIP int `json:"limitIp"`
|
|
Reset int `json:"reset"`
|
|
Group string `json:"group,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
InboundIds []int `json:"inboundIds"`
|
|
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
UpdatedAt int64 `json:"updatedAt"`
|
|
}
|
|
|
|
// ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
|
|
// All fields are optional — the empty value means "no filter" / defaults.
|
|
//
|
|
// Filter / Protocol / Inbound accept either a single value or a comma-separated
|
|
// list; matching is OR within a field and AND across fields. The numeric range
|
|
// fields treat 0 as "unset" on the lower bound and 0 (or negative) as
|
|
// "unbounded" on the upper bound.
|
|
type ClientPageParams struct {
|
|
Page int `form:"page"`
|
|
PageSize int `form:"pageSize"`
|
|
Search string `form:"search"`
|
|
Filter string `form:"filter"`
|
|
Protocol string `form:"protocol"`
|
|
Inbound string `form:"inbound"`
|
|
Sort string `form:"sort"`
|
|
Order string `form:"order"`
|
|
|
|
ExpiryFrom int64 `form:"expiryFrom"`
|
|
ExpiryTo int64 `form:"expiryTo"`
|
|
UsageFrom int64 `form:"usageFrom"`
|
|
UsageTo int64 `form:"usageTo"`
|
|
AutoRenew string `form:"autoRenew"`
|
|
HasTgID string `form:"hasTgId"`
|
|
HasComment string `form:"hasComment"`
|
|
Group string `form:"group"`
|
|
}
|
|
|
|
// ClientPageResponse is the shape returned by ListPaged. `Total` is the
|
|
// row count in the DB; `Filtered` is the count after Search/Filter/Protocol
|
|
// were applied, before pagination. The page contains at most PageSize items.
|
|
// Summary is computed across the full DB row set so dashboard counters
|
|
// on the clients page stay stable as the user paginates/filters.
|
|
type ClientPageResponse struct {
|
|
Items []ClientSlim `json:"items"`
|
|
Total int `json:"total"`
|
|
Filtered int `json:"filtered"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
Summary ClientsSummary `json:"summary"`
|
|
Groups []string `json:"groups"`
|
|
}
|
|
|
|
// ClientsSummary collects per-bucket counts plus the matching email lists so
|
|
// the clients page can render the dashboard stat cards and their hover
|
|
// popovers without shipping the full client array.
|
|
type ClientsSummary struct {
|
|
Total int `json:"total"`
|
|
Active int `json:"active"`
|
|
Online []string `json:"online"`
|
|
Depleted []string `json:"depleted"`
|
|
Expiring []string `json:"expiring"`
|
|
Deactive []string `json:"deactive"`
|
|
}
|
|
|
|
const (
|
|
clientPageDefaultSize = 25
|
|
clientPageMaxSize = 200
|
|
)
|
|
|
|
// ListPaged loads every client (with traffic + attachments) into memory,
|
|
// applies the requested filter / search / protocol predicates, sorts, and
|
|
// returns the requested page along with total and filtered counts. The DB
|
|
// query itself is unchanged from List(); the win is that the response
|
|
// only carries 25-ish slim rows over the wire instead of all 2000 full
|
|
// records, which on real panels was the dominant cost.
|
|
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
|
all, err := s.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
total := len(all)
|
|
|
|
pageSize := params.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = clientPageDefaultSize
|
|
}
|
|
if pageSize > clientPageMaxSize {
|
|
pageSize = clientPageMaxSize
|
|
}
|
|
page := params.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
protocols := parseCSVStrings(params.Protocol)
|
|
inboundIDs := parseCSVInts(params.Inbound)
|
|
buckets := parseCSVStrings(params.Filter)
|
|
|
|
var protocolByInbound map[int]string
|
|
if len(protocols) > 0 {
|
|
inbounds, err := inboundSvc.GetAllInbounds()
|
|
if err == nil {
|
|
protocolByInbound = make(map[int]string, len(inbounds))
|
|
for _, ib := range inbounds {
|
|
protocolByInbound[ib.Id] = string(ib.Protocol)
|
|
}
|
|
}
|
|
}
|
|
|
|
onlines := inboundSvc.GetOnlineClients()
|
|
onlineSet := make(map[string]struct{}, len(onlines))
|
|
for _, e := range onlines {
|
|
onlineSet[e] = struct{}{}
|
|
}
|
|
|
|
var expireDiffMs, trafficDiffBytes int64
|
|
if settingSvc != nil {
|
|
if v, err := settingSvc.GetExpireDiff(); err == nil {
|
|
expireDiffMs = int64(v) * 86400000
|
|
}
|
|
if v, err := settingSvc.GetTrafficDiff(); err == nil {
|
|
trafficDiffBytes = int64(v) * 1073741824
|
|
}
|
|
}
|
|
|
|
nowMs := time.Now().UnixMilli()
|
|
summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
|
|
|
|
needle := strings.ToLower(strings.TrimSpace(params.Search))
|
|
|
|
filtered := make([]ClientWithAttachments, 0, len(all))
|
|
for _, c := range all {
|
|
if needle != "" && !clientMatchesSearch(c, needle) {
|
|
continue
|
|
}
|
|
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
|
|
continue
|
|
}
|
|
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
|
|
continue
|
|
}
|
|
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
|
continue
|
|
}
|
|
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
|
|
continue
|
|
}
|
|
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
|
|
continue
|
|
}
|
|
if !clientMatchesAutoRenew(c, params.AutoRenew) {
|
|
continue
|
|
}
|
|
if !clientMatchesHasTgID(c, params.HasTgID) {
|
|
continue
|
|
}
|
|
if !clientMatchesHasComment(c, params.HasComment) {
|
|
continue
|
|
}
|
|
if !clientMatchesAnyGroup(c, params.Group) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, c)
|
|
}
|
|
|
|
sortClients(filtered, params.Sort, params.Order)
|
|
|
|
filteredCount := len(filtered)
|
|
start := (page - 1) * pageSize
|
|
end := start + pageSize
|
|
if start > filteredCount {
|
|
start = filteredCount
|
|
}
|
|
if end > filteredCount {
|
|
end = filteredCount
|
|
}
|
|
pageRows := filtered[start:end]
|
|
|
|
items := make([]ClientSlim, 0, len(pageRows))
|
|
for _, c := range pageRows {
|
|
items = append(items, toClientSlim(c))
|
|
}
|
|
|
|
groupRows, gErr := s.ListGroups()
|
|
if gErr != nil {
|
|
return nil, gErr
|
|
}
|
|
groups := make([]string, 0, len(groupRows))
|
|
for _, g := range groupRows {
|
|
groups = append(groups, g.Name)
|
|
}
|
|
|
|
return &ClientPageResponse{
|
|
Items: items,
|
|
Total: total,
|
|
Filtered: filteredCount,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
Summary: summary,
|
|
Groups: groups,
|
|
}, nil
|
|
}
|
|
|
|
func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
|
|
s := ClientsSummary{
|
|
Total: len(all),
|
|
Online: []string{},
|
|
Depleted: []string{},
|
|
Expiring: []string{},
|
|
Deactive: []string{},
|
|
}
|
|
for _, c := range all {
|
|
used := int64(0)
|
|
if c.Traffic != nil {
|
|
used = c.Traffic.Up + c.Traffic.Down
|
|
}
|
|
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
|
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
|
if c.Enable {
|
|
if _, ok := onlineSet[c.Email]; ok {
|
|
s.Online = append(s.Online, c.Email)
|
|
}
|
|
}
|
|
if exhausted || expired {
|
|
s.Depleted = append(s.Depleted, c.Email)
|
|
continue
|
|
}
|
|
if !c.Enable {
|
|
s.Deactive = append(s.Deactive, c.Email)
|
|
continue
|
|
}
|
|
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
|
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
|
if nearExpiry || nearLimit {
|
|
s.Expiring = append(s.Expiring, c.Email)
|
|
} else {
|
|
s.Active++
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func toClientSlim(c ClientWithAttachments) ClientSlim {
|
|
return ClientSlim{
|
|
Email: c.Email,
|
|
SubID: c.SubID,
|
|
Enable: c.Enable,
|
|
TotalGB: c.TotalGB,
|
|
ExpiryTime: c.ExpiryTime,
|
|
LimitIP: c.LimitIP,
|
|
Reset: c.Reset,
|
|
Group: c.Group,
|
|
Comment: c.Comment,
|
|
InboundIds: c.InboundIds,
|
|
Traffic: c.Traffic,
|
|
CreatedAt: c.CreatedAt,
|
|
UpdatedAt: c.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
|
|
if needle == "" {
|
|
return true
|
|
}
|
|
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
|
|
for _, v := range candidates {
|
|
if v != "" && strings.Contains(strings.ToLower(v), needle) {
|
|
return true
|
|
}
|
|
}
|
|
if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
|
|
// and drops blanks. Returns nil when the input has no usable entries — the
|
|
// caller can then skip the predicate entirely.
|
|
func parseCSVStrings(raw string) []string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
s := strings.ToLower(strings.TrimSpace(p))
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
|
|
// non-positive entries are silently dropped.
|
|
func parseCSVInts(raw string) []int {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]int, 0, len(parts))
|
|
for _, p := range parts {
|
|
s := strings.TrimSpace(p)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
|
|
for _, id := range c.InboundIds {
|
|
p := byInbound[id]
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if slices.Contains(protocols, strings.ToLower(p)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
|
|
for _, id := range c.InboundIds {
|
|
if slices.Contains(inboundIds, id) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
|
for _, b := range buckets {
|
|
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
|
|
if fromMs <= 0 && toMs <= 0 {
|
|
return true
|
|
}
|
|
// expiryTime of 0 means "never expires"; treat it as outside any bounded
|
|
// range so users filtering by date see only clients with concrete expiries.
|
|
if c.ExpiryTime == 0 {
|
|
return false
|
|
}
|
|
// Negative expiry is the "delayed start" sentinel; same treatment as never.
|
|
if c.ExpiryTime < 0 {
|
|
return false
|
|
}
|
|
if fromMs > 0 && c.ExpiryTime < fromMs {
|
|
return false
|
|
}
|
|
if toMs > 0 && c.ExpiryTime > toMs {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
|
|
if fromBytes <= 0 && toBytes <= 0 {
|
|
return true
|
|
}
|
|
used := int64(0)
|
|
if c.Traffic != nil {
|
|
used = c.Traffic.Up + c.Traffic.Down
|
|
}
|
|
if fromBytes > 0 && used < fromBytes {
|
|
return false
|
|
}
|
|
if toBytes > 0 && used > toBytes {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "on":
|
|
return c.Reset > 0
|
|
case "off":
|
|
return c.Reset <= 0
|
|
}
|
|
return true
|
|
}
|
|
|
|
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "yes":
|
|
return c.TgID != 0
|
|
case "no":
|
|
return c.TgID == 0
|
|
}
|
|
return true
|
|
}
|
|
|
|
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "yes":
|
|
return strings.TrimSpace(c.Comment) != ""
|
|
case "no":
|
|
return strings.TrimSpace(c.Comment) == ""
|
|
}
|
|
return true
|
|
}
|
|
|
|
func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
|
|
groups := parseCSVStrings(csv)
|
|
if len(groups) == 0 {
|
|
return true
|
|
}
|
|
current := strings.TrimSpace(c.Group)
|
|
for _, g := range groups {
|
|
if g == "" {
|
|
if current == "" {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if strings.EqualFold(g, current) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
|
if bucket == "" {
|
|
return true
|
|
}
|
|
used := int64(0)
|
|
if c.Traffic != nil {
|
|
used = c.Traffic.Up + c.Traffic.Down
|
|
}
|
|
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
|
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
|
switch bucket {
|
|
case "online":
|
|
if onlineSet == nil {
|
|
return false
|
|
}
|
|
_, ok := onlineSet[c.Email]
|
|
return ok && c.Enable
|
|
case "depleted":
|
|
return exhausted || expired
|
|
case "deactive":
|
|
return !c.Enable
|
|
case "active":
|
|
return c.Enable && !exhausted && !expired
|
|
case "expiring":
|
|
if !c.Enable || exhausted || expired {
|
|
return false
|
|
}
|
|
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
|
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
|
return nearExpiry || nearLimit
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sortClients(rows []ClientWithAttachments, sortKey, order string) {
|
|
if sortKey == "" {
|
|
return
|
|
}
|
|
desc := order == "descend"
|
|
less := func(i, j int) bool {
|
|
a, b := rows[i], rows[j]
|
|
switch sortKey {
|
|
case "enable":
|
|
if a.Enable == b.Enable {
|
|
return false
|
|
}
|
|
return !a.Enable && b.Enable
|
|
case "email":
|
|
return strings.ToLower(a.Email) < strings.ToLower(b.Email)
|
|
case "inboundIds":
|
|
return len(a.InboundIds) < len(b.InboundIds)
|
|
case "traffic":
|
|
ua := int64(0)
|
|
if a.Traffic != nil {
|
|
ua = a.Traffic.Up + a.Traffic.Down
|
|
}
|
|
ub := int64(0)
|
|
if b.Traffic != nil {
|
|
ub = b.Traffic.Up + b.Traffic.Down
|
|
}
|
|
return ua < ub
|
|
case "remaining":
|
|
ra := int64(1<<62 - 1)
|
|
if a.TotalGB > 0 {
|
|
used := int64(0)
|
|
if a.Traffic != nil {
|
|
used = a.Traffic.Up + a.Traffic.Down
|
|
}
|
|
ra = a.TotalGB - used
|
|
}
|
|
rb := int64(1<<62 - 1)
|
|
if b.TotalGB > 0 {
|
|
used := int64(0)
|
|
if b.Traffic != nil {
|
|
used = b.Traffic.Up + b.Traffic.Down
|
|
}
|
|
rb = b.TotalGB - used
|
|
}
|
|
return ra < rb
|
|
case "expiryTime":
|
|
ea := int64(1<<62 - 1)
|
|
if a.ExpiryTime > 0 {
|
|
ea = a.ExpiryTime
|
|
}
|
|
eb := int64(1<<62 - 1)
|
|
if b.ExpiryTime > 0 {
|
|
eb = b.ExpiryTime
|
|
}
|
|
return ea < eb
|
|
case "createdAt":
|
|
if a.CreatedAt == b.CreatedAt {
|
|
return a.Id < b.Id
|
|
}
|
|
return a.CreatedAt < b.CreatedAt
|
|
case "updatedAt":
|
|
if a.UpdatedAt == b.UpdatedAt {
|
|
return a.Id < b.Id
|
|
}
|
|
return a.UpdatedAt < b.UpdatedAt
|
|
case "lastOnline":
|
|
la := int64(0)
|
|
if a.Traffic != nil {
|
|
la = a.Traffic.LastOnline
|
|
}
|
|
lb := int64(0)
|
|
if b.Traffic != nil {
|
|
lb = b.Traffic.LastOnline
|
|
}
|
|
if la == lb {
|
|
return a.Id < b.Id
|
|
}
|
|
return la < lb
|
|
}
|
|
return false
|
|
}
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
if desc {
|
|
return less(j, i)
|
|
}
|
|
return less(i, j)
|
|
})
|
|
}
|