Files
3x-ui/internal/web/service/client_portable.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

336 lines
10 KiB
Go

package service
import (
"strings"
"time"
"github.com/google/uuid"
"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"
"gorm.io/gorm"
)
// 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
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]ClientCreatePayload, 0, len(rows))
if len(rows) == 0 {
return out, nil
}
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))
for _, batch := range chunkInts(ids, sqlInChunk) {
var links []model.ClientInbound
if err := db.Where("client_id IN ?", batch).Order("inbound_id ASC").Find(&links).Error; err != nil {
return nil, err
}
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
}
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
// clients; the canonical column can be left stale by SyncInbound (#4792).
if flow, err := s.EffectiveFlow(db, rows[i].Id); err == nil && flow != "" {
client.Flow = flow
}
out = append(out, ClientCreatePayload{
Client: *client,
InboundIds: attachments[rows[i].Id],
LimitHwid: rows[i].LimitHwid,
Traffic: trafficByEmail[rows[i].Email],
})
}
return out, nil
}
// 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 {
return result, false, nil
}
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) == "" {
email = "(missing email)"
}
result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
}
needRestart := false
if len(attached) > 0 {
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()
for i := range orphans {
client := orphans[i].Client
email := strings.TrimSpace(client.Email)
if email == "" {
skip("", "client email is required")
continue
}
if verr := validateClientEmail(email); verr != nil {
skip(email, verr.Error())
continue
}
if verr := validateClientSubID(client.SubID); verr != nil {
skip(email, verr.Error())
continue
}
if verr := validateClientRenewal(client); verr != nil {
skip(email, verr.Error())
continue
}
if verr := validateClientResetMax(client.ResetMax); verr != nil {
skip(email, verr.Error())
continue
}
if verr := validateClientTrafficReset(client.TrafficReset, client.TrafficResetDay); verr != nil {
skip(email, verr.Error())
continue
}
// An existing record (in the DB or just created from the attached set
// above) always wins — import never clobbers a live client.
var taken int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&taken).Error; err != nil {
return result, needRestart, err
}
if taken > 0 {
skip(email, "email already in use: "+email)
continue
}
client.Email = email
if client.SubID == "" {
client.SubID = uuid.NewString()
}
if client.SubID != "" {
var subTaken int64
if err := db.Model(&model.ClientRecord{}).
Where("sub_id = ? AND email <> ?", client.SubID, email).
Count(&subTaken).Error; err != nil {
return result, needRestart, err
}
if subTaken > 0 {
skip(email, "subId already in use: "+client.SubID)
continue
}
}
// Preserve exported enable so a disabled orphan stays disabled (#6478).
now := time.Now().UnixMilli()
if client.CreatedAt == 0 {
client.CreatedAt = now
}
client.UpdatedAt = now
rec := client.ToRecord()
rec.LimitHwid = orphans[i].LimitHwid
if err := db.Create(rec).Error; err != nil {
skip(email, err.Error())
continue
}
// gorm default:true drops enable=false on Create — restate (#6478).
if !client.Enable {
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
UpdateColumn("enable", false).Error; err != nil {
return result, needRestart, err
}
}
result.Created++
inserted = append(inserted, orphanSrc[i])
}
if err := applyPortableTraffics(inboundSvc, items, inserted); err != nil {
return result, needRestart, err
}
return result, needRestart, nil
}
// 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")
var rows []model.ClientRecord
if err := db.Where("id NOT IN (?)", sub).Order("id ASC").Find(&rows).Error; err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
ids := make([]int, 0, len(rows))
emails := make([]string, 0, len(rows))
subIDs := 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)
}
subIDs = append(subIDs, rows[i].SubID)
}
tombstoneClientEmails(emails)
if err := runSerializedTx(func(tx *gorm.DB) error {
if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
return e
}
if e := clearClientHwidsBySubIDTx(tx, subIDs...); e != nil {
return e
}
for _, batch := range chunkInts(ids, sqlInChunk) {
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
return e
}
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientExternalLink{}).Error; e != nil {
return e
}
}
if len(emails) > 0 {
for _, batch := range chunkStrings(emails, sqlInChunk) {
if e := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; e != nil {
return e
}
if e := tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; e != nil {
return e
}
}
if e := clearGlobalTraffic(tx, emails...); e != nil {
return e
}
}
for _, batch := range chunkInts(ids, sqlInChunk) {
if e := tx.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; e != nil {
return e
}
}
return nil
}); err != nil {
return 0, err
}
return len(ids), nil
}