3 커밋 0b0b6250d6 ... 4854f9c1b8

작성자 SHA1 메시지 날짜
  MHSanaei 4854f9c1b8 fix(node-sync): give client-IP sync its own deadline; fix log spacing 9 시간 전
  MHSanaei 7d23a2c15b perf: prevent cron job overlap, auto-set GOMEMLIMIT, fix tgbot userStates race 9 시간 전
  Sanaei 679d2e1cca fix: resolve a batch of open bug-tagged issues (traffic accounting, share strategy, sub address, CPU) (#5477) 11 시간 전
37개의 변경된 파일503개의 추가작업 그리고 88개의 파일을 삭제
  1. 8 0
      docker-compose.yml
  2. 2 1
      frontend/src/pages/inbounds/InboundsPage.tsx
  3. 11 1
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  4. 52 1
      frontend/src/test/inbound-form-modal.test.tsx
  5. 2 1
      internal/database/index_tags_test.go
  6. 1 1
      internal/database/model/client_global_traffic.go
  7. 24 5
      internal/eventbus/filter.go
  8. 25 0
      internal/sub/build_urls_test.go
  9. 10 4
      internal/sub/service.go
  10. 78 0
      internal/util/sys/memlimit.go
  11. 1 1
      internal/web/job/check_client_ip_job.go
  12. 21 17
      internal/web/job/node_traffic_sync_job.go
  13. 15 1
      internal/web/service/client_traffic.go
  14. 26 0
      internal/web/service/global_traffic_test.go
  15. 23 2
      internal/web/service/inbound_disable.go
  16. 3 2
      internal/web/service/inbound_node.go
  17. 8 2
      internal/web/service/inbound_traffic.go
  18. 46 3
      internal/web/service/node_client_traffic_sum_test.go
  19. 61 1
      internal/web/service/tgbot/tgbot.go
  20. 17 16
      internal/web/service/tgbot/tgbot_router.go
  21. 1 1
      internal/web/service/tgbot/tgbot_send.go
  22. 2 2
      internal/web/translation/ar-EG.json
  23. 1 1
      internal/web/translation/en-US.json
  24. 2 2
      internal/web/translation/es-ES.json
  25. 2 2
      internal/web/translation/fa-IR.json
  26. 2 2
      internal/web/translation/id-ID.json
  27. 2 2
      internal/web/translation/ja-JP.json
  28. 2 2
      internal/web/translation/pt-BR.json
  29. 2 2
      internal/web/translation/ru-RU.json
  30. 2 2
      internal/web/translation/tr-TR.json
  31. 2 2
      internal/web/translation/uk-UA.json
  32. 2 2
      internal/web/translation/vi-VN.json
  33. 2 2
      internal/web/translation/zh-CN.json
  34. 2 2
      internal/web/translation/zh-TW.json
  35. 13 3
      internal/web/web.go
  36. 13 0
      internal/xray/api.go
  37. 17 0
      main.go

+ 8 - 0
docker-compose.yml

@@ -5,6 +5,9 @@ services:
       dockerfile: ./Dockerfile
       dockerfile: ./Dockerfile
     container_name: 3xui_app
     container_name: 3xui_app
     # hostname: yourhostname <- optional
     # hostname: yourhostname <- optional
+    # Optional hard memory cap. When set, the panel auto-derives its Go soft
+    # limit (GOMEMLIMIT, ~90%) from this so it GCs before the OOM killer fires.
+    # mem_limit: 512m
     # The bundled Fail2ban (XUI_ENABLE_FAIL2BAN below) enforces the IP limit
     # The bundled Fail2ban (XUI_ENABLE_FAIL2BAN below) enforces the IP limit
     # with iptables, which needs NET_ADMIN. Without these caps a ban is logged
     # with iptables, which needs NET_ADMIN. Without these caps a ban is logged
     # and shown in fail2ban status but never actually applied. NET_RAW covers
     # and shown in fail2ban status but never actually applied. NET_RAW covers
@@ -18,6 +21,11 @@ services:
     environment:
     environment:
       XRAY_VMESS_AEAD_FORCED: "false"
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"
       XUI_ENABLE_FAIL2BAN: "true"
+      # Go memory soft limit. If neither is set, the panel auto-detects the
+      # cgroup/host limit and targets ~90%. Pin it explicitly with one of:
+      # XUI_MEMORY_LIMIT: "400"      # in MiB
+      # GOMEMLIMIT: "400MiB"         # Go syntax, takes precedence
+      # XUI_PPROF: "true"           # expose pprof on 127.0.0.1:6060 for profiling
       # XUI_INIT_WEB_BASE_PATH: "/"
       # XUI_INIT_WEB_BASE_PATH: "/"
       # XUI_PORT: "8080"
       # XUI_PORT: "8080"
       # To use PostgreSQL instead of the default SQLite, run:
       # To use PostgreSQL instead of the default SQLite, run:

+ 2 - 1
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -100,7 +100,7 @@ export default function InboundsPage() {
   const [messageApi, messageContextHolder] = message.useMessage();
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
 
-  const { nodes: nodesList } = useNodesQuery();
+  const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
   const nodesById = useMemo(() => {
   const nodesById = useMemo(() => {
     const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     for (const n of nodesList || []) map.set(n.id, n);
     for (const n of nodesList || []) map.set(n.id, n);
@@ -647,6 +647,7 @@ export default function InboundsPage() {
             dbInbound={formDbInbound}
             dbInbound={formDbInbound}
             dbInbounds={dbInbounds}
             dbInbounds={dbInbounds}
             availableNodes={nodesList}
             availableNodes={nodesList}
+            availableNodesFetched={nodesFetched}
           />
           />
         </LazyMount>
         </LazyMount>
         <LazyMount when={infoOpen}>
         <LazyMount when={infoOpen}>

+ 11 - 1
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -138,6 +138,7 @@ interface InboundFormModalProps {
   dbInbound: DBInbound | null;
   dbInbound: DBInbound | null;
   dbInbounds: DBInbound[];
   dbInbounds: DBInbound[];
   availableNodes?: NodeRecord[];
   availableNodes?: NodeRecord[];
+  availableNodesFetched?: boolean;
 }
 }
 
 
 function buildAddModeValues(): InboundFormValues {
 function buildAddModeValues(): InboundFormValues {
@@ -167,6 +168,7 @@ export default function InboundFormModal({
   dbInbound,
   dbInbound,
   dbInbounds,
   dbInbounds,
   availableNodes,
   availableNodes,
+  availableNodesFetched = true,
 }: InboundFormModalProps) {
 }: InboundFormModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
   const [messageApi, messageContextHolder] = message.useMessage();
@@ -373,14 +375,22 @@ export default function InboundFormModal({
   // offered (no node, or a protocol that can't deploy to one) fall back to
   // offered (no node, or a protocol that can't deploy to one) fall back to
   // `listen`, which yields the same link for a local inbound. Mirrors how the
   // `listen`, which yields the same link for a local inbound. Mirrors how the
   // protocol reset drops a nodeId that no longer applies.
   // protocol reset drops a nodeId that no longer applies.
+  // Only downgrade once the inputs this decision depends on are settled, so a
+  // persisted `node` strategy is never clobbered by transient mount state (#5375):
+  //  - `availableNodesFetched`: an empty `availableNodes` during the async
+  //    /nodes/list fetch is a placeholder, not "no nodes".
+  //  - `protocol`: `Form.useWatch('protocol')` is briefly empty on the first
+  //    edit render before initialValues apply, which would momentarily make the
+  //    node option look unavailable.
   useEffect(() => {
   useEffect(() => {
     if (!open) return;
     if (!open) return;
+    if (!availableNodesFetched || !protocol) return;
     const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
     const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
       form.setFieldValue('shareAddrStrategy', 'listen');
       form.setFieldValue('shareAddrStrategy', 'listen');
     }
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open, nodeShareOptionAvailable, shareAddrStrategy]);
+  }, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]);
 
 
   // Why: protocol picker reset cascades through the form — clearing the
   // Why: protocol picker reset cascades through the form — clearing the
   // settings DU branch and dropping a nodeId that no longer applies. The
   // settings DU branch and dropping a nodeId that no longer applies. The

+ 52 - 1
frontend/src/test/inbound-form-modal.test.tsx

@@ -1,8 +1,9 @@
 import { describe, it, expect } from 'vitest';
 import { describe, it, expect } from 'vitest';
-import { screen, act } from '@testing-library/react';
+import { screen, act, render, cleanup } from '@testing-library/react';
 
 
 import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
 import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
 import { DBInbound } from '@/models/dbinbound';
 import { DBInbound } from '@/models/dbinbound';
+import { ThemeProvider } from '@/hooks/useTheme';
 import {
 import {
   renderWithProviders,
   renderWithProviders,
   fieldLabels,
   fieldLabels,
@@ -90,4 +91,54 @@ describe('InboundFormModal', () => {
     const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
     const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
     expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
     expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
   });
   });
+
+  it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
+    const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
+    const buildInbound = () => new DBInbound({
+      id: 1,
+      port: 23456,
+      listen: '',
+      protocol: 'vless',
+      remark: 'noded',
+      enable: true,
+      settings: { clients: [] },
+      streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
+      sniffing: { enabled: false },
+      nodeId: 1,
+      shareAddrStrategy: 'node',
+    });
+    const flush = async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); };
+    const strategyItem = (title: string) =>
+      document.querySelector(`.ant-select-content[title="${title}"]`);
+    const modal = (nodes: never[], fetched: boolean) => (
+      <ThemeProvider>
+        <InboundFormModal
+          open
+          mode="edit"
+          dbInbound={buildInbound()}
+          dbInbounds={[]}
+          availableNodes={nodes}
+          availableNodesFetched={fetched}
+          onClose={() => {}}
+          onSaved={() => {}}
+        />
+      </ThemeProvider>
+    );
+
+    // Baseline: nodes already loaded, so the node option is offered and selected.
+    render(modal([node], true));
+    await flush();
+    expect(strategyItem('Node address')).toBeTruthy();
+    cleanup();
+
+    // Race: the modal mounts before /nodes/list resolves (empty placeholder),
+    // then nodes arrive. The persisted 'node' strategy must survive the gap and
+    // stay selected once the option reappears — not silently revert to listen.
+    const { rerender } = render(modal([], false));
+    await flush();
+    rerender(modal([node], true));
+    await flush();
+    expect(strategyItem('Node address')).toBeTruthy();
+    expect(strategyItem('Inbound listen')).toBeFalsy();
+  });
 });
 });

