fix(inbounds): preserve client data on delete and show traffic in detail

Deleting an inbound now only detaches its clients (removes the
client_inbounds rows). It no longer deletes client_traffics or client IP
logs: those are keyed centrally by email (one row per client) and must
survive, since a client may stay attached to other inbounds and is
managed from the Clients page.

Separately, /get/:id now uses a new GetInboundDetail that preloads and
enriches ClientStats, so hydrated records (info / QR / export) carry
per-client traffic instead of null. DBInbound.toJSON drops the internal
_clientStatsMap cache so it no longer leaks into the exported JSON.
This commit is contained in:
MHSanaei 2026-05-30 23:53:28 +02:00
parent a08bb91f58
commit 6bb5a3b56b
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
3 changed files with 18 additions and 50 deletions

View file

@ -190,6 +190,12 @@ export class DBInbound {
this._clientStatsMap = null;
}
toJSON(): Record<string, unknown> {
const out: Record<string, unknown> = { ...(this as unknown as Record<string, unknown>) };
delete out._clientStatsMap;
return out;
}
getClientStats(email: string): ClientStats | undefined {
if (!this._clientStatsMap) {
this._clientStatsMap = new Map();

View file

@ -121,7 +121,7 @@ func (a *InboundController) getInbound(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inbound, err := a.inboundService.GetInbound(id)
inbound, err := a.inboundService.GetInboundDetail(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return

View file

@ -238,11 +238,6 @@ func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.
}
}
// InboundOption is the lightweight projection of an inbound used by client UI
// pickers — only the fields needed to render labels, filter by protocol, and
// decide whether the XTLS Vision flow selector should appear. Keeping this
// payload minimal avoids shipping per-client settings and traffic stats just
// to populate a dropdown.
type InboundOption struct {
Id int `json:"id"`
Remark string `json:"remark"`
@ -252,10 +247,6 @@ type InboundOption struct {
TlsFlowCapable bool `json:"tlsFlowCapable"`
}
// GetInboundOptions returns the picker-sized projection of the user's inbounds.
// The TlsFlowCapable flag mirrors Inbound.canEnableTlsFlow() on the frontend
// (VLESS over TCP with tls or reality) so the client modal does not need
// StreamSettings to decide whether to show the Flow field.
func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
db := database.GetDB()
var rows []struct {
@ -619,40 +610,9 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
logger.Debug("DelInbound: inbound not found, id:", id)
}
// Delete client traffics of inbounds
err := db.Where("inbound_id = ?", id).Delete(xray.ClientTraffic{}).Error
if err != nil {
return false, err
}
if err := s.clientService.DetachInbound(db, id); err != nil {
return false, err
}
inbound, err := s.GetInbound(id)
if err != nil {
return false, err
}
clients, err := s.GetClients(inbound)
if err != nil {
return false, err
}
// Bulk-delete client IPs for every email in this inbound. The previous
// per-client loop fired one DELETE per row — at 7k+ clients that meant
// thousands of synchronous SQL roundtrips and a multi-second freeze.
// Chunked to stay under SQLite's bind-variable limit on huge inbounds.
if len(clients) > 0 {
emails := make([]string, 0, len(clients))
for i := range clients {
if clients[i].Email != "" {
emails = append(emails, clients[i].Email)
}
}
for _, batch := range chunkStrings(uniqueNonEmptyStrings(emails), sqliteMaxVars) {
if err := db.Where("client_email IN ?", batch).
Delete(model.InboundClientIps{}).Error; err != nil {
return false, err
}
}
}
return needRestart, db.Delete(model.Inbound{}, id).Error
}
@ -667,15 +627,17 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
return inbound, nil
}
// SetInboundEnable toggles only the enable flag of an inbound, without
// rewriting the (potentially multi-MB) settings JSON. Used by the UI's
// per-row enable switch — for inbounds with thousands of clients the full
// UpdateInbound path is an order of magnitude too slow for an interactive
// toggle (parses + reserialises every client, runs O(N) traffic diff).
//
// Returns (needRestart, error). needRestart is true when the xray runtime
// could not be re-synced from the cached config and a full restart is
// required to pick up the change.
func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}
err := db.Model(model.Inbound{}).Preload("ClientStats").First(inbound, id).Error
if err != nil {
return nil, err
}
s.enrichClientStats(db, []*model.Inbound{inbound})
return inbound, nil
}
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
inbound, err := s.GetInbound(id)
if err != nil {