fix(inbound): avoid UNIQUE email constraint when importing inbounds that share clients

Importing a second inbound whose clients overlap an already-imported inbound
failed with "UNIQUE constraint failed: client_traffics.email". The import path
carries exported ClientStats, and tx.Save(inbound) cascaded that has-many
association as INSERTs whose ON CONFLICT targets only the primary key, so a
shared email (already owning a row from the first import) tripped the global
unique constraint.

Omit the ClientStats association on save and insert the carried stats ourselves
with the same OnConflict{email, DoNothing} guard AddClientStat already uses:
new clients keep their imported counters, shared emails reuse the existing row.
Then run an idempotent AddClientStat pass over all clients so any client present
in settings but missing from the stats payload still gets a traffic row (else it
would escape quota/expiry accounting), and propagate insert errors so the tx
rolls back instead of committing a partial state.
This commit is contained in:
MHSanaei
2026-06-12 13:00:04 +02:00
parent 0cefadd166
commit 3af1afc53b
2 changed files with 160 additions and 8 deletions
+33 -8
View File
@@ -19,6 +19,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type InboundService struct {
@@ -550,16 +551,40 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
}
}()
err = tx.Save(inbound).Error
if err == nil {
if len(inbound.ClientStats) == 0 {
for _, client := range clients {
s.AddClientStat(tx, inbound.Id, &client)
}
}
} else {
// Omit the ClientStats has-many association: GORM's cascade would INSERT
// those rows with an ON CONFLICT target on the primary key only, which
// collides with the globally-unique client_traffics.email when an imported
// inbound carries clients that another inbound already created (e.g.
// importing two inbounds that share the same clients). We insert the stats
// ourselves below with the same email-conflict guard AddClientStat uses.
err = tx.Omit("ClientStats").Save(inbound).Error
if err != nil {
return inbound, false, err
}
// Imported stats first, so their traffic counters survive; emails that
// already own a (shared) row are skipped instead of tripping the unique
// constraint.
for i := range inbound.ClientStats {
if inbound.ClientStats[i].Email == "" {
continue
}
inbound.ClientStats[i].Id = 0
inbound.ClientStats[i].InboundId = inbound.Id
if err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoNothing: true,
}).Create(&inbound.ClientStats[i]).Error; err != nil {
return inbound, false, err
}
}
// Then make sure every client has a stats row. AddClientStat is a no-op
// where one exists (including the rows just inserted), and fills the gap
// for clients an import payload didn't carry stats for.
for _, client := range clients {
if err = s.AddClientStat(tx, inbound.Id, &client); err != nil {
return inbound, false, err
}
}
if err = s.clientService.SyncInbound(tx, inbound.Id, clients); err != nil {
return inbound, false, err