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
+8 -4
View File
@@ -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) {
@@ -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"