| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581 |
- 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
- }
- }
- 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)
- })
- }
|