From 7cb2adf429d7467d9967049ad6f612c2663f5f53 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 7 Jul 2026 12:35:27 +0200 Subject: [PATCH] fix(client): clean node_client_traffics rows when deleting a client Delete and DeleteByEmail removed client_traffics, global-traffic, and inbound_client_ips rows but never the per-node baseline rows in node_client_traffics, so every deleted client left orphaned baselines behind for each registered node. The shared DelClientStat and delClientStatsByEmails helpers already clean that table; mirror the same cleanup in both row-cleanup paths so the record-only and record-less delete flows stop leaking baselines. Closes #5841 --- internal/web/service/client_crud.go | 6 ++ .../client_delete_node_baseline_test.go | 95 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 internal/web/service/client_delete_node_baseline_test.go diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index e0c28a9c6..dc36d9d2a 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -523,6 +523,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { return err } + if err := tx.Where("email = ?", existing.Email).Delete(&model.NodeClientTraffic{}).Error; err != nil { + return err + } } return tx.Delete(&model.ClientRecord{}, id).Error }); err != nil { @@ -666,6 +669,9 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil { return needRestart, err } + if err := db.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil { + return needRestart, err + } } return needRestart, nil } diff --git a/internal/web/service/client_delete_node_baseline_test.go b/internal/web/service/client_delete_node_baseline_test.go new file mode 100644 index 000000000..d908dff76 --- /dev/null +++ b/internal/web/service/client_delete_node_baseline_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func TestClientDelete_CleansNodeBaselines(t *testing.T) { + db := initTrafficTestDB(t) + + const email = "orphan@example.com" + rec := &model.ClientRecord{Email: email, SubID: "sub-orphan", Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("seed client record: %v", err) + } + if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 20}).Error; err != nil { + t.Fatalf("seed node baseline 1: %v", err) + } + if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 30, Down: 40}).Error; err != nil { + t.Fatalf("seed node baseline 2: %v", err) + } + + svc := &ClientService{} + if _, err := svc.Delete(&InboundService{}, rec.Id, false); err != nil { + t.Fatalf("Delete: %v", err) + } + + var cnt int64 + if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil { + t.Fatalf("count baselines: %v", err) + } + if cnt != 0 { + t.Errorf("expected node baselines cleaned on client delete, found %d", cnt) + } +} + +func TestClientDelete_KeepTrafficPreservesNodeBaselines(t *testing.T) { + db := initTrafficTestDB(t) + + const email = "kept@example.com" + rec := &model.ClientRecord{Email: email, SubID: "sub-kept", Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("seed client record: %v", err) + } + if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 20}).Error; err != nil { + t.Fatalf("seed node baseline: %v", err) + } + + svc := &ClientService{} + if _, err := svc.Delete(&InboundService{}, rec.Id, true); err != nil { + t.Fatalf("Delete(keepTraffic): %v", err) + } + + var cnt int64 + if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil { + t.Fatalf("count baselines: %v", err) + } + if cnt != 1 { + t.Errorf("keepTraffic delete must preserve node baselines, found %d", cnt) + } +} + +func TestDeleteByEmail_FallbackCleansNodeBaselines(t *testing.T) { + db := initTrafficTestDB(t) + + const email = "recordless@example.com" + ib := &model.Inbound{ + UserId: 1, + Tag: "local-fallback", + Enable: true, + Port: 41001, + Protocol: model.VLESS, + Settings: `{"clients": [{"id": "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a001", "email": "recordless@example.com", "enable": true}]}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + if err := db.Create(&model.NodeClientTraffic{NodeId: 3, Email: email, Up: 5, Down: 6}).Error; err != nil { + t.Fatalf("seed node baseline: %v", err) + } + + svc := &ClientService{} + if _, err := svc.DeleteByEmail(&InboundService{}, email, false); err != nil { + t.Fatalf("DeleteByEmail: %v", err) + } + + var cnt int64 + if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil { + t.Fatalf("count baselines: %v", err) + } + if cnt != 0 { + t.Errorf("expected node baselines cleaned on record-less delete, found %d", cnt) + } +}