diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index 503de5734..58678d57c 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/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": "" }, 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": "" }, 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 diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 635445f76..5f403f933 100644 --- a/docs/public/openapi.json +++ b/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\": \"\" }, 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\": \"\" }, 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, diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 635445f76..5f403f933 100644 --- a/frontend/public/openapi.json +++ b/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\": \"\" }, 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\": \"\" }, 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, diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index c1026795e..d505869ef 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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": "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', path: '/panel/api/clients/import', summary: - 'Import clients from a JSON body { "data": "" }, 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": "" }, 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}', 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}', diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 1dcd1d772..da3a723c2 100644 --- a/internal/web/controller/client.go +++ b/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) { diff --git a/internal/web/controller/client_partial_apply_test.go b/internal/web/controller/client_partial_apply_test.go index fba3b37ab..33c0001df 100644 --- a/internal/web/controller/client_partial_apply_test.go +++ b/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 = "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. func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) { const email = "partial-detach@example.com" diff --git a/internal/web/service/client.go b/internal/web/service/client.go index 568b4113a..7429f4bee 100644 --- a/internal/web/service/client.go +++ b/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, }) } diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 561dadd82..e1855b7bc 100644 --- a/internal/web/service/client_bulk.go +++ b/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) { diff --git a/internal/web/service/client_groups.go b/internal/web/service/client_groups.go index 2f3b70e20..96ab14830 100644 --- a/internal/web/service/client_groups.go +++ b/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 } } diff --git a/internal/web/service/client_portable.go b/internal/web/service/client_portable.go index db47feea9..64c258a9c 100644 --- a/internal/web/service/client_portable.go +++ b/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") diff --git a/internal/web/service/client_portable_test.go b/internal/web/service/client_portable_test.go index 248e6134a..98d50c287 100644 --- a/internal/web/service/client_portable_test.go +++ b/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) + } + }) + } +}