+ 2 - 1
internal/database/index_tags_test.go

@@ -21,7 +21,7 @@ func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) {
 	if err != nil {
 	if err != nil {
 		t.Fatalf("open sqlite: %v", err)
 		t.Fatalf("open sqlite: %v", err)
 	}
 	}
-	if err := db.AutoMigrate(&model.ClientRecord{}, &xray.ClientTraffic{}); err != nil {
+	if err := db.AutoMigrate(&model.ClientRecord{}, &xray.ClientTraffic{}, &model.ClientGlobalTraffic{}); err != nil {
 		t.Fatalf("automigrate: %v", err)
 		t.Fatalf("automigrate: %v", err)
 	}
 	}
 
 
@@ -32,6 +32,7 @@ func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) {
 		{&model.ClientRecord{}, "idx_client_record_group"},
 		{&model.ClientRecord{}, "idx_client_record_group"},
 		{&xray.ClientTraffic{}, "idx_client_traffics_inbound"},
 		{&xray.ClientTraffic{}, "idx_client_traffics_inbound"},
 		{&xray.ClientTraffic{}, "idx_client_traffics_renew"},
 		{&xray.ClientTraffic{}, "idx_client_traffics_renew"},
+		{&model.ClientGlobalTraffic{}, "idx_client_global_email"},
 	}
 	}
 	for _, c := range cases {
 	for _, c := range cases {
 		if !db.Migrator().HasIndex(c.model, c.index) {
 		if !db.Migrator().HasIndex(c.model, c.index) {

+ 1 - 1
internal/database/model/client_global_traffic.go

@@ -13,7 +13,7 @@ package model
 type ClientGlobalTraffic struct {
 type ClientGlobalTraffic struct {
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	MasterGuid string `json:"masterGuid" gorm:"uniqueIndex:idx_master_email,priority:1;not null"`
 	MasterGuid string `json:"masterGuid" gorm:"uniqueIndex:idx_master_email,priority:1;not null"`
-	Email      string `json:"email" gorm:"uniqueIndex:idx_master_email,priority:2;not null"`
+	Email      string `json:"email" gorm:"uniqueIndex:idx_master_email,priority:2;index:idx_client_global_email;not null"`
 	Up         int64  `json:"up"`
 	Up         int64  `json:"up"`
 	Down       int64  `json:"down"`
 	Down       int64  `json:"down"`
 	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`

+ 24 - 5
internal/eventbus/filter.go

@@ -7,9 +7,10 @@ import (
 
 
 // RateLimiter prevents notification spam from flapping events.
 // RateLimiter prevents notification spam from flapping events.
 type RateLimiter struct {
 type RateLimiter struct {
-	mu       sync.Mutex
-	lastSent map[string]time.Time
-	cooldown time.Duration
+	mu        sync.Mutex
+	lastSent  map[string]time.Time
+	cooldown  time.Duration
+	lastPrune time.Time
 }
 }
 
 
 // NewRateLimiter creates a rate limiter with the given cooldown period.
 // NewRateLimiter creates a rate limiter with the given cooldown period.
@@ -23,11 +24,29 @@ func NewRateLimiter(cooldown time.Duration) *RateLimiter {
 // Allow returns true if the event should be sent (cooldown has elapsed).
 // Allow returns true if the event should be sent (cooldown has elapsed).
 func (r *RateLimiter) Allow(eventType EventType, source string) bool {
 func (r *RateLimiter) Allow(eventType EventType, source string) bool {
 	key := string(eventType) + ":" + source
 	key := string(eventType) + ":" + source
+	now := time.Now()
 	r.mu.Lock()
 	r.mu.Lock()
 	defer r.mu.Unlock()
 	defer r.mu.Unlock()
-	if time.Since(r.lastSent[key]) < r.cooldown {
+	r.pruneLocked(now)
+	if now.Sub(r.lastSent[key]) < r.cooldown {
 		return false
 		return false
 	}
 	}
-	r.lastSent[key] = time.Now()
+	r.lastSent[key] = now
 	return true
 	return true
 }
 }
+
+// pruneLocked drops keys whose cooldown has elapsed. Such an entry no longer
+// affects Allow's result, so removing it is safe and keeps the map from
+// retaining one entry per (eventType, source) ever seen. Throttled to once per
+// cooldown so a busy bus doesn't sweep the whole map on every event.
+func (r *RateLimiter) pruneLocked(now time.Time) {
+	if now.Sub(r.lastPrune) < r.cooldown {
+		return
+	}
+	r.lastPrune = now
+	for k, v := range r.lastSent {
+		if now.Sub(v) >= r.cooldown {
+			delete(r.lastSent, k)
+		}
+	}
+}

+ 25 - 0
internal/sub/build_urls_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 	"testing"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 )
 
 
 func initSubDB(t *testing.T) {
 func initSubDB(t *testing.T) {
@@ -60,6 +61,30 @@ func TestBuildURLs_UsesSubscriberDomain(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// A local wildcard inbound (no node, no custom share address, blank/0.0.0.0
+// listen) must not advertise the raw request host when it carries a client IP
+// that leaked in behind NAT/proxy. The admin's configured panel host wins for
+// this last-resort fallback; without a configured host the request host stands.
+func TestResolveInboundAddress_PrefersConfiguredHostOverClientIP(t *testing.T) {
+	initSubDB(t)
+	local := &model.Inbound{Listen: "", ShareAddrStrategy: "node"}
+
+	s := &SubService{}
+	s.PrepareForRequest("192.168.1.50") // a client LAN IP that reached the panel
+	if got := s.resolveInboundAddress(local); got != "192.168.1.50" {
+		t.Fatalf("with no configured host the request host stands, got %q", got)
+	}
+
+	if err := database.GetDB().Create(&model.Setting{Key: "subDomain", Value: "panel.example.com"}).Error; err != nil {
+		t.Fatalf("set subDomain: %v", err)
+	}
+	s2 := &SubService{}
+	s2.PrepareForRequest("192.168.1.50")
+	if got := s2.resolveInboundAddress(local); got != "panel.example.com" {
+		t.Fatalf("configured host must win over the leaked client IP, got %q", got)
+	}
+}
+
 func TestBuildURLs_EmptySubId(t *testing.T) {
 func TestBuildURLs_EmptySubId(t *testing.T) {
 	initSubDB(t)
 	initSubDB(t)
 	s := &SubService{}
 	s := &SubService{}

+ 10 - 4
internal/sub/service.go

@@ -929,10 +929,13 @@ func (s *SubService) loadNodes() {
 //   - "node" (default, and any unknown value): the node's address for
 //   - "node" (default, and any unknown value): the node's address for
 //     node-managed inbounds, then a routable Listen — the pre-strategy order.
 //     node-managed inbounds, then a routable Listen — the pre-strategy order.
 //
 //
-// Every chain ends at the subscriber's request host (s.address). A
-// loopback/wildcard bind or a unix-domain-socket listen is a server-side
-// detail and is never advertised; External Proxy still overrides everything
-// upstream of this call.
+// Every chain ends at the admin's configured public host (Sub/Web domain) and
+// then the subscriber's request host (s.address). Preferring the configured
+// host over the request host for this last resort keeps a wildcard local inbound
+// from advertising a bogus client IP that leaked into the request Host header
+// behind NAT/proxy/CDN (#5425). A loopback/wildcard bind or a unix-domain-socket
+// listen is a server-side detail and is never advertised; External Proxy still
+// overrides everything upstream of this call.
 func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
 func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
 	var nodeAddr string
 	var nodeAddr string
 	if inbound.NodeID != nil && s.nodesByID != nil {
 	if inbound.NodeID != nil && s.nodesByID != nil {
@@ -957,6 +960,9 @@ func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
 			return c
 			return c
 		}
 		}
 	}
 	}
+	if d := s.configuredPublicHost(); d != "" {
+		return d
+	}
 	return s.address
 	return s.address
 }
 }
 
 

+ 78 - 0
internal/util/sys/memlimit.go

@@ -0,0 +1,78 @@
+package sys
+
+import (
+	"os"
+	"runtime/debug"
+	"strconv"
+	"strings"
+
+	"github.com/shirou/gopsutil/v4/mem"
+)
+
+// memLimitHeadroomPercent is the share of detected memory used for the soft
+// limit, leaving room for non-heap (stacks, mmap, the xray child) before the OS
+// OOM-kills the process.
+const memLimitHeadroomPercent = 90
+
+// ApplyMemoryLimit sets a Go soft memory limit (the runtime's GOMEMLIMIT) when
+// one is not already configured, so a long-running panel in a memory-capped
+// container or VPS triggers GC as it approaches the cap instead of growing RSS
+// until the OS OOM-kills it. Precedence: an explicit GOMEMLIMIT env is left to
+// the runtime; otherwise XUI_MEMORY_LIMIT (in MiB) wins; otherwise the limit is
+// derived from the cgroup memory limit, falling back to total system RAM.
+// Returns the limit applied in bytes (0 when none) and a short source label.
+func ApplyMemoryLimit() (int64, string) {
+	if strings.TrimSpace(os.Getenv("GOMEMLIMIT")) != "" {
+		return 0, "GOMEMLIMIT env (handled by the Go runtime)"
+	}
+
+	if v := strings.TrimSpace(os.Getenv("XUI_MEMORY_LIMIT")); v != "" {
+		if mb, err := strconv.ParseInt(v, 10, 64); err == nil && mb > 0 {
+			limit := mb << 20
+			debug.SetMemoryLimit(limit)
+			return limit, "XUI_MEMORY_LIMIT=" + v + "MiB"
+		}
+	}
+
+	total, source := detectAvailableMemory()
+	if total <= 0 {
+		return 0, "undetectable; left at Go default"
+	}
+	limit := total / 100 * memLimitHeadroomPercent
+	debug.SetMemoryLimit(limit)
+	return limit, source
+}
+
+func detectAvailableMemory() (int64, string) {
+	if v, ok := cgroupMemoryLimit(); ok {
+		return v, "cgroup limit"
+	}
+	if vm, err := mem.VirtualMemory(); err == nil && vm.Total > 0 {
+		return int64(vm.Total), "system RAM"
+	}
+	return 0, ""
+}
+
+// cgroupMemoryLimit reads the container memory limit from cgroup v2 then v1.
+// A "max" value or the v1 unlimited sentinel (~8 EiB) means no limit at this
+// level, so it reports not-found and the caller falls back to system RAM. The
+// files are absent off Linux, which also yields not-found.
+func cgroupMemoryLimit() (int64, bool) {
+	const unlimited = int64(1) << 62
+
+	if b, err := os.ReadFile("/sys/fs/cgroup/memory.max"); err == nil {
+		if s := strings.TrimSpace(string(b)); s != "" && s != "max" {
+			if v, err := strconv.ParseInt(s, 10, 64); err == nil && v > 0 && v < unlimited {
+				return v, true
+			}
+		}
+	}
+
+	if b, err := os.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes"); err == nil {
+		if v, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64); err == nil && v > 0 && v < unlimited {
+			return v, true
+		}
+	}
+
+	return 0, false
+}

+ 1 - 1
internal/web/job/check_client_ip_job.go

@@ -157,7 +157,7 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 	db := database.GetDB()
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	var inbounds []*model.Inbound
 
 
-	err := db.Model(model.Inbound{}).Find(&inbounds).Error
+	err := db.Model(model.Inbound{}).Where("settings LIKE ?", "%limitIp%").Find(&inbounds).Error
 	if err != nil {
 	if err != nil {
 		return false
 		return false
 	}
 	}

+ 21 - 17
internal/web/job/node_traffic_sync_job.go

@@ -19,6 +19,7 @@ const (
 	nodeTrafficSyncRequestTimeout = 4 * time.Second
 	nodeTrafficSyncRequestTimeout = 4 * time.Second
 	nodeReconcileTimeout          = 30 * time.Second
 	nodeReconcileTimeout          = 30 * time.Second
 	nodeClientIpSyncInterval      = 10 * time.Second
 	nodeClientIpSyncInterval      = 10 * time.Second
+	nodeClientIpSyncTimeout       = 6 * time.Second
 	nodeGlobalPushInterval        = 30 * time.Second
 	nodeGlobalPushInterval        = 30 * time.Second
 )
 )
 
 
@@ -204,7 +205,7 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
 		}
 		}
 		traffics, err := j.inboundService.GetNodeClientTraffics(n.Id)
 		traffics, err := j.inboundService.GetNodeClientTraffics(n.Id)
 		if err != nil {
 		if err != nil {
-			logger.Warning("node traffic sync: load globals for", n.Name, "failed:", err)
+			logger.Warningf("node traffic sync: load globals for %s failed: %v", n.Name, err)
 			continue
 			continue
 		}
 		}
 		if len(traffics) == 0 {
 		if len(traffics) == 0 {
@@ -222,9 +223,9 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
 				// An old-build node without the endpoint answers 404 — not worth a
 				// An old-build node without the endpoint answers 404 — not worth a
 				// warning every cycle.
 				// warning every cycle.
 				if strings.Contains(err.Error(), "HTTP 404") {
 				if strings.Contains(err.Error(), "HTTP 404") {
-					logger.Debug("node traffic sync: node", n.Name, "has no global-traffic endpoint (old build)")
+					logger.Debugf("node traffic sync: node %s has no global-traffic endpoint (old build)", n.Name)
 				} else {
 				} else {
-					logger.Warning("node traffic sync: push globals to", n.Name, "failed:", err)
+					logger.Warningf("node traffic sync: push globals to %s failed: %v", n.Name, err)
 				}
 				}
 			}
 			}
 		})
 		})
@@ -235,7 +236,7 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
 func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
 func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
 	rt, err := mgr.RemoteFor(n)
 	rt, err := mgr.RemoteFor(n)
 	if err != nil {
 	if err != nil {
-		logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
+		logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
 		return
 		return
 	}
 	}
 
 
@@ -244,11 +245,11 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
 		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
 		reconcileCancel()
 		reconcileCancel()
 		if reconcileErr != nil {
 		if reconcileErr != nil {
-			logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
+			logger.Warningf("node traffic sync: reconcile for %s failed: %v", n.Name, reconcileErr)
 			return
 			return
 		}
 		}
 		if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
 		if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
-			logger.Warning("node traffic sync: clear dirty for", n.Name, "failed:", clearErr)
+			logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
 		}
 		}
 		j.structural.set()
 		j.structural.set()
 	}
 	}
@@ -258,7 +259,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 
 
 	snap, err := rt.FetchTrafficSnapshot(ctx)
 	snap, err := rt.FetchTrafficSnapshot(ctx)
 	if err != nil {
 	if err != nil {
-		logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
+		logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
 		j.inboundService.ClearNodeOnlineClients(n.Id)
 		j.inboundService.ClearNodeOnlineClients(n.Id)
 		return
 		return
 	}
 	}
@@ -266,7 +267,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	if err != nil {
 	if err != nil {
-		logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
+		logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
 		return
 		return
 	}
 	}
 	if changed {
 	if changed {
@@ -277,34 +278,37 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		return
 		return
 	}
 	}
 
 
-	nodeIps, err := rt.FetchAllClientIps(ctx)
+	ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout)
+	defer ipCancel()
+
+	nodeIps, err := rt.FetchAllClientIps(ipCtx)
 	if err == nil && len(nodeIps) > 0 {
 	if err == nil && len(nodeIps) > 0 {
 		if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
 		if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
-			logger.Warning("node traffic sync: merge client ips from", n.Name, "failed:", err)
+			logger.Warningf("node traffic sync: merge client ips from %s failed: %v", n.Name, err)
 		}
 		}
 	} else if err != nil {
 	} else if err != nil {
-		logger.Warning("node traffic sync: fetch client ips from", n.Name, "failed:", err)
+		logger.Warningf("node traffic sync: fetch client ips from %s failed: %v", n.Name, err)
 	}
 	}
 
 
 	masterIps, err := j.inboundService.GetAllInboundClientIps()
 	masterIps, err := j.inboundService.GetAllInboundClientIps()
 	if err != nil {
 	if err != nil {
-		logger.Warning("node traffic sync: load client ips for push to", n.Name, "failed:", err)
+		logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
 		return
 		return
 	}
 	}
 	if len(masterIps) > 0 {
 	if len(masterIps) > 0 {
-		if err := rt.PushAllClientIps(ctx, masterIps); err != nil {
-			logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
+		if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
+			logger.Warningf("node traffic sync: push client ips to %s failed: %v", n.Name, err)
 		}
 		}
 	}
 	}
 
 
 	// Per-node IP attribution: pull the node's guid-keyed subtree (its own
 	// Per-node IP attribution: pull the node's guid-keyed subtree (its own
 	// observations plus any descendants) so the master can tell which node each
 	// observations plus any descendants) so the master can tell which node each
 	// IP is on. Old nodes without the endpoint just return an error — skip them.
 	// IP is on. Old nodes without the endpoint just return an error — skip them.
-	if guidTrees, err := rt.FetchClientIpsByGuid(ctx); err != nil {
-		logger.Debug("node traffic sync: fetch client ip attribution from", n.Name, "failed:", err)
+	if guidTrees, err := rt.FetchClientIpsByGuid(ipCtx); err != nil {
+		logger.Debugf("node traffic sync: fetch client ip attribution from %s failed: %v", n.Name, err)
 	} else if len(guidTrees) > 0 {
 	} else if len(guidTrees) > 0 {
 		if err := j.inboundService.MergeClientIpsByGuid(guidTrees); err != nil {
 		if err := j.inboundService.MergeClientIpsByGuid(guidTrees); err != nil {
-			logger.Warning("node traffic sync: merge client ip attribution from", n.Name, "failed:", err)
+			logger.Warningf("node traffic sync: merge client ip attribution from %s failed: %v", n.Name, err)
 		}
 		}
 	}
 	}
 }
 }

+ 15 - 1
internal/web/service/client_traffic.go

@@ -101,7 +101,15 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 				}
 				}
 				affected += int(res.RowsAffected)
 				affected += int(res.RowsAffected)
 			}
 			}
-			return clearGlobalTraffic(tx, cleanEmails...)
+			if err := clearGlobalTraffic(tx, cleanEmails...); err != nil {
+				return err
+			}
+			for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+				if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+					return err
+				}
+			}
+			return nil
 		})
 		})
 	})
 	})
 	if err != nil {
 	if err != nil {
@@ -154,6 +162,12 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 			return err
 			return err
 		}
 		}
 
 
+		for _, batch := range chunkStrings(resetEmails, sqlInChunk) {
+			if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+				return err
+			}
+		}
+
 		inboundWhereText := "id "
 		inboundWhereText := "id "
 		if id == -1 {
 		if id == -1 {
 			inboundWhereText += " > ?"
 			inboundWhereText += " > ?"

+ 26 - 0
internal/web/service/global_traffic_test.go

@@ -64,6 +64,32 @@ func TestAcceptGlobalTraffic_OverwriteAndMultiMaster(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestDepletedCond_ProbeGuard(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	// No global rows: the cross-panel EXISTS branch is skipped (#5392), but a
+	// client over its local quota is still disabled.
+	if got := depletedCond(db); got != depletedClientsCondLocal {
+		t.Fatalf("empty globals must use the local-only predicate")
+	}
+	seedClientRow(t, "local-cap", 1, 600, 600, 1000)
+	if _, count, _, err := svc.disableInvalidClients(db); err != nil {
+		t.Fatalf("disableInvalidClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("local over-quota client must be disabled, disabled %d", count)
+	}
+
+	// Once a master pushes a global row, the full predicate is used so combined
+	// quota is enforced.
+	if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+	if got := depletedCond(db); got != depletedClientsCond {
+		t.Fatalf("with globals present the cross-panel predicate must be used")
+	}
+}
+
 func TestGlobalUsage_DisablesClient(t *testing.T) {
 func TestGlobalUsage_DisablesClient(t *testing.T) {
 	db := initTrafficTestDB(t)
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}
 	svc := &InboundService{}

+ 23 - 2
internal/web/service/inbound_disable.go

@@ -60,13 +60,34 @@ const depletedClientsCond = `((total > 0 AND up + down >= total)
 		WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
 		WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
 	)))`
 	)))`
 
 
+// depletedClientsCondLocal is depletedClientsCond without the cross-panel
+// client_global_traffics check. The EXISTS branch is a correlated subquery that
+// turns every traffic poll into a full client_traffics scan; on a panel no
+// master pushes to (the common case) client_global_traffics is empty, so the
+// branch can never match and is pure CPU cost (#5392).
+const depletedClientsCondLocal = `((total > 0 AND up + down >= total)
+	OR (expiry_time > 0 AND expiry_time <= ?))`
+
+// depletedCond returns the local-only predicate unless this panel actually
+// holds global-traffic rows, in which case the cross-panel EXISTS check is
+// needed to enforce combined quota. Both variants take the same single
+// expiry_time placeholder, so callers pass identical args either way.
+func depletedCond(tx *gorm.DB) string {
+	var probe int64
+	if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 {
+		return depletedClientsCond
+	}
+	return depletedClientsCondLocal
+}
+
 func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
 func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
 	now := time.Now().Unix() * 1000
 	now := time.Now().Unix() * 1000
 	needRestart := false
 	needRestart := false
+	cond := depletedCond(tx)
 
 
 	var depletedRows []xray.ClientTraffic
 	var depletedRows []xray.ClientTraffic
 	err := tx.Model(xray.ClientTraffic{}).
 	err := tx.Model(xray.ClientTraffic{}).
-		Where(depletedClientsCond+" AND enable = ?", now, true).
+		Where(cond+" AND enable = ?", now, true).
 		Find(&depletedRows).Error
 		Find(&depletedRows).Error
 	if err != nil {
 	if err != nil {
 		return false, 0, nil, err
 		return false, 0, nil, err
@@ -142,7 +163,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 	}
 	}
 
 
 	result := tx.Model(xray.ClientTraffic{}).
 	result := tx.Model(xray.ClientTraffic{}).
-		Where(depletedClientsCond+" AND enable = ?", now, true).
+		Where(cond+" AND enable = ?", now, true).
 		Update("enable", false)
 		Update("enable", false)
 	err = result.Error
 	err = result.Error
 	count := result.RowsAffected
 	count := result.RowsAffected

+ 3 - 2
internal/web/service/inbound_node.go

@@ -407,6 +407,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				}
 				}
 				continue
 				continue
 			}
 			}
