Преглед изворни кода

feat(clients): preserve traffic counters in portable export/import (#6469)

* feat(clients): preserve traffic counters in portable export/import

ExportAll now attaches client_traffics up/down (plus resetCount and
last-seen fields) on each portable payload, and ImportClients restores
them only for newly created emails so skipped/existing clients keep
their live counters. Fixes #5858.

* fix(clients): restore imported traffic only onto rows the import created

Review of the portable-traffic export/import (#5858) found four defects:

- An orphan's restored row was hand-built, dropping reset_weekday and
  forcing enable=true; a row kept by a keepTraffic delete kept the old
  client's limits. depletedClientsClause then matched a weekly-renewing
  over-quota orphan and DelDepleted deleted it. Orphan rows now go
  through AddClientStat, whose upsert refreshes config and keeps counters,
  so the unused traffic.total field is dropped from the export.
- Created clients were inferred from Skipped emails, so a duplicate email
  in the file left the created copy with zero counters. bulkCreate now
  reports which payloads inserted a record, and only those are restored.
- Each client took its own serialized-writer commit: 2000 clients spent
  3.66s instead of 0.52s. Counters now apply in batched transactions
  (0.51s).
- importClients discarded needRestart when the late restore step failed
  after clients were committed; it now flags and notifies first, as
  create already does.

The /clients/export and /clients/import API docs now describe traffic.

* fix(groups): keep imported traffic out of group totals

Group totals keep a deleted client's usage (#5675), and the portable
import restores that same usage onto the re-created client. Export,
delete, re-import therefore counted it twice in ListGroups, and a fresh
panel showed the migrated usage as consumption of its groups.

Restored counters are usage from before the import, so the import now
shifts each group's baseline up by what it restored, in the same
transaction. A group total no longer moves at import time; only traffic
consumed afterwards counts. The baseline shift reuses the #5675 helper,
now signed.

---------

Co-authored-by: Sanaei <[email protected]>
mrchatam пре 3 часа
родитељ
комит
33a469315a

+ 38 - 26
docs/content/docs/en/reference/api/clients.mdx

@@ -81,21 +81,27 @@ _openapi:
         Returns the deleted count. Cannot be undone.
       url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
     - depth: 2
-      title: Return every client as a {client, inboundIds} array — the same shape
-        /bulkCreate and /import accept — so the payload round-trips straight
-        back through /import. Clients with no inbound attachment are included
-        with an empty inboundIds list. The UI shows this in a CodeMirror viewer
-        (copy / download); programmatic callers get the array in obj.
-      url: '#return-every-client-as-a-client-inboundids-array--the-same-shape-bulkcreate-and-import-accept--so-the-payload-round-trips-straight-back-through-import-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj'
+      title: Return every client as a {client, inboundIds, traffic} array — the shape
+        /import accepts — so the payload round-trips straight back through
+        /import. traffic carries the usage counters (up, down, resetCount,
+        lastOnline, lastSubFetch) and is omitted for a client with no traffic
+        row; the quota itself stays in client.totalGB. Clients with no inbound
+        attachment are included with an empty inboundIds list. The UI shows this
+        in a CodeMirror viewer (copy / download); programmatic callers get the
+        array in obj.
+      url: '#return-every-client-as-a-client-inboundids-traffic-array--the-shape-import-accepts--so-the-payload-round-trips-straight-back-through-import-traffic-carries-the-usage-counters-up-down-resetcount-lastonline-lastsubfetch-and-is-omitted-for-a-client-with-no-traffic-row-the-quota-itself-stays-in-clienttotalgb-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj'
     - depth: 2
       title: 'Import clients from a JSON body { "data": "<json>" }, where data is a
-        string-encoded array produced by /export ([{client, inboundIds}]). Items
-        with inboundIds are created and attached to those inbounds; items with
-        an empty inboundIds list are restored as unattached client records.
-        Existing emails are never overwritten — they are returned in skipped.
-        Triggers a single Xray restart at the end if any target inbound was
-        running.'
-      url: '#import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-existing-emails-are-never-overwritten--they-are-returned-in-skipped-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running'
+        string-encoded array produced by /export ([{client, inboundIds,
+        traffic}]). Items with inboundIds are created and attached to those
+        inbounds; items with an empty inboundIds list are restored as unattached
+        client records. An optional traffic object restores the usage counters,
+        only for clients this import creates. Existing emails are never
+        overwritten — they are returned in skipped, and their live counters are
+        left untouched. Triggers a single Xray restart at the end if any target
+        inbound was running; a failure while restoring counters still reports
+        success=false after the clients were created.'
+      url: '#import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-traffic-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-an-optional-traffic-object-restores-the-usage-counters-only-for-clients-this-import-creates-existing-emails-are-never-overwritten--they-are-returned-in-skipped-and-their-live-counters-are-left-untouched-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running-a-failure-while-restoring-counters-still-reports-successfalse-after-the-clients-were-created'
     - depth: 2
       title: 'Shift expiry and/or traffic quota for many clients in one call.
         addDays/addBytes may be negative. Clients with unlimited expiry
@@ -356,20 +362,26 @@ _openapi:
           clearing clients left unattached after their inbounds were removed.
           Returns the deleted count. Cannot be undone.
         id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
-      - content: Return every client as a {client, inboundIds} array — the same shape
-          /bulkCreate and /import accept — so the payload round-trips straight
-          back through /import. Clients with no inbound attachment are included
-          with an empty inboundIds list. The UI shows this in a CodeMirror
-          viewer (copy / download); programmatic callers get the array in obj.
-        id: return-every-client-as-a-client-inboundids-array--the-same-shape-bulkcreate-and-import-accept--so-the-payload-round-trips-straight-back-through-import-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj
+      - content: Return every client as a {client, inboundIds, traffic} array — the
+          shape /import accepts — so the payload round-trips straight back
+          through /import. traffic carries the usage counters (up, down,
+          resetCount, lastOnline, lastSubFetch) and is omitted for a client with
+          no traffic row; the quota itself stays in client.totalGB. Clients with
+          no inbound attachment are included with an empty inboundIds list. The
+          UI shows this in a CodeMirror viewer (copy / download); programmatic
+          callers get the array in obj.
+        id: return-every-client-as-a-client-inboundids-traffic-array--the-shape-import-accepts--so-the-payload-round-trips-straight-back-through-import-traffic-carries-the-usage-counters-up-down-resetcount-lastonline-lastsubfetch-and-is-omitted-for-a-client-with-no-traffic-row-the-quota-itself-stays-in-clienttotalgb-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj
       - content: 'Import clients from a JSON body { "data": "<json>" }, where data is a
-          string-encoded array produced by /export ([{client, inboundIds}]).
-          Items with inboundIds are created and attached to those inbounds;
-          items with an empty inboundIds list are restored as unattached client
-          records. Existing emails are never overwritten — they are returned in
-          skipped. Triggers a single Xray restart at the end if any target
-          inbound was running.'
-        id: import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-existing-emails-are-never-overwritten--they-are-returned-in-skipped-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running
+          string-encoded array produced by /export ([{client, inboundIds,
+          traffic}]). Items with inboundIds are created and attached to those
+          inbounds; items with an empty inboundIds list are restored as
+          unattached client records. An optional traffic object restores the
+          usage counters, only for clients this import creates. Existing emails
+          are never overwritten — they are returned in skipped, and their live
+          counters are left untouched. Triggers a single Xray restart at the end
+          if any target inbound was running; a failure while restoring counters
+          still reports success=false after the clients were created.'
+        id: import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-traffic-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-an-optional-traffic-object-restores-the-usage-counters-only-for-clients-this-import-creates-existing-emails-are-never-overwritten--they-are-returned-in-skipped-and-their-live-counters-are-left-untouched-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running-a-failure-while-restoring-counters-still-reports-successfalse-after-the-clients-were-created
       - content: 'Shift expiry and/or traffic quota for many clients in one call.
           addDays/addBytes may be negative. Clients with unlimited expiry
           (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the

+ 9 - 3
docs/public/openapi.json

@@ -9130,7 +9130,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
+        "summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
         "operationId": "get_panel_api_clients_export",
         "responses": {
           "200": {
@@ -9165,7 +9165,13 @@
                       "inboundIds": [
                         7,
                         9
-                      ]
+                      ],
+                      "traffic": {
+                        "up": 1048576,
+                        "down": 2097152,
+                        "resetCount": 0,
+                        "lastOnline": 1735680000000
+                      }
                     }
                   ]
                 }
@@ -9180,7 +9186,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
+        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
         "operationId": "post_panel_api_clients_import",
         "requestBody": {
           "required": true,

+ 9 - 3
frontend/public/openapi.json

@@ -9130,7 +9130,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
+        "summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
         "operationId": "get_panel_api_clients_export",
         "responses": {
           "200": {
@@ -9165,7 +9165,13 @@
                       "inboundIds": [
                         7,
                         9
-                      ]
+                      ],
+                      "traffic": {
+                        "up": 1048576,
+                        "down": 2097152,
+                        "resetCount": 0,
+                        "lastOnline": 1735680000000
+                      }
                     }
                   ]
                 }
