Files
3x-ui/internal/web/service/client_portable_test.go
T
mrchatam 33a469315a 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>
2026-09-27 02:48:40 +02:00

460 lines
15 KiB
Go

package service
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
// real enable flag; ImportClients must not force enable=true.
func TestExportImportPreservesDisabledEnable(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 26001, model.VLESS, `{"clients":[]}`)
const email = "portable@disabled"
const subID = "sub-portable-disabled"
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{
Email: email, SubID: subID, Enable: true,
ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
},
InboundIds: []int{ib.Id},
}); err != nil {
t.Fatalf("Create: %v", err)
}
rec := lookupClientRecord(t, email)
updated := rec.ToClient()
updated.Enable = false
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update disable: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
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].Client.Enable {
t.Fatal("ExportAll should carry enable=false for a disabled client")
}
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].Client.Enable {
t.Fatal("JSON round-trip lost enable=false")
}
if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
t.Fatalf("Delete: %v", err)
}
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)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestImportClientsPreservesOrphanDisabledEnable(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
items := []ClientCreatePayload{{
Client: model.Client{
Email: "orphan@disabled", SubID: "sub-orphan-disabled", Enable: false,
ID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
},
InboundIds: nil,
}}
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; skipped=%v", res.Created, res.Skipped)
}
if got := recordEnableOf(t, svc, "orphan@disabled"); got {
t.Fatal("orphan import forced enable=true; want false")
}
}
func TestBulkCreatePreservesExplicitDisable(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 26002, model.VLESS, `{"clients":[]}`)
const email = "bulk@disabled"
res, _, err := svc.BulkCreate(inboundSvc, []ClientCreatePayload{{
Client: model.Client{
Email: email, SubID: "sub-bulk-disabled", Enable: false,
ID: "cccccccc-cccc-cccc-cccc-cccccccccccc",
},
InboundIds: []int{ib.Id},
}})
if err != nil {
t.Fatalf("BulkCreate: %v", err)
}
if res.Created != 1 {
t.Fatalf("BulkCreate result=%+v", res)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestClientCreatePayload_OmitEnableDefaultsTrue(t *testing.T) {
raw := []byte(`{"client":{"email":"omit@x","id":"dddddddd-dddd-dddd-dddd-dddddddddddd","subId":"sub-omit"},"inboundIds":[1]}`)
var p ClientCreatePayload
if err := json.Unmarshal(raw, &p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !p.Client.Enable {
t.Fatal("omitted enable must default to true")
}
rawFalse := []byte(`{"client":{"email":"off@x","id":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee","subId":"sub-off","enable":false},"inboundIds":[1]}`)
var pFalse ClientCreatePayload
if err := json.Unmarshal(rawFalse, &pFalse); err != nil {
t.Fatalf("unmarshal false: %v", err)
}
if pFalse.Client.Enable {
t.Fatal("explicit enable:false must stay false")
}
}
func TestBulkCreate_DisabledOnNodeSkipsAddClient(t *testing.T) {
setupBulkDB(t)
nodeID, fake := setupNodeRuntime(t)
ib := nodeInbound(t, nodeID, 26003, nil)
svc := &ClientService{}
inboundSvc := &InboundService{}
const email = "node@disabled"
res, _, err := svc.BulkCreate(inboundSvc, []ClientCreatePayload{{
Client: model.Client{
Email: email, SubID: "sub-node-disabled", Enable: false,
ID: "ffffffff-ffff-ffff-ffff-ffffffffffff",
},
InboundIds: []int{ib.Id},
}})
if err != nil {
t.Fatalf("BulkCreate: %v", err)
}
if res.Created != 1 {
t.Fatalf("BulkCreate result=%+v", res)
}
if got := fake.addClient.Load(); got != 0 {
t.Fatalf("AddClient RPCs = %d, want 0 for enable=false", got)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
t.Fatalf("NodeSyncState: %v", err)
} else if !dirty {
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)
}
})
}
}