+			reportedRemoteTagConflict.Delete(fmt.Sprintf("%d:%s", nodeID, snapIb.Tag))
 			newIb := model.Inbound{
 			newIb := model.Inbound{
 				UserId:               defaultUserId,
 				UserId:               defaultUserId,
 				NodeID:               &nodeID,
 				NodeID:               &nodeID,
@@ -571,10 +572,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			var deltaUp, deltaDown int64
 			var deltaUp, deltaDown int64
 			if seen {
 			if seen {
 				if deltaUp = canon.Up - base.Up; deltaUp < 0 {
 				if deltaUp = canon.Up - base.Up; deltaUp < 0 {
-					deltaUp = canon.Up
+					deltaUp = 0
 				}
 				}
 				if deltaDown = canon.Down - base.Down; deltaDown < 0 {
 				if deltaDown = canon.Down - base.Down; deltaDown < 0 {
-					deltaDown = canon.Down
+					deltaDown = 0
 				}
 				}
 			}
 			}
 
 

+ 8 - 2
internal/web/service/inbound_traffic.go

@@ -506,9 +506,12 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
 		if err := clearGlobalTraffic(db, clientEmail); err != nil {
 		if err := clearGlobalTraffic(db, clientEmail); err != nil {
 			return err
 			return err
 		}
 		}
-		return db.Model(xray.ClientTraffic{}).
+		if err := db.Model(xray.ClientTraffic{}).
 			Where("email = ?", clientEmail).
 			Where("email = ?", clientEmail).
-			Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error
+			Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
+			return err
+		}
+		return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
 	})
 	})
 }
 }
 
 
