| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- 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
- }
|