fix(clients): re-enable only quota-disabled clients on a traffic reset

Every traffic reset re-enabled its clients unconditionally, in the
client record and in the traffic row a master treats as the client's
state. A master forwards an inbound's scheduled reset and "reset all"
to its nodes through the per-client reset calls, so a client disabled
by hand on a node inbound came back on at the next scheduled reset.

Each reset now decides, before it zeroes the counters, which of its
clients the quota switched off, counting cross-panel usage, and
re-enables only those; a client disabled below its quota keeps its
record and traffic row off. The reset API docs say so.
This commit is contained in:
MHSanaei
2026-11-01 08:31:27 +01:00
parent 67820dc20d
commit 7f64a67ba5
10 changed files with 249 additions and 60 deletions
+20 -16
View File
@@ -182,10 +182,11 @@ _openapi:
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running' url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running'
- depth: 2 - depth: 2
title: Zero up/down counters for many clients in one call. Loops the title: Zero up/down counters for many clients in one call. Loops the
single-reset path so each client is re-enabled across its attached single-reset path so each client its quota had disabled is re-enabled
inbounds and pushed to Xray/remote nodes. Returns the count of across its attached inbounds and pushed to Xray/remote nodes; a client
successfully reset clients. disabled by hand stays disabled. Returns the count of successfully reset
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients' clients.
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients'
- depth: 2 - depth: 2
title: List all client groups with their member counts. Merges persisted groups title: List all client groups with their member counts. Merges persisted groups
(rows in client_groups, including empty placeholders) with the distinct (rows in client_groups, including empty placeholders) with the distinct
@@ -223,10 +224,11 @@ _openapi:
client_groups row if the group exists only as a derived label. client_groups row if the group exists only as a derived label.
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label' url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
- depth: 2 - depth: 2
title: Zero out a single client’s up/down counters. Re-enables the client across title: Zero out a single client’s up/down counters. Re-enables a client its
every attached inbound and pushes the change to Xray (or the remote quota had disabled across every attached inbound and pushes the change
node) so depleted users can connect again immediately. to Xray (or the remote node) so depleted users can connect again
url: '#zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately' immediately; a client disabled by hand stays disabled.
url: '#zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled'
- depth: 2 - depth: 2
title: Manually adjust a client’s upload + download counters. Useful for title: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems. migrations from external accounting systems.
@@ -457,10 +459,11 @@ _openapi:
any target inbound was running." any target inbound was running."
id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running
- content: Zero up/down counters for many clients in one call. Loops the - content: Zero up/down counters for many clients in one call. Loops the
single-reset path so each client is re-enabled across its attached single-reset path so each client its quota had disabled is re-enabled
inbounds and pushed to Xray/remote nodes. Returns the count of across its attached inbounds and pushed to Xray/remote nodes; a client
successfully reset clients. disabled by hand stays disabled. Returns the count of successfully
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients reset clients.
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients
- content: List all client groups with their member counts. Merges persisted - content: List all client groups with their member counts. Merges persisted
groups (rows in client_groups, including empty placeholders) with the groups (rows in client_groups, including empty placeholders) with the
distinct group_name values currently set on clients. Sorted distinct group_name values currently set on clients. Sorted
@@ -492,10 +495,11 @@ _openapi:
Creates the client_groups row if the group exists only as a derived Creates the client_groups row if the group exists only as a derived
label. label.
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
- content: Zero out a single client’s up/down counters. Re-enables the client - content: Zero out a single client’s up/down counters. Re-enables a client its
across every attached inbound and pushes the change to Xray (or the quota had disabled across every attached inbound and pushes the change
remote node) so depleted users can connect again immediately. to Xray (or the remote node) so depleted users can connect again
id: zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately immediately; a client disabled by hand stays disabled.
id: zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled
- content: Manually adjust a client’s upload + download counters. Useful for - content: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems. migrations from external accounting systems.
id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems
+2 -2
View File
@@ -10163,7 +10163,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.", "summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
"operationId": "post_panel_api_clients_bulkResetTraffic", "operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -10504,7 +10504,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.", "summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
"operationId": "post_panel_api_clients_resetTraffic_email", "operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [ "parameters": [
{ {
+2 -2
View File
@@ -10163,7 +10163,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.", "summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
"operationId": "post_panel_api_clients_bulkResetTraffic", "operationId": "post_panel_api_clients_bulkResetTraffic",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -10504,7 +10504,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.", "summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
"operationId": "post_panel_api_clients_resetTraffic_email", "operationId": "post_panel_api_clients_resetTraffic_email",
"parameters": [ "parameters": [
{ {
+2 -2
View File
@@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/clients/bulkResetTraffic', path: '/panel/api/clients/bulkResetTraffic',
summary: summary:
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.', 'Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.',
body: '{\n "emails": ["alice", "bob"]\n}', body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}', response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
}, },
@@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/clients/resetTraffic/:email', path: '/panel/api/clients/resetTraffic/:email',
summary: summary:
'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.', 'Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.',
params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }], params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
}, },
{ {
+103 -1
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"fmt"
"testing" "testing"
"time" "time"
@@ -48,7 +49,7 @@ func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
if err := svc.SyncInbound(nil, ib.Id, source); err != nil { if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err) t.Fatalf("seed linkage: %v", err)
} }
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false) mkTraffic(t, ib.Id, "alice@x", 10, 20, 30, 0, false)
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true) mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true) mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
@@ -231,3 +232,104 @@ func TestDelDepletedIgnoresPaddedUsageRow(t *testing.T) {
t.Fatalf("alice was deleted through a look-alike usage row: %v", err) t.Fatalf("alice was deleted through a look-alike usage row: %v", err)
} }
} }
// trafficResets is every way to reset a client's traffic; a master forwards its
// scheduled and "reset all" resets to a node through the first two.
var trafficResets = []struct {
name string
run func(ib *model.Inbound, email string) error
}{
{"ResetTrafficByEmail", func(_ *model.Inbound, email string) error {
_, err := (&ClientService{}).ResetTrafficByEmail(&InboundService{}, email)
return err
}},
{"BulkResetTraffic", func(_ *model.Inbound, email string) error {
_, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{email})
return err
}},
{"ResetAllClientTraffics", func(ib *model.Inbound, _ string) error {
_, err := (&ClientService{}).ResetAllClientTraffics(&InboundService{}, ib.Id)
return err
}},
{"ResetAllTraffics", func(*model.Inbound, string) error {
_, err := (&ClientService{}).ResetAllTraffics()
return err
}},
{"ResetClientTraffic", func(ib *model.Inbound, email string) error {
_, err := (&InboundService{}).ResetClientTraffic(ib.Id, email)
return err
}},
{"ResetClientTrafficByEmail", func(_ *model.Inbound, email string) error {
return (&InboundService{}).ResetClientTrafficByEmail(email)
}},
}
func TestTrafficReset_LeavesOperatorDisabledClientOff(t *testing.T) {
const email = "banned@x"
for _, total := range []int64{0, 1000} {
for _, reset := range trafficResets {
t.Run(fmt.Sprintf("%s/quota=%d", reset.name, total), func(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
// Disabled with usage below quota: nothing but a human did that.
ib := seedLocalDisabledClient(t, svc, 52020, "", email, total, 0, 10, 10)
if err := reset.run(ib, email); err != nil {
t.Fatalf("%s: %v", reset.name, err)
}
assertEnableEverywhere(t, svc, &InboundService{}, ib.Id, email, false)
if tr := trafficOf(t, email); tr.Up != 0 || tr.Down != 0 {
t.Fatalf("traffic after reset up=%d down=%d, want 0/0", tr.Up, tr.Down)
}
})
}
}
}
func TestTrafficReset_RestoresQuotaDisabledClient(t *testing.T) {
const email = "depleted@x"
for _, reset := range trafficResets {
t.Run(reset.name, func(t *testing.T) {
setupBulkDB(t)
ib := seedLocalDisabledClient(t, &ClientService{}, 52021, "", email, 1000, 0, 600, 500)
if err := reset.run(ib, email); err != nil {
t.Fatalf("%s: %v", reset.name, err)
}
if tr := trafficOf(t, email); !tr.Enable || tr.Up != 0 || tr.Down != 0 {
t.Fatalf("traffic after reset enable=%v up=%d down=%d, want true 0/0", tr.Enable, tr.Up, tr.Down)
}
})
}
}
// A node cuts a client on the usage its master pushed while the node's own
// share is still below the quota; a reset must still count that as depleted.
func TestTrafficReset_RestoresClientDisabledByGlobalUsage(t *testing.T) {
const email = "shared@x"
for i, reset := range trafficResets {
t.Run(reset.name, func(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := seedLocalDisabledClient(t, svc, 52022, "", email, 1000, 0, 100, 100)
if err := inboundSvc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: email, Up: 800, Down: 700}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if err := reset.run(ib, email); err != nil {
t.Fatalf("%s: %v", reset.name, err)
}
if !trafficOf(t, email).Enable {
t.Fatal("client_traffics.enable = false: the pushed usage was not counted as depleted")
}
// Only the ClientService per-client resets restore the record and settings.
if i < 2 {
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
}
})
}
}
+9
View File
@@ -160,6 +160,15 @@ func quotaDisabledEmails(tx *gorm.DB, within []string) ([]string, error) {
if err := clientResetCycles(tx).Where("c.enable = ?", false).Scan(&cycles).Error; err != nil { if err := clientResetCycles(tx).Where("c.enable = ?", false).Scan(&cycles).Error; err != nil {
return nil, err return nil, err
} }
// Cross-panel usage depletes too: a client cut off on pushed usage was the quota's doing.
rows := make([]xray.ClientTraffic, len(cycles))
for i, c := range cycles {
rows[i] = xray.ClientTraffic{Email: c.Email, Up: c.Up, Down: c.Down, Total: c.Total}
}
overlayGlobalTrafficValues(tx, rows)
for i := range cycles {
cycles[i].Up, cycles[i].Down = rows[i].Up, rows[i].Down
}
var in map[string]bool var in map[string]bool
if within != nil { if within != nil {
in = make(map[string]bool, len(within)) in = make(map[string]bool, len(within))
@@ -137,12 +137,12 @@ func depleteClientTraffic(t *testing.T, inboundId int, email string) {
t.Helper() t.Helper()
db := database.GetDB() db := database.GetDB()
res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email). res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).
Updates(map[string]any{"enable": false, "up": 1, "down": 1}) Updates(map[string]any{"enable": false, "up": 1, "down": 1, "total": 2})
if res.Error != nil { if res.Error != nil {
t.Fatalf("deplete traffic: %v", res.Error) t.Fatalf("deplete traffic: %v", res.Error)
} }
if res.RowsAffected == 0 { if res.RowsAffected == 0 {
if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1}).Error; err != nil { if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1, Total: 2}).Error; err != nil {
t.Fatalf("create depleted traffic: %v", err) t.Fatalf("create depleted traffic: %v", err)
} }
} }
+92 -27
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"slices"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -24,6 +25,11 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
if err != nil { if err != nil {
return false, err return false, err
} }
// Decided before the resets below zero the counters the rule reads.
quotaDisabled, err := quotaDisabledEmails(database.GetDB(), []string{email})
if err != nil {
return false, err
}
needRestart := false needRestart := false
if len(inboundIds) == 0 { if len(inboundIds) == 0 {
@@ -44,9 +50,9 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
needRestart = nr needRestart = nr
} }
// Enable only once the counters are zero: a still-depleted client enabled // Enable only a client the quota switched off, once the counters are zero: a
// first is switched off again by the next traffic tick. // hand-disabled one stays off, and a still-depleted one would be switched off again.
if !rec.Enable { if !rec.Enable && len(quotaDisabled) > 0 {
updated := rec.ToClient() updated := rec.ToClient()
updated.Enable = true updated.Enable = true
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid) nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
@@ -73,22 +79,25 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
if err != nil { if err != nil {
return 0, err return 0, err
} }
affected := 0 var affected int64
var restore []string
var resetNodes []int var resetNodes []int
err = submitTrafficWrite(func() error { err = submitTrafficWrite(func() error {
db := database.GetDB() db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
var err error
if restore, err = quotaDisabledEmails(tx, cleanEmails); err != nil {
return err
}
keepOff, err := handDisabledEmails(tx, cleanEmails)
if err != nil {
return err
}
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil { if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
return err return err
} }
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) { if affected, err = zeroClientTraffics(tx, cleanEmails, keepOff); err != nil {
res := tx.Model(xray.ClientTraffic{}). return err
Where("email IN ?", batch).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if res.Error != nil {
return res.Error
}
affected += int(res.RowsAffected)
} }
if err := clearGlobalTraffic(tx, cleanEmails...); err != nil { if err := clearGlobalTraffic(tx, cleanEmails...); err != nil {
return err return err
@@ -110,7 +119,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
inboundSvc.deliverNodeResetsNow(resetNodes) inboundSvc.deliverNodeResetsNow(resetNodes)
// After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted // After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted
// client first lets the next traffic tick switch it off again. // client first lets the next traffic tick switch it off again.
for _, e := range cleanEmails { for _, e := range restore {
rec := recordsByEmail[e] rec := recordsByEmail[e]
if rec == nil || rec.Enable { if rec == nil || rec.Enable {
continue continue
@@ -121,7 +130,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr) logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
} }
} }
return affected, nil return int(affected), nil
} }
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) (bool, error) { func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) (bool, error) {
@@ -180,17 +189,17 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string
if restore, qErr = quotaDisabledEmails(tx, resetEmails); qErr != nil { if restore, qErr = quotaDisabledEmails(tx, resetEmails); qErr != nil {
return qErr return qErr
} }
keepOff, qErr := handDisabledEmails(tx, resetEmails)
if qErr != nil {
return qErr
}
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil { if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
return err return err
} }
result := tx.Model(xray.ClientTraffic{}). if _, err := zeroClientTraffics(tx, resetEmails, keepOff); err != nil {
Where("email IN ?", resetEmails). return err
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if result.Error != nil {
return result.Error
} }
if err := clearGlobalTraffic(tx, resetEmails...); err != nil { if err := clearGlobalTraffic(tx, resetEmails...); err != nil {
@@ -213,7 +222,7 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string
inboundWhereText += " = ?" inboundWhereText += " = ?"
} }
result = tx.Model(model.Inbound{}). result := tx.Model(model.Inbound{}).
Where(inboundWhereText, id). Where(inboundWhereText, id).
Update("last_traffic_reset_time", now) Update("last_traffic_reset_time", now)
@@ -234,13 +243,13 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
if restore, qErr = quotaDisabledEmails(tx, nil); qErr != nil { if restore, qErr = quotaDisabledEmails(tx, nil); qErr != nil {
return qErr return qErr
} }
res := tx.Model(&xray.ClientTraffic{}). keepOff, qErr := handDisabledEmails(tx, nil)
Where("1 = 1"). if qErr != nil {
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) return qErr
if res.Error != nil { }
return res.Error if affected, qErr = zeroClientTraffics(tx, nil, keepOff); qErr != nil {
return qErr
} }
affected = res.RowsAffected
if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil { if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
return err return err
} }
@@ -266,3 +275,59 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
} }
return affected > 0 || needRestart, nil return affected > 0 || needRestart, nil
} }
// handDisabledEmails lists, among emails (every client when nil), the clients switched off by
// hand: a disabled record whose quota is not spent. Decide it before a reset zeroes the counters.
func handDisabledEmails(tx *gorm.DB, emails []string) ([]string, error) {
var disabled []string
q := func() *gorm.DB { return tx.Model(&model.ClientRecord{}).Where("enable = ?", false) }
if emails == nil {
if err := q().Pluck("email", &disabled).Error; err != nil {
return nil, err
}
} else {
for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []string
if err := q().Where("email IN ?", batch).Pluck("email", &page).Error; err != nil {
return nil, err
}
disabled = append(disabled, page...)
}
}
if len(disabled) == 0 {
return nil, nil
}
quota, err := quotaDisabledEmails(tx, disabled)
if err != nil {
return nil, err
}
return slices.DeleteFunc(disabled, func(e string) bool { return slices.Contains(quota, e) }), nil
}
// zeroClientTraffics zeroes the counters of emails (every row when nil) and lifts their traffic
// gate, except for keepOff: a master pushes that flag to its nodes as the client's state.
func zeroClientTraffics(tx *gorm.DB, emails, keepOff []string) (int64, error) {
var affected int64
reset := map[string]any{"enable": true, "up": 0, "down": 0}
if emails == nil {
res := tx.Model(&xray.ClientTraffic{}).Where("1 = 1").Updates(reset)
if res.Error != nil {
return 0, res.Error
}
affected = res.RowsAffected
} else {
for _, batch := range chunkStrings(emails, sqlInChunk) {
res := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Updates(reset)
if res.Error != nil {
return 0, res.Error
}
affected += res.RowsAffected
}
}
for _, batch := range chunkStrings(keepOff, sqlInChunk) {
if err := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Update("enable", false).Error; err != nil {
return 0, err
}
}
return affected, nil
}
@@ -103,12 +103,12 @@ func TestResetTrafficByEmail_LeavesRecordEnableTrue(t *testing.T) {
inboundSvc := &InboundService{} inboundSvc := &InboundService{}
email := "r-attached@x" email := "r-attached@x"
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false} c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false, TotalGB: 30}
ib := mkInbound(t, 53003, model.VLESS, clientsSettings(t, []model.Client{c})) ib := mkInbound(t, 53003, model.VLESS, clientsSettings(t, []model.Client{c}))
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil { if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
t.Fatalf("seed linkage: %v", err) t.Fatalf("seed linkage: %v", err)
} }
mkTraffic(t, ib.Id, email, 10, 20, 0, 0, false) mkTraffic(t, ib.Id, email, 10, 20, 30, 0, false)
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil { if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
t.Fatalf("ResetTrafficByEmail: %v", err) t.Fatalf("ResetTrafficByEmail: %v", err)
@@ -142,6 +142,7 @@ func TestResetTrafficByEmail_NoInbound_LeavesRecordEnableTrue(t *testing.T) {
t.Fatalf("create record: %v", err) t.Fatalf("create record: %v", err)
} }
forceRecordDisabled(t, svc, email) forceRecordDisabled(t, svc, email)
mkTraffic(t, 0, email, 10, 20, 30, 0, false)
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil { if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
t.Fatalf("ResetTrafficByEmail: %v", err) t.Fatalf("ResetTrafficByEmail: %v", err)
+14 -6
View File
@@ -680,12 +680,14 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil { if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err return err
} }
if err := clearGlobalTraffic(tx, clientEmail); err != nil { keepOff, err := handDisabledEmails(tx, []string{clientEmail})
if err != nil {
return err return err
} }
if err := tx.Model(xray.ClientTraffic{}). if _, err := zeroClientTraffics(tx, []string{clientEmail}, keepOff); err != nil {
Where("email = ?", clientEmail). return err
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil { }
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
return err return err
} }
return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
@@ -723,8 +725,14 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
if err != nil { if err != nil {
return false, nil, err return false, nil, err
} }
// A reset lifts the quota gate only: a client switched off by hand stays off.
keepOff, err := handDisabledEmails(database.GetDB(), []string{clientEmail})
if err != nil {
return false, nil, err
}
handDisabled := len(keepOff) > 0
if !traffic.Enable { if !traffic.Enable && !handDisabled {
inbound, err := s.GetInbound(id) inbound, err := s.GetInbound(id)
if err != nil { if err != nil {
return false, nil, err return false, nil, err
@@ -766,7 +774,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
traffic.Up = 0 traffic.Up = 0
traffic.Down = 0 traffic.Down = 0
traffic.Enable = true traffic.Enable = !handDisabled
db := database.GetDB() db := database.GetDB()
now := time.Now().UnixMilli() now := time.Now().UnixMilli()