@@ -602,6 +605,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 	if err := clearGlobalTraffic(db, clientEmail); err != nil {
 	if err := clearGlobalTraffic(db, clientEmail); err != nil {
 		return false, err
 		return false, err
 	}
 	}
+	if err := db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+		return false, err
+	}
 
 
 	now := time.Now().UnixMilli()
 	now := time.Now().UnixMilli()
 	_ = db.Model(model.Inbound{}).
 	_ = db.Model(model.Inbound{}).

+ 46 - 3
internal/web/service/node_client_traffic_sum_test.go

@@ -169,7 +169,7 @@ func TestGhostData_NoPhantomTraffic(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts")
 	assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts")
 }
 }
 
 
-func TestNodeCounterReset_Clamped(t *testing.T) {
+func TestNodeCounterReset_NoReAdd(t *testing.T) {
 	db := initTrafficTestDB(t)
 	db := initTrafficTestDB(t)
 	createNodeInbound(t, db, 1, "n1-in", 41001)
 	createNodeInbound(t, db, 1, "n1-in", 41001)
 	svc := &InboundService{}
 	svc := &InboundService{}
@@ -180,13 +180,19 @@ func TestNodeCounterReset_Clamped(t *testing.T) {
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
 	assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset")
 	assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset")
 
 
-	// Counter resets to 50 (Xray restart). delta=50-950=-900 → clamped → adds 50.
+	// Node reboot drops the counter to 50. delta=50-950=-900 is a counter reset,
+	// not new traffic: add 0 and rebaseline to 50, never re-add the node's full
+	// cumulative counter onto the master total (#5456).
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
 	ct := readTraffic(t, db, email)
 	ct := readTraffic(t, db, email)
 	if ct.Up < 0 || ct.Down < 0 {
 	if ct.Up < 0 || ct.Down < 0 {
 		t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
 		t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
 	}
 	}
-	assertUpDown(t, ct, 100, 100, "after node counter reset (clamped)")
+	assertUpDown(t, ct, 50, 50, "after node counter reset: rebaselined, not re-added")
+
+	// Post-reset accrual resumes from the new baseline: 80-50=30.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 80, Down: 80, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 80, 80, "post-reset delta accrues from rebaselined counter")
 }
 }
 
 
 func TestCentralReset_NoReAdd(t *testing.T) {
 func TestCentralReset_NoReAdd(t *testing.T) {
@@ -212,6 +218,43 @@ func TestCentralReset_NoReAdd(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
 	assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
 }
 }
 
 
+// A real reset (ResetClientTrafficByEmail) must clear the per-node baseline so
+// the node's pre-reset cumulative — including traffic it counted but had not yet
+// synced — cannot leak back onto the master after the reset (#5476, #5390).
+func TestCentralResetClearsNodeBaseline_NoLeak(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	StartTrafficWriter()
+	svc := &InboundService{}
+
+	const email = "reset-revert"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 200, 200, "before reset")
+
+	if err := svc.ResetClientTrafficByEmail(email); err != nil {
+		t.Fatalf("ResetClientTrafficByEmail: %v", err)
+	}
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "right after reset")
+
+	var baselines int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&baselines).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if baselines != 0 {
+		t.Fatalf("reset must clear node baseline rows, found %d", baselines)
+	}
+
+	// Node still reports its pre-reset cumulative (340 > last synced 300: usage it
+	// had not synced before the reset). It must not revert the reset.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 340, Down: 340, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "stale node counter must not revert reset")
+
+	// Genuine post-reset usage accrues from the rebaselined counter: 370-340=30.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 370, Down: 370, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 30, 30, "post-reset usage accrues")
+}
+
 func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) {
 func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) {
 	db := initTrafficTestDB(t)
 	db := initTrafficTestDB(t)
 	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared")
 	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared")

+ 61 - 1
internal/web/service/tgbot/tgbot.go

@@ -83,7 +83,65 @@ var (
 	client_Reset         int
 	client_Reset         int
 )
 )
 
 
