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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
mrchatam
2026-09-27 04:18:40 +03:30
committed by GitHub
parent ac43b19cfa
commit 33a469315a
11 changed files with 551 additions and 69 deletions
+38 -26
View File
@@ -81,21 +81,27 @@ _openapi:
Returns the deleted count. Cannot be undone. 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' 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 - depth: 2
title: Return every client as a {client, inboundIds} array — the same shape title: Return every client as a {client, inboundIds, traffic} array — the shape
/bulkCreate and /import accept — so the payload round-trips straight /import accepts — so the payload round-trips straight back through
back through /import. Clients with no inbound attachment are included /import. traffic carries the usage counters (up, down, resetCount,
with an empty inboundIds list. The UI shows this in a CodeMirror viewer lastOnline, lastSubFetch) and is omitted for a client with no traffic
(copy / download); programmatic callers get the array in obj. row; the quota itself stays in client.totalGB. Clients with no inbound
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' 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 - depth: 2
title: 'Import clients from a JSON body { "data": "<json>" }, where data is a title: 'Import clients from a JSON body { "data": "<json>" }, where data is a
string-encoded array produced by /export ([{client, inboundIds}]). Items string-encoded array produced by /export ([{client, inboundIds,
with inboundIds are created and attached to those inbounds; items with traffic}]). Items with inboundIds are created and attached to those
an empty inboundIds list are restored as unattached client records. inbounds; items with an empty inboundIds list are restored as unattached
Existing emails are never overwritten — they are returned in skipped. client records. An optional traffic object restores the usage counters,
Triggers a single Xray restart at the end if any target inbound was only for clients this import creates. Existing emails are never
running.' overwritten — they are returned in skipped, and their live counters are
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' 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 - depth: 2
title: 'Shift expiry and/or traffic quota for many clients in one call. title: 'Shift expiry and/or traffic quota for many clients in one call.
addDays/addBytes may be negative. Clients with unlimited expiry addDays/addBytes may be negative. Clients with unlimited expiry
@@ -356,20 +362,26 @@ _openapi:
clearing clients left unattached after their inbounds were removed. clearing clients left unattached after their inbounds were removed.
Returns the deleted count. Cannot be undone. 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 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 - content: Return every client as a {client, inboundIds, traffic} array — the
/bulkCreate and /import accept — so the payload round-trips straight shape /import accepts — so the payload round-trips straight back
back through /import. Clients with no inbound attachment are included through /import. traffic carries the usage counters (up, down,
with an empty inboundIds list. The UI shows this in a CodeMirror resetCount, lastOnline, lastSubFetch) and is omitted for a client with
viewer (copy / download); programmatic callers get the array in obj. no traffic row; the quota itself stays in client.totalGB. Clients with
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 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 - content: 'Import clients from a JSON body { "data": "<json>" }, where data is a
string-encoded array produced by /export ([{client, inboundIds}]). string-encoded array produced by /export ([{client, inboundIds,
Items with inboundIds are created and attached to those inbounds; traffic}]). Items with inboundIds are created and attached to those
items with an empty inboundIds list are restored as unattached client inbounds; items with an empty inboundIds list are restored as
records. Existing emails are never overwritten — they are returned in unattached client records. An optional traffic object restores the
skipped. Triggers a single Xray restart at the end if any target usage counters, only for clients this import creates. Existing emails
inbound was running.' are never overwritten — they are returned in skipped, and their live
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 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. - content: 'Shift expiry and/or traffic quota for many clients in one call.
addDays/addBytes may be negative. Clients with unlimited expiry addDays/addBytes may be negative. Clients with unlimited expiry
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
+9 -3
View File
@@ -9130,7 +9130,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "get_panel_api_clients_export",
"responses": { "responses": {
"200": { "200": {
@@ -9165,7 +9165,13 @@
"inboundIds": [ "inboundIds": [
7, 7,
9 9
] ],
"traffic": {
"up": 1048576,
"down": 2097152,
"resetCount": 0,
"lastOnline": 1735680000000
}
} }
] ]
} }
@@ -9180,7 +9186,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "post_panel_api_clients_import",
"requestBody": { "requestBody": {
"required": true, "required": true,
+9 -3
View File
@@ -9130,7 +9130,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "get_panel_api_clients_export",
"responses": { "responses": {
"200": { "200": {
@@ -9165,7 +9165,13 @@
"inboundIds": [ "inboundIds": [
7, 7,
9 9
] ],
"traffic": {
"up": 1048576,
"down": 2097152,
"resetCount": 0,
"lastOnline": 1735680000000
}
} }
] ]
} }
@@ -9180,7 +9186,7 @@
"tags": [ "tags": [
"Clients" "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", "operationId": "post_panel_api_clients_import",
"requestBody": { "requestBody": {
"required": true, "required": true,
+3 -3
View File
@@ -1329,15 +1329,15 @@ export const sections: readonly Section[] = [
method: 'GET', method: 'GET',
path: '/panel/api/clients/export', path: '/panel/api/clients/export',
summary: 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: response:
'{\n "success": true,\n "obj": [\n {\n "client": {\n "email": "alice@example.com",\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": "alice@example.com",\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', method: 'POST',
path: '/panel/api/clients/import', path: '/panel/api/clients/import',
summary: 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\\":\\"alice@example.com\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}', body: '{\n "data": "[{\\"client\\":{\\"email\\":\\"alice@example.com\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
response: response:
'{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use: alice@example.com" }\n ]\n }\n}', '{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use: alice@example.com" }\n ]\n }\n}',
+8 -4
View File
@@ -517,15 +517,19 @@ func (a *ClientController) importClients(c *gin.Context) {
return return
} }
result, needRestart, err := a.clientService.ImportClients(&a.inboundService, items) 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 { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonObj(c, result, nil) jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
} }
func (a *ClientController) delOrphans(c *gin.Context) { func (a *ClientController) delOrphans(c *gin.Context) {
@@ -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 = "import-partial@example.com"
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. // TestDetachHandlerFlagsRestartOnPartialApply covers the third converted path.
func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) { func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) {
const email = "partial-detach@example.com" const email = "partial-detach@example.com"
+12 -7
View File
@@ -66,9 +66,10 @@ type ClientService struct{}
var ErrClientNotInInbound = errors.New("client not found in inbound") var ErrClientNotInInbound = errors.New("client not found in inbound")
type ClientCreatePayload struct { type ClientCreatePayload struct {
Client model.Client `json:"client"` Client model.Client `json:"client"`
InboundIds []int `json:"inboundIds"` InboundIds []int `json:"inboundIds"`
LimitHwid int `json:"-"` LimitHwid int `json:"-"`
Traffic *ClientPortableTraffic `json:"traffic,omitempty"`
} }
const sqlInChunk = 400 const sqlInChunk = 400
@@ -80,8 +81,9 @@ type clientPayloadWithHwid struct {
func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error { func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
var raw struct { var raw struct {
Client json.RawMessage `json:"client"` Client json.RawMessage `json:"client"`
InboundIds []int `json:"inboundIds"` InboundIds []int `json:"inboundIds"`
Traffic *ClientPortableTraffic `json:"traffic"`
} }
if err := json.Unmarshal(data, &raw); err != nil { if err := json.Unmarshal(data, &raw); err != nil {
return err return err
@@ -95,6 +97,7 @@ func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
p.Client = withHwid.Client p.Client = withHwid.Client
p.InboundIds = raw.InboundIds p.InboundIds = raw.InboundIds
p.LimitHwid = withHwid.LimitHwid p.LimitHwid = withHwid.LimitHwid
p.Traffic = raw.Traffic
// Omit enable → true (legacy API); explicit false is preserved (#6478). // Omit enable → true (legacy API); explicit false is preserved (#6478).
var keys map[string]json.RawMessage var keys map[string]json.RawMessage
if len(raw.Client) > 0 && json.Unmarshal(raw.Client, &keys) == nil { 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) { func (p ClientCreatePayload) MarshalJSON() ([]byte, error) {
return json.Marshal(struct { return json.Marshal(struct {
Client clientPayloadWithHwid `json:"client"` Client clientPayloadWithHwid `json:"client"`
InboundIds []int `json:"inboundIds"` InboundIds []int `json:"inboundIds"`
Traffic *ClientPortableTraffic `json:"traffic,omitempty"`
}{ }{
Client: clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid}, Client: clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid},
InboundIds: p.InboundIds, InboundIds: p.InboundIds,
Traffic: p.Traffic,
}) })
} }
+20 -6
View File
@@ -1265,9 +1265,16 @@ type BulkCreateReport struct {
} }
func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, bool, error) { 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{} result := BulkCreateResult{}
if len(payloads) == 0 { if len(payloads) == 0 {
return result, false, nil return result, nil, false, nil
} }
skip := func(email, reason string) { skip := func(email, reason string) {
@@ -1281,6 +1288,8 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
client model.Client client model.Client
inboundIds []int inboundIds []int
limitHwid int limitHwid int
payloadIdx int
reused bool
} }
prep := make([]prepared, 0, len(payloads)) prep := make([]prepared, 0, len(payloads))
emails := make([]string, 0, len(payloads)) emails := make([]string, 0, len(payloads))
@@ -1343,13 +1352,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
seenEmail[le] = struct{}{} seenEmail[le] = struct{}{}
seenSubID[client.SubID] = le 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) emails = append(emails, email)
subIDs = append(subIDs, client.SubID) subIDs = append(subIDs, client.SubID)
} }
if len(prep) == 0 { if len(prep) == 0 {
return result, false, nil return result, nil, false, nil
} }
db := database.GetDB() db := database.GetDB()
@@ -1359,7 +1368,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
end := min(start+lookupChunk, len(emails)) end := min(start+lookupChunk, len(emails))
var rows []model.ClientRecord var rows []model.ClientRecord
if e := db.Where("email IN ?", emails[start:end]).Find(&rows).Error; e != nil { 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 { for i := range rows {
existingByEmail[strings.ToLower(rows[i].Email)] = rows[i] 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)) end := min(start+lookupChunk, len(subIDs))
var rows []model.ClientRecord var rows []model.ClientRecord
if e := db.Where("sub_id IN ?", subIDs[start:end]).Find(&rows).Error; e != nil { 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 { for i := range rows {
existingSubOwner[rows[i].SubID] = strings.ToLower(rows[i].Email) 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 reason[idx] = "email already in use: " + prep[idx].client.Email
continue continue
} }
prep[idx].reused = true
if prep[idx].client.ID == "" { if prep[idx].client.ID == "" {
prep[idx].client.ID = rec.UUID 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 { for idx := range prep {
if failed[idx] { if failed[idx] {
skip(prep[idx].client.Email, reason[idx]) skip(prep[idx].client.Email, reason[idx])
@@ -1500,10 +1511,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
continue continue
} }
result.Created++ 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 // 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. // 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) { func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
+13 -3
View File
@@ -68,6 +68,16 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
// adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients' // adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients'
// current counters so ListGroups totals survive a traffic reset or client delete (#5675). // current counters so ListGroups totals survive a traffic reset or client delete (#5675).
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error { 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 { if len(emails) == 0 {
return nil return nil
} }
@@ -101,14 +111,14 @@ func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
continue continue
} }
res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{ res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{
"reset_up": gorm.Expr("reset_up - ?", d.Up), "reset_up": gorm.Expr("reset_up + ?", sign*d.Up),
"reset_down": gorm.Expr("reset_down - ?", d.Down), "reset_down": gorm.Expr("reset_down + ?", sign*d.Down),
}) })
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
if res.RowsAffected == 0 { 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 return err
} }
} }
+100 -14
View File
@@ -13,10 +13,18 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ExportAll returns every client in the same {client, inboundIds} shape that // ClientPortableTraffic is the client_traffics snapshot carried in export/import.
// /add and /bulkCreate accept, so an exported file round-trips straight back // model.Client only has the limit (totalGB); usage counters live in this table.
// through Import. Clients with no inbound attachment are included with an empty type ClientPortableTraffic struct {
// inboundIds list so an export taken before DeleteOrphans can restore them. 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) { func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
db := database.GetDB() db := database.GetDB()
var rows []model.ClientRecord var rows []model.ClientRecord
@@ -29,8 +37,12 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
} }
ids := make([]int, 0, len(rows)) ids := make([]int, 0, len(rows))
emails := make([]string, 0, len(rows))
for i := range rows { for i := range rows {
ids = append(ids, rows[i].Id) ids = append(ids, rows[i].Id)
if rows[i].Email != "" {
emails = append(emails, rows[i].Email)
}
} }
attachments := make(map[int][]int, len(rows)) 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 { for i := range rows {
client := rows[i].ToClient() client := rows[i].ToClient()
// The per-inbound flow_override is the reliable flow for multi-inbound // The per-inbound flow_override is the reliable flow for multi-inbound
@@ -55,16 +85,14 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
Client: *client, Client: *client,
InboundIds: attachments[rows[i].Id], InboundIds: attachments[rows[i].Id],
LimitHwid: rows[i].LimitHwid, LimitHwid: rows[i].LimitHwid,
Traffic: trafficByEmail[rows[i].Email],
}) })
} }
return out, nil return out, nil
} }
// ImportClients recreates clients from an exported list. Items that carry // ImportClients recreates exported clients; existing emails are Skipped.
// inboundIds go through the normal BulkCreate path (added to every inbound and // Traffic is applied only for newly created emails so live counters stay intact (#5858).
// 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.
func (s *ClientService) ImportClients(inboundSvc *InboundService, items []ClientCreatePayload) (BulkCreateResult, bool, error) { func (s *ClientService) ImportClients(inboundSvc *InboundService, items []ClientCreatePayload) (BulkCreateResult, bool, error) {
result := BulkCreateResult{} result := BulkCreateResult{}
if len(items) == 0 { if len(items) == 0 {
@@ -72,14 +100,19 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
} }
attached := make([]ClientCreatePayload, 0, len(items)) attached := make([]ClientCreatePayload, 0, len(items))
attachedSrc := make([]int, 0, len(items))
orphans := make([]ClientCreatePayload, 0) orphans := make([]ClientCreatePayload, 0)
orphanSrc := make([]int, 0)
for i := range items { for i := range items {
if len(items[i].InboundIds) > 0 { if len(items[i].InboundIds) > 0 {
attached = append(attached, items[i]) attached = append(attached, items[i])
attachedSrc = append(attachedSrc, i)
} else { } else {
orphans = append(orphans, items[i]) orphans = append(orphans, items[i])
orphanSrc = append(orphanSrc, i)
} }
} }
inserted := make([]int, 0, len(items))
skip := func(email, reason string) { skip := func(email, reason string) {
if strings.TrimSpace(email) == "" { if strings.TrimSpace(email) == "" {
@@ -90,13 +123,16 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
needRestart := false needRestart := false
if len(attached) > 0 { if len(attached) > 0 {
sub, nr, err := s.BulkCreate(inboundSvc, attached) sub, subInserted, nr, err := s.bulkCreate(inboundSvc, attached)
if err != nil { if err != nil {
return result, needRestart, err return result, needRestart, err
} }
needRestart = needRestart || nr needRestart = needRestart || nr
result.Created += sub.Created result.Created += sub.Created
result.Skipped = append(result.Skipped, sub.Skipped...) result.Skipped = append(result.Skipped, sub.Skipped...)
for _, j := range subInserted {
inserted = append(inserted, attachedSrc[j])
}
} }
db := database.GetDB() db := database.GetDB()
@@ -176,15 +212,65 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
} }
} }
result.Created++ result.Created++
inserted = append(inserted, orphanSrc[i])
}
if err := applyPortableTraffics(inboundSvc, items, inserted); err != nil {
return result, needRestart, err
} }
return result, needRestart, nil return result, needRestart, nil
} }
// DeleteOrphans removes every client that is not attached to any inbound, // applyPortableTraffics restores counters only for items that inserted a record,
// together with its traffic rows, IP log, and external links. It mirrors the // in batched serialized transactions rather than one writer round-trip per client.
// cleanup the single-client Delete performs, batched into one transaction. func applyPortableTraffics(inboundSvc *InboundService, items []ClientCreatePayload, inserted []int) error {
// Returns the number of clients deleted. 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) { func (s *ClientService) DeleteOrphans() (int, error) {
db := database.GetDB() db := database.GetDB()
sub := database.GetDB().Table("client_inbounds").Select("client_id") sub := database.GetDB().Table("client_inbounds").Select("client_id")
@@ -4,7 +4,9 @@ import (
"encoding/json" "encoding/json"
"testing" "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/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
) )
// TestExportImportPreservesDisabledEnable covers #6478: ExportAll keeps the // 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") 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)
}
})
}
}