fix(clients): preserve ClientRecord on inbound delete + filter Attached inbounds to multi-client protocols

Replace the global orphan sweep in setRemoteTrafficLocked with a
per-inbound diff cleanup: only delete a ClientRecord whose email
disappeared from a snap-tracked inbound (i.e. a node-side delete).
Inbounds that vanished entirely from the snap (e.g. admin deleted
the inbound on master) aren't iterated, so a client whose last
attachment came from that inbound is now left alone instead of
being deleted alongside the inbound.

ClientFormModal and ClientBulkAddModal now filter the Attached
inbounds dropdown to protocols that actually support multiple
clients: shadowsocks, vless, vmess, trojan, hysteria, hysteria2,
and portfallback (which routes through VLESS settings).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-18 00:29:20 +02:00
parent ef98a932d7
commit ad56b1bbd7
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
3 changed files with 71 additions and 21 deletions

View file

@ -72,8 +72,14 @@ const delayedExpireDays = computed({
set: (days) => { form.expiryTime = -86400000 * (days || 0); }, set: (days) => { form.expiryTime = -86400000 * (days || 0); },
}); });
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2', 'portfallback',
]);
const inboundOptions = computed(() => const inboundOptions = computed(() =>
(props.inbounds || []).map((ib) => ({ (props.inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id, value: ib.id,
})), })),

View file

@ -85,8 +85,14 @@ function gbToBytes(gb) {
return Math.round(gb * 1024 * 1024 * 1024); return Math.round(gb * 1024 * 1024 * 1024);
} }
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2', 'portfallback',
]);
const inboundOptions = computed(() => const inboundOptions = computed(() =>
(props.inbounds || []).map((ib) => ({ (props.inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id, value: ib.id,
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`, title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,

View file

@ -1277,6 +1277,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
} }
type oldSet struct {
inboundID int
emails map[string]struct{}
}
var perInboundOld []oldSet
for _, snapIb := range snap.Inbounds { for _, snapIb := range snap.Inbounds {
if snapIb == nil { if snapIb == nil {
continue continue
@ -1285,6 +1290,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if !ok { if !ok {
continue continue
} }
var oldEmailsRows []string
if err := tx.Table("clients").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", c.Id).
Pluck("email", &oldEmailsRows).Error; err == nil {
oldEmails := make(map[string]struct{}, len(oldEmailsRows))
for _, e := range oldEmailsRows {
if e != "" {
oldEmails[e] = struct{}{}
}
}
perInboundOld = append(perInboundOld, oldSet{inboundID: c.Id, emails: oldEmails})
}
clients, gcErr := s.GetClients(snapIb) clients, gcErr := s.GetClients(snapIb)
if gcErr != nil { if gcErr != nil {
@ -1310,21 +1328,41 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
} }
var orphanEmails []string for _, old := range perInboundOld {
var stillAttached []string
if err := tx.Table("clients"). if err := tx.Table("clients").
Joins("LEFT JOIN client_inbounds ON client_inbounds.client_id = clients.id"). Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.client_id IS NULL"). Where("client_inbounds.inbound_id = ?", old.inboundID).
Pluck("clients.email", &orphanEmails).Error; err != nil { Pluck("email", &stillAttached).Error; err != nil {
logger.Warning("setRemoteTraffic: orphan sweep query failed:", err) continue
} else if len(orphanEmails) > 0 {
if err := tx.Where("email IN ?", orphanEmails).Delete(&model.ClientRecord{}).Error; err != nil {
logger.Warning("setRemoteTraffic: orphan sweep delete ClientRecord failed:", err)
} }
if err := tx.Where("email IN ?", orphanEmails).Delete(&xray.ClientTraffic{}).Error; err != nil { stillSet := make(map[string]struct{}, len(stillAttached))
logger.Warning("setRemoteTraffic: orphan sweep delete ClientTraffic failed:", err) for _, e := range stillAttached {
stillSet[e] = struct{}{}
}
for email := range old.emails {
if _, kept := stillSet[email]; kept {
continue
}
var attachmentCount int64
if err := tx.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email = ?", email).
Count(&attachmentCount).Error; err != nil {
continue
}
if attachmentCount > 0 {
continue
}
if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
logger.Warning("setRemoteTraffic: delete ClientRecord", email, "failed:", err)
}
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
logger.Warning("setRemoteTraffic: delete ClientTraffic", email, "failed:", err)
} }
structuralChange = true structuralChange = true
} }
}
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return false, err return false, err