-var userStates = make(map[int64]string)
+// userStateStore guards the per-chat conversation states. The Telegram command
+// and callback handlers run on a worker-pool goroutine while the message handler
+// runs on the dispatch goroutine, so a bare map would be a concurrent-map-write
+// crash. It also expires abandoned conversations so a user who starts a flow and
+// goes silent doesn't leave an entry forever.
+type userStateStore struct {
+	mu        sync.Mutex
+	states    map[int64]userStateEntry
+	lastPrune time.Time
+}
+
+type userStateEntry struct {
+	state string
+	at    time.Time
+}
+
+var userStateMgr = &userStateStore{states: make(map[int64]userStateEntry)}
+
+func (s *userStateStore) set(chatID int64, state string) {
+	s.mu.Lock()
+	s.states[chatID] = userStateEntry{state: state, at: time.Now()}
+	s.mu.Unlock()
+}
+
+func (s *userStateStore) get(chatID int64) (string, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	e, ok := s.states[chatID]
+	return e.state, ok
+}
+
+func (s *userStateStore) clear(chatID int64) {
+	s.mu.Lock()
+	delete(s.states, chatID)
+	s.mu.Unlock()
+}
+
+func (s *userStateStore) reset() {
+	s.mu.Lock()
+	s.states = make(map[int64]userStateEntry)
+	s.mu.Unlock()
+}
+
+// maybePrune drops conversations older than maxAge, at most once per maxAge so a
+// busy bot doesn't sweep the whole map on every message.
+func (s *userStateStore) maybePrune(maxAge time.Duration) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	now := time.Now()
+	if now.Sub(s.lastPrune) < maxAge {
+		return
+	}
+	s.lastPrune = now
+	for id, e := range s.states {
+		if now.Sub(e.at) > maxAge {
+			delete(s.states, id)
+		}
+	}
+}
 
 
 // LoginStatus represents the result of a login attempt.
 // LoginStatus represents the result of a login attempt.
 type LoginStatus byte
 type LoginStatus byte
