mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 19:22:10 +03:00
33a469315a
* 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>
336 lines
10 KiB
Go
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
|
|
}
|