@@ -9180,7 +9186,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
+        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
         "operationId": "post_panel_api_clients_import",
         "requestBody": {
           "required": true,

+ 3 - 3
frontend/src/pages/api-docs/endpoints.ts

@@ -1329,15 +1329,15 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/clients/export',
         summary:
-          'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
+          'Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "limitHwid": 2,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "limitHwid": 2,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9],\n      "traffic": {\n        "up": 1048576,\n        "down": 2097152,\n        "resetCount": 0,\n        "lastOnline": 1735680000000\n      }\n    }\n  ]\n}',
       },
       {
         method: 'POST',
         path: '/panel/api/clients/import',
         summary:
-          'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.',
+          'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.',
         body: '{\n  "data": "[{\\"client\\":{\\"email\\":\\"[email protected]\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
         response:
           '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use: [email protected]" }\n    ]\n  }\n}',

+ 8 - 4
internal/web/controller/client.go

@@ -517,15 +517,19 @@ func (a *ClientController) importClients(c *gin.Context) {
 		return
 	}
 	result, needRestart, err := a.clientService.ImportClients(&a.inboundService, items)
+	// Flagged before the error check: a failed traffic restore still leaves the
+	// clients created before it committed, and those need the restart and refresh.
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	if needRestart || result.Created > 0 || err == nil {
+		notifyClientsChanged()
+	}
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
 	jsonObj(c, result, nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-	notifyClientsChanged()
 }
 
 func (a *ClientController) delOrphans(c *gin.Context) {

+ 51 - 0
internal/web/controller/client_partial_apply_test.go

@@ -124,6 +124,57 @@ func TestDeleteHandlerFlagsRestartOnPartialApply(t *testing.T) {
 	}
 }
 
+// TestImportHandlerFlagsRestartWhenTrafficRestoreFails: the traffic restore runs
+// after the clients are committed, so its failure must not discard their restart.
+func TestImportHandlerFlagsRestartWhenTrafficRestoreFails(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	db := database.GetDB()
+	ib := &model.Inbound{
+		UserId: 1, Enable: true, Port: 43340, Tag: "in-import-partial",
+		Protocol: model.VLESS, Settings: `{"clients": []}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	trigger := `CREATE TRIGGER fail_traffic_restore BEFORE UPDATE OF up ON client_traffics
+		BEGIN SELECT RAISE(ABORT, 'injected traffic restore failure'); END`
+	if err := db.Exec(trigger).Error; err != nil {
+		t.Fatalf("create failure trigger: %v", err)
+	}
+
+	const email = "[email protected]"
+	data, err := json.Marshal([]service.ClientCreatePayload{{
+		Client:     model.Client{Email: email, SubID: "sub-import-partial", Enable: true},
+		InboundIds: []int{ib.Id},
+		Traffic:    &service.ClientPortableTraffic{Up: 5, Down: 6},
+	}})
+	if err != nil {
+		t.Fatalf("marshal import data: %v", err)
+	}
+	a := &ClientController{}
+	a.xrayService.IsNeedRestartAndSetFalse()
+	c, w := postCtx(t, "", importClientsRequest{Data: string(data)})
+	a.importClients(c)
+
+	assertPartialApply(t, w)
+	var created int64
+	if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&created).Error; err != nil {
+		t.Fatalf("count imported client: %v", err)
+	}
+	if created != 1 {
+		t.Fatalf("imported client count=%d, want 1 committed before the restore failed", created)
+	}
+	if !a.xrayService.IsNeedRestartAndSetFalse() {
+		t.Fatal("a failed traffic restore left the imported clients' Xray restart unflagged")
+	}
+}
+
 // TestDetachHandlerFlagsRestartOnPartialApply covers the third converted path.
 func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) {
 	const email = "[email protected]"

+ 12 - 7
internal/web/service/client.go

@@ -66,9 +66,10 @@ type ClientService struct{}
 var ErrClientNotInInbound = errors.New("client not found in inbound")
 
 type ClientCreatePayload struct {
-	Client     model.Client `json:"client"`
-	InboundIds []int        `json:"inboundIds"`
-	LimitHwid  int          `json:"-"`
+	Client     model.Client           `json:"client"`
+	InboundIds []int                  `json:"inboundIds"`
+	LimitHwid  int                    `json:"-"`
+	Traffic    *ClientPortableTraffic `json:"traffic,omitempty"`
 }
 
 const sqlInChunk = 400
@@ -80,8 +81,9 @@ type clientPayloadWithHwid struct {
 
 func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
 	var raw struct {
-		Client     json.RawMessage `json:"client"`
-		InboundIds []int           `json:"inboundIds"`
+		Client     json.RawMessage        `json:"client"`
+		InboundIds []int                  `json:"inboundIds"`
+		Traffic    *ClientPortableTraffic `json:"traffic"`
 	}
 	if err := json.Unmarshal(data, &raw); err != nil {
 		return err
@@ -95,6 +97,7 @@ func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
 	p.Client = withHwid.Client
 	p.InboundIds = raw.InboundIds
 	p.LimitHwid = withHwid.LimitHwid
+	p.Traffic = raw.Traffic
 	// Omit enable → true (legacy API); explicit false is preserved (#6478).
 	var keys map[string]json.RawMessage
 	if len(raw.Client) > 0 && json.Unmarshal(raw.Client, &keys) == nil {
@@ -107,10 +110,12 @@ func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
 
 func (p ClientCreatePayload) MarshalJSON() ([]byte, error) {
 	return json.Marshal(struct {
-		Client     clientPayloadWithHwid `json:"client"`
-		InboundIds []int                 `json:"inboundIds"`
+		Client     clientPayloadWithHwid  `json:"client"`
+		InboundIds []int                  `json:"inboundIds"`
+		Traffic    *ClientPortableTraffic `json:"traffic,omitempty"`
 	}{
 		Client:     clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid},
 		InboundIds: p.InboundIds,
+		Traffic:    p.Traffic,
 	})
 }

+ 20 - 6
internal/web/service/client_bulk.go

@@ -1265,9 +1265,16 @@ type BulkCreateReport struct {
 }
 
 func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, bool, error) {
+	result, _, needRestart, err := s.bulkCreate(inboundSvc, payloads)
+	return result, needRestart, err
+}
+
+// bulkCreate also returns the payload indexes that inserted a new client record;
+// a Created payload whose email already existed only reused that client.
+func (s *ClientService) bulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, []int, bool, error) {
 	result := BulkCreateResult{}
 	if len(payloads) == 0 {
-		return result, false, nil
+		return result, nil, false, nil
 	}
 
 	skip := func(email, reason string) {
@@ -1281,6 +1288,8 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		client     model.Client
 		inboundIds []int
 		limitHwid  int
+		payloadIdx int
+		reused     bool
 	}
 	prep := make([]prepared, 0, len(payloads))
 	emails := make([]string, 0, len(payloads))
@@ -1343,13 +1352,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		seenEmail[le] = struct{}{}
 		seenSubID[client.SubID] = le
 
-		prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds, limitHwid: payloads[i].LimitHwid})
+		prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds, limitHwid: payloads[i].LimitHwid, payloadIdx: i})
 		emails = append(emails, email)
 		subIDs = append(subIDs, client.SubID)
 	}
 
 	if len(prep) == 0 {
-		return result, false, nil
+		return result, nil, false, nil
 	}
 
 	db := database.GetDB()
@@ -1359,7 +1368,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		end := min(start+lookupChunk, len(emails))
 		var rows []model.ClientRecord
 		if e := db.Where("email IN ?", emails[start:end]).Find(&rows).Error; e != nil {
-			return result, false, e
+			return result, nil, false, e
 		}
 		for i := range rows {
 			existingByEmail[strings.ToLower(rows[i].Email)] = rows[i]
@@ -1370,7 +1379,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		end := min(start+lookupChunk, len(subIDs))
 		var rows []model.ClientRecord
 		if e := db.Where("sub_id IN ?", subIDs[start:end]).Find(&rows).Error; e != nil {
-			return result, false, e
+			return result, nil, false, e
 		}
 		for i := range rows {
 			existingSubOwner[rows[i].SubID] = strings.ToLower(rows[i].Email)
@@ -1405,6 +1414,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 				reason[idx] = "email already in use: " + prep[idx].client.Email
 				continue
 			}
+			prep[idx].reused = true
 			if prep[idx].client.ID == "" {
 				prep[idx].client.ID = rec.UUID
 			}
@@ -1487,6 +1497,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		}
 	}
 
+	inserted := make([]int, 0, len(prep))
 	for idx := range prep {
 		if failed[idx] {
 			skip(prep[idx].client.Email, reason[idx])
@@ -1500,10 +1511,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 			continue
 		}
 		result.Created++
+		if !prep[idx].reused {
+			inserted = append(inserted, prep[idx].payloadIdx)
+		}
 	}
 	// A re-created email is a live identity again: a delete tombstone left
 	// standing makes the next node merge prune the new client's inbound links.
-	return result, needRestart, nil
+	return result, inserted, needRestart, nil
 }
 
 func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {

+ 13 - 3
internal/web/service/client_groups.go

@@ -68,6 +68,16 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 // adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients'
 // current counters so ListGroups totals survive a traffic reset or client delete (#5675).
 func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
+	return shiftGroupBaselines(tx, emails, -1)
+}
+
+// adjustGroupBaselinesForRestoredTraffic shifts group baselines up by counters an
+// import restored, so usage from before the import never enters a group total.
+func adjustGroupBaselinesForRestoredTraffic(tx *gorm.DB, emails []string) error {
+	return shiftGroupBaselines(tx, emails, 1)
+}
+
+func shiftGroupBaselines(tx *gorm.DB, emails []string, sign int64) error {
 	if len(emails) == 0 {
 		return nil
 	}
@@ -101,14 +111,14 @@ func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
 			continue
 		}
 		res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{
-			"reset_up":   gorm.Expr("reset_up - ?", d.Up),
-			"reset_down": gorm.Expr("reset_down - ?", d.Down),
+			"reset_up":   gorm.Expr("reset_up + ?", sign*d.Up),
+			"reset_down": gorm.Expr("reset_down + ?", sign*d.Down),
 		})
 		if res.Error != nil {
 			return res.Error
 		}
 		if res.RowsAffected == 0 {
-			if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: -d.Up, ResetDown: -d.Down}).Error; err != nil {
+			if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: sign * d.Up, ResetDown: sign * d.Down}).Error; err != nil {
 				return err
 			}
 		}

+ 100 - 14
internal/web/service/client_portable.go

@@ -13,10 +13,18 @@ import (
 	"gorm.io/gorm"
 )
 
-// ExportAll returns every client in the same {client, inboundIds} shape that
-// /add and /bulkCreate accept, so an exported file round-trips straight back
-// through Import. Clients with no inbound attachment are included with an empty
-// inboundIds list so an export taken before DeleteOrphans can restore them.
+// ClientPortableTraffic is the client_traffics snapshot carried in export/import.
+// model.Client only has the limit (totalGB); usage counters live in this table.
+type ClientPortableTraffic struct {
+	Up           int64 `json:"up"`
+	Down         int64 `json:"down"`
+	ResetCount   int   `json:"resetCount"`
+	LastOnline   int64 `json:"lastOnline,omitempty"`
+	LastSubFetch int64 `json:"lastSubFetch,omitempty"`
+}
+
+// ExportAll returns every client as {client, inboundIds[, traffic]} for round-trip
+// import; orphan clients keep empty inboundIds, and traffic preserves usage (#5858).
 func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
 	db := database.GetDB()
 	var rows []model.ClientRecord
@@ -29,8 +37,12 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
 	}
 
 	ids := make([]int, 0, len(rows))
+	emails := make([]string, 0, len(rows))
 	for i := range rows {
 		ids = append(ids, rows[i].Id)
+		if rows[i].Email != "" {
+			emails = append(emails, rows[i].Email)
+		}
 	}
 
 	attachments := make(map[int][]int, len(rows))
@@ -44,6 +56,24 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
 		}
 	}
 
+	trafficByEmail := make(map[string]*ClientPortableTraffic, len(emails))
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var traffics []xray.ClientTraffic
+		if err := db.Where("email IN ?", batch).Find(&traffics).Error; err != nil {
+			return nil, err
+		}
+		for i := range traffics {
+			t := traffics[i]
+			trafficByEmail[t.Email] = &ClientPortableTraffic{
+				Up:           t.Up,
+				Down:         t.Down,
+				ResetCount:   t.ResetCount,
+				LastOnline:   t.LastOnline,
+				LastSubFetch: t.LastSubFetch,
+			}
+		}
+	}
+
 	for i := range rows {
 		client := rows[i].ToClient()
 		// The per-inbound flow_override is the reliable flow for multi-inbound
@@ -55,16 +85,14 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
 			Client:     *client,
 			InboundIds: attachments[rows[i].Id],
 			LimitHwid:  rows[i].LimitHwid,
+			Traffic:    trafficByEmail[rows[i].Email],
 		})
 	}
 	return out, nil
 }
 
-// ImportClients recreates clients from an exported list. Items that carry
-// inboundIds go through the normal BulkCreate path (added to every inbound and
-// pushed to xray); items with no inboundIds are restored as bare records so an
-// orphan-inclusive export round-trips. Existing emails are never overwritten —
-// they are reported in Skipped. The boolean reports whether xray needs a restart.
+// ImportClients recreates exported clients; existing emails are Skipped.
+// Traffic is applied only for newly created emails so live counters stay intact (#5858).
 func (s *ClientService) ImportClients(inboundSvc *InboundService, items []ClientCreatePayload) (BulkCreateResult, bool, error) {
 	result := BulkCreateResult{}
 	if len(items) == 0 {
@@ -72,14 +100,19 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 	}
 
 	attached := make([]ClientCreatePayload, 0, len(items))
+	attachedSrc := make([]int, 0, len(items))
 	orphans := make([]ClientCreatePayload, 0)
+	orphanSrc := make([]int, 0)
 	for i := range items {
 		if len(items[i].InboundIds) > 0 {
 			attached = append(attached, items[i])
+			attachedSrc = append(attachedSrc, i)
 		} else {
 			orphans = append(orphans, items[i])
+			orphanSrc = append(orphanSrc, i)
 		}
 	}
+	inserted := make([]int, 0, len(items))
 
 	skip := func(email, reason string) {
 		if strings.TrimSpace(email) == "" {
@@ -90,13 +123,16 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 
 	needRestart := false
 	if len(attached) > 0 {
-		sub, nr, err := s.BulkCreate(inboundSvc, attached)
+		sub, subInserted, nr, err := s.bulkCreate(inboundSvc, attached)
 		if err != nil {
 			return result, needRestart, err
 		}
 		needRestart = needRestart || nr
 		result.Created += sub.Created
 		result.Skipped = append(result.Skipped, sub.Skipped...)
+		for _, j := range subInserted {
+			inserted = append(inserted, attachedSrc[j])
+		}
 	}
 
 	db := database.GetDB()
@@ -176,15 +212,65 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 			}
 		}
 		result.Created++
+		inserted = append(inserted, orphanSrc[i])
+	}
+
+	if err := applyPortableTraffics(inboundSvc, items, inserted); err != nil {
+		return result, needRestart, err
 	}
 
 	return result, needRestart, nil
 }
 
-// DeleteOrphans removes every client that is not attached to any inbound,
-// together with its traffic rows, IP log, and external links. It mirrors the
-// cleanup the single-client Delete performs, batched into one transaction.
-// Returns the number of clients deleted.
+// applyPortableTraffics restores counters only for items that inserted a record,
+// in batched serialized transactions rather than one writer round-trip per client.
+func applyPortableTraffics(inboundSvc *InboundService, items []ClientCreatePayload, inserted []int) error {
+	const batchSize = 400
+	withTraffic := make([]int, 0, len(inserted))
+	for _, i := range inserted {
+		if items[i].Traffic != nil {
+			withTraffic = append(withTraffic, i)
+		}
+	}
+	for start := 0; start < len(withTraffic); start += batchSize {
+		batch := withTraffic[start:min(start+batchSize, len(withTraffic))]
+		if err := runSerializedTx(func(tx *gorm.DB) error {
+			emails := make([]string, 0, len(batch))
+			for _, i := range batch {
+				if err := applyPortableTraffic(tx, inboundSvc, items[i]); err != nil {
+					return err
+				}
+				emails = append(emails, strings.TrimSpace(items[i].Client.Email))
+			}
+			return adjustGroupBaselinesForRestoredTraffic(tx, emails)
+		}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// applyPortableTraffic writes the exported counters. Attached clients got their row
+// on create; an orphan's row (new, or kept by a keepTraffic delete) is upserted here.
+func applyPortableTraffic(tx *gorm.DB, inboundSvc *InboundService, item ClientCreatePayload) error {
+	client := item.Client
+	client.Email = strings.TrimSpace(client.Email)
+	if len(item.InboundIds) == 0 {
+		if err := inboundSvc.AddClientStat(tx, 0, &client); err != nil {
+			return err
+		}
+	}
+	return tx.Model(&xray.ClientTraffic{}).Where("email = ?", client.Email).Updates(map[string]any{
+		"up":             item.Traffic.Up,
+		"down":           item.Traffic.Down,
+		"reset_count":    item.Traffic.ResetCount,
+		"last_online":    item.Traffic.LastOnline,
+		"last_sub_fetch": item.Traffic.LastSubFetch,
+	}).Error
+}
+
+// DeleteOrphans removes every unattached client plus its traffic, IP log, and
+// external links in one transaction; returns how many clients were deleted.
 func (s *ClientService) DeleteOrphans() (int, error) {
 	db := database.GetDB()
 	sub := database.GetDB().Table("client_inbounds").Select("client_id")

+ 288 - 0
internal/web/service/client_portable_test.go

@@ -4,7 +4,9 @@ import (
 	"encoding/json"
 	"testing"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 // TestExportImportPreservesDisabledEnable covers #6478: ExportAll keeps the
@@ -169,3 +171,289 @@ func TestBulkCreate_DisabledOnNodeSkipsAddClient(t *testing.T) {
 		t.Fatal("disabled node create must leave node dirty for reconcile")
 	}
 }
+
+func TestExportImportPreservesTrafficCounters(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	ib := mkInbound(t, 25001, model.VLESS, `{"clients":[]}`)
+	const email = "portable@traffic"
+	const subID = "sub-portable-traffic"
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client: model.Client{
+			Email: email, SubID: subID, Enable: true,
+			TotalGB: 10 << 30, ExpiryTime: 1_700_000_000_000,
+		},
+		InboundIds: []int{ib.Id},
+	}); err != nil {
+		t.Fatalf("Create: %v", err)
+	}
+
+	db := database.GetDB()
+	if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Updates(map[string]any{
+		"up": 111, "down": 222, "reset_count": 3, "last_online": 999,
+	}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	exported, err := svc.ExportAll()
+	if err != nil {
+		t.Fatalf("ExportAll: %v", err)
+	}
+	if len(exported) != 1 {
+		t.Fatalf("ExportAll len=%d, want 1", len(exported))
+	}
+	if exported[0].Traffic == nil {
+		t.Fatal("ExportAll missing traffic snapshot")
+	}
+	if exported[0].Traffic.Up != 111 || exported[0].Traffic.Down != 222 || exported[0].Traffic.ResetCount != 3 {
+		t.Fatalf("exported traffic = %+v, want up=111 down=222 resetCount=3", exported[0].Traffic)
+	}
+
+	raw, err := json.Marshal(exported)
+	if err != nil {
+		t.Fatalf("marshal export: %v", err)
+	}
+	var roundTrip []ClientCreatePayload
+	if err := json.Unmarshal(raw, &roundTrip); err != nil {
+		t.Fatalf("unmarshal export: %v", err)
+	}
+	if roundTrip[0].Traffic == nil || roundTrip[0].Traffic.Up != 111 {
+		t.Fatalf("JSON round-trip lost traffic: %+v", roundTrip[0].Traffic)
+	}
+
+	rec := lookupClientRecord(t, email)
+	if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
+		t.Fatalf("Delete: %v", err)
+	}
+	var gone int64
+	if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&gone).Error; err != nil {
+		t.Fatalf("count after delete: %v", err)
+	}
+	if gone != 0 {
+		t.Fatalf("client_traffics still present after delete: %d", gone)
+	}
+
+	res, _, err := svc.ImportClients(inboundSvc, roundTrip)
+	if err != nil {
+		t.Fatalf("ImportClients: %v", err)
+	}
+	if res.Created != 1 || len(res.Skipped) != 0 {
+		t.Fatalf("ImportClients result=%+v", res)
+	}
+	var restored xray.ClientTraffic
+	if err := db.Where("email = ?", email).First(&restored).Error; err != nil {
+		t.Fatalf("lookup restored traffic: %v", err)
+	}
+	if restored.Up != 111 || restored.Down != 222 || restored.ResetCount != 3 || restored.LastOnline != 999 {
+		t.Fatalf("restored traffic = %+v, want up=111 down=222 resetCount=3 lastOnline=999", restored)
+	}
+
+	if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Updates(map[string]any{
+		"up": 5000, "down": 6000,
+	}).Error; err != nil {
+		t.Fatalf("bump live traffic: %v", err)
+	}
+	// Same email+subId is a BulkCreate reuse (may count as Created), not a hard
+	// skip — traffic apply must still refuse to overwrite the live counters.
+	if _, _, err := svc.ImportClients(inboundSvc, roundTrip); err != nil {
+		t.Fatalf("second ImportClients: %v", err)
+	}
+	var live xray.ClientTraffic
+	if err := db.Where("email = ?", email).First(&live).Error; err != nil {
+		t.Fatalf("lookup live traffic: %v", err)
+	}
+	if live.Up != 5000 || live.Down != 6000 {
+		t.Fatalf("re-import of existing email must leave live traffic alone, got up=%d down=%d", live.Up, live.Down)
+	}
+}
+
+func TestImportClientsAppliesTrafficForOrphans(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+
+	items := []ClientCreatePayload{{
+		Client: model.Client{
+			Email: "orphan@traffic", SubID: "sub-orphan-traffic", Enable: true,
+			TotalGB: 1 << 30,
+		},
+		InboundIds: nil,
+		Traffic: &ClientPortableTraffic{
+			Up: 7, Down: 8, ResetCount: 1,
+		},
+	}}
+	res, _, err := svc.ImportClients(&InboundService{}, items)
+	if err != nil {
+		t.Fatalf("ImportClients orphan: %v", err)
+	}
+	if res.Created != 1 {
+		t.Fatalf("created=%d, want 1", res.Created)
+	}
+	var traf xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "orphan@traffic").First(&traf).Error; err != nil {
+		t.Fatalf("orphan traffic row missing: %v", err)
+	}
+	if traf.Up != 7 || traf.Down != 8 || traf.ResetCount != 1 {
+		t.Fatalf("orphan traffic = %+v", traf)
+	}
+}
+
+// An orphan's restored row must carry its weekly schedule, or depletedClientsClause
+// treats the over-quota renewing client as depleted and DelDepleted deletes it.
+func TestImportClientsOrphanTrafficKeepsWeeklyRenewal(t *testing.T) {
+	const email = "weekly@orphan"
+	cases := []struct {
+		name string
+		seed func(t *testing.T, svc *ClientService, inboundSvc *InboundService)
+	}{
+		{name: "no prior row", seed: func(*testing.T, *ClientService, *InboundService) {}},
+		{
+			name: "row kept by keepTraffic delete",
+			seed: func(t *testing.T, svc *ClientService, inboundSvc *InboundService) {
+				ib := mkInbound(t, 25003, model.VLESS, `{"clients":[]}`)
+				if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+					Client:     model.Client{Email: email, SubID: "sub-weekly-old", Enable: true, TotalGB: 1 << 30},
+					InboundIds: []int{ib.Id},
+				}); err != nil {
+					t.Fatalf("Create: %v", err)
+				}
+				if _, err := svc.Delete(inboundSvc, lookupClientRecord(t, email).Id, true); err != nil {
+					t.Fatalf("Delete keepTraffic: %v", err)
+				}
+			},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupBulkDB(t)
+			svc := &ClientService{}
+			inboundSvc := &InboundService{}
+			tc.seed(t, svc, inboundSvc)
+
+			items := []ClientCreatePayload{{
+				Client: model.Client{
+					Email: email, SubID: "sub-weekly-orphan", Enable: true,
+					TotalGB: 1 << 30, ResetWeekday: 3,
+				},
+				Traffic: &ClientPortableTraffic{Up: 1 << 30, Down: 1},
+			}}
+			if res, _, err := svc.ImportClients(inboundSvc, items); err != nil || res.Created != 1 {
+				t.Fatalf("ImportClients result=%+v err=%v, want 1 created", res, err)
+			}
+			deleted, _, err := svc.DelDepleted(inboundSvc)
+			if err != nil {
+				t.Fatalf("DelDepleted: %v", err)
+			}
+			if deleted != 0 {
+				t.Fatalf("DelDepleted deleted %d weekly-renewing client(s), want 0", deleted)
+			}
+			lookupClientRecord(t, email)
+		})
+	}
+}
+
+// Restored counters are usage from before the import, so they must not move the
+// group total at import time: a delete+re-import would otherwise count them twice.
+func TestImportClientsTrafficLeavesGroupTotalUnchanged(t *testing.T) {
+	t.Run("re-import after delete on the same panel", func(t *testing.T) {
+		setupBulkDB(t)
+		svc := &ClientService{}
+		inboundSvc := &InboundService{}
+		ib := mkInbound(t, 25004, model.VLESS, `{"clients":[]}`)
+		const email = "grouped@traffic"
+		if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+			Client:     model.Client{Email: email, SubID: "sub-grouped", Enable: true, Group: "g"},
+			InboundIds: []int{ib.Id},
+		}); err != nil {
+			t.Fatalf("Create: %v", err)
+		}
+		if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).
+			Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
+			t.Fatalf("seed traffic: %v", err)
+		}
+		exported, err := svc.ExportAll()
+		if err != nil {
+			t.Fatalf("ExportAll: %v", err)
+		}
+		if _, err := svc.Delete(inboundSvc, lookupClientRecord(t, email).Id, false); err != nil {
+			t.Fatalf("Delete: %v", err)
+		}
+		if g := groupByName(t, svc, "g"); g.TrafficUsed != 300 {
+			t.Fatalf("group after delete = %d, want the kept 300", g.TrafficUsed)
+		}
+
+		if res, _, err := svc.ImportClients(inboundSvc, exported); err != nil || res.Created != 1 {
+			t.Fatalf("ImportClients result=%+v err=%v, want 1 created", res, err)
+		}
+		if g := groupByName(t, svc, "g"); g.Up != 100 || g.Down != 200 {
+			t.Fatalf("group after re-import up=%d down=%d, want unchanged 100/200", g.Up, g.Down)
+		}
+	})
+
+	t.Run("new panel starts the group at zero", func(t *testing.T) {
+		setupBulkDB(t)
+		svc := &ClientService{}
+		ib := mkInbound(t, 25005, model.VLESS, `{"clients":[]}`)
+		items := []ClientCreatePayload{
+			{Client: model.Client{Email: "attached@g", SubID: "sub-attached-g", Enable: true, Group: "g"}, InboundIds: []int{ib.Id}, Traffic: &ClientPortableTraffic{Up: 100, Down: 200}},
+			{Client: model.Client{Email: "orphan@g", SubID: "sub-orphan-g", Enable: true, Group: "g"}, Traffic: &ClientPortableTraffic{Up: 10, Down: 20}},
+		}
+		if res, _, err := svc.ImportClients(&InboundService{}, items); err != nil || res.Created != 2 {
+			t.Fatalf("ImportClients result=%+v err=%v, want 2 created", res, err)
+		}
+		if g := groupByName(t, svc, "g"); g.TrafficUsed != 0 {
+			t.Fatalf("group after import = %d (up=%d down=%d), want 0", g.TrafficUsed, g.Up, g.Down)
+		}
+	})
+}
+
+// A duplicate email in the file is skipped, but the copy that was created must
+// still get its own counters rather than none or the skipped copy's.
+func TestImportClientsDuplicateEmailRestoresCreatedCopy(t *testing.T) {
+	cases := []struct {
+		name  string
+		items func(ibID int) []ClientCreatePayload
+	}{
+		{
+			name: "second attached copy skipped",
+			items: func(ibID int) []ClientCreatePayload {
+				return []ClientCreatePayload{
+					{Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-a", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 11, Down: 12}},
+					{Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-b", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 99, Down: 99}},
+				}
+			},
+		},
+		{
+			name: "earlier orphan copy skipped",
+			items: func(ibID int) []ClientCreatePayload {
+				return []ClientCreatePayload{
+					{Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-b", Enable: true}, Traffic: &ClientPortableTraffic{Up: 99, Down: 99}},
+					{Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-a", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 11, Down: 12}},
+				}
+			},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupBulkDB(t)
+			svc := &ClientService{}
+			ib := mkInbound(t, 25002, model.VLESS, `{"clients":[]}`)
+
+			res, _, err := svc.ImportClients(&InboundService{}, tc.items(ib.Id))
+			if err != nil {
+				t.Fatalf("ImportClients: %v", err)
+			}
+			if res.Created != 1 || len(res.Skipped) != 1 {
+				t.Fatalf("ImportClients result=%+v, want 1 created and 1 skipped", res)
+			}
+			var row xray.ClientTraffic
+			if err := database.GetDB().Where("email = ?", "dup@traffic").First(&row).Error; err != nil {
+				t.Fatalf("lookup traffic: %v", err)
+			}
+			if row.Up != 11 || row.Down != 12 {
+				t.Fatalf("traffic up=%d down=%d, want the created copy's 11/12", row.Up, row.Down)
+			}
+		})
+	}
+}