@@ -411,6 +469,8 @@ func StopBot() {
 	isRunning = false
 	isRunning = false
 	tgBotMutex.Unlock()
 	tgBotMutex.Unlock()
 
 
+	userStateMgr.reset()
+
 	if handler != nil {
 	if handler != nil {
 		handler.Stop()
 		handler.Stop()
 	}
 	}

+ 17 - 16
internal/web/service/tgbot/tgbot_router.go

@@ -47,7 +47,7 @@ func (t *Tgbot) OnReceive() {
 		tgBotMutex.Unlock()
 		tgBotMutex.Unlock()
 
 
 		h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
 		h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
-			delete(userStates, message.Chat.ID)
+			userStateMgr.clear(message.Chat.ID)
 			t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
 			t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
 			return nil
 			return nil
 		}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
 		}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
@@ -62,7 +62,7 @@ func (t *Tgbot) OnReceive() {
 				messageWorkerPool <- struct{}{}        // Acquire worker
 				messageWorkerPool <- struct{}{}        // Acquire worker
 				defer func() { <-messageWorkerPool }() // Release worker
 				defer func() { <-messageWorkerPool }() // Release worker
 
 
-				delete(userStates, message.Chat.ID)
+				userStateMgr.clear(message.Chat.ID)
 				t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
 				t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
 			}()
 			}()
 			return nil
 			return nil
@@ -74,25 +74,26 @@ func (t *Tgbot) OnReceive() {
 				messageWorkerPool <- struct{}{}        // Acquire worker
 				messageWorkerPool <- struct{}{}        // Acquire worker
 				defer func() { <-messageWorkerPool }() // Release worker
 				defer func() { <-messageWorkerPool }() // Release worker
 
 
-				delete(userStates, query.Message.GetChat().ID)
+				userStateMgr.clear(query.Message.GetChat().ID)
 				t.answerCallback(&query, checkAdmin(query.From.ID))
 				t.answerCallback(&query, checkAdmin(query.From.ID))
 			}()
 			}()
 			return nil
 			return nil
 		}, th.AnyCallbackQueryWithMessage())
 		}, th.AnyCallbackQueryWithMessage())
 
 
 		h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
 		h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
