client_paging.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. package service
  2. import (
  3. "slices"
  4. "sort"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  9. )
  10. // ClientSlim is the row-shape used by the clients page. It drops fields the
  11. // table never reads (UUID, password, auth, flow, security, reverse, tgId)
  12. // so the list payload stays compact even when the panel manages thousands
  13. // of clients. Modals that need the full record still call /get/:email.
  14. type ClientSlim struct {
  15. Email string `json:"email"`
  16. SubID string `json:"subId"`
  17. Enable bool `json:"enable"`
  18. TotalGB int64 `json:"totalGB"`
  19. ExpiryTime int64 `json:"expiryTime"`
  20. LimitIP int `json:"limitIp"`
  21. Reset int `json:"reset"`
  22. Group string `json:"group,omitempty"`
  23. Comment string `json:"comment,omitempty"`
  24. InboundIds []int `json:"inboundIds"`
  25. Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
  26. CreatedAt int64 `json:"createdAt"`
  27. UpdatedAt int64 `json:"updatedAt"`
  28. }
  29. // ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
  30. // All fields are optional — the empty value means "no filter" / defaults.
  31. //
  32. // Filter / Protocol / Inbound accept either a single value or a comma-separated
  33. // list; matching is OR within a field and AND across fields. The numeric range
  34. // fields treat 0 as "unset" on the lower bound and 0 (or negative) as
  35. // "unbounded" on the upper bound.
  36. type ClientPageParams struct {
  37. Page int `form:"page"`
  38. PageSize int `form:"pageSize"`
  39. Search string `form:"search"`
  40. Filter string `form:"filter"`
  41. Protocol string `form:"protocol"`
  42. Inbound string `form:"inbound"`
  43. Sort string `form:"sort"`
  44. Order string `form:"order"`
  45. ExpiryFrom int64 `form:"expiryFrom"`
  46. ExpiryTo int64 `form:"expiryTo"`
  47. UsageFrom int64 `form:"usageFrom"`
  48. UsageTo int64 `form:"usageTo"`
  49. AutoRenew string `form:"autoRenew"`
  50. HasTgID string `form:"hasTgId"`
  51. HasComment string `form:"hasComment"`
  52. Group string `form:"group"`
  53. }
  54. // ClientPageResponse is the shape returned by ListPaged. `Total` is the
  55. // row count in the DB; `Filtered` is the count after Search/Filter/Protocol
  56. // were applied, before pagination. The page contains at most PageSize items.
  57. // Summary is computed across the full DB row set so dashboard counters
  58. // on the clients page stay stable as the user paginates/filters.
  59. type ClientPageResponse struct {
  60. Items []ClientSlim `json:"items"`
  61. Total int `json:"total"`
  62. Filtered int `json:"filtered"`
  63. Page int `json:"page"`
  64. PageSize int `json:"pageSize"`
  65. Summary ClientsSummary `json:"summary"`
  66. Groups []string `json:"groups"`
  67. }
  68. // ClientsSummary collects per-bucket counts plus the matching email lists so
  69. // the clients page can render the dashboard stat cards and their hover
  70. // popovers without shipping the full client array.
  71. type ClientsSummary struct {
  72. Total int `json:"total"`
  73. Active int `json:"active"`
  74. Online []string `json:"online"`
  75. Depleted []string `json:"depleted"`
  76. Expiring []string `json:"expiring"`
  77. Deactive []string `json:"deactive"`
  78. }
  79. const (
  80. clientPageDefaultSize = 25
  81. clientPageMaxSize = 200
  82. )
  83. // ListPaged loads every client (with traffic + attachments) into memory,
  84. // applies the requested filter / search / protocol predicates, sorts, and
  85. // returns the requested page along with total and filtered counts. The DB
  86. // query itself is unchanged from List(); the win is that the response
  87. // only carries 25-ish slim rows over the wire instead of all 2000 full
  88. // records, which on real panels was the dominant cost.
  89. func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
  90. all, err := s.List()
  91. if err != nil {
  92. return nil, err
  93. }
  94. total := len(all)
  95. pageSize := params.PageSize
  96. if pageSize <= 0 {
  97. pageSize = clientPageDefaultSize
  98. }
  99. if pageSize > clientPageMaxSize {
  100. pageSize = clientPageMaxSize
  101. }
  102. page := params.Page
  103. if page <= 0 {
  104. page = 1
  105. }
  106. protocols := parseCSVStrings(params.Protocol)
  107. inboundIDs := parseCSVInts(params.Inbound)
  108. buckets := parseCSVStrings(params.Filter)
  109. var protocolByInbound map[int]string
  110. if len(protocols) > 0 {
  111. inbounds, err := inboundSvc.GetAllInbounds()
  112. if err == nil {
  113. protocolByInbound = make(map[int]string, len(inbounds))
  114. for _, ib := range inbounds {
  115. protocolByInbound[ib.Id] = string(ib.Protocol)
  116. }
  117. }
  118. }
  119. onlines := inboundSvc.GetOnlineClients()
  120. onlineSet := make(map[string]struct{}, len(onlines))
  121. for _, e := range onlines {
  122. onlineSet[e] = struct{}{}
  123. }
  124. var expireDiffMs, trafficDiffBytes int64
  125. if settingSvc != nil {
  126. if v, err := settingSvc.GetExpireDiff(); err == nil {
  127. expireDiffMs = int64(v) * 86400000
  128. }
  129. if v, err := settingSvc.GetTrafficDiff(); err == nil {
  130. trafficDiffBytes = int64(v) * 1073741824
  131. }
  132. }
  133. nowMs := time.Now().UnixMilli()
  134. summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
  135. needle := strings.ToLower(strings.TrimSpace(params.Search))
  136. filtered := make([]ClientWithAttachments, 0, len(all))
  137. for _, c := range all {
  138. if needle != "" && !clientMatchesSearch(c, needle) {
  139. continue
  140. }
  141. if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
  142. continue
  143. }
  144. if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
  145. continue
  146. }
  147. if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
  148. continue
  149. }
  150. if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
  151. continue
  152. }
  153. if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
  154. continue
  155. }
  156. if !clientMatchesAutoRenew(c, params.AutoRenew) {
  157. continue
  158. }
  159. if !clientMatchesHasTgID(c, params.HasTgID) {
  160. continue
  161. }
  162. if !clientMatchesHasComment(c, params.HasComment) {
  163. continue
  164. }
  165. if !clientMatchesAnyGroup(c, params.Group) {
  166. continue
  167. }
  168. filtered = append(filtered, c)
  169. }
  170. sortClients(filtered, params.Sort, params.Order)
  171. filteredCount := len(filtered)
  172. start := (page - 1) * pageSize
  173. end := start + pageSize
  174. if start > filteredCount {
  175. start = filteredCount
  176. }
  177. if end > filteredCount {
  178. end = filteredCount
  179. }
  180. pageRows := filtered[start:end]
  181. items := make([]ClientSlim, 0, len(pageRows))
  182. for _, c := range pageRows {
  183. items = append(items, toClientSlim(c))
  184. }
  185. groupRows, gErr := s.ListGroups()
  186. if gErr != nil {
  187. return nil, gErr
  188. }
  189. groups := make([]string, 0, len(groupRows))
  190. for _, g := range groupRows {
  191. groups = append(groups, g.Name)
  192. }
  193. return &ClientPageResponse{
  194. Items: items,
  195. Total: total,
  196. Filtered: filteredCount,
  197. Page: page,
  198. PageSize: pageSize,
  199. Summary: summary,
  200. Groups: groups,
  201. }, nil
  202. }
  203. func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
  204. s := ClientsSummary{
  205. Total: len(all),
  206. Online: []string{},
  207. Depleted: []string{},
  208. Expiring: []string{},
  209. Deactive: []string{},
  210. }
  211. for _, c := range all {
  212. used := int64(0)
  213. if c.Traffic != nil {
  214. used = c.Traffic.Up + c.Traffic.Down
  215. }
  216. exhausted := c.TotalGB > 0 && used >= c.TotalGB
  217. expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
  218. if c.Enable {
  219. if _, ok := onlineSet[c.Email]; ok {
  220. s.Online = append(s.Online, c.Email)
  221. }
  222. }
  223. if exhausted || expired {
  224. s.Depleted = append(s.Depleted, c.Email)
  225. continue
  226. }
  227. if !c.Enable {
  228. s.Deactive = append(s.Deactive, c.Email)
  229. continue
  230. }
  231. nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
  232. nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
  233. if nearExpiry || nearLimit {
  234. s.Expiring = append(s.Expiring, c.Email)
  235. } else {
  236. s.Active++
  237. }
  238. }
  239. return s
  240. }
  241. func toClientSlim(c ClientWithAttachments) ClientSlim {
  242. return ClientSlim{
  243. Email: c.Email,
  244. SubID: c.SubID,
  245. Enable: c.Enable,
  246. TotalGB: c.TotalGB,
  247. ExpiryTime: c.ExpiryTime,
  248. LimitIP: c.LimitIP,
  249. Reset: c.Reset,
  250. Group: c.Group,
  251. Comment: c.Comment,
  252. InboundIds: c.InboundIds,
  253. Traffic: c.Traffic,
  254. CreatedAt: c.CreatedAt,
  255. UpdatedAt: c.UpdatedAt,
  256. }
  257. }
  258. func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
  259. if needle == "" {
  260. return true
  261. }
  262. candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
  263. for _, v := range candidates {
  264. if v != "" && strings.Contains(strings.ToLower(v), needle) {
  265. return true
  266. }
  267. }
  268. if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
  269. return true
  270. }
  271. return false
  272. }
  273. // parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
  274. // and drops blanks. Returns nil when the input has no usable entries — the
  275. // caller can then skip the predicate entirely.
  276. func parseCSVStrings(raw string) []string {
  277. if raw == "" {
  278. return nil
  279. }
  280. parts := strings.Split(raw, ",")
  281. out := make([]string, 0, len(parts))
  282. for _, p := range parts {
  283. s := strings.ToLower(strings.TrimSpace(p))
  284. if s != "" {
  285. out = append(out, s)
  286. }
  287. }
  288. if len(out) == 0 {
  289. return nil
  290. }
  291. return out
  292. }
  293. // parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
  294. // non-positive entries are silently dropped.
  295. func parseCSVInts(raw string) []int {
  296. if raw == "" {
  297. return nil
  298. }
  299. parts := strings.Split(raw, ",")
  300. out := make([]int, 0, len(parts))
  301. for _, p := range parts {
  302. s := strings.TrimSpace(p)
  303. if s == "" {
  304. continue
  305. }
  306. if n, err := strconv.Atoi(s); err == nil && n > 0 {
  307. out = append(out, n)
  308. }
  309. }
  310. if len(out) == 0 {
  311. return nil
  312. }
  313. return out
  314. }
  315. func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
  316. for _, id := range c.InboundIds {
  317. p := byInbound[id]
  318. if p == "" {
  319. continue
  320. }
  321. if slices.Contains(protocols, strings.ToLower(p)) {
  322. return true
  323. }
  324. }
  325. return false
  326. }
  327. func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
  328. for _, id := range c.InboundIds {
  329. if slices.Contains(inboundIds, id) {
  330. return true
  331. }
  332. }
  333. return false
  334. }
  335. func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
  336. for _, b := range buckets {
  337. if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
  338. return true
  339. }
  340. }
  341. return false
  342. }
  343. func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
  344. if fromMs <= 0 && toMs <= 0 {
  345. return true
  346. }
  347. // expiryTime of 0 means "never expires"; treat it as outside any bounded
  348. // range so users filtering by date see only clients with concrete expiries.
  349. if c.ExpiryTime == 0 {
  350. return false
  351. }
  352. // Negative expiry is the "delayed start" sentinel; same treatment as never.
  353. if c.ExpiryTime < 0 {
  354. return false
  355. }
  356. if fromMs > 0 && c.ExpiryTime < fromMs {
  357. return false
  358. }
  359. if toMs > 0 && c.ExpiryTime > toMs {
  360. return false
  361. }
  362. return true
  363. }
  364. func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
  365. if fromBytes <= 0 && toBytes <= 0 {
  366. return true
  367. }
  368. used := int64(0)
  369. if c.Traffic != nil {
  370. used = c.Traffic.Up + c.Traffic.Down
  371. }
  372. if fromBytes > 0 && used < fromBytes {
  373. return false
  374. }
  375. if toBytes > 0 && used > toBytes {
  376. return false
  377. }
  378. return true
  379. }
  380. func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
  381. switch strings.ToLower(strings.TrimSpace(mode)) {
  382. case "on":
  383. return c.Reset > 0
  384. case "off":
  385. return c.Reset <= 0
  386. }
  387. return true
  388. }
  389. func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
  390. switch strings.ToLower(strings.TrimSpace(mode)) {
  391. case "yes":
  392. return c.TgID != 0
  393. case "no":
  394. return c.TgID == 0
  395. }
  396. return true
  397. }
  398. func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
  399. switch strings.ToLower(strings.TrimSpace(mode)) {
  400. case "yes":
  401. return strings.TrimSpace(c.Comment) != ""
  402. case "no":
  403. return strings.TrimSpace(c.Comment) == ""
  404. }
  405. return true
  406. }
  407. func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
  408. groups := parseCSVStrings(csv)
  409. if len(groups) == 0 {
  410. return true
  411. }
  412. current := strings.TrimSpace(c.Group)
  413. for _, g := range groups {
  414. if g == "" {
  415. if current == "" {
  416. return true
  417. }
  418. continue
  419. }
  420. if strings.EqualFold(g, current) {
  421. return true
  422. }
  423. }
  424. return false
  425. }
  426. func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
  427. if bucket == "" {
  428. return true
  429. }
  430. used := int64(0)
  431. if c.Traffic != nil {
  432. used = c.Traffic.Up + c.Traffic.Down
  433. }
  434. exhausted := c.TotalGB > 0 && used >= c.TotalGB
  435. expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
  436. switch bucket {
  437. case "online":
  438. if onlineSet == nil {
  439. return false
  440. }
  441. _, ok := onlineSet[c.Email]
  442. return ok && c.Enable
  443. case "depleted":
  444. return exhausted || expired
  445. case "deactive":
  446. return !c.Enable
  447. case "active":
  448. return c.Enable && !exhausted && !expired
  449. case "expiring":
  450. if !c.Enable || exhausted || expired {
  451. return false
  452. }
  453. nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
  454. nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
  455. return nearExpiry || nearLimit
  456. }
  457. return true
  458. }
  459. func sortClients(rows []ClientWithAttachments, sortKey, order string) {
  460. if sortKey == "" {
  461. return
  462. }
  463. desc := order == "descend"
  464. less := func(i, j int) bool {
  465. a, b := rows[i], rows[j]
  466. switch sortKey {
  467. case "enable":
  468. if a.Enable == b.Enable {
  469. return false
  470. }
  471. return !a.Enable && b.Enable
  472. case "email":
  473. return strings.ToLower(a.Email) < strings.ToLower(b.Email)
  474. case "inboundIds":
  475. return len(a.InboundIds) < len(b.InboundIds)
  476. case "traffic":
  477. ua := int64(0)
  478. if a.Traffic != nil {
  479. ua = a.Traffic.Up + a.Traffic.Down
  480. }
  481. ub := int64(0)
  482. if b.Traffic != nil {
  483. ub = b.Traffic.Up + b.Traffic.Down
  484. }
  485. return ua < ub
  486. case "remaining":
  487. ra := int64(1<<62 - 1)
  488. if a.TotalGB > 0 {
  489. used := int64(0)
  490. if a.Traffic != nil {
  491. used = a.Traffic.Up + a.Traffic.Down
  492. }
  493. ra = a.TotalGB - used
  494. }
  495. rb := int64(1<<62 - 1)
  496. if b.TotalGB > 0 {
  497. used := int64(0)
  498. if b.Traffic != nil {
  499. used = b.Traffic.Up + b.Traffic.Down
  500. }
  501. rb = b.TotalGB - used
  502. }
  503. return ra < rb
  504. case "expiryTime":
  505. ea := int64(1<<62 - 1)
  506. if a.ExpiryTime > 0 {
  507. ea = a.ExpiryTime
  508. }
  509. eb := int64(1<<62 - 1)
  510. if b.ExpiryTime > 0 {
  511. eb = b.ExpiryTime
  512. }
  513. return ea < eb
  514. case "createdAt":
  515. if a.CreatedAt == b.CreatedAt {
  516. return a.Id < b.Id
  517. }
  518. return a.CreatedAt < b.CreatedAt
  519. case "updatedAt":
  520. if a.UpdatedAt == b.UpdatedAt {
  521. return a.Id < b.Id
  522. }
  523. return a.UpdatedAt < b.UpdatedAt
  524. case "lastOnline":
  525. la := int64(0)
  526. if a.Traffic != nil {
  527. la = a.Traffic.LastOnline
  528. }
  529. lb := int64(0)
  530. if b.Traffic != nil {
  531. lb = b.Traffic.LastOnline
  532. }
  533. if la == lb {
  534. return a.Id < b.Id
  535. }
  536. return la < lb
  537. }
  538. return false
  539. }
  540. sort.SliceStable(rows, func(i, j int) bool {
  541. if desc {
  542. return less(j, i)
  543. }
  544. return less(i, j)
  545. })
  546. }