client_paging.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  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. return false
  269. }
  270. // parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
  271. // and drops blanks. Returns nil when the input has no usable entries — the
  272. // caller can then skip the predicate entirely.
  273. func parseCSVStrings(raw string) []string {
  274. if raw == "" {
  275. return nil
  276. }
  277. parts := strings.Split(raw, ",")
  278. out := make([]string, 0, len(parts))
  279. for _, p := range parts {
  280. s := strings.ToLower(strings.TrimSpace(p))
  281. if s != "" {
  282. out = append(out, s)
  283. }
  284. }
  285. if len(out) == 0 {
  286. return nil
  287. }
  288. return out
  289. }
  290. // parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
  291. // non-positive entries are silently dropped.
  292. func parseCSVInts(raw string) []int {
  293. if raw == "" {
  294. return nil
  295. }
  296. parts := strings.Split(raw, ",")
  297. out := make([]int, 0, len(parts))
  298. for _, p := range parts {
  299. s := strings.TrimSpace(p)
  300. if s == "" {
  301. continue
  302. }
  303. if n, err := strconv.Atoi(s); err == nil && n > 0 {
  304. out = append(out, n)
  305. }
  306. }
  307. if len(out) == 0 {
  308. return nil
  309. }
  310. return out
  311. }
  312. func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
  313. for _, id := range c.InboundIds {
  314. p := byInbound[id]
  315. if p == "" {
  316. continue
  317. }
  318. if slices.Contains(protocols, strings.ToLower(p)) {
  319. return true
  320. }
  321. }
  322. return false
  323. }
  324. func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
  325. for _, id := range c.InboundIds {
  326. if slices.Contains(inboundIds, id) {
  327. return true
  328. }
  329. }
  330. return false
  331. }
  332. func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
  333. for _, b := range buckets {
  334. if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
  335. return true
  336. }
  337. }
  338. return false
  339. }
  340. func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
  341. if fromMs <= 0 && toMs <= 0 {
  342. return true
  343. }
  344. // expiryTime of 0 means "never expires"; treat it as outside any bounded
  345. // range so users filtering by date see only clients with concrete expiries.
  346. if c.ExpiryTime == 0 {
  347. return false
  348. }
  349. // Negative expiry is the "delayed start" sentinel; same treatment as never.
  350. if c.ExpiryTime < 0 {
  351. return false
  352. }
  353. if fromMs > 0 && c.ExpiryTime < fromMs {
  354. return false
  355. }
  356. if toMs > 0 && c.ExpiryTime > toMs {
  357. return false
  358. }
  359. return true
  360. }
  361. func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
  362. if fromBytes <= 0 && toBytes <= 0 {
  363. return true
  364. }
  365. used := int64(0)
  366. if c.Traffic != nil {
  367. used = c.Traffic.Up + c.Traffic.Down
  368. }
  369. if fromBytes > 0 && used < fromBytes {
  370. return false
  371. }
  372. if toBytes > 0 && used > toBytes {
  373. return false
  374. }
  375. return true
  376. }
  377. func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
  378. switch strings.ToLower(strings.TrimSpace(mode)) {
  379. case "on":
  380. return c.Reset > 0
  381. case "off":
  382. return c.Reset <= 0
  383. }
  384. return true
  385. }
  386. func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
  387. switch strings.ToLower(strings.TrimSpace(mode)) {
  388. case "yes":
  389. return c.TgID != 0
  390. case "no":
  391. return c.TgID == 0
  392. }
  393. return true
  394. }
  395. func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
  396. switch strings.ToLower(strings.TrimSpace(mode)) {
  397. case "yes":
  398. return strings.TrimSpace(c.Comment) != ""
  399. case "no":
  400. return strings.TrimSpace(c.Comment) == ""
  401. }
  402. return true
  403. }
  404. func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
  405. groups := parseCSVStrings(csv)
  406. if len(groups) == 0 {
  407. return true
  408. }
  409. current := strings.TrimSpace(c.Group)
  410. for _, g := range groups {
  411. if g == "" {
  412. if current == "" {
  413. return true
  414. }
  415. continue
  416. }
  417. if strings.EqualFold(g, current) {
  418. return true
  419. }
  420. }
  421. return false
  422. }
  423. func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
  424. if bucket == "" {
  425. return true
  426. }
  427. used := int64(0)
  428. if c.Traffic != nil {
  429. used = c.Traffic.Up + c.Traffic.Down
  430. }
  431. exhausted := c.TotalGB > 0 && used >= c.TotalGB
  432. expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
  433. switch bucket {
  434. case "online":
  435. if onlineSet == nil {
  436. return false
  437. }
  438. _, ok := onlineSet[c.Email]
  439. return ok && c.Enable
  440. case "depleted":
  441. return exhausted || expired
  442. case "deactive":
  443. return !c.Enable
  444. case "active":
  445. return c.Enable && !exhausted && !expired
  446. case "expiring":
  447. if !c.Enable || exhausted || expired {
  448. return false
  449. }
  450. nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
  451. nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
  452. return nearExpiry || nearLimit
  453. }
  454. return true
  455. }
  456. func sortClients(rows []ClientWithAttachments, sortKey, order string) {
  457. if sortKey == "" {
  458. return
  459. }
  460. desc := order == "descend"
  461. less := func(i, j int) bool {
  462. a, b := rows[i], rows[j]
  463. switch sortKey {
  464. case "enable":
  465. if a.Enable == b.Enable {
  466. return false
  467. }
  468. return !a.Enable && b.Enable
  469. case "email":
  470. return strings.ToLower(a.Email) < strings.ToLower(b.Email)
  471. case "inboundIds":
  472. return len(a.InboundIds) < len(b.InboundIds)
  473. case "traffic":
  474. ua := int64(0)
  475. if a.Traffic != nil {
  476. ua = a.Traffic.Up + a.Traffic.Down
  477. }
  478. ub := int64(0)
  479. if b.Traffic != nil {
  480. ub = b.Traffic.Up + b.Traffic.Down
  481. }
  482. return ua < ub
  483. case "remaining":
  484. ra := int64(1<<62 - 1)
  485. if a.TotalGB > 0 {
  486. used := int64(0)
  487. if a.Traffic != nil {
  488. used = a.Traffic.Up + a.Traffic.Down
  489. }
  490. ra = a.TotalGB - used
  491. }
  492. rb := int64(1<<62 - 1)
  493. if b.TotalGB > 0 {
  494. used := int64(0)
  495. if b.Traffic != nil {
  496. used = b.Traffic.Up + b.Traffic.Down
  497. }
  498. rb = b.TotalGB - used
  499. }
  500. return ra < rb
  501. case "expiryTime":
  502. ea := int64(1<<62 - 1)
  503. if a.ExpiryTime > 0 {
  504. ea = a.ExpiryTime
  505. }
  506. eb := int64(1<<62 - 1)
  507. if b.ExpiryTime > 0 {
  508. eb = b.ExpiryTime
  509. }
  510. return ea < eb
  511. case "createdAt":
  512. if a.CreatedAt == b.CreatedAt {
  513. return a.Id < b.Id
  514. }
  515. return a.CreatedAt < b.CreatedAt
  516. case "updatedAt":
  517. if a.UpdatedAt == b.UpdatedAt {
  518. return a.Id < b.Id
  519. }
  520. return a.UpdatedAt < b.UpdatedAt
  521. case "lastOnline":
  522. la := int64(0)
  523. if a.Traffic != nil {
  524. la = a.Traffic.LastOnline
  525. }
  526. lb := int64(0)
  527. if b.Traffic != nil {
  528. lb = b.Traffic.LastOnline
  529. }
  530. if la == lb {
  531. return a.Id < b.Id
  532. }
  533. return la < lb
  534. }
  535. return false
  536. }
  537. sort.SliceStable(rows, func(i, j int) bool {
  538. if desc {
  539. return less(j, i)
  540. }
  541. return less(i, j)
  542. })
  543. }