-			if userState, exists := userStates[message.Chat.ID]; exists {
+			userStateMgr.maybePrune(time.Hour)
+			if userState, exists := userStateMgr.get(message.Chat.ID); exists {
 				switch userState {
 				switch userState {
 				case "awaiting_email":
 				case "awaiting_email":
 					if client_Email == strings.TrimSpace(message.Text) {
 					if client_Email == strings.TrimSpace(message.Text) {
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
-						delete(userStates, message.Chat.ID)
+						userStateMgr.clear(message.Chat.ID)
 						return nil
 						return nil
 					}
 					}
 
 
 					client_Email = strings.TrimSpace(message.Text)
 					client_Email = strings.TrimSpace(message.Text)
 					if t.isSingleWord(client_Email) {
 					if t.isSingleWord(client_Email) {
-						userStates[message.Chat.ID] = "awaiting_email"
+						userStateMgr.set(message.Chat.ID, "awaiting_email")
 
 
 						cancel_btn_markup := tu.InlineKeyboard(
 						cancel_btn_markup := tu.InlineKeyboard(
 							tu.InlineKeyboardRow(
 							tu.InlineKeyboardRow(
@@ -103,26 +104,26 @@ func (t *Tgbot) OnReceive() {
 						t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
 						t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
 					} else {
 					} else {
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
-						delete(userStates, message.Chat.ID)
+						userStateMgr.clear(message.Chat.ID)
 						t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 						t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 					}
 					}
 				case "awaiting_comment":
 				case "awaiting_comment":
 					if client_Comment == strings.TrimSpace(message.Text) {
 					if client_Comment == strings.TrimSpace(message.Text) {
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
-						delete(userStates, message.Chat.ID)
+						userStateMgr.clear(message.Chat.ID)
 						return nil
 						return nil
 					}
 					}
 
 
 					client_Comment = strings.TrimSpace(message.Text)
 					client_Comment = strings.TrimSpace(message.Text)
 					t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
 					t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
-					delete(userStates, message.Chat.ID)
+					userStateMgr.clear(message.Chat.ID)
 					t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 					t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 				case "awaiting_tg_id":
 				case "awaiting_tg_id":
 					input := strings.TrimSpace(message.Text)
 					input := strings.TrimSpace(message.Text)
 					if input == "" || input == "-" || strings.EqualFold(input, "none") {
 					if input == "" || input == "-" || strings.EqualFold(input, "none") {
 						client_TgID = ""
 						client_TgID = ""
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 						t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
-						delete(userStates, message.Chat.ID)
+						userStateMgr.clear(message.Chat.ID)
 						t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 						t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 						return nil
 						return nil
 					}
 					}
@@ -137,7 +138,7 @@ func (t *Tgbot) OnReceive() {
 					}
 					}
 					client_TgID = input
 					client_TgID = input
 					t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove())
 					t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove())
-					delete(userStates, message.Chat.ID)
+					userStateMgr.clear(message.Chat.ID)
 					t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 					t.addClient(message.Chat.ID, t.BuildClientDraftMessage())
 				}
 				}
 
 
@@ -1236,7 +1237,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds)
 	case "add_client_ch_default_email":
 	case "add_client_ch_default_email":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
-		userStates[chatId] = "awaiting_email"
+		userStateMgr.set(chatId, "awaiting_email")
 		cancel_btn_markup := tu.InlineKeyboard(
 		cancel_btn_markup := tu.InlineKeyboard(
 			tu.InlineKeyboardRow(
 			tu.InlineKeyboardRow(
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
@@ -1246,7 +1247,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 	case "add_client_ch_default_comment":
 	case "add_client_ch_default_comment":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
-		userStates[chatId] = "awaiting_comment"
+		userStateMgr.set(chatId, "awaiting_comment")
 		cancel_btn_markup := tu.InlineKeyboard(
 		cancel_btn_markup := tu.InlineKeyboard(
 			tu.InlineKeyboardRow(
 			tu.InlineKeyboardRow(
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
@@ -1256,7 +1257,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 	case "add_client_ch_default_tg_id":
 	case "add_client_ch_default_tg_id":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
-		userStates[chatId] = "awaiting_tg_id"
+		userStateMgr.set(chatId, "awaiting_tg_id")
 		cancel_btn_markup := tu.InlineKeyboard(
 		cancel_btn_markup := tu.InlineKeyboard(
 			tu.InlineKeyboardRow(
 			tu.InlineKeyboardRow(
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
@@ -1357,10 +1358,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 	case "add_client_default_info":
 	case "add_client_default_info":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
 		t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
-		delete(userStates, chatId)
+		userStateMgr.clear(chatId)
 		t.addClient(chatId, t.BuildClientDraftMessage())
 		t.addClient(chatId, t.BuildClientDraftMessage())
 	case "add_client_cancel":
 	case "add_client_cancel":
-		delete(userStates, chatId)
+		userStateMgr.clear(chatId)
 		receiver_inbound_ID = 0
 		receiver_inbound_ID = 0
 		receiver_inbound_IDs = nil
 		receiver_inbound_IDs = nil
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())

+ 1 - 1
internal/web/service/tgbot/tgbot_send.go

@@ -232,7 +232,7 @@ func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSecon
 	go func() {
 	go func() {
 		time.Sleep(time.Duration(delayInSeconds) * time.Second) // Wait for the specified delay
 		time.Sleep(time.Duration(delayInSeconds) * time.Second) // Wait for the specified delay
 		t.deleteMessageTgBot(chatId, sentMsg.MessageID)         // Delete the message
 		t.deleteMessageTgBot(chatId, sentMsg.MessageID)         // Delete the message
-		delete(userStates, chatId)
+		userStateMgr.clear(chatId)
 	}()
 	}()
 }
 }
 
 

+ 2 - 2
internal/web/translation/ar-EG.json

@@ -6,7 +6,7 @@
   "cancel": "إلغاء",
   "cancel": "إلغاء",
   "close": "إغلاق",
   "close": "إغلاق",
   "save": "حفظ",
   "save": "حفظ",
-  "logout": "تسجيل خروج",
+  "logout": "تسجيل خروج <3",
   "create": "إنشاء",
   "create": "إنشاء",
   "add": "إضافة",
   "add": "إضافة",
   "remove": "إزالة",
   "remove": "إزالة",
@@ -2054,4 +2054,4 @@
     "statusDown": "غير متصل",
     "statusDown": "غير متصل",
     "statusUp": "متصل"
     "statusUp": "متصل"
   }
   }
-}
+}

+ 1 - 1
internal/web/translation/en-US.json

@@ -6,7 +6,7 @@
   "cancel": "Cancel",
   "cancel": "Cancel",
   "close": "Close",
   "close": "Close",
   "save": "Save",
   "save": "Save",
-  "logout": "Log Out",
+  "logout": "Log Out <3",
   "create": "Create",
   "create": "Create",
   "add": "Add",
   "add": "Add",
   "remove": "Remove",
   "remove": "Remove",

+ 2 - 2
internal/web/translation/es-ES.json

@@ -6,7 +6,7 @@
   "cancel": "Cancelar",
   "cancel": "Cancelar",
   "close": "Cerrar",
   "close": "Cerrar",
   "save": "Guardar",
   "save": "Guardar",
-  "logout": "Cerrar Sesión",
+  "logout": "Cerrar Sesión <3",
   "create": "Crear",
   "create": "Crear",
   "add": "Añadir",
   "add": "Añadir",
   "remove": "Quitar",
   "remove": "Quitar",
@@ -2054,4 +2054,4 @@
     "statusDown": "CAÍDO",
     "statusDown": "CAÍDO",
     "statusUp": "ACTIVO"
     "statusUp": "ACTIVO"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/fa-IR.json

@@ -6,7 +6,7 @@
   "cancel": "انصراف",
   "cancel": "انصراف",
   "close": "بستن",
   "close": "بستن",
   "save": "ذخیره",
   "save": "ذخیره",
-  "logout": "خروج",
+  "logout": "خروج <3",
   "create": "ایجاد",
   "create": "ایجاد",
   "add": "افزودن",
   "add": "افزودن",
   "remove": "حذف",
   "remove": "حذف",
@@ -2054,4 +2054,4 @@
     "statusDown": "قطع",
     "statusDown": "قطع",
     "statusUp": "وصل"
     "statusUp": "وصل"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/id-ID.json

@@ -6,7 +6,7 @@
   "cancel": "Batal",
   "cancel": "Batal",
   "close": "Tutup",
   "close": "Tutup",
   "save": "Simpan",
   "save": "Simpan",
-  "logout": "Keluar",
+  "logout": "Keluar <3",
   "create": "Buat",
   "create": "Buat",
   "add": "Tambah",
   "add": "Tambah",
   "remove": "Hapus",
   "remove": "Hapus",
@@ -2054,4 +2054,4 @@
     "statusDown": "MATI",
     "statusDown": "MATI",
     "statusUp": "AKTIF"
     "statusUp": "AKTIF"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/ja-JP.json

@@ -6,7 +6,7 @@
   "cancel": "キャンセル",
   "cancel": "キャンセル",
   "close": "閉じる",
   "close": "閉じる",
   "save": "保存",
   "save": "保存",
-  "logout": "ログアウト",
+  "logout": "ログアウト <3",
   "create": "作成",
   "create": "作成",
   "add": "追加",
   "add": "追加",
   "remove": "削除",
   "remove": "削除",
@@ -2054,4 +2054,4 @@
     "statusDown": "ダウン",
     "statusDown": "ダウン",
     "statusUp": "アップ"
     "statusUp": "アップ"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/pt-BR.json

@@ -6,7 +6,7 @@
   "cancel": "Cancelar",
   "cancel": "Cancelar",
   "close": "Fechar",
   "close": "Fechar",
   "save": "Salvar",
   "save": "Salvar",
-  "logout": "Sair",
+  "logout": "Sair <3",
   "create": "Criar",
   "create": "Criar",
   "add": "Adicionar",
   "add": "Adicionar",
   "remove": "Remover",
   "remove": "Remover",
@@ -2054,4 +2054,4 @@
     "statusDown": "INATIVO",
     "statusDown": "INATIVO",
     "statusUp": "ATIVO"
     "statusUp": "ATIVO"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/ru-RU.json

@@ -6,7 +6,7 @@
   "cancel": "Отмена",
   "cancel": "Отмена",
   "close": "Закрыть",
   "close": "Закрыть",
   "save": "Сохранить",
   "save": "Сохранить",
-  "logout": "Выход",
+  "logout": "Выход <3",
   "create": "Создать",
   "create": "Создать",
   "add": "Добавить",
   "add": "Добавить",
   "remove": "Удалить",
   "remove": "Удалить",
@@ -2054,4 +2054,4 @@
     "statusDown": "НЕДОСТУПЕН",
     "statusDown": "НЕДОСТУПЕН",
     "statusUp": "РАБОТАЕТ"
     "statusUp": "РАБОТАЕТ"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/tr-TR.json

@@ -6,7 +6,7 @@
   "cancel": "İptal",
   "cancel": "İptal",
   "close": "Kapat",
   "close": "Kapat",
   "save": "Kaydet",
   "save": "Kaydet",
-  "logout": "Çıkış Yap",
+  "logout": "Çıkış Yap <3",
   "create": "Oluştur",
   "create": "Oluştur",
   "add": "Ekle",
   "add": "Ekle",
   "remove": "Kaldır",
   "remove": "Kaldır",
@@ -2054,4 +2054,4 @@
     "statusDown": "ÇEVRİMDIŞI",
     "statusDown": "ÇEVRİMDIŞI",
     "statusUp": "ÇEVRİMİÇİ"
     "statusUp": "ÇEVRİMİÇİ"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/uk-UA.json

@@ -6,7 +6,7 @@
   "cancel": "Скасувати",
   "cancel": "Скасувати",
   "close": "Закрити",
   "close": "Закрити",
   "save": "Зберегти",
   "save": "Зберегти",
-  "logout": "Вийти",
+  "logout": "Вийти <3",
   "create": "Створити",
   "create": "Створити",
   "add": "Додати",
   "add": "Додати",
   "remove": "Видалити",
   "remove": "Видалити",
@@ -2054,4 +2054,4 @@
     "statusDown": "НЕДОСТУПНО",
     "statusDown": "НЕДОСТУПНО",
     "statusUp": "ДОСТУПНО"
     "statusUp": "ДОСТУПНО"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/vi-VN.json

@@ -6,7 +6,7 @@
   "cancel": "Hủy bỏ",
   "cancel": "Hủy bỏ",
   "close": "Đóng",
   "close": "Đóng",
   "save": "Lưu",
   "save": "Lưu",
-  "logout": "Đăng xuất",
+  "logout": "Đăng xuất <3",
   "create": "Tạo",
   "create": "Tạo",
   "add": "Thêm",
   "add": "Thêm",
   "remove": "Xóa",
   "remove": "Xóa",
@@ -2054,4 +2054,4 @@
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusUp": "HOẠT ĐỘNG"
     "statusUp": "HOẠT ĐỘNG"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/zh-CN.json

@@ -6,7 +6,7 @@
   "cancel": "取消",
   "cancel": "取消",
   "close": "关闭",
   "close": "关闭",
   "save": "保存",
   "save": "保存",
-  "logout": "登出",
+  "logout": "登出 <3",
   "create": "创建",
   "create": "创建",
   "add": "添加",
   "add": "添加",
   "remove": "移除",
   "remove": "移除",
@@ -2054,4 +2054,4 @@
     "statusDown": "断开",
     "statusDown": "断开",
     "statusUp": "恢复"
     "statusUp": "恢复"
   }
   }
-}
+}

+ 2 - 2
internal/web/translation/zh-TW.json

@@ -6,7 +6,7 @@
   "cancel": "取消",
   "cancel": "取消",
   "close": "關閉",
   "close": "關閉",
   "save": "儲存",
   "save": "儲存",
-  "logout": "登出",
+  "logout": "登出 <3",
   "create": "建立",
   "create": "建立",
   "add": "新增",
   "add": "新增",
   "remove": "移除",
   "remove": "移除",
@@ -2054,4 +2054,4 @@
     "statusDown": "中斷",
     "statusDown": "中斷",
     "statusUp": "恢復"
     "statusUp": "恢復"
   }
   }
-}
+}

+ 13 - 3
internal/web/web.go

@@ -476,9 +476,19 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 	}
 	}
 	service.StartTrafficWriter()
 	service.StartTrafficWriter()
 
 
-	// cron.Recover wraps every job so a panic is logged and the scheduler keeps
-	// running, instead of the panic taking down the whole panel process.
-	s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds(), cron.WithChain(cron.Recover(cron.PrintfLogger(cronPanicLogger{}))))
+	// SkipIfStillRunning stops a slow job (e.g. the 5s traffic poll on a large
+	// install) from overlapping itself: two concurrent runs of the same job race
+	// the shared xrayAPI — leaking a grpc connection — and the StatsLastValues
+	// map, whose concurrent write is a fatal runtime throw cron.Recover can't
+	// catch. cron.Recover then logs any panic and keeps the scheduler alive.
+	s.cron = cron.New(
+		cron.WithLocation(loc),
+		cron.WithSeconds(),
+		cron.WithChain(
+			cron.SkipIfStillRunning(cron.DiscardLogger),
+			cron.Recover(cron.PrintfLogger(cronPanicLogger{})),
+		),
+	)
 	s.cron.Start()
 	s.cron.Start()
 
 
 	// Wire the inbound-runtime manager once so InboundService can route
 	// Wire the inbound-runtime manager once so InboundService can route

+ 13 - 0
internal/xray/api.go

@@ -594,6 +594,19 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
 			processClientTraffic(matches, value, emailTrafficMap)
 			processClientTraffic(matches, value, emailTrafficMap)
 		}
 		}
 	}
 	}
+
+	// Drop delta baselines for stats that no longer exist (deleted inbounds or
+	// clients), which otherwise linger until the next Xray restart. Only rebuild
+	// when the map has drifted past 2x the live set, so the steady-state hot path
+	// stays allocation-free.
+	if n := len(resp.GetStat()); n > 0 && len(x.StatsLastValues) > 2*n {
+		pruned := make(map[string]int64, n)
+		for _, stat := range resp.GetStat() {
+			pruned[stat.Name] = x.StatsLastValues[stat.Name]
+		}
+		x.StatsLastValues = pruned
+	}
+
 	return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
 	return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
 }
 }
 
 

+ 17 - 0
main.go

@@ -6,6 +6,8 @@ import (
 	"flag"
 	"flag"
 	"fmt"
 	"fmt"
 	"log"
 	"log"
+	"net/http"
+	_ "net/http/pprof"
 	"os"
 	"os"
 	"os/signal"
 	"os/signal"
 	"syscall"
 	"syscall"
@@ -49,6 +51,21 @@ func runWebServer() {
 
 
 	godotenv.Load()
 	godotenv.Load()
 
 
+	if limit, source := sys.ApplyMemoryLimit(); limit > 0 {
+		logger.Infof("Go memory soft limit set to %d MiB (%s)", limit>>20, source)
+	} else {
+		logger.Info("Go memory soft limit not enforced: ", source)
+	}
+
+	if os.Getenv("XUI_PPROF") == "true" {
+		go func() {
+			logger.Info("pprof profiling server listening on 127.0.0.1:6060")
+			if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
+				logger.Warning("pprof server stopped: ", err)
+			}
+		}()
+	}
+
 	err := database.InitDB(config.GetDBPath())
 	err := database.InitDB(config.GetDBPath())
 	if err != nil {
 	if err != nil {
 		log.Fatalf("Error initializing database: %v", err)
 		log.Fatalf("Error initializing database: %v", err)